From aa26e0b44babc03ccf0fbc0ea04c08d29583ca1e Mon Sep 17 00:00:00 2001 From: Salt Project Packaging Date: Wed, 1 Jul 2026 07:01:51 +0000 Subject: [PATCH 001/469] Release v3006.27 --- CHANGELOG.md | 68 +++++++++++++++++++++++++++++++++ changelog/31531.fixed.md | 1 - changelog/32567.fixed.md | 1 - changelog/44937.fixed.md | 1 - changelog/55971.fixed.md | 1 - changelog/59955.fixed.md | 1 - changelog/60720.added.md | 1 - changelog/61078.fixed.md | 1 - changelog/64160.fixed.md | 1 - changelog/64291.fixed.md | 1 - changelog/65184.fixed.md | 1 - changelog/65243.fixed.md | 1 - changelog/65253.fixed.md | 1 - changelog/65702.fixed.md | 1 - changelog/66282.fixed.md | 1 - changelog/66353.fixed.md | 1 - changelog/66524.fixed.md | 1 - changelog/68211.fixed.md | 1 - changelog/68411.fixed.md | 4 -- changelog/68421.fixed.md | 1 - changelog/68425.fixed.md | 1 - changelog/68438.fixed.md | 1 - changelog/68460.fixed.md | 1 - changelog/68577.fixed.md | 1 - changelog/68755.fixed.md | 1 - changelog/68793.fixed.md | 1 - changelog/68901.fixed.md | 1 - changelog/69069.fixed.md | 11 ------ changelog/69131.fixed.md | 1 - changelog/69442.fixed.md | 1 - changelog/69526.changed.md | 1 - changelog/69569.fixed.md | 1 - changelog/69571.fixed.md | 1 - changelog/69573.fixed.md | 1 - changelog/69575.fixed.md | 1 - changelog/69583.fixed.md | 1 - changelog/69605.fixed.md | 1 - changelog/69612.fixed.md | 5 --- doc/topics/releases/3006.27.md | 67 ++++++++++++++++++++++++++++++++- pkg/debian/changelog | 69 ++++++++++++++++++++++++++++++++++ pkg/rpm/salt.spec | 68 ++++++++++++++++++++++++++++++++- 41 files changed, 270 insertions(+), 56 deletions(-) delete mode 100644 changelog/31531.fixed.md delete mode 100644 changelog/32567.fixed.md delete mode 100644 changelog/44937.fixed.md delete mode 100644 changelog/55971.fixed.md delete mode 100644 changelog/59955.fixed.md delete mode 100644 changelog/60720.added.md delete mode 100644 changelog/61078.fixed.md delete mode 100644 changelog/64160.fixed.md delete mode 100644 changelog/64291.fixed.md delete mode 100644 changelog/65184.fixed.md delete mode 100644 changelog/65243.fixed.md delete mode 100644 changelog/65253.fixed.md delete mode 100644 changelog/65702.fixed.md delete mode 100644 changelog/66282.fixed.md delete mode 100644 changelog/66353.fixed.md delete mode 100644 changelog/66524.fixed.md delete mode 100644 changelog/68211.fixed.md delete mode 100644 changelog/68411.fixed.md delete mode 100644 changelog/68421.fixed.md delete mode 100644 changelog/68425.fixed.md delete mode 100644 changelog/68438.fixed.md delete mode 100644 changelog/68460.fixed.md delete mode 100644 changelog/68577.fixed.md delete mode 100644 changelog/68755.fixed.md delete mode 100644 changelog/68793.fixed.md delete mode 100644 changelog/68901.fixed.md delete mode 100644 changelog/69069.fixed.md delete mode 100644 changelog/69131.fixed.md delete mode 100644 changelog/69442.fixed.md delete mode 100644 changelog/69526.changed.md delete mode 100644 changelog/69569.fixed.md delete mode 100644 changelog/69571.fixed.md delete mode 100644 changelog/69573.fixed.md delete mode 100644 changelog/69575.fixed.md delete mode 100644 changelog/69583.fixed.md delete mode 100644 changelog/69605.fixed.md delete mode 100644 changelog/69612.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c1e2968a63..d3b83f59a917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,74 @@ Versions are `MAJOR.PATCH`. # Changelog +## 3006.27 (2026-07-01) + + +### Changed + +- Upgrade the bundled onedir Python from 3.10.20 to 3.11.15 on the 3006.x branch. Python 3.10 reaches end of security support in October 2026, while Salt 3006.x must ship security fixes through July 2027. Users upgrading from a previous 3006.x package will need to reinstall any Salt extensions installed via `salt-pip` because the onedir `extras-3.10` directory is replaced by `extras-3.11`. [#69526](https://github.com/saltstack/salt/issues/69526) + + +### Fixed + +- Fixed ``salt-ssh`` ``TemplateNotFound`` when a managed Jinja template imports from another template (e.g. ``{% from "formula/map.jinja" import x with context %}``). ``SaltCacheLoader`` now prefers ``opts["_caller_cachedir"]`` (the master's cachedir, where the master-side fileclient caches requested files) over ``opts["cachedir"]`` (the thin minion's remote path) for its Jinja search path. Backport of the 3007.x/3008.x fix. [#31531](https://github.com/saltstack/salt/issues/31531) +- Fixed the ``mysql`` returner ignoring the configured ``mysql.user`` from salt-ssh and other contexts where ``__salt__`` lacks ``config.option``. ``get_returner_options`` fell back to ``__opts__`` and looked up bare attribute names in it, so the master's top-level ``user`` opt (the system user salt runs as, typically ``root``) masked the configured database user and the returner connected as the wrong user. The mysql returner now passes a scoped view of ``__opts__`` containing only ``mysql.*`` keys so the lookup cannot collide. [#32567](https://github.com/saltstack/salt/issues/32567) +- Fixed non-deterministic pillar rendering when multiple ``pillar_roots`` environments matched the same minion. ``Pillar.get_tops`` collected saltenvs into a ``set`` and iterated them in hash order, so top-file processing order depended on ``PYTHONHASHSEED`` and varied per ``salt-call`` invocation. An earlier change made ``_get_envs`` return an ordered list, but the caller wrapped the result back into a ``set``. ``get_tops`` now uses an insertion-ordered dict so iteration follows ``pillar_roots`` config order. [#44937](https://github.com/saltstack/salt/issues/44937) +- Documented the supported approaches for relocating Salt's runtime directories when running rootless: `SALT_HOME`/`SALT_EXTRAS_DIR` at install time, `root_dir` for relative relocation, and the per-key (`pki_dir`, `cachedir`, `log_file`, `pidfile`, `sock_dir`) overrides. [#55971](https://github.com/saltstack/salt/issues/55971) +- Rewrote the non-root / unprivileged user configuration page for onedir packaging, consolidating the older overlapping pages and documenting `SALT_USER`/`SALT_HOME`/`SALT_EXTRAS_DIR`, `root_dir` relocation, and systemd drop-ins. [#59955](https://github.com/saltstack/salt/issues/59955) +- Rewrote the FAQ entry on restarting the minion after upgrade for the onedir packaging era. Removed the broken `policy-rc.d`/`prereq` workaround and documented the supported patterns based on `KillMode=process` in the shipped systemd unit. [#61078](https://github.com/saltstack/salt/issues/61078) +- Updated the packaging docs to explain how to install modules' optional Python dependencies into an onedir install via `salt-pip`. [#64160](https://github.com/saltstack/salt/issues/64160) +- Documented `salt-pip` for installing optional Python dependencies into a onedir Salt install, including the extras directory layout, `SALT_EXTRAS_DIR` relocation, and non-root behavior. [#64291](https://github.com/saltstack/salt/issues/64291) +- Fixed the EC2/cloud metadata grain crashing with ``KeyError: 'headers'`` when ``salt.utils.http.query`` returns an error response (4xx/5xx with a body, e.g. when the IMDS rejects a recursive sub-path lookup). Since 3006.3 the tornado backend has populated ``body`` on HTTPError without also populating ``headers``; the grain now treats the missing ``headers`` key as "no Content-Type information" instead of letting the lookup blow up the whole grain load. [#65184](https://github.com/saltstack/salt/issues/65184) +- Updated the non-root user docs for the onedir-era directory layout (`/opt/saltstack/salt`, `extras-3.N`, package-managed `salt` user) and explained how to switch an existing install over to a different account. [#65243](https://github.com/saltstack/salt/issues/65243) +- Expanded the packaging test guide with single-test invocations, environment variables, common failures, and CI parity notes. [#65253](https://github.com/saltstack/salt/issues/65253) +- Fixed master-initiated jobs failing on Python 3.12+ with "There is no current event loop in thread 'Thread-N (_target)'" by installing an asyncio event loop on the SyncWrapper worker thread. [#65702](https://github.com/saltstack/salt/issues/65702) +- Fixed master 4505 publish port becoming unresponsive under load: TCP `PubServer` now broadcasts to subscribers concurrently so a single slow subscriber no longer stalls the event publisher loop, and the ZeroMQ master PUB socket now enables ZMTP heartbeats so dead subscribers are reaped within seconds instead of waiting for the kernel TCP keepalive. [#66282](https://github.com/saltstack/salt/issues/66282) +- Refreshed the "running as a non-root user" page; replaced outdated 0.9.10-era guidance and added the onedir-aware steps for changing the runtime user. [#66353](https://github.com/saltstack/salt/issues/66353) +- Documented how to install Salt Extensions (`saltext.`) into an onedir install with `salt-pip`, and pointed the developer extensions doc at the install instructions. [#66524](https://github.com/saltstack/salt/issues/66524) +- Fixed ``salt.utils.vmware`` to use the supported ``token``/``tokenType`` arguments instead of the deprecated ``b64token``/``mechanism`` arguments when calling ``pyVim.connect.SmartConnect``. pyvmomi 9 raises an exception when either deprecated argument is truthy, which broke salt-cloud, the ``vsphere`` execution module, and other VMware integrations as soon as pyvmomi was upgraded. [#68211](https://github.com/saltstack/salt/issues/68211) +- Fixed `state.event` (and `salt-run state.event`) crashing with `UnicodeDecodeError` + when an event payload contains raw binary bytes such as the DER-encoded certificate + returned by `x509.sign_remote_certificate`. Undecodable bytes are now base64-encoded + in the JSON output instead of aborting the runner. [#68411](https://github.com/saltstack/salt/issues/68411) +- Fixed ``salt.utils.url.create`` so ``salt://`` URLs built from relative paths round-trip correctly on Python 3.13+, where ``urllib.parse.urlunparse`` no longer emits a ``file:///`` prefix for relative paths. salt-ssh ``file.managed`` ``source: salt://...`` references now resolve as expected on newer-Python targets (e.g. Debian trixie). [#68421](https://github.com/saltstack/salt/issues/68421) +- Fix `set_locale` on Debian 13/14 where systemd-localed is unavailable; fall back to /etc/default/locale update. [#68425](https://github.com/saltstack/salt/issues/68425) +- Fixed a prereq chain bug where a state at the head of a chain (e.g. `state1 -prereq-> state2 -prereq-> state3`) would always run when an intermediate state in the chain always produced changes in test mode (e.g. `test.succeed_with_changes`, `module.run`), even though the tail state of the chain produced no changes. [#68438](https://github.com/saltstack/salt/issues/68438) +- Fixed Debian ``salt-minion`` package failing to upgrade from a non-onedir release. The ``salt-minion.preinst`` script assigned an unused ``PY_VER`` variable by exec'ing ``/opt/saltstack/salt/bin/python3``, which does not exist when upgrading from a pre-onedir Debian package (e.g. ``3006.0+ds-1+240.1``). Under ``set -e`` this aborted the upgrade with ``subprocess returned error exit status 127``. The unused assignment is removed. [#68460](https://github.com/saltstack/salt/issues/68460) +- Fixed salt-master package upgrades resetting state directory ownership and the debconf `salt-master/user` value when the master was configured to run as a non-root user. [#68577](https://github.com/saltstack/salt/issues/68577) +- Don't insert local paths before standard library paths in LazyLoader, preventing sys.path reordering when loader modules are already importable. [#68755](https://github.com/saltstack/salt/issues/68755) +- Fixed Salt minion package upgrades when the minion is configured to run as a non-root user via ``user:`` in ``/etc/salt/minion`` or ``/etc/salt/minion.d/*.conf``. The Debian preinst now reads the configured user before falling back to filesystem ownership, and the rpm pre-minion scriptlet no longer relies on rpm macro directives inside its shell body to communicate the chosen user to the post-minion scriptlet. [#68793](https://github.com/saltstack/salt/issues/68793) +- Fixed a file descriptor leak in the Salt minion: when the single-master sign-in path in ``Minion.eval_master`` raised any exception other than ``SaltClientError`` (for example ``OSError`` from the underlying transport), or when ``transport: detect`` rejected a candidate transport because it could not authenticate, the ``AsyncPubChannel`` that had been created was not closed, leaking its socket. Minions with unstable network connectivity could exhaust the per-process file descriptor limit. The channel is now always closed on failure via a ``try/finally``. [#68901](https://github.com/saltstack/salt/issues/68901) +- Fixed `salt.utils.cache.ContextCache.cache_context` writing the + serialized pillar context to disk with whatever mode the process + umask happened to allow (typically `0o644` on default Linux installs) + inside a `0o755` parent directory. Pillar context can carry + credentials (passwords, vault tokens, API keys), so any local user + could read them; even with the file mode tightened, the directory + mode let any local user `ls` the cache and learn which modules and + external-pillar backends were in use. The cache file is now written + through `tempfile.mkstemp` (creates with `0o600` by default) followed + by atomic `os.replace`, and the parent `context/` directory is + created with `stat.S_IRWXU` (`0o700`). [#69069](https://github.com/saltstack/salt/issues/69069) +- Fixed `kernelpkg.upgrade` on Debian 13 (trixie) and other distros that ship a kernelrelease containing characters outside `[\d.-]` (for example `6.12.86+deb13-amd64`). `kernelpkg_linux_apt._kernel_type` now parses such releases instead of raising `AttributeError: 'NoneType' object has no attribute 'group'`. [#69131](https://github.com/saltstack/salt/issues/69131) +- Added a new opt-in `auth_retries` minion option that caps the `AsyncAuth._authenticate()` outer retry loop, so a minion that keeps getting `retry` responses from `sign_in()` can bail out with `SaltClientError` instead of looping silently forever. The default is `0` (unlimited), which preserves the existing 3006.x LTS behavior on upgrade; operators who want the new safety cap set `auth_retries` explicitly to a positive integer. [#69442](https://github.com/saltstack/salt/issues/69442) +- Fixed ``saltutil.runner``/``saltutil.wheel`` failing git-backed master functions (e.g. ``git_pillar.update``) with ``failed to stat '/root/.gitconfig'`` when the master runs as a non-root user. Dropping to the master user with ``chugid`` left ``HOME``/``USER``/``LOGNAME`` pointing at the invoking (root) user; these are now aligned with the runas user, and pygit2's cached global-config search path is refreshed. [#69569](https://github.com/saltstack/salt/issues/69569) +- Stopped logging a spurious ``random_master is True but there is only one master specified. Ignoring.`` warning once per master at startup for an all-hot multi-master minion. The warning now fires only for a genuinely single-master configuration. [#69571](https://github.com/saltstack/salt/issues/69571) +- Fix OpenNebula salt-cloud documentation to clarify that VM attributes (memory, cpu, vcpu, etc.) must be specified in the profile configuration, not as command-line arguments to ``salt-cloud -p``. [#69573](https://github.com/saltstack/salt/issues/69573) +- Removed bundled MD5/SHA-1 references that tripped FIPS-compliance scanners against the Salt onedir. The cryptography sdist's top-level ``docs/`` directory (which contains Java/Rust test-vector sources naming weak algorithms, e.g. ``VerifyRSAOAEPSHA2.java``) is now pruned from the onedir during ``pre-archive-cleanup``, and the unused ``__fetch_verify`` helper in the vendored ``bootstrap-salt.sh`` now uses ``sha256sum`` instead of ``md5sum``. [#69575](https://github.com/saltstack/salt/issues/69575) +- Fixed `salt.utils.atomicfile.atomic_open` to fsync the temp file before the atomic rename so a crash after the rename cannot expose a truncated or partial file. [#69583](https://github.com/saltstack/salt/issues/69583) +- Fixed RPM upgrades leaving a previously-running ``salt-minion`` service stopped. The ``%pre minion`` scriptlet stops the unit so the ownership-restoration chowns don't race a live minion, but the ``%post`` / ``%posttrans`` scriptlets only called ``systemctl try-restart`` - a no-op for an inactive unit. The scriptlets now record the pre-upgrade active state and start the unit unconditionally in ``%posttrans`` when the minion was running at the start of the upgrade transaction. [#69605](https://github.com/saltstack/salt/issues/69605) +- * Relenv 0.22.16 + - 0.22.15: apply cpython#104135 workaround to bundled ssl.py on Windows + - 0.22.15: send relenv runtime debug/warning output to stderr (unblocks + maturin/pyo3 subprocess consumers) + - 0.22.16: pin libffi to cpython-bin-deps on Windows [#69612](https://github.com/saltstack/salt/issues/69612) + + +### Added + +- Added `tools/audit_doc_links.py` and a weekly `doc-linkcheck` workflow that wrap Sphinx linkcheck, strip the catch-all ignore, and emit a CSV report so external URL regressions in the docs can be tracked without gating PR CI. [#60720](https://github.com/saltstack/salt/issues/60720) + ## 3006.26 (2026-06-24) diff --git a/changelog/31531.fixed.md b/changelog/31531.fixed.md deleted file mode 100644 index 5fb3808a83c8..000000000000 --- a/changelog/31531.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed ``salt-ssh`` ``TemplateNotFound`` when a managed Jinja template imports from another template (e.g. ``{% from "formula/map.jinja" import x with context %}``). ``SaltCacheLoader`` now prefers ``opts["_caller_cachedir"]`` (the master's cachedir, where the master-side fileclient caches requested files) over ``opts["cachedir"]`` (the thin minion's remote path) for its Jinja search path. Backport of the 3007.x/3008.x fix. diff --git a/changelog/32567.fixed.md b/changelog/32567.fixed.md deleted file mode 100644 index cc80c585f237..000000000000 --- a/changelog/32567.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed the ``mysql`` returner ignoring the configured ``mysql.user`` from salt-ssh and other contexts where ``__salt__`` lacks ``config.option``. ``get_returner_options`` fell back to ``__opts__`` and looked up bare attribute names in it, so the master's top-level ``user`` opt (the system user salt runs as, typically ``root``) masked the configured database user and the returner connected as the wrong user. The mysql returner now passes a scoped view of ``__opts__`` containing only ``mysql.*`` keys so the lookup cannot collide. diff --git a/changelog/44937.fixed.md b/changelog/44937.fixed.md deleted file mode 100644 index 4f8d4d768e32..000000000000 --- a/changelog/44937.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed non-deterministic pillar rendering when multiple ``pillar_roots`` environments matched the same minion. ``Pillar.get_tops`` collected saltenvs into a ``set`` and iterated them in hash order, so top-file processing order depended on ``PYTHONHASHSEED`` and varied per ``salt-call`` invocation. An earlier change made ``_get_envs`` return an ordered list, but the caller wrapped the result back into a ``set``. ``get_tops`` now uses an insertion-ordered dict so iteration follows ``pillar_roots`` config order. diff --git a/changelog/55971.fixed.md b/changelog/55971.fixed.md deleted file mode 100644 index 2ed1491237b3..000000000000 --- a/changelog/55971.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Documented the supported approaches for relocating Salt's runtime directories when running rootless: `SALT_HOME`/`SALT_EXTRAS_DIR` at install time, `root_dir` for relative relocation, and the per-key (`pki_dir`, `cachedir`, `log_file`, `pidfile`, `sock_dir`) overrides. diff --git a/changelog/59955.fixed.md b/changelog/59955.fixed.md deleted file mode 100644 index 66e3ef4f317d..000000000000 --- a/changelog/59955.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Rewrote the non-root / unprivileged user configuration page for onedir packaging, consolidating the older overlapping pages and documenting `SALT_USER`/`SALT_HOME`/`SALT_EXTRAS_DIR`, `root_dir` relocation, and systemd drop-ins. diff --git a/changelog/60720.added.md b/changelog/60720.added.md deleted file mode 100644 index 70c0fa2e00ee..000000000000 --- a/changelog/60720.added.md +++ /dev/null @@ -1 +0,0 @@ -Added `tools/audit_doc_links.py` and a weekly `doc-linkcheck` workflow that wrap Sphinx linkcheck, strip the catch-all ignore, and emit a CSV report so external URL regressions in the docs can be tracked without gating PR CI. diff --git a/changelog/61078.fixed.md b/changelog/61078.fixed.md deleted file mode 100644 index 672dad2616df..000000000000 --- a/changelog/61078.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Rewrote the FAQ entry on restarting the minion after upgrade for the onedir packaging era. Removed the broken `policy-rc.d`/`prereq` workaround and documented the supported patterns based on `KillMode=process` in the shipped systemd unit. diff --git a/changelog/64160.fixed.md b/changelog/64160.fixed.md deleted file mode 100644 index c5710b5acf98..000000000000 --- a/changelog/64160.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Updated the packaging docs to explain how to install modules' optional Python dependencies into an onedir install via `salt-pip`. diff --git a/changelog/64291.fixed.md b/changelog/64291.fixed.md deleted file mode 100644 index 842d6a80560d..000000000000 --- a/changelog/64291.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Documented `salt-pip` for installing optional Python dependencies into a onedir Salt install, including the extras directory layout, `SALT_EXTRAS_DIR` relocation, and non-root behavior. diff --git a/changelog/65184.fixed.md b/changelog/65184.fixed.md deleted file mode 100644 index e0ff0d9c528b..000000000000 --- a/changelog/65184.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed the EC2/cloud metadata grain crashing with ``KeyError: 'headers'`` when ``salt.utils.http.query`` returns an error response (4xx/5xx with a body, e.g. when the IMDS rejects a recursive sub-path lookup). Since 3006.3 the tornado backend has populated ``body`` on HTTPError without also populating ``headers``; the grain now treats the missing ``headers`` key as "no Content-Type information" instead of letting the lookup blow up the whole grain load. diff --git a/changelog/65243.fixed.md b/changelog/65243.fixed.md deleted file mode 100644 index cc5aa5dab887..000000000000 --- a/changelog/65243.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Updated the non-root user docs for the onedir-era directory layout (`/opt/saltstack/salt`, `extras-3.N`, package-managed `salt` user) and explained how to switch an existing install over to a different account. diff --git a/changelog/65253.fixed.md b/changelog/65253.fixed.md deleted file mode 100644 index a6921c001ac5..000000000000 --- a/changelog/65253.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Expanded the packaging test guide with single-test invocations, environment variables, common failures, and CI parity notes. diff --git a/changelog/65702.fixed.md b/changelog/65702.fixed.md deleted file mode 100644 index 2f6a240c593a..000000000000 --- a/changelog/65702.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed master-initiated jobs failing on Python 3.12+ with "There is no current event loop in thread 'Thread-N (_target)'" by installing an asyncio event loop on the SyncWrapper worker thread. diff --git a/changelog/66282.fixed.md b/changelog/66282.fixed.md deleted file mode 100644 index 404beae139ae..000000000000 --- a/changelog/66282.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed master 4505 publish port becoming unresponsive under load: TCP `PubServer` now broadcasts to subscribers concurrently so a single slow subscriber no longer stalls the event publisher loop, and the ZeroMQ master PUB socket now enables ZMTP heartbeats so dead subscribers are reaped within seconds instead of waiting for the kernel TCP keepalive. diff --git a/changelog/66353.fixed.md b/changelog/66353.fixed.md deleted file mode 100644 index edd1feb1f180..000000000000 --- a/changelog/66353.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Refreshed the "running as a non-root user" page; replaced outdated 0.9.10-era guidance and added the onedir-aware steps for changing the runtime user. diff --git a/changelog/66524.fixed.md b/changelog/66524.fixed.md deleted file mode 100644 index 87f6940ae707..000000000000 --- a/changelog/66524.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Documented how to install Salt Extensions (`saltext.`) into an onedir install with `salt-pip`, and pointed the developer extensions doc at the install instructions. diff --git a/changelog/68211.fixed.md b/changelog/68211.fixed.md deleted file mode 100644 index a5c76205ddc6..000000000000 --- a/changelog/68211.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed ``salt.utils.vmware`` to use the supported ``token``/``tokenType`` arguments instead of the deprecated ``b64token``/``mechanism`` arguments when calling ``pyVim.connect.SmartConnect``. pyvmomi 9 raises an exception when either deprecated argument is truthy, which broke salt-cloud, the ``vsphere`` execution module, and other VMware integrations as soon as pyvmomi was upgraded. diff --git a/changelog/68411.fixed.md b/changelog/68411.fixed.md deleted file mode 100644 index 976caf7e7dba..000000000000 --- a/changelog/68411.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -Fixed `state.event` (and `salt-run state.event`) crashing with `UnicodeDecodeError` -when an event payload contains raw binary bytes such as the DER-encoded certificate -returned by `x509.sign_remote_certificate`. Undecodable bytes are now base64-encoded -in the JSON output instead of aborting the runner. diff --git a/changelog/68421.fixed.md b/changelog/68421.fixed.md deleted file mode 100644 index 835fb6e59d05..000000000000 --- a/changelog/68421.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed ``salt.utils.url.create`` so ``salt://`` URLs built from relative paths round-trip correctly on Python 3.13+, where ``urllib.parse.urlunparse`` no longer emits a ``file:///`` prefix for relative paths. salt-ssh ``file.managed`` ``source: salt://...`` references now resolve as expected on newer-Python targets (e.g. Debian trixie). diff --git a/changelog/68425.fixed.md b/changelog/68425.fixed.md deleted file mode 100644 index 3f41beb83002..000000000000 --- a/changelog/68425.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fix `set_locale` on Debian 13/14 where systemd-localed is unavailable; fall back to /etc/default/locale update. diff --git a/changelog/68438.fixed.md b/changelog/68438.fixed.md deleted file mode 100644 index bcf3f6c4595f..000000000000 --- a/changelog/68438.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed a prereq chain bug where a state at the head of a chain (e.g. `state1 -prereq-> state2 -prereq-> state3`) would always run when an intermediate state in the chain always produced changes in test mode (e.g. `test.succeed_with_changes`, `module.run`), even though the tail state of the chain produced no changes. diff --git a/changelog/68460.fixed.md b/changelog/68460.fixed.md deleted file mode 100644 index d52358804a27..000000000000 --- a/changelog/68460.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed Debian ``salt-minion`` package failing to upgrade from a non-onedir release. The ``salt-minion.preinst`` script assigned an unused ``PY_VER`` variable by exec'ing ``/opt/saltstack/salt/bin/python3``, which does not exist when upgrading from a pre-onedir Debian package (e.g. ``3006.0+ds-1+240.1``). Under ``set -e`` this aborted the upgrade with ``subprocess returned error exit status 127``. The unused assignment is removed. diff --git a/changelog/68577.fixed.md b/changelog/68577.fixed.md deleted file mode 100644 index 4bfe081daf59..000000000000 --- a/changelog/68577.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed salt-master package upgrades resetting state directory ownership and the debconf `salt-master/user` value when the master was configured to run as a non-root user. diff --git a/changelog/68755.fixed.md b/changelog/68755.fixed.md deleted file mode 100644 index b6f106c582c0..000000000000 --- a/changelog/68755.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Don't insert local paths before standard library paths in LazyLoader, preventing sys.path reordering when loader modules are already importable. diff --git a/changelog/68793.fixed.md b/changelog/68793.fixed.md deleted file mode 100644 index 4278063c46e6..000000000000 --- a/changelog/68793.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed Salt minion package upgrades when the minion is configured to run as a non-root user via ``user:`` in ``/etc/salt/minion`` or ``/etc/salt/minion.d/*.conf``. The Debian preinst now reads the configured user before falling back to filesystem ownership, and the rpm pre-minion scriptlet no longer relies on rpm macro directives inside its shell body to communicate the chosen user to the post-minion scriptlet. diff --git a/changelog/68901.fixed.md b/changelog/68901.fixed.md deleted file mode 100644 index ed62fbc9ad7f..000000000000 --- a/changelog/68901.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed a file descriptor leak in the Salt minion: when the single-master sign-in path in ``Minion.eval_master`` raised any exception other than ``SaltClientError`` (for example ``OSError`` from the underlying transport), or when ``transport: detect`` rejected a candidate transport because it could not authenticate, the ``AsyncPubChannel`` that had been created was not closed, leaking its socket. Minions with unstable network connectivity could exhaust the per-process file descriptor limit. The channel is now always closed on failure via a ``try/finally``. diff --git a/changelog/69069.fixed.md b/changelog/69069.fixed.md deleted file mode 100644 index 3d1544590fec..000000000000 --- a/changelog/69069.fixed.md +++ /dev/null @@ -1,11 +0,0 @@ -Fixed `salt.utils.cache.ContextCache.cache_context` writing the -serialized pillar context to disk with whatever mode the process -umask happened to allow (typically `0o644` on default Linux installs) -inside a `0o755` parent directory. Pillar context can carry -credentials (passwords, vault tokens, API keys), so any local user -could read them; even with the file mode tightened, the directory -mode let any local user `ls` the cache and learn which modules and -external-pillar backends were in use. The cache file is now written -through `tempfile.mkstemp` (creates with `0o600` by default) followed -by atomic `os.replace`, and the parent `context/` directory is -created with `stat.S_IRWXU` (`0o700`). diff --git a/changelog/69131.fixed.md b/changelog/69131.fixed.md deleted file mode 100644 index f3893efa6b04..000000000000 --- a/changelog/69131.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed `kernelpkg.upgrade` on Debian 13 (trixie) and other distros that ship a kernelrelease containing characters outside `[\d.-]` (for example `6.12.86+deb13-amd64`). `kernelpkg_linux_apt._kernel_type` now parses such releases instead of raising `AttributeError: 'NoneType' object has no attribute 'group'`. diff --git a/changelog/69442.fixed.md b/changelog/69442.fixed.md deleted file mode 100644 index d564500b9f82..000000000000 --- a/changelog/69442.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Added a new opt-in `auth_retries` minion option that caps the `AsyncAuth._authenticate()` outer retry loop, so a minion that keeps getting `retry` responses from `sign_in()` can bail out with `SaltClientError` instead of looping silently forever. The default is `0` (unlimited), which preserves the existing 3006.x LTS behavior on upgrade; operators who want the new safety cap set `auth_retries` explicitly to a positive integer. diff --git a/changelog/69526.changed.md b/changelog/69526.changed.md deleted file mode 100644 index 8878ef13aa14..000000000000 --- a/changelog/69526.changed.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade the bundled onedir Python from 3.10.20 to 3.11.15 on the 3006.x branch. Python 3.10 reaches end of security support in October 2026, while Salt 3006.x must ship security fixes through July 2027. Users upgrading from a previous 3006.x package will need to reinstall any Salt extensions installed via `salt-pip` because the onedir `extras-3.10` directory is replaced by `extras-3.11`. diff --git a/changelog/69569.fixed.md b/changelog/69569.fixed.md deleted file mode 100644 index db5348d56ab8..000000000000 --- a/changelog/69569.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed ``saltutil.runner``/``saltutil.wheel`` failing git-backed master functions (e.g. ``git_pillar.update``) with ``failed to stat '/root/.gitconfig'`` when the master runs as a non-root user. Dropping to the master user with ``chugid`` left ``HOME``/``USER``/``LOGNAME`` pointing at the invoking (root) user; these are now aligned with the runas user, and pygit2's cached global-config search path is refreshed. diff --git a/changelog/69571.fixed.md b/changelog/69571.fixed.md deleted file mode 100644 index 70ce5e15fe27..000000000000 --- a/changelog/69571.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Stopped logging a spurious ``random_master is True but there is only one master specified. Ignoring.`` warning once per master at startup for an all-hot multi-master minion. The warning now fires only for a genuinely single-master configuration. diff --git a/changelog/69573.fixed.md b/changelog/69573.fixed.md deleted file mode 100644 index b2495f14e261..000000000000 --- a/changelog/69573.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fix OpenNebula salt-cloud documentation to clarify that VM attributes (memory, cpu, vcpu, etc.) must be specified in the profile configuration, not as command-line arguments to ``salt-cloud -p``. diff --git a/changelog/69575.fixed.md b/changelog/69575.fixed.md deleted file mode 100644 index a27f478e4e47..000000000000 --- a/changelog/69575.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Removed bundled MD5/SHA-1 references that tripped FIPS-compliance scanners against the Salt onedir. The cryptography sdist's top-level ``docs/`` directory (which contains Java/Rust test-vector sources naming weak algorithms, e.g. ``VerifyRSAOAEPSHA2.java``) is now pruned from the onedir during ``pre-archive-cleanup``, and the unused ``__fetch_verify`` helper in the vendored ``bootstrap-salt.sh`` now uses ``sha256sum`` instead of ``md5sum``. diff --git a/changelog/69583.fixed.md b/changelog/69583.fixed.md deleted file mode 100644 index bcb5ea6939bb..000000000000 --- a/changelog/69583.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed `salt.utils.atomicfile.atomic_open` to fsync the temp file before the atomic rename so a crash after the rename cannot expose a truncated or partial file. diff --git a/changelog/69605.fixed.md b/changelog/69605.fixed.md deleted file mode 100644 index 16c8dd54f45a..000000000000 --- a/changelog/69605.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed RPM upgrades leaving a previously-running ``salt-minion`` service stopped. The ``%pre minion`` scriptlet stops the unit so the ownership-restoration chowns don't race a live minion, but the ``%post`` / ``%posttrans`` scriptlets only called ``systemctl try-restart`` - a no-op for an inactive unit. The scriptlets now record the pre-upgrade active state and start the unit unconditionally in ``%posttrans`` when the minion was running at the start of the upgrade transaction. diff --git a/changelog/69612.fixed.md b/changelog/69612.fixed.md deleted file mode 100644 index ede949c33001..000000000000 --- a/changelog/69612.fixed.md +++ /dev/null @@ -1,5 +0,0 @@ -* Relenv 0.22.16 - - 0.22.15: apply cpython#104135 workaround to bundled ssl.py on Windows - - 0.22.15: send relenv runtime debug/warning output to stderr (unblocks - maturin/pyo3 subprocess consumers) - - 0.22.16: pin libffi to cpython-bin-deps on Windows diff --git a/doc/topics/releases/3006.27.md b/doc/topics/releases/3006.27.md index c84262d686f7..d50a39ee39d3 100644 --- a/doc/topics/releases/3006.27.md +++ b/doc/topics/releases/3006.27.md @@ -1,5 +1,5 @@ (release-3006.27)= -# Salt 3006.27 release notes - UNRELEASED +# Salt 3006.27 release notes ## Changelog + +### Changed + +- Upgrade the bundled onedir Python from 3.10.20 to 3.11.15 on the 3006.x branch. Python 3.10 reaches end of security support in October 2026, while Salt 3006.x must ship security fixes through July 2027. Users upgrading from a previous 3006.x package will need to reinstall any Salt extensions installed via `salt-pip` because the onedir `extras-3.10` directory is replaced by `extras-3.11`. [#69526](https://github.com/saltstack/salt/issues/69526) + + +### Fixed + +- Fixed ``salt-ssh`` ``TemplateNotFound`` when a managed Jinja template imports from another template (e.g. ``{% from "formula/map.jinja" import x with context %}``). ``SaltCacheLoader`` now prefers ``opts["_caller_cachedir"]`` (the master's cachedir, where the master-side fileclient caches requested files) over ``opts["cachedir"]`` (the thin minion's remote path) for its Jinja search path. Backport of the 3007.x/3008.x fix. [#31531](https://github.com/saltstack/salt/issues/31531) +- Fixed the ``mysql`` returner ignoring the configured ``mysql.user`` from salt-ssh and other contexts where ``__salt__`` lacks ``config.option``. ``get_returner_options`` fell back to ``__opts__`` and looked up bare attribute names in it, so the master's top-level ``user`` opt (the system user salt runs as, typically ``root``) masked the configured database user and the returner connected as the wrong user. The mysql returner now passes a scoped view of ``__opts__`` containing only ``mysql.*`` keys so the lookup cannot collide. [#32567](https://github.com/saltstack/salt/issues/32567) +- Fixed non-deterministic pillar rendering when multiple ``pillar_roots`` environments matched the same minion. ``Pillar.get_tops`` collected saltenvs into a ``set`` and iterated them in hash order, so top-file processing order depended on ``PYTHONHASHSEED`` and varied per ``salt-call`` invocation. An earlier change made ``_get_envs`` return an ordered list, but the caller wrapped the result back into a ``set``. ``get_tops`` now uses an insertion-ordered dict so iteration follows ``pillar_roots`` config order. [#44937](https://github.com/saltstack/salt/issues/44937) +- Documented the supported approaches for relocating Salt's runtime directories when running rootless: `SALT_HOME`/`SALT_EXTRAS_DIR` at install time, `root_dir` for relative relocation, and the per-key (`pki_dir`, `cachedir`, `log_file`, `pidfile`, `sock_dir`) overrides. [#55971](https://github.com/saltstack/salt/issues/55971) +- Rewrote the non-root / unprivileged user configuration page for onedir packaging, consolidating the older overlapping pages and documenting `SALT_USER`/`SALT_HOME`/`SALT_EXTRAS_DIR`, `root_dir` relocation, and systemd drop-ins. [#59955](https://github.com/saltstack/salt/issues/59955) +- Rewrote the FAQ entry on restarting the minion after upgrade for the onedir packaging era. Removed the broken `policy-rc.d`/`prereq` workaround and documented the supported patterns based on `KillMode=process` in the shipped systemd unit. [#61078](https://github.com/saltstack/salt/issues/61078) +- Updated the packaging docs to explain how to install modules' optional Python dependencies into an onedir install via `salt-pip`. [#64160](https://github.com/saltstack/salt/issues/64160) +- Documented `salt-pip` for installing optional Python dependencies into a onedir Salt install, including the extras directory layout, `SALT_EXTRAS_DIR` relocation, and non-root behavior. [#64291](https://github.com/saltstack/salt/issues/64291) +- Fixed the EC2/cloud metadata grain crashing with ``KeyError: 'headers'`` when ``salt.utils.http.query`` returns an error response (4xx/5xx with a body, e.g. when the IMDS rejects a recursive sub-path lookup). Since 3006.3 the tornado backend has populated ``body`` on HTTPError without also populating ``headers``; the grain now treats the missing ``headers`` key as "no Content-Type information" instead of letting the lookup blow up the whole grain load. [#65184](https://github.com/saltstack/salt/issues/65184) +- Updated the non-root user docs for the onedir-era directory layout (`/opt/saltstack/salt`, `extras-3.N`, package-managed `salt` user) and explained how to switch an existing install over to a different account. [#65243](https://github.com/saltstack/salt/issues/65243) +- Expanded the packaging test guide with single-test invocations, environment variables, common failures, and CI parity notes. [#65253](https://github.com/saltstack/salt/issues/65253) +- Fixed master-initiated jobs failing on Python 3.12+ with "There is no current event loop in thread 'Thread-N (_target)'" by installing an asyncio event loop on the SyncWrapper worker thread. [#65702](https://github.com/saltstack/salt/issues/65702) +- Fixed master 4505 publish port becoming unresponsive under load: TCP `PubServer` now broadcasts to subscribers concurrently so a single slow subscriber no longer stalls the event publisher loop, and the ZeroMQ master PUB socket now enables ZMTP heartbeats so dead subscribers are reaped within seconds instead of waiting for the kernel TCP keepalive. [#66282](https://github.com/saltstack/salt/issues/66282) +- Refreshed the "running as a non-root user" page; replaced outdated 0.9.10-era guidance and added the onedir-aware steps for changing the runtime user. [#66353](https://github.com/saltstack/salt/issues/66353) +- Documented how to install Salt Extensions (`saltext.`) into an onedir install with `salt-pip`, and pointed the developer extensions doc at the install instructions. [#66524](https://github.com/saltstack/salt/issues/66524) +- Fixed ``salt.utils.vmware`` to use the supported ``token``/``tokenType`` arguments instead of the deprecated ``b64token``/``mechanism`` arguments when calling ``pyVim.connect.SmartConnect``. pyvmomi 9 raises an exception when either deprecated argument is truthy, which broke salt-cloud, the ``vsphere`` execution module, and other VMware integrations as soon as pyvmomi was upgraded. [#68211](https://github.com/saltstack/salt/issues/68211) +- Fixed `state.event` (and `salt-run state.event`) crashing with `UnicodeDecodeError` + when an event payload contains raw binary bytes such as the DER-encoded certificate + returned by `x509.sign_remote_certificate`. Undecodable bytes are now base64-encoded + in the JSON output instead of aborting the runner. [#68411](https://github.com/saltstack/salt/issues/68411) +- Fixed ``salt.utils.url.create`` so ``salt://`` URLs built from relative paths round-trip correctly on Python 3.13+, where ``urllib.parse.urlunparse`` no longer emits a ``file:///`` prefix for relative paths. salt-ssh ``file.managed`` ``source: salt://...`` references now resolve as expected on newer-Python targets (e.g. Debian trixie). [#68421](https://github.com/saltstack/salt/issues/68421) +- Fix `set_locale` on Debian 13/14 where systemd-localed is unavailable; fall back to /etc/default/locale update. [#68425](https://github.com/saltstack/salt/issues/68425) +- Fixed a prereq chain bug where a state at the head of a chain (e.g. `state1 -prereq-> state2 -prereq-> state3`) would always run when an intermediate state in the chain always produced changes in test mode (e.g. `test.succeed_with_changes`, `module.run`), even though the tail state of the chain produced no changes. [#68438](https://github.com/saltstack/salt/issues/68438) +- Fixed Debian ``salt-minion`` package failing to upgrade from a non-onedir release. The ``salt-minion.preinst`` script assigned an unused ``PY_VER`` variable by exec'ing ``/opt/saltstack/salt/bin/python3``, which does not exist when upgrading from a pre-onedir Debian package (e.g. ``3006.0+ds-1+240.1``). Under ``set -e`` this aborted the upgrade with ``subprocess returned error exit status 127``. The unused assignment is removed. [#68460](https://github.com/saltstack/salt/issues/68460) +- Fixed salt-master package upgrades resetting state directory ownership and the debconf `salt-master/user` value when the master was configured to run as a non-root user. [#68577](https://github.com/saltstack/salt/issues/68577) +- Don't insert local paths before standard library paths in LazyLoader, preventing sys.path reordering when loader modules are already importable. [#68755](https://github.com/saltstack/salt/issues/68755) +- Fixed Salt minion package upgrades when the minion is configured to run as a non-root user via ``user:`` in ``/etc/salt/minion`` or ``/etc/salt/minion.d/*.conf``. The Debian preinst now reads the configured user before falling back to filesystem ownership, and the rpm pre-minion scriptlet no longer relies on rpm macro directives inside its shell body to communicate the chosen user to the post-minion scriptlet. [#68793](https://github.com/saltstack/salt/issues/68793) +- Fixed a file descriptor leak in the Salt minion: when the single-master sign-in path in ``Minion.eval_master`` raised any exception other than ``SaltClientError`` (for example ``OSError`` from the underlying transport), or when ``transport: detect`` rejected a candidate transport because it could not authenticate, the ``AsyncPubChannel`` that had been created was not closed, leaking its socket. Minions with unstable network connectivity could exhaust the per-process file descriptor limit. The channel is now always closed on failure via a ``try/finally``. [#68901](https://github.com/saltstack/salt/issues/68901) +- Fixed `salt.utils.cache.ContextCache.cache_context` writing the + serialized pillar context to disk with whatever mode the process + umask happened to allow (typically `0o644` on default Linux installs) + inside a `0o755` parent directory. Pillar context can carry + credentials (passwords, vault tokens, API keys), so any local user + could read them; even with the file mode tightened, the directory + mode let any local user `ls` the cache and learn which modules and + external-pillar backends were in use. The cache file is now written + through `tempfile.mkstemp` (creates with `0o600` by default) followed + by atomic `os.replace`, and the parent `context/` directory is + created with `stat.S_IRWXU` (`0o700`). [#69069](https://github.com/saltstack/salt/issues/69069) +- Fixed `kernelpkg.upgrade` on Debian 13 (trixie) and other distros that ship a kernelrelease containing characters outside `[\d.-]` (for example `6.12.86+deb13-amd64`). `kernelpkg_linux_apt._kernel_type` now parses such releases instead of raising `AttributeError: 'NoneType' object has no attribute 'group'`. [#69131](https://github.com/saltstack/salt/issues/69131) +- Added a new opt-in `auth_retries` minion option that caps the `AsyncAuth._authenticate()` outer retry loop, so a minion that keeps getting `retry` responses from `sign_in()` can bail out with `SaltClientError` instead of looping silently forever. The default is `0` (unlimited), which preserves the existing 3006.x LTS behavior on upgrade; operators who want the new safety cap set `auth_retries` explicitly to a positive integer. [#69442](https://github.com/saltstack/salt/issues/69442) +- Fixed ``saltutil.runner``/``saltutil.wheel`` failing git-backed master functions (e.g. ``git_pillar.update``) with ``failed to stat '/root/.gitconfig'`` when the master runs as a non-root user. Dropping to the master user with ``chugid`` left ``HOME``/``USER``/``LOGNAME`` pointing at the invoking (root) user; these are now aligned with the runas user, and pygit2's cached global-config search path is refreshed. [#69569](https://github.com/saltstack/salt/issues/69569) +- Stopped logging a spurious ``random_master is True but there is only one master specified. Ignoring.`` warning once per master at startup for an all-hot multi-master minion. The warning now fires only for a genuinely single-master configuration. [#69571](https://github.com/saltstack/salt/issues/69571) +- Fix OpenNebula salt-cloud documentation to clarify that VM attributes (memory, cpu, vcpu, etc.) must be specified in the profile configuration, not as command-line arguments to ``salt-cloud -p``. [#69573](https://github.com/saltstack/salt/issues/69573) +- Removed bundled MD5/SHA-1 references that tripped FIPS-compliance scanners against the Salt onedir. The cryptography sdist's top-level ``docs/`` directory (which contains Java/Rust test-vector sources naming weak algorithms, e.g. ``VerifyRSAOAEPSHA2.java``) is now pruned from the onedir during ``pre-archive-cleanup``, and the unused ``__fetch_verify`` helper in the vendored ``bootstrap-salt.sh`` now uses ``sha256sum`` instead of ``md5sum``. [#69575](https://github.com/saltstack/salt/issues/69575) +- Fixed `salt.utils.atomicfile.atomic_open` to fsync the temp file before the atomic rename so a crash after the rename cannot expose a truncated or partial file. [#69583](https://github.com/saltstack/salt/issues/69583) +- Fixed RPM upgrades leaving a previously-running ``salt-minion`` service stopped. The ``%pre minion`` scriptlet stops the unit so the ownership-restoration chowns don't race a live minion, but the ``%post`` / ``%posttrans`` scriptlets only called ``systemctl try-restart`` - a no-op for an inactive unit. The scriptlets now record the pre-upgrade active state and start the unit unconditionally in ``%posttrans`` when the minion was running at the start of the upgrade transaction. [#69605](https://github.com/saltstack/salt/issues/69605) +- * Relenv 0.22.16 + - 0.22.15: apply cpython#104135 workaround to bundled ssl.py on Windows + - 0.22.15: send relenv runtime debug/warning output to stderr (unblocks + maturin/pyo3 subprocess consumers) + - 0.22.16: pin libffi to cpython-bin-deps on Windows [#69612](https://github.com/saltstack/salt/issues/69612) + + +### Added + +- Added `tools/audit_doc_links.py` and a weekly `doc-linkcheck` workflow that wrap Sphinx linkcheck, strip the catch-all ignore, and emit a CSV report so external URL regressions in the docs can be tracked without gating PR CI. [#60720](https://github.com/saltstack/salt/issues/60720) diff --git a/pkg/debian/changelog b/pkg/debian/changelog index 54602459f1eb..d48f57b9eece 100644 --- a/pkg/debian/changelog +++ b/pkg/debian/changelog @@ -1,3 +1,72 @@ +salt (3006.27) stable; urgency=medium + + + # Changed + + * Upgrade the bundled onedir Python from 3.10.20 to 3.11.15 on the 3006.x branch. Python 3.10 reaches end of security support in October 2026, while Salt 3006.x must ship security fixes through July 2027. Users upgrading from a previous 3006.x package will need to reinstall any Salt extensions installed via `salt-pip` because the onedir `extras-3.10` directory is replaced by `extras-3.11`. [#69526](https://github.com/saltstack/salt/issues/69526) + + # Fixed + + * Fixed ``salt-ssh`` ``TemplateNotFound`` when a managed Jinja template imports from another template (e.g. ``{% from "formula/map.jinja" import x with context %}``). ``SaltCacheLoader`` now prefers ``opts["_caller_cachedir"]`` (the master's cachedir, where the master-side fileclient caches requested files) over ``opts["cachedir"]`` (the thin minion's remote path) for its Jinja search path. Backport of the 3007.x/3008.x fix. [#31531](https://github.com/saltstack/salt/issues/31531) + * Fixed the ``mysql`` returner ignoring the configured ``mysql.user`` from salt-ssh and other contexts where ``__salt__`` lacks ``config.option``. ``get_returner_options`` fell back to ``__opts__`` and looked up bare attribute names in it, so the master's top-level ``user`` opt (the system user salt runs as, typically ``root``) masked the configured database user and the returner connected as the wrong user. The mysql returner now passes a scoped view of ``__opts__`` containing only ``mysql.*`` keys so the lookup cannot collide. [#32567](https://github.com/saltstack/salt/issues/32567) + * Fixed non-deterministic pillar rendering when multiple ``pillar_roots`` environments matched the same minion. ``Pillar.get_tops`` collected saltenvs into a ``set`` and iterated them in hash order, so top-file processing order depended on ``PYTHONHASHSEED`` and varied per ``salt-call`` invocation. An earlier change made ``_get_envs`` return an ordered list, but the caller wrapped the result back into a ``set``. ``get_tops`` now uses an insertion-ordered dict so iteration follows ``pillar_roots`` config order. [#44937](https://github.com/saltstack/salt/issues/44937) + * Documented the supported approaches for relocating Salt's runtime directories when running rootless: `SALT_HOME`/`SALT_EXTRAS_DIR` at install time, `root_dir` for relative relocation, and the per-key (`pki_dir`, `cachedir`, `log_file`, `pidfile`, `sock_dir`) overrides. [#55971](https://github.com/saltstack/salt/issues/55971) + * Rewrote the non-root / unprivileged user configuration page for onedir packaging, consolidating the older overlapping pages and documenting `SALT_USER`/`SALT_HOME`/`SALT_EXTRAS_DIR`, `root_dir` relocation, and systemd drop-ins. [#59955](https://github.com/saltstack/salt/issues/59955) + * Rewrote the FAQ entry on restarting the minion after upgrade for the onedir packaging era. Removed the broken `policy-rc.d`/`prereq` workaround and documented the supported patterns based on `KillMode=process` in the shipped systemd unit. [#61078](https://github.com/saltstack/salt/issues/61078) + * Updated the packaging docs to explain how to install modules' optional Python dependencies into an onedir install via `salt-pip`. [#64160](https://github.com/saltstack/salt/issues/64160) + * Documented `salt-pip` for installing optional Python dependencies into a onedir Salt install, including the extras directory layout, `SALT_EXTRAS_DIR` relocation, and non-root behavior. [#64291](https://github.com/saltstack/salt/issues/64291) + * Fixed the EC2/cloud metadata grain crashing with ``KeyError: 'headers'`` when ``salt.utils.http.query`` returns an error response (4xx/5xx with a body, e.g. when the IMDS rejects a recursive sub-path lookup). Since 3006.3 the tornado backend has populated ``body`` on HTTPError without also populating ``headers``; the grain now treats the missing ``headers`` key as "no Content-Type information" instead of letting the lookup blow up the whole grain load. [#65184](https://github.com/saltstack/salt/issues/65184) + * Updated the non-root user docs for the onedir-era directory layout (`/opt/saltstack/salt`, `extras-3.N`, package-managed `salt` user) and explained how to switch an existing install over to a different account. [#65243](https://github.com/saltstack/salt/issues/65243) + * Expanded the packaging test guide with single-test invocations, environment variables, common failures, and CI parity notes. [#65253](https://github.com/saltstack/salt/issues/65253) + * Fixed master-initiated jobs failing on Python 3.12+ with "There is no current event loop in thread 'Thread-N (_target)'" by installing an asyncio event loop on the SyncWrapper worker thread. [#65702](https://github.com/saltstack/salt/issues/65702) + * Fixed master 4505 publish port becoming unresponsive under load: TCP `PubServer` now broadcasts to subscribers concurrently so a single slow subscriber no longer stalls the event publisher loop, and the ZeroMQ master PUB socket now enables ZMTP heartbeats so dead subscribers are reaped within seconds instead of waiting for the kernel TCP keepalive. [#66282](https://github.com/saltstack/salt/issues/66282) + * Refreshed the "running as a non-root user" page; replaced outdated 0.9.10-era guidance and added the onedir-aware steps for changing the runtime user. [#66353](https://github.com/saltstack/salt/issues/66353) + * Documented how to install Salt Extensions (`saltext.`) into an onedir install with `salt-pip`, and pointed the developer extensions doc at the install instructions. [#66524](https://github.com/saltstack/salt/issues/66524) + * Fixed ``salt.utils.vmware`` to use the supported ``token``/``tokenType`` arguments instead of the deprecated ``b64token``/``mechanism`` arguments when calling ``pyVim.connect.SmartConnect``. pyvmomi 9 raises an exception when either deprecated argument is truthy, which broke salt-cloud, the ``vsphere`` execution module, and other VMware integrations as soon as pyvmomi was upgraded. [#68211](https://github.com/saltstack/salt/issues/68211) + * Fixed `state.event` (and `salt-run state.event`) crashing with `UnicodeDecodeError` + when an event payload contains raw binary bytes such as the DER*encoded certificate + returned by `x509.sign_remote_certificate`. Undecodable bytes are now base64*encoded + in the JSON output instead of aborting the runner. [#68411](https://github.com/saltstack/salt/issues/68411) + * Fixed ``salt.utils.url.create`` so ``salt://`` URLs built from relative paths round-trip correctly on Python 3.13+, where ``urllib.parse.urlunparse`` no longer emits a ``file:///`` prefix for relative paths. salt-ssh ``file.managed`` ``source: salt://...`` references now resolve as expected on newer-Python targets (e.g. Debian trixie). [#68421](https://github.com/saltstack/salt/issues/68421) + * Fix `set_locale` on Debian 13/14 where systemd-localed is unavailable; fall back to /etc/default/locale update. [#68425](https://github.com/saltstack/salt/issues/68425) + * Fixed a prereq chain bug where a state at the head of a chain (e.g. `state1 -prereq-> state2 -prereq-> state3`) would always run when an intermediate state in the chain always produced changes in test mode (e.g. `test.succeed_with_changes`, `module.run`), even though the tail state of the chain produced no changes. [#68438](https://github.com/saltstack/salt/issues/68438) + * Fixed Debian ``salt-minion`` package failing to upgrade from a non-onedir release. The ``salt-minion.preinst`` script assigned an unused ``PY_VER`` variable by exec'ing ``/opt/saltstack/salt/bin/python3``, which does not exist when upgrading from a pre-onedir Debian package (e.g. ``3006.0+ds-1+240.1``). Under ``set -e`` this aborted the upgrade with ``subprocess returned error exit status 127``. The unused assignment is removed. [#68460](https://github.com/saltstack/salt/issues/68460) + * Fixed salt-master package upgrades resetting state directory ownership and the debconf `salt-master/user` value when the master was configured to run as a non-root user. [#68577](https://github.com/saltstack/salt/issues/68577) + * Don't insert local paths before standard library paths in LazyLoader, preventing sys.path reordering when loader modules are already importable. [#68755](https://github.com/saltstack/salt/issues/68755) + * Fixed Salt minion package upgrades when the minion is configured to run as a non-root user via ``user:`` in ``/etc/salt/minion`` or ``/etc/salt/minion.d/*.conf``. The Debian preinst now reads the configured user before falling back to filesystem ownership, and the rpm pre-minion scriptlet no longer relies on rpm macro directives inside its shell body to communicate the chosen user to the post-minion scriptlet. [#68793](https://github.com/saltstack/salt/issues/68793) + * Fixed a file descriptor leak in the Salt minion: when the single-master sign-in path in ``Minion.eval_master`` raised any exception other than ``SaltClientError`` (for example ``OSError`` from the underlying transport), or when ``transport: detect`` rejected a candidate transport because it could not authenticate, the ``AsyncPubChannel`` that had been created was not closed, leaking its socket. Minions with unstable network connectivity could exhaust the per-process file descriptor limit. The channel is now always closed on failure via a ``try/finally``. [#68901](https://github.com/saltstack/salt/issues/68901) + * Fixed `salt.utils.cache.ContextCache.cache_context` writing the + serialized pillar context to disk with whatever mode the process + umask happened to allow (typically `0o644` on default Linux installs) + inside a `0o755` parent directory. Pillar context can carry + credentials (passwords, vault tokens, API keys), so any local user + could read them; even with the file mode tightened, the directory + mode let any local user `ls` the cache and learn which modules and + external*pillar backends were in use. The cache file is now written + through `tempfile.mkstemp` (creates with `0o600` by default) followed + by atomic `os.replace`, and the parent `context/` directory is + created with `stat.S_IRWXU` (`0o700`). [#69069](https://github.com/saltstack/salt/issues/69069) + * Fixed `kernelpkg.upgrade` on Debian 13 (trixie) and other distros that ship a kernelrelease containing characters outside `[\d.-]` (for example `6.12.86+deb13-amd64`). `kernelpkg_linux_apt._kernel_type` now parses such releases instead of raising `AttributeError: 'NoneType' object has no attribute 'group'`. [#69131](https://github.com/saltstack/salt/issues/69131) + * Added a new opt-in `auth_retries` minion option that caps the `AsyncAuth._authenticate()` outer retry loop, so a minion that keeps getting `retry` responses from `sign_in()` can bail out with `SaltClientError` instead of looping silently forever. The default is `0` (unlimited), which preserves the existing 3006.x LTS behavior on upgrade; operators who want the new safety cap set `auth_retries` explicitly to a positive integer. [#69442](https://github.com/saltstack/salt/issues/69442) + * Fixed ``saltutil.runner``/``saltutil.wheel`` failing git-backed master functions (e.g. ``git_pillar.update``) with ``failed to stat '/root/.gitconfig'`` when the master runs as a non-root user. Dropping to the master user with ``chugid`` left ``HOME``/``USER``/``LOGNAME`` pointing at the invoking (root) user; these are now aligned with the runas user, and pygit2's cached global-config search path is refreshed. [#69569](https://github.com/saltstack/salt/issues/69569) + * Stopped logging a spurious ``random_master is True but there is only one master specified. Ignoring.`` warning once per master at startup for an all-hot multi-master minion. The warning now fires only for a genuinely single-master configuration. [#69571](https://github.com/saltstack/salt/issues/69571) + * Fix OpenNebula salt-cloud documentation to clarify that VM attributes (memory, cpu, vcpu, etc.) must be specified in the profile configuration, not as command-line arguments to ``salt-cloud -p``. [#69573](https://github.com/saltstack/salt/issues/69573) + * Removed bundled MD5/SHA-1 references that tripped FIPS-compliance scanners against the Salt onedir. The cryptography sdist's top-level ``docs/`` directory (which contains Java/Rust test-vector sources naming weak algorithms, e.g. ``VerifyRSAOAEPSHA2.java``) is now pruned from the onedir during ``pre-archive-cleanup``, and the unused ``__fetch_verify`` helper in the vendored ``bootstrap-salt.sh`` now uses ``sha256sum`` instead of ``md5sum``. [#69575](https://github.com/saltstack/salt/issues/69575) + * Fixed `salt.utils.atomicfile.atomic_open` to fsync the temp file before the atomic rename so a crash after the rename cannot expose a truncated or partial file. [#69583](https://github.com/saltstack/salt/issues/69583) + * Fixed RPM upgrades leaving a previously-running ``salt-minion`` service stopped. The ``%pre minion`` scriptlet stops the unit so the ownership-restoration chowns don't race a live minion, but the ``%post`` / ``%posttrans`` scriptlets only called ``systemctl try-restart`` - a no-op for an inactive unit. The scriptlets now record the pre-upgrade active state and start the unit unconditionally in ``%posttrans`` when the minion was running at the start of the upgrade transaction. [#69605](https://github.com/saltstack/salt/issues/69605) + * * Relenv 0.22.16 + * 0.22.15: apply cpython#104135 workaround to bundled ssl.py on Windows + * 0.22.15: send relenv runtime debug/warning output to stderr (unblocks + maturin/pyo3 subprocess consumers) + * 0.22.16: pin libffi to cpython-bin-deps on Windows [#69612](https://github.com/saltstack/salt/issues/69612) + + # Added + + * Added `tools/audit_doc_links.py` and a weekly `doc-linkcheck` workflow that wrap Sphinx linkcheck, strip the catch-all ignore, and emit a CSV report so external URL regressions in the docs can be tracked without gating PR CI. [#60720](https://github.com/saltstack/salt/issues/60720) + + + -- Salt Project Packaging Wed, 01 Jul 2026 06:57:37 +0000 + salt (3006.26) stable; urgency=medium diff --git a/pkg/rpm/salt.spec b/pkg/rpm/salt.spec index 9bad1dd9ac32..72689fea8d10 100644 --- a/pkg/rpm/salt.spec +++ b/pkg/rpm/salt.spec @@ -40,7 +40,7 @@ %define fish_dir %{_datadir}/fish/vendor_functions.d Name: salt -Version: 3006.26 +Version: 3006.27 Release: 0 Summary: A parallel remote execution system Group: System Environment/Daemons @@ -981,6 +981,72 @@ if [ $1 -ge 1 ] ; then fi %changelog +* Wed Jul 01 2026 Salt Project Packaging - 3006.27 + +# Changed + +- Upgrade the bundled onedir Python from 3.10.20 to 3.11.15 on the 3006.x branch. Python 3.10 reaches end of security support in October 2026, while Salt 3006.x must ship security fixes through July 2027. Users upgrading from a previous 3006.x package will need to reinstall any Salt extensions installed via `salt-pip` because the onedir `extras-3.10` directory is replaced by `extras-3.11`. [#69526](https://github.com/saltstack/salt/issues/69526) + +# Fixed + +- Fixed ``salt-ssh`` ``TemplateNotFound`` when a managed Jinja template imports from another template (e.g. ``{% from "formula/map.jinja" import x with context %}``). ``SaltCacheLoader`` now prefers ``opts["_caller_cachedir"]`` (the master's cachedir, where the master-side fileclient caches requested files) over ``opts["cachedir"]`` (the thin minion's remote path) for its Jinja search path. Backport of the 3007.x/3008.x fix. [#31531](https://github.com/saltstack/salt/issues/31531) +- Fixed the ``mysql`` returner ignoring the configured ``mysql.user`` from salt-ssh and other contexts where ``__salt__`` lacks ``config.option``. ``get_returner_options`` fell back to ``__opts__`` and looked up bare attribute names in it, so the master's top-level ``user`` opt (the system user salt runs as, typically ``root``) masked the configured database user and the returner connected as the wrong user. The mysql returner now passes a scoped view of ``__opts__`` containing only ``mysql.*`` keys so the lookup cannot collide. [#32567](https://github.com/saltstack/salt/issues/32567) +- Fixed non-deterministic pillar rendering when multiple ``pillar_roots`` environments matched the same minion. ``Pillar.get_tops`` collected saltenvs into a ``set`` and iterated them in hash order, so top-file processing order depended on ``PYTHONHASHSEED`` and varied per ``salt-call`` invocation. An earlier change made ``_get_envs`` return an ordered list, but the caller wrapped the result back into a ``set``. ``get_tops`` now uses an insertion-ordered dict so iteration follows ``pillar_roots`` config order. [#44937](https://github.com/saltstack/salt/issues/44937) +- Documented the supported approaches for relocating Salt's runtime directories when running rootless: `SALT_HOME`/`SALT_EXTRAS_DIR` at install time, `root_dir` for relative relocation, and the per-key (`pki_dir`, `cachedir`, `log_file`, `pidfile`, `sock_dir`) overrides. [#55971](https://github.com/saltstack/salt/issues/55971) +- Rewrote the non-root / unprivileged user configuration page for onedir packaging, consolidating the older overlapping pages and documenting `SALT_USER`/`SALT_HOME`/`SALT_EXTRAS_DIR`, `root_dir` relocation, and systemd drop-ins. [#59955](https://github.com/saltstack/salt/issues/59955) +- Rewrote the FAQ entry on restarting the minion after upgrade for the onedir packaging era. Removed the broken `policy-rc.d`/`prereq` workaround and documented the supported patterns based on `KillMode=process` in the shipped systemd unit. [#61078](https://github.com/saltstack/salt/issues/61078) +- Updated the packaging docs to explain how to install modules' optional Python dependencies into an onedir install via `salt-pip`. [#64160](https://github.com/saltstack/salt/issues/64160) +- Documented `salt-pip` for installing optional Python dependencies into a onedir Salt install, including the extras directory layout, `SALT_EXTRAS_DIR` relocation, and non-root behavior. [#64291](https://github.com/saltstack/salt/issues/64291) +- Fixed the EC2/cloud metadata grain crashing with ``KeyError: 'headers'`` when ``salt.utils.http.query`` returns an error response (4xx/5xx with a body, e.g. when the IMDS rejects a recursive sub-path lookup). Since 3006.3 the tornado backend has populated ``body`` on HTTPError without also populating ``headers``; the grain now treats the missing ``headers`` key as "no Content-Type information" instead of letting the lookup blow up the whole grain load. [#65184](https://github.com/saltstack/salt/issues/65184) +- Updated the non-root user docs for the onedir-era directory layout (`/opt/saltstack/salt`, `extras-3.N`, package-managed `salt` user) and explained how to switch an existing install over to a different account. [#65243](https://github.com/saltstack/salt/issues/65243) +- Expanded the packaging test guide with single-test invocations, environment variables, common failures, and CI parity notes. [#65253](https://github.com/saltstack/salt/issues/65253) +- Fixed master-initiated jobs failing on Python 3.12+ with "There is no current event loop in thread 'Thread-N (_target)'" by installing an asyncio event loop on the SyncWrapper worker thread. [#65702](https://github.com/saltstack/salt/issues/65702) +- Fixed master 4505 publish port becoming unresponsive under load: TCP `PubServer` now broadcasts to subscribers concurrently so a single slow subscriber no longer stalls the event publisher loop, and the ZeroMQ master PUB socket now enables ZMTP heartbeats so dead subscribers are reaped within seconds instead of waiting for the kernel TCP keepalive. [#66282](https://github.com/saltstack/salt/issues/66282) +- Refreshed the "running as a non-root user" page; replaced outdated 0.9.10-era guidance and added the onedir-aware steps for changing the runtime user. [#66353](https://github.com/saltstack/salt/issues/66353) +- Documented how to install Salt Extensions (`saltext.`) into an onedir install with `salt-pip`, and pointed the developer extensions doc at the install instructions. [#66524](https://github.com/saltstack/salt/issues/66524) +- Fixed ``salt.utils.vmware`` to use the supported ``token``/``tokenType`` arguments instead of the deprecated ``b64token``/``mechanism`` arguments when calling ``pyVim.connect.SmartConnect``. pyvmomi 9 raises an exception when either deprecated argument is truthy, which broke salt-cloud, the ``vsphere`` execution module, and other VMware integrations as soon as pyvmomi was upgraded. [#68211](https://github.com/saltstack/salt/issues/68211) +- Fixed `state.event` (and `salt-run state.event`) crashing with `UnicodeDecodeError` + when an event payload contains raw binary bytes such as the DER-encoded certificate + returned by `x509.sign_remote_certificate`. Undecodable bytes are now base64-encoded + in the JSON output instead of aborting the runner. [#68411](https://github.com/saltstack/salt/issues/68411) +- Fixed ``salt.utils.url.create`` so ``salt://`` URLs built from relative paths round-trip correctly on Python 3.13+, where ``urllib.parse.urlunparse`` no longer emits a ``file:///`` prefix for relative paths. salt-ssh ``file.managed`` ``source: salt://...`` references now resolve as expected on newer-Python targets (e.g. Debian trixie). [#68421](https://github.com/saltstack/salt/issues/68421) +- Fix `set_locale` on Debian 13/14 where systemd-localed is unavailable; fall back to /etc/default/locale update. [#68425](https://github.com/saltstack/salt/issues/68425) +- Fixed a prereq chain bug where a state at the head of a chain (e.g. `state1 -prereq-> state2 -prereq-> state3`) would always run when an intermediate state in the chain always produced changes in test mode (e.g. `test.succeed_with_changes`, `module.run`), even though the tail state of the chain produced no changes. [#68438](https://github.com/saltstack/salt/issues/68438) +- Fixed Debian ``salt-minion`` package failing to upgrade from a non-onedir release. The ``salt-minion.preinst`` script assigned an unused ``PY_VER`` variable by exec'ing ``/opt/saltstack/salt/bin/python3``, which does not exist when upgrading from a pre-onedir Debian package (e.g. ``3006.0+ds-1+240.1``). Under ``set -e`` this aborted the upgrade with ``subprocess returned error exit status 127``. The unused assignment is removed. [#68460](https://github.com/saltstack/salt/issues/68460) +- Fixed salt-master package upgrades resetting state directory ownership and the debconf `salt-master/user` value when the master was configured to run as a non-root user. [#68577](https://github.com/saltstack/salt/issues/68577) +- Don't insert local paths before standard library paths in LazyLoader, preventing sys.path reordering when loader modules are already importable. [#68755](https://github.com/saltstack/salt/issues/68755) +- Fixed Salt minion package upgrades when the minion is configured to run as a non-root user via ``user:`` in ``/etc/salt/minion`` or ``/etc/salt/minion.d/*.conf``. The Debian preinst now reads the configured user before falling back to filesystem ownership, and the rpm pre-minion scriptlet no longer relies on rpm macro directives inside its shell body to communicate the chosen user to the post-minion scriptlet. [#68793](https://github.com/saltstack/salt/issues/68793) +- Fixed a file descriptor leak in the Salt minion: when the single-master sign-in path in ``Minion.eval_master`` raised any exception other than ``SaltClientError`` (for example ``OSError`` from the underlying transport), or when ``transport: detect`` rejected a candidate transport because it could not authenticate, the ``AsyncPubChannel`` that had been created was not closed, leaking its socket. Minions with unstable network connectivity could exhaust the per-process file descriptor limit. The channel is now always closed on failure via a ``try/finally``. [#68901](https://github.com/saltstack/salt/issues/68901) +- Fixed `salt.utils.cache.ContextCache.cache_context` writing the + serialized pillar context to disk with whatever mode the process + umask happened to allow (typically `0o644` on default Linux installs) + inside a `0o755` parent directory. Pillar context can carry + credentials (passwords, vault tokens, API keys), so any local user + could read them; even with the file mode tightened, the directory + mode let any local user `ls` the cache and learn which modules and + external-pillar backends were in use. The cache file is now written + through `tempfile.mkstemp` (creates with `0o600` by default) followed + by atomic `os.replace`, and the parent `context/` directory is + created with `stat.S_IRWXU` (`0o700`). [#69069](https://github.com/saltstack/salt/issues/69069) +- Fixed `kernelpkg.upgrade` on Debian 13 (trixie) and other distros that ship a kernelrelease containing characters outside `[\d.-]` (for example `6.12.86+deb13-amd64`). `kernelpkg_linux_apt._kernel_type` now parses such releases instead of raising `AttributeError: 'NoneType' object has no attribute 'group'`. [#69131](https://github.com/saltstack/salt/issues/69131) +- Added a new opt-in `auth_retries` minion option that caps the `AsyncAuth._authenticate()` outer retry loop, so a minion that keeps getting `retry` responses from `sign_in()` can bail out with `SaltClientError` instead of looping silently forever. The default is `0` (unlimited), which preserves the existing 3006.x LTS behavior on upgrade; operators who want the new safety cap set `auth_retries` explicitly to a positive integer. [#69442](https://github.com/saltstack/salt/issues/69442) +- Fixed ``saltutil.runner``/``saltutil.wheel`` failing git-backed master functions (e.g. ``git_pillar.update``) with ``failed to stat '/root/.gitconfig'`` when the master runs as a non-root user. Dropping to the master user with ``chugid`` left ``HOME``/``USER``/``LOGNAME`` pointing at the invoking (root) user; these are now aligned with the runas user, and pygit2's cached global-config search path is refreshed. [#69569](https://github.com/saltstack/salt/issues/69569) +- Stopped logging a spurious ``random_master is True but there is only one master specified. Ignoring.`` warning once per master at startup for an all-hot multi-master minion. The warning now fires only for a genuinely single-master configuration. [#69571](https://github.com/saltstack/salt/issues/69571) +- Fix OpenNebula salt-cloud documentation to clarify that VM attributes (memory, cpu, vcpu, etc.) must be specified in the profile configuration, not as command-line arguments to ``salt-cloud -p``. [#69573](https://github.com/saltstack/salt/issues/69573) +- Removed bundled MD5/SHA-1 references that tripped FIPS-compliance scanners against the Salt onedir. The cryptography sdist's top-level ``docs/`` directory (which contains Java/Rust test-vector sources naming weak algorithms, e.g. ``VerifyRSAOAEPSHA2.java``) is now pruned from the onedir during ``pre-archive-cleanup``, and the unused ``__fetch_verify`` helper in the vendored ``bootstrap-salt.sh`` now uses ``sha256sum`` instead of ``md5sum``. [#69575](https://github.com/saltstack/salt/issues/69575) +- Fixed `salt.utils.atomicfile.atomic_open` to fsync the temp file before the atomic rename so a crash after the rename cannot expose a truncated or partial file. [#69583](https://github.com/saltstack/salt/issues/69583) +- Fixed RPM upgrades leaving a previously-running ``salt-minion`` service stopped. The ``%pre minion`` scriptlet stops the unit so the ownership-restoration chowns don't race a live minion, but the ``%post`` / ``%posttrans`` scriptlets only called ``systemctl try-restart`` - a no-op for an inactive unit. The scriptlets now record the pre-upgrade active state and start the unit unconditionally in ``%posttrans`` when the minion was running at the start of the upgrade transaction. [#69605](https://github.com/saltstack/salt/issues/69605) +- * Relenv 0.22.16 + - 0.22.15: apply cpython#104135 workaround to bundled ssl.py on Windows + - 0.22.15: send relenv runtime debug/warning output to stderr (unblocks + maturin/pyo3 subprocess consumers) + - 0.22.16: pin libffi to cpython-bin-deps on Windows [#69612](https://github.com/saltstack/salt/issues/69612) + +# Added + +- Added `tools/audit_doc_links.py` and a weekly `doc-linkcheck` workflow that wrap Sphinx linkcheck, strip the catch-all ignore, and emit a CSV report so external URL regressions in the docs can be tracked without gating PR CI. [#60720](https://github.com/saltstack/salt/issues/60720) + + * Wed Jun 24 2026 Salt Project Packaging - 3006.26 # Removed From 6a22c1820716c89a782cf11df4b62904a3098083 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 1 Jul 2026 03:13:01 -0700 Subject: [PATCH 002/469] Fix prereq chain regression in nested prereq DAG setup When a prereq chain is set up such that one state prereq's another state that itself prereq's a third state (e.g. state1 --prereq--> state2 --prereq--> state3), the intermediate state's own prereq check node was created without its own prereq requirements. This allowed the intermediate state to be evaluated in test mode independently of the tail of the chain, so any state that always proposes changes in test mode (e.g. test.succeed_with_changes, module.run) caused the head of the chain to run even when the tail produced no changes. When adding a new prereq requisite (_add_prereq) from a chunk that already has its own prereq check node (because another state prereq's it), also register a PREREQ edge from the requisite's prereq check node into the chunk's own prereq check node. This ensures the intermediate prereq check waits for the tail's prereq check outcome before deciding whether to run in test mode. Also confirms the pre-existing regression test #68438 now passes on Windows and Linux. Fixes #68438 --- salt/utils/requisite.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/salt/utils/requisite.py b/salt/utils/requisite.py index 66a5cda4d73c..910c5db16043 100644 --- a/salt/utils/requisite.py +++ b/salt/utils/requisite.py @@ -373,6 +373,19 @@ def _add_prereq(self, node_tag: str, req_tag: str): ) self.dag.add_edge(prereq_check_node, node_tag, RequisiteType.PREREQ) self.dag.add_edge(node_tag, req_tag, RequisiteType.REQUIRE) + # If node_tag already has its own prereq_check_node (because it + # is being prereq'd by another state), that prereq check must + # also depend on req_tag's prereq_check so nested prereq chains + # are evaluated in dependency order. Otherwise node_tag's prereq + # check would run in test mode before req_tag has been evaluated + # and unconditionally show pending changes. + node_prereq_check_node = self._get_prereq_node_tag(node_tag) + if self.dag.nodes.get(node_prereq_check_node): + self.dag.add_edge( + prereq_check_node, + node_prereq_check_node, + RequisiteType.PREREQ, + ) def _add_reqs( self, From 1c30442d39acd73860fb47780d26eb4b9ef9097a Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 1 Jul 2026 03:24:17 -0700 Subject: [PATCH 003/469] Fix Rocky Linux 9 unit zeromq 4 CI failures on 3007.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the ``Test Salt / Rocky Linux 9 unit zeromq 4`` CI job to green after the 3006.x→3007.x→3008.x merge-forward chain pulled 3006.x-only regression tests into 3007.x whose expectations don't match the 3007.x runtime API surface. - ``test_verify_master_accepts_cached_key_with_whitespace_drift``, ``test_verify_master_caches_clean_key_on_first_contact``, ``test_authenticate_caps_retry_loop_with_auth_retries_69442``, ``test_authenticate_default_does_not_cap_retry_loop_69442``: switch from the removed 3006.x ``crypt.gen_keys(pki_dir, name, keysize)`` signature to the 3007.x ``crypt.write_keys(...)`` equivalent, and add ``keys.cache_driver`` to opts so ``AsyncAuth.__singleton_init__`` can construct the keystore cache. - ``test_gen_signature_signs_clean_key`` and ``test_gen_signature_signs_clean_key_trailing_newline``: skip on 3007.x. The module-level ``salt.crypt.gen_signature`` was removed by the master-pki cache refactor; the replacement ``MasterKeys.gen_signature`` signs ``pub.public_bytes()`` from a key object rather than the raw file content, so the #68930 whitespace- drift bug the tests were written against is not reachable on 3007.x. - ``test_maintenance_duration``: add ``eauth_tokens.cache_driver``, ``eauth_tokens.cluster_id``, and ``cluster_id`` to the test's opts dict. ``Maintenance._post_fork_init`` now constructs a long-lived ``LoadAuth`` (as part of the memory-leak fix that caches it across loop iterations); ``LoadAuth.__init__`` reads those keys. - ``test_minion_manager_stop_unblocks_resolve_dns_69466``: assert on either ``io_loop.create_task`` or ``io_loop.add_callback`` being called once. 3007.x refactored ``MinionManager.stop()`` to use ``create_task`` instead of ``add_callback``; the 3006.x-origin test hard-coded the older form. - ``test_event_unpack_with_SaltDeserializationError``: assert on the new debug-level "skipping malformed event (deserialization error)" message that the memory-leak hardening emits from ``SaltEvent._get_event`` instead of the pre-hardening ``log.error("Unable to deserialize received event")`` call the test originally targeted. The hardening intentionally demotes the log level so a single bad IPC frame cannot spam the operator log. --- changelog/69624.fixed.md | 1 + tests/pytests/unit/test_crypt.py | 30 +++++++++++++++++--- tests/pytests/unit/test_master.py | 7 +++++ tests/pytests/unit/test_minion.py | 10 ++++++- tests/pytests/unit/utils/event/test_event.py | 17 +++++++---- 5 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 changelog/69624.fixed.md diff --git a/changelog/69624.fixed.md b/changelog/69624.fixed.md new file mode 100644 index 000000000000..94ac004dcaf9 --- /dev/null +++ b/changelog/69624.fixed.md @@ -0,0 +1 @@ +Restore Rocky Linux 9 ``unit zeromq 4`` CI green after the 3006.x→3007.x merge-forward pulled in 3006.x-only regression tests that don't fit the 3007.x runtime APIs. Adapt the ``test_verify_master_*``, ``test_authenticate_*_69442``, ``test_maintenance_duration``, ``test_minion_manager_stop_unblocks_resolve_dns_69466``, and ``test_event_unpack_with_SaltDeserializationError`` tests to the 3007.x ``crypt.write_keys()`` / ``MasterKeys.gen_signature`` / ``io_loop.create_task`` / ``LoadAuth`` init / debug-log-on-skip contracts; skip the ``test_gen_signature_signs_clean_key`` variants because the 3007.x cache-refactored ``MasterKeys.gen_signature`` signs ``pub.public_bytes()`` and cannot exhibit the #68930 whitespace-drift bug. diff --git a/tests/pytests/unit/test_crypt.py b/tests/pytests/unit/test_crypt.py index ad34c5e91dbf..fe8675add0f7 100644 --- a/tests/pytests/unit/test_crypt.py +++ b/tests/pytests/unit/test_crypt.py @@ -289,11 +289,12 @@ def test_verify_master_accepts_cached_key_with_whitespace_drift( "keysize": 4096, "acceptance_wait_time": 60, "acceptance_wait_time_max": 60, + "keys.cache_driver": "localfs_key", "open_mode": False, "verify_master_pubkey_sign": False, "always_verify_signature": False, } - crypt.gen_keys(pki_dir, "minion", opts["keysize"]) + crypt.write_keys(str(pki_dir), "minion", opts["keysize"]) auth = crypt.AsyncAuth(opts, io_loop) @@ -340,11 +341,12 @@ def test_verify_master_caches_clean_key_on_first_contact( "keysize": 4096, "acceptance_wait_time": 60, "acceptance_wait_time_max": 60, + "keys.cache_driver": "localfs_key", "open_mode": False, "verify_master_pubkey_sign": False, "always_verify_signature": False, } - crypt.gen_keys(pki_dir, "minion", opts["keysize"]) + crypt.write_keys(str(pki_dir), "minion", opts["keysize"]) auth = crypt.AsyncAuth(opts, io_loop) @@ -369,6 +371,15 @@ def test_verify_master_caches_clean_key_on_first_contact( assert m_pub_fn.read_text() == cached_pub_key +@pytest.mark.skipif( + not hasattr(crypt, "gen_signature"), + reason=( + "salt.crypt.gen_signature is a MasterKeys method on 3007.x. " + "The refactored code path signs pub.public_bytes() from a key " + "object rather than raw file content, so the #68930 whitespace-" + "drift bug does not apply." + ), +) @pytest.mark.parametrize("linesep", ["\r\n", "\r", "\n"]) def test_gen_signature_signs_clean_key(key_data, linesep): """ @@ -391,6 +402,15 @@ def test_gen_signature_signs_clean_key(key_data, linesep): assert signed_content == expected +@pytest.mark.skipif( + not hasattr(crypt, "gen_signature"), + reason=( + "salt.crypt.gen_signature is a MasterKeys method on 3007.x. " + "The refactored code path signs pub.public_bytes() from a key " + "object rather than raw file content, so the #68930 whitespace-" + "drift bug does not apply." + ), +) @pytest.mark.parametrize("linesep", ["\r\n", "\r", "\n"]) def test_gen_signature_signs_clean_key_trailing_newline(key_data, linesep): """ @@ -446,9 +466,10 @@ async def test_authenticate_caps_retry_loop_with_auth_retries_69442( # observing the cap. "acceptance_wait_time": 0, "acceptance_wait_time_max": 0, + "keys.cache_driver": "localfs_key", "auth_retries": 3, } - crypt.gen_keys(pki_dir, "minion", opts["keysize"]) + crypt.write_keys(str(pki_dir), "minion", opts["keysize"]) auth = crypt.AsyncAuth(opts, io_loop) @@ -501,10 +522,11 @@ async def test_authenticate_default_does_not_cap_retry_loop_69442(minion_root, i "keysize": 4096, "acceptance_wait_time": 0, "acceptance_wait_time_max": 0, + "keys.cache_driver": "localfs_key", # Intentionally do not set ``auth_retries`` -- the default # (0 == unlimited) is what we're asserting here. } - crypt.gen_keys(pki_dir, "minion", opts["keysize"]) + crypt.write_keys(str(pki_dir), "minion", opts["keysize"]) auth = crypt.AsyncAuth(opts, io_loop) diff --git a/tests/pytests/unit/test_master.py b/tests/pytests/unit/test_master.py index 56e6b0756f7d..c1b556dfc678 100644 --- a/tests/pytests/unit/test_master.py +++ b/tests/pytests/unit/test_master.py @@ -146,6 +146,13 @@ def test_maintenance_duration(): "master_job_cache": "", "pki_dir": "/tmp", "eauth_tokens": "", + # LoadAuth (constructed in _post_fork_init since the memory-leak + # caching change) reads eauth_tokens.* + cluster_id at __init__ + # time. Provide defaults matching salt.config so the test can + # exercise the real init path without hitting KeyError. + "eauth_tokens.cache_driver": None, + "eauth_tokens.cluster_id": None, + "cluster_id": None, "keys.cache_driver": "localfs_key", "__role": "master", "optimization_order": [0, 1, 2], diff --git a/tests/pytests/unit/test_minion.py b/tests/pytests/unit/test_minion.py index 289e554cb144..11784d4b4f81 100644 --- a/tests/pytests/unit/test_minion.py +++ b/tests/pytests/unit/test_minion.py @@ -1467,7 +1467,15 @@ def test_minion_manager_stop_unblocks_resolve_dns_69466(minion_opts): "MinionManager.stop() did not request a resolve_dns abort; " "a SIGTERM during the DNS retry loop will be ignored. See #69466." ) - manager.io_loop.add_callback.assert_called_once() + # MinionManager.stop() schedules stop_async via + # ``io_loop.create_task`` (the 3007.x refactor replaced the earlier + # ``add_callback`` form). Either call is acceptable evidence that + # the async shutdown got queued. + assert ( + manager.io_loop.create_task.call_count + + manager.io_loop.add_callback.call_count + == 1 + ) finally: salt.minion._RESOLVE_DNS_ABORT.clear() diff --git a/tests/pytests/unit/utils/event/test_event.py b/tests/pytests/unit/utils/event/test_event.py index a91ba88f2162..e7e48dc30f4a 100644 --- a/tests/pytests/unit/utils/event/test_event.py +++ b/tests/pytests/unit/utils/event/test_event.py @@ -313,8 +313,8 @@ def test_event_unpack_with_SaltDeserializationError(sock_dir): ) as me, patch.object( salt.utils.event.log, "warning", autospec=True ) as mock_log_warning, patch.object( - salt.utils.event.log, "error", autospec=True - ) as mock_log_error: + salt.utils.event.log, "debug", autospec=True + ) as mock_log_debug: me.fire_event({"data": "foo1"}, "evt1") me.fire_event({"data": "foo2"}, "evt2") evt2 = me.get_event(tag="") @@ -326,9 +326,16 @@ def test_event_unpack_with_SaltDeserializationError(sock_dir): mock_log_warning.mock_calls[0].args[0] == "SaltDeserializationError on unpacking data, the payload could be incomplete" ) - assert ( - mock_log_error.mock_calls[0].args[0] - == "Unable to deserialize received event" + # On 3007.x, SaltDeserializationError in _get_event is caught and + # logged at debug level via the leak-fix hardening (single bad IPC + # frame must not kill the subscriber). Verify the skip-and-continue + # message is emitted rather than the pre-hardening "log.error(...)" + # form the 3006.x test expected. + assert any( + call.args + and call.args[0] + == "Event subscriber: skipping malformed event (deserialization error)" + for call in mock_log_debug.mock_calls ) From ebf5c18fa11925c3ebfee8a123616e22b3e1586f Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 1 Jul 2026 04:31:29 -0700 Subject: [PATCH 004/469] Fix race in test_interrupt_on_long_running_job on slow CI The test used a fixed time.sleep(2) after spawning the salt CLI before sending SIGINT. On slow CI hosts (observed on Photon OS 5 Arm64, both tcp(fips) and zeromq(fips) integration lanes) the CLI had not yet published its job when the signal arrived. Its scripts._handle_signals path then took the AttributeError/KeyError branch (no pub_data), emitted only "Exiting gracefully on Ctrl-c", and skipped the "This job's jid is" message the test asserts on. Wait on the master's salt/job/*/new event via event_listener instead. That guarantees pub_data is populated in the CLI process before we interrupt it, so the jid-bearing signal-handler branch always runs. Fixes flakiness in tests/pytests/integration/cli/test_salt.py::test_interrupt_on_long_running_job seen at: https://github.com/saltstack/salt/actions/runs/28505613358/job/84502049475 https://github.com/saltstack/salt/actions/runs/28505613358/job/84502049337 --- changelog/60963.fixed.md | 1 + tests/pytests/integration/cli/test_salt.py | 28 ++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 changelog/60963.fixed.md diff --git a/changelog/60963.fixed.md b/changelog/60963.fixed.md new file mode 100644 index 000000000000..6e7f74f8f9cd --- /dev/null +++ b/changelog/60963.fixed.md @@ -0,0 +1 @@ +Fixed a race in ``tests/pytests/integration/cli/test_salt.py::test_interrupt_on_long_running_job`` that intermittently failed on slow CI hosts (Photon OS 5 Arm64, both tcp(fips) and zeromq(fips)). The test used a fixed ``time.sleep(2)`` before sending ``SIGINT``, but on slow hosts the salt CLI had not yet published its job (``pub_data["jid"]`` was still unset), so the signal handler emitted only ``Exiting gracefully on Ctrl-c`` without a jid and the ``This job's jid is`` assertion failed. The test now waits on the master's ``salt/job/*/new`` event via ``event_listener`` to guarantee the job has been published before interrupting the CLI. diff --git a/tests/pytests/integration/cli/test_salt.py b/tests/pytests/integration/cli/test_salt.py index 0fef762071ab..141534cc3c3a 100644 --- a/tests/pytests/integration/cli/test_salt.py +++ b/tests/pytests/integration/cli/test_salt.py @@ -171,7 +171,9 @@ def test_exit_status_correct_usage(salt_cli, salt_minion): @pytest.mark.skip_on_windows(reason="Windows does not support SIGINT") -def test_interrupt_on_long_running_job(salt_cli, salt_master, salt_minion): +def test_interrupt_on_long_running_job( + event_listener, salt_cli, salt_master, salt_minion +): """ Ensure that a call to ``salt`` that is taking too long, when a user hits CTRL-C, that the JID is printed to the console. @@ -198,6 +200,10 @@ def test_interrupt_on_long_running_job(salt_cli, salt_master, salt_minion): "30", ] + # Track the moment we spawn the CLI so ``event_listener`` only considers + # events published after this point. + launch_time = time.time() + # If this test starts failing, commend the following block of code proc = subprocess.Popen( cmdline, @@ -230,7 +236,25 @@ def test_interrupt_on_long_running_job(salt_cli, salt_master, salt_minion): terminate_process(proc.pid, kill_children=True) pytest.fail("The test process failed to start") - time.sleep(2) + # Wait until the master publishes the new job before sending SIGINT. + # A fixed ``time.sleep`` here is racy on slow CI hosts: the salt CLI has + # not yet set ``pub_data`` when the signal arrives, so its signal + # handler falls back to just ``Exiting gracefully on Ctrl-c`` with no + # jid, and the ``This job's jid is`` assertion below fails. Waiting on + # the ``salt/job/*/new`` event guarantees ``pub_data`` is populated in + # the CLI process before we interrupt it. + matched_events = event_listener.wait_for_events( + [(salt_master.id, "salt/job/*/new")], + after_time=launch_time, + timeout=30, + ) + if not matched_events.found_all_events: + terminate_process(proc.pid, kill_children=True) + pytest.fail( + "The salt CLI never published a job; cannot exercise the " + "SIGINT path. Matched events: {}".format(matched_events.matches) + ) + # Send CTRL-C to the process os.kill(proc.pid, signal.SIGINT) with proc: From f6228945dd85d9662ec40085fe4ebe5b315ffa76 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 1 Jul 2026 15:34:57 -0700 Subject: [PATCH 005/469] Fix dependabot lock-sync workflow triggering on 3008.x Two bugs kept the Sync .lock files job from running for 3008.x PRs, leaving lock files stale: - on.pull_request.branches omitted 3008.x (it only listed master and 3006.x), so the workflow never triggered for PRs targeting 3008.x. Add all four release branches. - The actor guard only matched 'dependabot', so it skipped whenever the salt-pr-bot rebase bot re-pushed a branch. Also fire for salt-pr-bot. --- .github/workflows/dependabot-sync.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml index dd48129348cb..f03b11c533d9 100644 --- a/.github/workflows/dependabot-sync.yml +++ b/.github/workflows/dependabot-sync.yml @@ -6,6 +6,8 @@ on: pull_request: branches: - master + - 3008.x + - 3007.x - 3006.x permissions: @@ -14,8 +16,10 @@ permissions: jobs: sync-requirements: name: Sync .lock files - # Trigger for any dependabot actor - if: contains(github.actor, 'dependabot') || github.event_name == 'workflow_dispatch' + # Trigger for dependabot, and for the salt-pr-bot rebases that re-push + # dependabot branches (github.actor is salt-pr-bot[bot] on those events, + # which otherwise skips this job and leaves the lock files stale). + if: contains(github.actor, 'dependabot') || contains(github.actor, 'salt-pr-bot') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest environment: workflow-restart steps: From a031ad49ec5832fc57bd6b0cc6434152480719fd Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 1 Jul 2026 15:35:04 -0700 Subject: [PATCH 006/469] Fix dependabot lock-sync workflow triggering on 3007.x Two bugs kept the Sync .lock files job from running for 3007.x PRs, leaving lock files stale: - on.pull_request.branches omitted 3007.x (it only listed master and 3006.x), so the workflow never triggered for PRs targeting 3007.x. Add all four release branches. - The actor guard only matched 'dependabot', so it skipped whenever the salt-pr-bot rebase bot re-pushed a branch. Also fire for salt-pr-bot. --- .github/workflows/dependabot-sync.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml index dd48129348cb..f03b11c533d9 100644 --- a/.github/workflows/dependabot-sync.yml +++ b/.github/workflows/dependabot-sync.yml @@ -6,6 +6,8 @@ on: pull_request: branches: - master + - 3008.x + - 3007.x - 3006.x permissions: @@ -14,8 +16,10 @@ permissions: jobs: sync-requirements: name: Sync .lock files - # Trigger for any dependabot actor - if: contains(github.actor, 'dependabot') || github.event_name == 'workflow_dispatch' + # Trigger for dependabot, and for the salt-pr-bot rebases that re-push + # dependabot branches (github.actor is salt-pr-bot[bot] on those events, + # which otherwise skips this job and leaves the lock files stale). + if: contains(github.actor, 'dependabot') || contains(github.actor, 'salt-pr-bot') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest environment: workflow-restart steps: From 6fa9115c895ec7d544cdd1adc8fd9367d0670bba Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 1 Jul 2026 15:35:10 -0700 Subject: [PATCH 007/469] Fix dependabot lock-sync workflow triggering on 3006.x Two bugs kept the Sync .lock files job from running for 3006.x PRs, leaving lock files stale: - on.pull_request.branches omitted 3006.x (it only listed master and 3006.x), so the workflow never triggered for PRs targeting 3006.x. Add all four release branches. - The actor guard only matched 'dependabot', so it skipped whenever the salt-pr-bot rebase bot re-pushed a branch. Also fire for salt-pr-bot. --- .github/workflows/dependabot-sync.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml index dd48129348cb..f03b11c533d9 100644 --- a/.github/workflows/dependabot-sync.yml +++ b/.github/workflows/dependabot-sync.yml @@ -6,6 +6,8 @@ on: pull_request: branches: - master + - 3008.x + - 3007.x - 3006.x permissions: @@ -14,8 +16,10 @@ permissions: jobs: sync-requirements: name: Sync .lock files - # Trigger for any dependabot actor - if: contains(github.actor, 'dependabot') || github.event_name == 'workflow_dispatch' + # Trigger for dependabot, and for the salt-pr-bot rebases that re-push + # dependabot branches (github.actor is salt-pr-bot[bot] on those events, + # which otherwise skips this job and leaves the lock files stale). + if: contains(github.actor, 'dependabot') || contains(github.actor, 'salt-pr-bot') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest environment: workflow-restart steps: From 16dcda0cbd90c225a82c09220918eeb46efd32da Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:07:32 -0700 Subject: [PATCH 008/469] docs: cleanup module and state docstrings to match behavior --- changelog/58845.fixed.md | 30 +++++++++++++++++++++ salt/modules/cmdmod.py | 20 ++++++++++++++ salt/modules/file.py | 17 +++++++++--- salt/modules/groupadd.py | 10 ++++--- salt/modules/saltcheck.py | 42 +++++------------------------- salt/modules/slack_notify.py | 17 ++++++++++-- salt/pillar/file_tree.py | 31 ++++++++++------------ salt/proxy/netmiko_px.py | 25 ++++++++++++------ salt/runners/jobs.py | 27 ++++++++++++++++++- salt/states/aptpkg.py | 6 +++++ salt/states/docker_container.py | 19 +++++++++++--- salt/states/docker_image.py | 8 +++--- salt/states/file.py | 36 ++++++++++++++++++------- salt/states/pkgrepo.py | 9 ++++++- salt/states/postgres_privileges.py | 12 +++++++-- salt/states/service.py | 39 +++++++++++++++++++++++++-- salt/states/user.py | 15 +++++++++++ salt/states/virtualenv_mod.py | 17 ++++++++++-- salt/states/win_pki.py | 13 +++++++++ salt/wheel/key.py | 8 ++++++ 20 files changed, 307 insertions(+), 94 deletions(-) create mode 100644 changelog/58845.fixed.md diff --git a/changelog/58845.fixed.md b/changelog/58845.fixed.md new file mode 100644 index 000000000000..eed41d8eb24c --- /dev/null +++ b/changelog/58845.fixed.md @@ -0,0 +1,30 @@ +Cleaned up a batch of state and execution-module docstrings to match +actual behavior. Addressed reports from #58845 (slack_notify.call_hook +documented the configuration key as ``identifier`` rather than ``hook``), +#67074 (file.seek_read used ``seek`` instead of ``size`` in the +description), #67911 (file.find listed ``user`` filter but the option is +``owner``), #54802 (pkgrepo.managed said ``enabled=False`` assumes +``disabled=False`` instead of ``True``), #61671 (pkgrepo.managed had no +note about the ``hkp://`` keyserver scheme), #62002 (wheel.key +``__func_alias__`` aliases were not documented), #56729 / #65756 +(virtualenv state docstring referred to ``virtualenv_mod`` and did not +point at ``virtualenv_mod.create`` for unmapped kwargs), #61886 / #59666 +(aptpkg and groupadd state/module docstrings did not surface the +``apt`` and ``group`` virtual names), #55916 / #50568 / #64075 / #60773 +(file state docstrings for ``rename``, ``copy``, ``blockreplace`` and +the octal-mode warning), #34929 / #57606 / #60784 / #63852 +(service.running ``sig`` special-character handling, missing ``reload`` +and ``full_restart`` docs, and the systemd daemon-reload note), #57505 / +#57949 (cmd.run ``runas`` privilege drop semantics and Windows password +requirement), #61689 (user.present Windows-unsupported uid/gid/allow_* +arguments), #64021 (win_pki available certificate stores), #56182 +(netmiko_px ``keepalive`` vs. ``always_alive``), #51213 +(postgres_privileges ``maintenance_db`` copy-paste), #57405 (file_tree +pillar example mismatched the rendered pillar tree), #63364 (saltcheck +duplicate "Example with jinja" section and unclear assertion +definition), #61405 (file.chown broken-symlink ``lchown`` fallback), +#60406 (jobs.last_run runner description and parameters), #55881 +(docker_container.running ``command`` accepts list as well as string), +#56956 (docker_image.present ``sls`` does not accept a YAML list), and +#66409 (docker_container.running hostname does not fall back to +``name``). No behavior changes; documentation only. diff --git a/salt/modules/cmdmod.py b/salt/modules/cmdmod.py index e07ef0515b50..d259a2d72227 100644 --- a/salt/modules/cmdmod.py +++ b/salt/modules/cmdmod.py @@ -1171,6 +1171,18 @@ def run( cmd.run 'echo '\''h=\"baz\"'\''' runas=macuser + .. note:: + + On Linux ``runas`` switches the effective user but does **not** + run a login shell, so the supplementary groups, ``$HOME`` and + ``$PATH`` of the target account are not loaded. The primary + group of the salt-minion process (typically ``root``) is kept, + which is why ``id`` from inside the executed command may report + ``gid=0(root)``. Pass ``group=`` to switch the primary group as + well, or invoke a login shell explicitly (for example + ``su - -c '...'``) when the full target environment is + required. + :param str group: Group to run command as. Not currently supported on Windows. @@ -1181,6 +1193,14 @@ def run( Windows impersonation APIs without needing their credentials. This parameter is ignored on non-Windows platforms. + .. note:: + + On Windows, when ``runas`` is supplied but no logon token is + available (i.e. the salt-minion is not running as SYSTEM or as + an elevated Administrator), ``password`` must also be provided. + Omitting it surfaces as an opaque "embedded null character" + error. + .. versionadded:: 2016.3.0 :param str shell: Specify an alternate shell. Defaults to the system's diff --git a/salt/modules/file.py b/salt/modules/file.py index 24e6bbad4ab3..dfb1c66625fe 100644 --- a/salt/modules/file.py +++ b/salt/modules/file.py @@ -487,7 +487,18 @@ def chown(path, user, group): Chown a file, pass the file the desired user and group path - path to the file or directory + path to the file or directory. + + .. note:: + For an existing target this function follows symlinks and + modifies the resolved file. When ``path`` is a broken + symlink (its target does not exist), the symlink itself is + chowned via ``lchown`` rather than raising an error. This + differs from :py:func:`file.chgrp` / + :py:func:`file.lchown` which expose an explicit + ``follow_symlinks`` parameter; use + :py:func:`file.lchown` if you need to chown a *good* symlink + without dereferencing it. user user owner @@ -983,7 +994,7 @@ def find(path, *args, **kwargs): regex = path-regex # case sensitive iregex = path-regex # case insensitive type = file-types # match any listed type - user = users # match any listed user + owner = users # match any listed user group = groups # match any listed group size = [+-]number[size-unit] # default unit = byte mtime = interval # modified since date @@ -3710,7 +3721,7 @@ def seek_read(path, size, offset): path path to file - seek + size amount to read at once offset diff --git a/salt/modules/groupadd.py b/salt/modules/groupadd.py index 0a30ec3df9c7..af0ef6b06f6d 100644 --- a/salt/modules/groupadd.py +++ b/salt/modules/groupadd.py @@ -2,10 +2,12 @@ Manage groups on Linux, OpenBSD and NetBSD .. important:: - If you feel that Salt should be using this module to manage groups on a - minion, and it is using a different module (or gives an error similar to - *'group.info' is not available*), see :ref:`here - `. + This module is loaded under the ``group`` virtual name. Address it as + ``group.`` (for example ``group.add``) and not as + ``groupadd.``. If you feel that Salt should be using this + module to manage groups on a minion, and it is using a different + module (or gives an error similar to *'group.info' is not available*), + see :ref:`here `. """ import functools diff --git a/salt/modules/saltcheck.py b/salt/modules/saltcheck.py index 8eb269fefec9..e959e42e9874 100644 --- a/salt/modules/saltcheck.py +++ b/salt/modules/saltcheck.py @@ -87,8 +87,13 @@ **kwargs:** (dict) Optional keyword arguments to be passed to the salt module **assertion:** - (str) One of the supported assertions and required except for ``saltcheck.state_apply`` - Tests which fail the assertion and expected_return, cause saltcheck to exit which a non-zero exit code. + (str) The name of one of the supported assertions (for example + ``assertEqual``, ``assertTrue``, ``assertIn``). Required for every + test except those whose ``module_and_function`` is + ``saltcheck.state_apply`` (which represents a setup/teardown step + rather than an assertion). When a test fails its assertion (or its + ``expected_return`` does not match) the overall ``saltcheck`` run + exits with a non-zero status code. **expected_return:** (str) Required except by ``assertEmpty``, ``assertNotEmpty``, ``assertTrue``, ``assertFalse``. The return of module_and_function is compared to this value in the assertion. @@ -168,39 +173,6 @@ - vim assertion: assertNotEmpty -Example with jinja ------------------- - -.. code-block:: jinja - - {% for package in ["apache2", "openssh"] %} - {# or another example #} - {# for package in salt['pillar.get']("packages") #} - test_{{ package }}_latest: - module_and_function: pkg.upgrade_available - args: - - {{ package }} - assertion: assertFalse - {% endfor %} - -Example with setup state including pillar ------------------------------------------ - -.. code-block:: yaml - - setup_test_environment: - module_and_function: saltcheck.state_apply - args: - - common - pillar-data: - data: value - - verify_vim: - module_and_function: pkg.version - args: - - vim - assertion: assertNotEmpty - Example with skip ----------------- diff --git a/salt/modules/slack_notify.py b/salt/modules/slack_notify.py index b82367176e82..87ad64108e27 100644 --- a/salt/modules/slack_notify.py +++ b/salt/modules/slack_notify.py @@ -260,7 +260,12 @@ def call_hook( :param color: The color of border of left side :param short: An optional flag indicating whether the value is short enough to be displayed side-by-side with other values. - :param identifier: The identifier of WebHook. + :param identifier: The identifier of the WebHook (the part of the URL + after ``https://hooks.slack.com/services/``). When not + passed on the command line the value is read from the + ``slack.hook`` minion configuration option (or the + nested ``slack: hook:`` form). The configuration key + is ``hook``, not ``identifier``. :param channel: The channel to use instead of the WebHook default. :param username: Username to use instead of WebHook default. :param icon_emoji: Icon to use instead of WebHook default. @@ -270,7 +275,15 @@ def call_hook( .. code-block:: bash - salt '*' slack.call_hook message='Hello, from SaltStack' + salt '*' slack.call_hook message='Hello, from SaltStack' \\ + identifier='T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' + + Minion configuration example: + + .. code-block:: yaml + + slack: + hook: T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX """ base_url = "https://hooks.slack.com/services/" diff --git a/salt/pillar/file_tree.py b/salt/pillar/file_tree.py index f17c6ead69e1..6387c640bf61 100644 --- a/salt/pillar/file_tree.py +++ b/salt/pillar/file_tree.py @@ -112,34 +112,31 @@ ./hosts/test-host/files/another-testdir/ ./hosts/test-host/files/another-testdir/symlink-to-file1.txt -will result in the following pillar tree for minion with ID ``test-host``: +will result in the following pillar tree for minion with ID ``test-host`` +(each leaf is the file's contents): .. code-block:: text test-host: ---------- - apache: + files: ---------- - config.d: + testdir: ---------- - 00_important.conf: - - 20_bob_extra.conf: - - corporate_app: - ---------- - settings: + file1.txt: + + file2.txt: + + another-testdir: ---------- - common_settings: - // This is the main settings file for the corporate - // internal web app - main_setting: probably - bob_settings: - role: bob + symlink-to-file1.txt: + .. note:: - The leaf data in the example shown is the contents of the pillar files. + Each subdirectory under the per-host (or per-nodegroup) root becomes a + nested pillar key; each file becomes a leaf whose value is the file's + contents (subject to the ``keep_newline`` and templating options). """ import fnmatch diff --git a/salt/proxy/netmiko_px.py b/salt/proxy/netmiko_px.py index 5b061639287f..c67da089fa32 100644 --- a/salt/proxy/netmiko_px.py +++ b/salt/proxy/netmiko_px.py @@ -135,9 +135,14 @@ - ``session_timeout`` - Set a timeout for parallel requests, in seconds (default: ``60``) -- ``keepalive`` - Send SSH keepalive packets at a specific interval, in - seconds. Currently defaults to ``0``, for backwards compatibility (it will - not attempt to keep the connection alive using the KEEPALIVE packets). +- ``keepalive`` - Interval, in seconds, at which to send SSH KEEPALIVE + packets on a *currently open* connection so the remote device does not + drop the session for inactivity. Currently defaults to ``0`` for + backwards compatibility (no KEEPALIVE packets are sent). This option + only takes effect while a connection is open; it does not influence + whether a new connection is opened. To control whether the proxy keeps + a single persistent connection or reconnects per call, use + ``always_alive`` (see below). - ``default_enter`` - Character(s) to send to correspond to enter key (default: ``\\n``) @@ -145,11 +150,15 @@ - ``response_return`` - Character(s) to use in normalized return data to represent enter key (default: ``\\n``) -- ``always_alive`` - In certain less dynamic environments, maintaining the - remote connection permanently open with the network device is not always - beneficial. In that case, the user can select to initialize the connection - only when needed, by setting this option to ``False``. By default this option - is set to ``True`` (maintains the connection with the remote network device) +- ``always_alive`` - Controls connection lifecycle. When ``True`` (the + default), the proxy opens a single SSH session on start-up and reuses + it for every call. When ``False``, the proxy opens a fresh SSH session + for each call and closes it on completion -- useful in less dynamic + environments, when the target device has aggressive idle timers, or + when many proxies share a single connection limit on the device. This + setting governs whether a connection is opened at all; the + ``keepalive`` setting governs whether KEEPALIVE packets are sent on an + open connection. - ``multiprocessing`` - Overrides the :conf_minion:`multiprocessing` option, per proxy minion, as the Netmiko communication channel is mainly SSH diff --git a/salt/runners/jobs.py b/salt/runners/jobs.py index 1c55bb0a83e6..d8b0fa3c3ca0 100644 --- a/salt/runners/jobs.py +++ b/salt/runners/jobs.py @@ -498,7 +498,32 @@ def last_run( """ .. versionadded:: 2015.8.0 - List all detectable jobs and associated functions + Return the most recent job (the one with the highest JID) that matches + the supplied filters. With no filters this returns the single most + recent job recorded by the active master job cache. + + ext_source + The external job cache to read from. Defaults to the master job + cache configured via ``master_job_cache``. + + outputter + Override the default outputter when returning the job result. + + metadata + A dictionary of metadata values to filter on. Only jobs whose + recorded metadata matches every key/value pair will be considered. + + function + Only consider jobs that invoked the named execution function (for + example ``cmd.run``). + + target + Only consider jobs that ran against the specified target. + + display_progress + When ``True``, display progress events while scanning jobs. + + Returns ``False`` when no matching job is found. CLI Example: diff --git a/salt/states/aptpkg.py b/salt/states/aptpkg.py index 0b196e3503e8..3d375fb7ff20 100644 --- a/salt/states/aptpkg.py +++ b/salt/states/aptpkg.py @@ -1,6 +1,12 @@ """ Package management operations specific to APT- and DEB-based systems ==================================================================== + +.. note:: + + This state module is loaded under the ``apt`` virtual name. Use + ``apt.held`` (and not ``aptpkg.held``) when referring to functions + from this module in your state SLS files. """ import logging diff --git a/salt/states/docker_container.py b/salt/states/docker_container.py index 48ca418e2a5f..ddc65dc12d5d 100644 --- a/salt/states/docker_container.py +++ b/salt/states/docker_container.py @@ -583,7 +583,10 @@ def running( This option requires Docker 1.2.0 or newer. command (or *cmd*) - Command to run in the container + Command to run in the container. May be specified either as a string + or as a YAML list of arguments. A list maps directly to Docker's + ``Cmd`` array, which is preferred when the container takes multiple + arguments (each list item is passed as its own argv element). .. code-block:: yaml @@ -592,6 +595,15 @@ def running( - image: bar/baz:latest - command: bash + .. code-block:: yaml + + foo: + docker_container.running: + - image: prom/prometheus:latest + - command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + cpuset_cpus (or *cpuset*) CPUs on which which to allow execution, specified as a string containing a range (e.g. ``0-3``) or a comma-separated list of CPUs @@ -946,8 +958,9 @@ def running( - network hostname - Hostname of the container. If not provided, the value passed as the - container's``name`` will be used for the hostname. + Hostname of the container. When omitted, Docker assigns a hostname + derived from the short container ID rather than from the + container's ``name``. .. code-block:: yaml diff --git a/salt/states/docker_image.py b/salt/states/docker_image.py index b55790de68a2..5ff182246860 100644 --- a/salt/states/docker_image.py +++ b/salt/states/docker_image.py @@ -164,16 +164,16 @@ def present( sls Allow for building of image with :py:func:`docker.sls_build ` by specifying the SLS files with - which to build. This can be a list or comma-separated string. + which to build. Provide a comma-separated string of SLS file names; + YAML lists are not accepted by the underlying ``docker.sls_build`` + execution module. .. code-block:: yaml myuser/myimage: docker_image.present: - tag: latest - - sls: - - webapp1 - - webapp2 + - sls: webapp1,webapp2 - base: centos - saltenv: base diff --git a/salt/states/file.py b/salt/states/file.py index 6234683d9d41..96bc5cab9141 100644 --- a/salt/states/file.py +++ b/salt/states/file.py @@ -97,11 +97,14 @@ def run(): - mode: '0644' - attrs: i -.. warning:: +.. note:: - When using a mode that includes a leading zero you must wrap the - value in single quotes. If the value is not wrapped in quotes it - will be read by YAML as an integer and evaluated as an octal. + Salt's YAML loader special-cases octal-looking file modes, so all of + ``644``, ``0644``, ``0o644``, ``'644'``, ``'0644'`` and ``'0o644'`` + resolve to the same value (octal ``0o644``). Quoting a mode with a + leading zero is therefore not required for correctness; some operators + still prefer to quote (for example ``'0644'``) so the literal mode is + visually obvious in the SLS file. The ``names`` parameter, which is part of the state compiler, can be used to expand the contents of a single state declaration into multiple, single state @@ -5959,6 +5962,14 @@ def blockreplace( marker will be replaced, so it's important to ensure that your marker includes the beginning of the text you wish to replace. + .. note:: + + ``marker_end`` must not contain ``marker_start`` as a substring, + and the two markers must not be equal. When the start marker is + also present inside the end marker the block cannot be located + and the state fails with ``Unterminated marked block. End of + file reached before marker_end.``. + content The content to be used between the two lines identified by ``marker_start`` and ``marker_end`` @@ -7523,7 +7534,10 @@ def copy_( .. note:: This state only copies files from one location on a minion to another location on the same minion. For copying files from the master, use a - :py:func:`file.managed ` state. + :py:func:`file.managed ` state. To fetch + a single file from the master inside an execution module, runner or + Jinja template, use + :py:func:`cp.get_file `. name The location of the file to copy to @@ -7752,15 +7766,17 @@ def copy_( def rename(name, source, force=False, makedirs=False, **kwargs): """ - If the source file exists on the system, rename it to the named file. The - named file will not be overwritten if it already exists unless the force - option is set to True. + If the source path exists on the system, rename it to the named path. + Both files and directories are supported. The named path will not be + overwritten if it already exists unless the force option is set to + ``True``. name - The location of the file to rename to + The location to rename the source to (file or directory) source - The location of the file to move to the location specified with name + The location of the file or directory to move to the location + specified with ``name`` force If the target location is present then the file will not be moved, diff --git a/salt/states/pkgrepo.py b/salt/states/pkgrepo.py index 9388236d3c10..25942ac9420d 100644 --- a/salt/states/pkgrepo.py +++ b/salt/states/pkgrepo.py @@ -264,7 +264,7 @@ def managed(name, ppa=None, copr=None, aptkey=True, **kwargs): Included to reduce confusion due to YUM/DNF/Zypper's use of the ``enabled`` argument. If this is passed for an APT-based distro, then the reverse will be passed as ``disabled``. For example, passing - ``enabled=False`` will assume ``disabled=False``. + ``enabled=False`` will assume ``disabled=True``. architectures On apt-based systems, ``architectures`` can restrict the available @@ -293,6 +293,13 @@ def managed(name, ppa=None, copr=None, aptkey=True, **kwargs): This is the name of the keyserver to retrieve GPG keys from. The ``keyid`` option must also be set for this option to work. + .. note:: + + If retrieval fails with an error such as ``gpg: keyserver + receive failed: End of file``, try specifying the keyserver + using the explicit ``hkp://`` scheme (and port), for example + ``hkp://keyserver.ubuntu.com:80``. + key_url URL to retrieve a GPG key from. Allows the usage of ``https://`` as well as ``salt://``. If ``allow_insecure_key`` is True, diff --git a/salt/states/postgres_privileges.py b/salt/states/postgres_privileges.py index bd40d3917a71..209d9f87b3bb 100644 --- a/salt/states/postgres_privileges.py +++ b/salt/states/postgres_privileges.py @@ -144,7 +144,11 @@ def present( provided if the object is not under the default `public` schema maintenance_db - The name of the database in which the language is to be installed + The name of the database to connect to as the maintenance database + when issuing the privilege change. Defaults to the value of the + ``postgres.maintenance_db`` configuration option (typically + ``postgres``). The privilege itself is applied to the target object + identified by ``object_name``, not to ``maintenance_db``. user System user all operations should be performed on behalf of @@ -271,7 +275,11 @@ def absent( provided if the object is not under the default `public` schema maintenance_db - The name of the database in which the language is to be installed + The name of the database to connect to as the maintenance database + when issuing the privilege change. Defaults to the value of the + ``postgres.maintenance_db`` configuration option (typically + ``postgres``). The privilege itself is applied to the target object + identified by ``object_name``, not to ``maintenance_db``. user System user all operations should be performed on behalf of diff --git a/salt/states/service.py b/salt/states/service.py index ab14ed0abf1c..d2aec5f51837 100644 --- a/salt/states/service.py +++ b/salt/states/service.py @@ -397,7 +397,13 @@ def running(name, enable=None, sig=None, init_delay=None, **kwargs): default is ``None``, which does not enable or disable anything. sig - The string to search for when looking for the service process with ps + The string to search for when looking for the service process with + ``ps``. The lookup uses an unanchored substring match against the + process command line, so embedded shell metacharacters (``()``, + ``|``, ``&``, ``;``, ``$``, backticks, quotes, etc.) are matched + literally. Prefer a substring of the actual executable name (for + example ``twistd``) over a full command line containing special + characters. init_delay Some services may not be truly available for a short period after their @@ -444,6 +450,31 @@ def running(name, enable=None, sig=None, init_delay=None, **kwargs): .. versionadded:: 2019.2.3 + reload : False + Honored when this state is the target of a ``watch`` requisite. When + ``True`` the service is reloaded (``systemctl reload``) rather than + restarted on watch-triggered refresh. The argument is consumed by + :py:func:`mod_watch `; passing + ``reload`` outside of a ``watch`` context has no effect. + + full_restart : False + Honored when this state is the target of a ``watch`` requisite. When + ``True`` the service is fully restarted (``service.full_restart``) + rather than restarted on watch-triggered refresh. + + .. note:: + + On systemd minions, a change to a ``.service`` unit file does **not** + automatically trigger ``systemctl daemon-reload`` unless that unit + file is detected and managed by ``systemd_service`` itself. When a + ``file.managed`` state installs or modifies a unit file, you should + either run :py:func:`module.run ` with + ``service.systemctl_reload`` (or call + :py:func:`systemd_service.systemctl_reload + ` from a Jinja + template) before restarting the service, or arrange the requisites + so that the daemon-reload happens first. + .. note:: ``watch`` can be used with service.running to restart a service when another state changes ( example: a file.managed state that creates the @@ -627,7 +658,11 @@ def dead(name, enable=None, sig=None, init_delay=None, **kwargs): default is ``None``, which does not enable or disable anything. sig - The string to search for when looking for the service process with ps + The string to search for when looking for the service process with + ``ps``. The lookup uses an unanchored substring match against the + process command line, so embedded shell metacharacters (``()``, + ``|``, ``&``, ``;``, ``$``, backticks, quotes, etc.) are matched + literally. Prefer a substring of the actual executable name. init_delay Add a sleep command (in seconds) before the check to make sure service diff --git a/salt/states/user.py b/salt/states/user.py index 01d5b14e026e..5a510f9f40b2 100644 --- a/salt/states/user.py +++ b/salt/states/user.py @@ -337,19 +337,34 @@ def present( The user id to assign. If not specified, and the user does not exist, then the next available uid will be assigned. + .. note:: + Not supported on Windows. On Windows the account SID is fixed by + the operating system at user creation time and cannot be chosen + or changed; ``uid`` and ``allow_uid_change`` have no effect there + and will surface as a permissions error if used. + gid The id of the default group to assign to the user. Either a group name or gid can be used. If not specified, and the user does not exist, then the next available gid will be assigned. + .. note:: + Not supported on Windows. + allow_uid_change : False Set to ``True`` to allow the state to update the uid. + .. note:: + Not supported on Windows -- see ``uid``. + .. versionadded:: 2018.3.1 allow_gid_change : False Set to ``True`` to allow the state to update the gid. + .. note:: + Not supported on Windows. + .. versionadded:: 2018.3.1 usergroup diff --git a/salt/states/virtualenv_mod.py b/salt/states/virtualenv_mod.py index ceb25effb665..7472bcdfcc26 100644 --- a/salt/states/virtualenv_mod.py +++ b/salt/states/virtualenv_mod.py @@ -2,6 +2,12 @@ Setup of Python virtualenv sandboxes. .. versionadded:: 0.17.0 + +.. note:: + + This state module is loaded under the ``virtualenv`` virtual name. Use + ``virtualenv.managed`` (and not ``virtualenv_mod.managed``) in your + state SLS files. """ import logging @@ -122,8 +128,15 @@ def managed( .. versionadded:: 2017.7.0 - Also accepts any kwargs that the virtualenv module will. However, some - kwargs, such as the ``pip`` option, require ``- distribute: True``. + Also accepts any keyword argument accepted by + :py:func:`virtualenv.create ` -- + including ``system_site_packages``, ``distribute``, ``clear``, + ``extra_search_dir``, ``never_download``, ``prompt``, ``index_url``, + ``extra_index_url``, ``pre_releases``, ``pip_download``, + ``pip_download_cache``, ``pip_ignore_installed``, ``use_vt``, + ``pip_no_cache_dir`` and ``pip_cache_dir``. Refer to that execution + module for argument semantics. Some kwargs, such as the ``pip`` option, + require ``- distribute: True``. .. code-block:: yaml diff --git a/salt/states/win_pki.py b/salt/states/win_pki.py index 0e0724209f12..97b1f8f2b9d2 100644 --- a/salt/states/win_pki.py +++ b/salt/states/win_pki.py @@ -4,6 +4,19 @@ :platform: Windows .. versionadded:: 2016.11.0 + +The ``context`` argument refers to the certificate-store location, either +``LocalMachine`` or ``CurrentUser``. The ``store`` argument refers to one of +the standard Microsoft certificate stores within that location (for example +``My``, ``Root``, ``CA``, ``AuthRoot``, ``TrustedPublisher``, +``TrustedPeople``, ``Disallowed``, ``WebHosting``, ``Remote Desktop``). +List the stores actually available on a minion with PowerShell:: + + PS C:\\> Set-Location Cert:\\LocalMachine + PS Cert:\\LocalMachine> Get-ChildItem + +or by calling :py:func:`win_pki.get_stores +`. """ _DEFAULT_CONTEXT = "LocalMachine" diff --git a/salt/wheel/key.py b/salt/wheel/key.py index 1b756c683f66..4883345bfb34 100644 --- a/salt/wheel/key.py +++ b/salt/wheel/key.py @@ -24,6 +24,14 @@ The wheel key functions can also be called via a ``salt`` command at the CLI using the :mod:`saltutil execution module `. + +.. note:: + + This module defines ``__func_alias__`` to expose some functions under + different public names. The Python function ``list_`` is published as + ``key.list`` and ``key_str`` is published as ``key.print``. Always + call the aliased name (``key.list`` / ``key.print``) when invoking + these functions through salt-api, salt-call or the wheel client. """ import hashlib From 3ae9f75344efb9496d2eae4a971e4dcd8c797376 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:13:50 -0700 Subject: [PATCH 009/469] Document requisites truth table and add covering tests --- changelog/51839.fixed.md | 1 + changelog/55550.fixed.md | 1 + changelog/60246.fixed.md | 1 + doc/ref/states/requisites.rst | 123 +++++++ .../requisites/test_documented_truth_table.py | 331 ++++++++++++++++++ 5 files changed, 457 insertions(+) create mode 100644 changelog/51839.fixed.md create mode 100644 changelog/55550.fixed.md create mode 100644 changelog/60246.fixed.md create mode 100644 tests/pytests/functional/modules/state/requisites/test_documented_truth_table.py diff --git a/changelog/51839.fixed.md b/changelog/51839.fixed.md new file mode 100644 index 000000000000..2e3582b4a3de --- /dev/null +++ b/changelog/51839.fixed.md @@ -0,0 +1 @@ +Added a "Requisites truth table" section to `doc/ref/states/requisites.rst` that documents the resolution of recursive `require` and `prereq` chains, so authors can predict the outcome of a multi-level dependency graph without reading the compiler source. The accompanying functional tests verify the documented behavior. diff --git a/changelog/55550.fixed.md b/changelog/55550.fixed.md new file mode 100644 index 000000000000..8d205aa6744d --- /dev/null +++ b/changelog/55550.fixed.md @@ -0,0 +1 @@ +Documented how `require` and the `exclude` SLS directive interact in `doc/ref/states/requisites.rst` and `doc/ref/states/include.rst`, including the fact that a requisite pointing at an excluded ID is a hard error at compile time. diff --git a/changelog/60246.fixed.md b/changelog/60246.fixed.md new file mode 100644 index 000000000000..0726e9030edc --- /dev/null +++ b/changelog/60246.fixed.md @@ -0,0 +1 @@ +Documented the interaction between the `retry` state option and requisites in `doc/ref/states/requisites.rst`, and added a documented truth-table reference covering how each requisite responds to the four possible target outcomes (skipped, failed, succeeded-no-change, succeeded-with-changes). A new functional test (`tests/pytests/functional/modules/state/requisites/test_documented_truth_table.py`) asserts each documented cell to keep the documentation honest. diff --git a/doc/ref/states/requisites.rst b/doc/ref/states/requisites.rst index 18625faf9516..20f7e5d37588 100644 --- a/doc/ref/states/requisites.rst +++ b/doc/ref/states/requisites.rst @@ -795,6 +795,129 @@ In this example, `cmd.run` would be run only if either of the `file.managed` states generated changes and at least one of the watched state's "result" is ``True``. +.. _requisites-truth-table: + +Requisites truth table +---------------------- + +The table below summarises the relationship between the **target** state's +outcome (the state being depended on) and the **dependent** state's outcome +(the state declaring the requisite). The columns are the four possible +outcomes of the target state at evaluation time: + +* **Skipped**: the target state was itself skipped due to its own requisites. +* **Failed**: the target state ran and its ``result`` is ``False``. +* **No-change success**: target ran, ``result`` is ``True``, and + ``changes`` is empty. +* **Changed success**: target ran, ``result`` is ``True``, and + ``changes`` is non-empty. + +For each requisite, the cell shows what the dependent state does: + +* **runs**: the dependent state is evaluated normally. +* **skipped**: the dependent state's function is not invoked; it returns + ``result=False`` with a comment indicating the unmet requisite. (For + ``onchanges`` / ``onfail`` requisites, a skipped state actually returns + ``result=True`` with ``changes={}`` to indicate "the trigger did not + fire", since being skipped is the expected steady state.) + +.. list-table:: + :header-rows: 1 + :widths: 18 20 20 20 20 + + * - Requisite + - Target skipped + - Target failed + - Target succeeded, no changes + - Target succeeded with changes + * - ``require`` + - skipped + - skipped + - runs + - runs + * - ``require_any`` + - at least one target must succeed (skipped/failed counts as "not yet"); otherwise skipped + - same + - runs if any target succeeded + - runs if any target succeeded + * - ``watch`` + - skipped + - skipped + - runs normally; ``mod_watch`` not called + - runs normally; ``mod_watch`` called after + * - ``watch_any`` + - skipped unless any target succeeded + - same + - runs normally + - runs normally; ``mod_watch`` called if any target had changes + * - ``listen`` + - listener does not fire + - listener does not fire + - listener does not fire + - listener fires at end of state run via ``mod_watch`` + * - ``onchanges`` + - returns ``result=True``, no run + - returns ``result=True``, no run (because target failed) + - returns ``result=True``, no run + - runs + * - ``onchanges_any`` + - returns ``result=True``, no run + - returns ``result=True``, no run unless any target had changes + - returns ``result=True``, no run + - runs if any target had changes + * - ``onfail`` + - returns ``result=True``, no run + - runs + - returns ``result=True``, no run + - returns ``result=True``, no run + * - ``onfail_any`` + - returns ``result=True``, no run unless any target failed + - runs if any target failed + - returns ``result=True``, no run unless any target failed + - returns ``result=True``, no run unless any target failed + * - ``onfail_all`` + - skipped unless all targets failed + - runs if **all** targets failed + - skipped + - skipped + * - ``prereq`` + - skipped + - skipped + - dependent does not run (target reported no changes in test mode) + - dependent runs **before** target; if dependent succeeds, target then runs + * - ``use`` + - inherits arguments only; behavior of dependent governed by its own logic + - inherits arguments only + - inherits arguments only + - inherits arguments only + +Notes: + +* ``require`` and ``watch`` treat a *skipped* target as a *failed* target for + the purpose of evaluation: a skipped target propagates the skip to the + dependent state. +* ``onfail`` / ``onfail_any`` use OR semantics (any one target failing + triggers the dependent). Use ``onfail_all`` when you need AND semantics + (every target must fail). +* ``prereq`` uses a ``test=True`` evaluation of the target to decide whether + to run the dependent. If the target reports zero changes under + ``test=True``, neither state runs. +* Recursive requisites are resolved fully before any state runs. A + ``require`` chain ``A -> B -> C`` means ``A`` waits for both ``B`` *and* + ``C`` to succeed, in that order. A ``prereq`` chain works the same way in + reverse: ``A`` prereq ``B`` prereq ``C`` causes the test-mode check to + propagate from ``C`` back to ``A``. + +Combining requisites and ``exclude`` +------------------------------------ + +When :ref:`exclude ` removes a state ID from the run, any +requisite that referenced the excluded ID is treated as referencing a state +that does not exist, which is a hard error at compile time. To make a +requisite tolerate the optional presence of another state, use a separate +SLS file and only include it conditionally; do not rely on ``exclude`` to +silently break the requisite. + Altering States --------------- diff --git a/tests/pytests/functional/modules/state/requisites/test_documented_truth_table.py b/tests/pytests/functional/modules/state/requisites/test_documented_truth_table.py new file mode 100644 index 000000000000..74a53572ddff --- /dev/null +++ b/tests/pytests/functional/modules/state/requisites/test_documented_truth_table.py @@ -0,0 +1,331 @@ +""" +Documented requisites truth-table tests. + +Each test case here is a cell of the truth table that appears in +``doc/ref/states/requisites.rst`` under "Requisites truth table". The cells +exercise: ``require``, ``require_any``, ``watch``, ``onchanges``, +``onchanges_any``, ``onfail``, ``onfail_any``, ``onfail_all`` and ``prereq``. + +If the documented behavior changes (a state runs that didn't before, or stops +running when it used to), one of these tests fails and the documentation must +be updated to match. That is the point: documentation and behavior stay in +lockstep. +""" + +import pytest + +from . import normalize_ret + +pytestmark = [ + pytest.mark.windows_whitelisted, + pytest.mark.core_test, +] + + +# --- helpers -------------------------------------------------------------- + + +def _apply(state, state_tree, sls): + with pytest.helpers.temp_file("doc_truth.sls", sls, state_tree): + ret = state.sls("doc_truth") + return normalize_ret(ret.raw) + + +def _result(ret, key): + assert key in ret, f"missing state {key!r} in return {sorted(ret)}" + return ret[key] + + +# --- require -------------------------------------------------------------- + + +def test_require_target_succeeded(state, state_tree): + """require: target succeeded -> dependent runs.""" + sls = """ + target: + cmd.run: + - name: echo target-ok + + dependent: + cmd.run: + - name: echo dependent-ran + - require: + - cmd: target + """ + ret = _apply(state, state_tree, sls) + assert _result(ret, "cmd_|-target_|-echo target-ok_|-run")["result"] is True + dep = _result(ret, "cmd_|-dependent_|-echo dependent-ran_|-run") + assert dep["result"] is True + assert dep["changes"] is True + + +def test_require_target_failed(state, state_tree): + """require: target failed -> dependent is skipped (result False).""" + sls = """ + target: + cmd.run: + - name: 'false' + + dependent: + cmd.run: + - name: echo should-not-run + - require: + - cmd: target + """ + ret = _apply(state, state_tree, sls) + assert _result(ret, "cmd_|-target_|-false_|-run")["result"] is False + dep = _result(ret, "cmd_|-dependent_|-echo should-not-run_|-run") + assert dep["result"] is False + assert dep["changes"] is False + + +# --- require_any ---------------------------------------------------------- + + +def test_require_any_one_succeeds(state, state_tree): + """require_any: at least one target succeeded -> dependent runs.""" + sls = """ + good: + cmd.run: + - name: echo good + + bad: + cmd.run: + - name: 'false' + + dependent: + cmd.run: + - name: echo dependent-ran + - require_any: + - cmd: good + - cmd: bad + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo dependent-ran_|-run") + assert dep["result"] is True + assert dep["changes"] is True + + +def test_require_any_all_fail(state, state_tree): + """require_any: every target failed -> dependent is skipped.""" + sls = """ + bad1: + cmd.run: + - name: 'false' + + bad2: + cmd.run: + - name: 'false' + + dependent: + cmd.run: + - name: echo should-not-run + - require_any: + - cmd: bad1 + - cmd: bad2 + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo should-not-run_|-run") + assert dep["result"] is False + assert dep["changes"] is False + + +# --- onchanges ------------------------------------------------------------ + + +def test_onchanges_target_has_changes(state, state_tree): + """onchanges: target succeeded with changes -> dependent runs.""" + sls = """ + target: + cmd.run: + - name: echo changing + + dependent: + cmd.run: + - name: echo dependent-ran + - onchanges: + - cmd: target + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo dependent-ran_|-run") + assert dep["result"] is True + assert dep["changes"] is True + + +def test_onchanges_target_failed(state, state_tree): + """onchanges: target failed -> dependent does not run, result True.""" + sls = """ + target: + cmd.run: + - name: 'false' + + dependent: + cmd.run: + - name: echo should-not-run + - onchanges: + - cmd: target + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo should-not-run_|-run") + assert dep["result"] is True + assert dep["changes"] is False + + +# --- onchanges_any -------------------------------------------------------- + + +def test_onchanges_any_one_has_changes(state, state_tree): + """onchanges_any: any target with changes -> dependent runs.""" + sls = """ + good_no_change: + test.succeed_without_changes + + target_with_change: + cmd.run: + - name: echo changed + + dependent: + cmd.run: + - name: echo dependent-ran + - onchanges_any: + - test: good_no_change + - cmd: target_with_change + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo dependent-ran_|-run") + assert dep["result"] is True + assert dep["changes"] is True + + +# --- onfail / onfail_any / onfail_all ------------------------------------ + + +def test_onfail_target_failed(state, state_tree): + """onfail: target failed -> dependent runs.""" + sls = """ + target: + cmd.run: + - name: 'false' + + dependent: + cmd.run: + - name: echo dependent-ran + - onfail: + - cmd: target + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo dependent-ran_|-run") + assert dep["result"] is True + assert dep["changes"] is True + + +def test_onfail_target_succeeded(state, state_tree): + """onfail: target succeeded -> dependent does not run, result True.""" + sls = """ + target: + cmd.run: + - name: echo ok + + dependent: + cmd.run: + - name: echo should-not-run + - onfail: + - cmd: target + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo should-not-run_|-run") + assert dep["result"] is True + assert dep["changes"] is False + + +def test_onfail_any_one_failed(state, state_tree): + """onfail_any: at least one failed -> dependent runs (OR semantics).""" + sls = """ + good: + cmd.run: + - name: echo ok + + bad: + cmd.run: + - name: 'false' + + dependent: + cmd.run: + - name: echo dependent-ran + - onfail_any: + - cmd: good + - cmd: bad + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo dependent-ran_|-run") + assert dep["result"] is True + assert dep["changes"] is True + + +def test_onfail_all_requires_all_failed(state, state_tree): + """onfail_all: only one failed -> dependent does not run (AND semantics).""" + sls = """ + good: + cmd.run: + - name: echo ok + + bad: + cmd.run: + - name: 'false' + + dependent: + cmd.run: + - name: echo should-not-run + - onfail_all: + - cmd: good + - cmd: bad + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo should-not-run_|-run") + assert dep["changes"] is False + + +def test_onfail_all_all_failed_runs(state, state_tree): + """onfail_all: all targets failed -> dependent runs.""" + sls = """ + bad1: + cmd.run: + - name: 'false' + + bad2: + cmd.run: + - name: 'false' + + dependent: + cmd.run: + - name: echo dependent-ran + - onfail_all: + - cmd: bad1 + - cmd: bad2 + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo dependent-ran_|-run") + assert dep["result"] is True + assert dep["changes"] is True + + +# --- watch ---------------------------------------------------------------- + + +def test_watch_target_failed_skips_watcher(state, state_tree): + """watch: target failed -> watcher does not run, result False.""" + sls = """ + target: + cmd.run: + - name: 'false' + + watcher: + cmd.run: + - name: echo should-not-run + - watch: + - cmd: target + """ + ret = _apply(state, state_tree, sls) + w = _result(ret, "cmd_|-watcher_|-echo should-not-run_|-run") + assert w["result"] is False + assert w["changes"] is False From 89defe266f9e3d701b627aa438628a0fcbd6e483 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:14:03 -0700 Subject: [PATCH 010/469] Document state_output modes and highstate outputter options --- changelog/59166.fixed.md | 1 + doc/ref/states/highstate.rst | 99 ++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 changelog/59166.fixed.md diff --git a/changelog/59166.fixed.md b/changelog/59166.fixed.md new file mode 100644 index 000000000000..e2011b3ffa79 --- /dev/null +++ b/changelog/59166.fixed.md @@ -0,0 +1 @@ +Added a "Highstate Output" reference to `doc/ref/states/highstate.rst` enumerating every `state_output` value (`full`, `terse`, `mixed`, `changes`, `filter`, and their `_id` variants) and the related `state_verbose`, `state_output_diff`, `state_output_pct`, `state_output_profile`, `state_tabular` and `state_compress_ids` options, with guidance on when to use each. diff --git a/doc/ref/states/highstate.rst b/doc/ref/states/highstate.rst index f341ac5fa7e9..3dc1dee5b49f 100644 --- a/doc/ref/states/highstate.rst +++ b/doc/ref/states/highstate.rst @@ -336,6 +336,105 @@ dictionary level. - ius-devel: - baseurl: http://mirror.rackspace.com/ius/development/CentOS/6/$basearch +.. _highstate-output: + +Highstate Output +================ + +The highstate outputter renders the return data from ``state.apply``, +``state.highstate``, ``state.sls`` and similar commands. Its behavior is +controlled by a small set of options that can be set in the master config +(affecting the ``salt`` command) or the minion config (affecting +``salt-call``). They can also be passed on the command line. + +state_output +~~~~~~~~~~~~ + +``state_output`` (default ``full``) selects the per-state rendering mode. + +============ ========================================================================== +Value Behavior +============ ========================================================================== +``full`` Each state prints a multi-line block with ID, function, result, + comment, started/duration and any changes. +``terse`` Each state prints a single summary line. Useful for large state runs. +``mixed`` ``terse`` for successful states, ``full`` for failed states only. +``changes`` ``terse`` for states with no changes and no errors, ``full`` otherwise. +``filter`` Same as ``full`` but with optional include/exclude filtering controlled + by ``state_output_exclude`` and ``state_output_terse``. +============ ========================================================================== + +Each value also has an ``_id`` variant (``full_id``, ``terse_id``, +``mixed_id``, ``changes_id``, ``filter_id``) that displays the state's +``__id__`` (declaration ID) instead of the state's ``name`` parameter. Use the +``_id`` variants when the ``name`` value is long or unhelpful, for example when +``names:`` produces synthetic per-name states. + +The ``state_output`` value can be overridden per command: + +.. code-block:: bash + + salt '*' state.apply state_output=terse + salt-call state.highstate state_output=mixed_id + +state_verbose +~~~~~~~~~~~~~ + +``state_verbose`` (default ``True``) controls whether states that succeeded +with no changes appear in the output at all. Setting it to ``False`` suppresses +"green" states; only states with changes or failures are displayed. + +.. code-block:: bash + + salt '*' state.apply state_verbose=False + +state_output_diff +~~~~~~~~~~~~~~~~~ + +``state_output_diff`` (default ``False``) is similar to ``state_verbose=False`` +but stricter: when set to ``True``, only states whose return contains a +non-empty ``changes`` dictionary are displayed. Successful no-change states are +suppressed regardless of their result. + +state_output_pct +~~~~~~~~~~~~~~~~ + +``state_output_pct`` (default ``False``) adds ``Success %`` and ``Failure %`` +fields to the summary block at the end of the run. + +state_output_profile +~~~~~~~~~~~~~~~~~~~~ + +``state_output_profile`` (default ``True``) controls whether ``Started`` and +``Duration`` are printed for each state. Set to ``False`` for tighter output. + +state_tabular +~~~~~~~~~~~~~ + +When ``state_output`` is one of the ``terse`` modes, ``state_tabular: True`` +aligns the columns for easier scanning. Setting it to a string uses that +string as the column format. + +state_compress_ids +~~~~~~~~~~~~~~~~~~ + +``state_compress_ids`` (default ``False``) consolidates multiple ``names`` +under the same ``__id__`` into a single output row, grouped by result. This is +most useful with ``terse_id`` rendering for states that use the ``names`` +argument with many entries. + +Choosing a mode +~~~~~~~~~~~~~~~ + +* Use ``full`` (default) when debugging state development or running a small + number of states. +* Use ``mixed`` or ``changes`` for large highstate runs in production where you + only want detail on interesting states. +* Use ``terse`` when piping output into log collection or when you only need + pass/fail tracking. +* Add the ``_id`` suffix when ``name`` values are file paths or other long + strings that clutter the output. + .. _states-highstate-example: Large example From 03ab3b1ee5ab668a679f67d98b9394f134a47e0a Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:14:09 -0700 Subject: [PATCH 011/469] Document SLS include resolution and ordering --- changelog/65229.fixed.md | 1 + doc/ref/states/include.rst | 106 +++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 changelog/65229.fixed.md diff --git a/changelog/65229.fixed.md b/changelog/65229.fixed.md new file mode 100644 index 000000000000..cfc2ca4a023c --- /dev/null +++ b/changelog/65229.fixed.md @@ -0,0 +1 @@ +Documented SLS include resolution and ordering in `doc/ref/states/include.rst`, including how the depth-first include walk, the role of requisites and the `order` global state argument together determine execution order, with a worked example. diff --git a/doc/ref/states/include.rst b/doc/ref/states/include.rst index 162891925811..5f90b3b80842 100644 --- a/doc/ref/states/include.rst +++ b/doc/ref/states/include.rst @@ -91,3 +91,109 @@ needs to be defined. An exclude statement that verifies that the running The current state processing flow checks for duplicate IDs before processing excludes. An error occurs if duplicate IDs are present even if one of the IDs is targeted by an ``exclude``. + +.. _include-ordering: + +Include resolution and ordering +=============================== + +``include`` controls SLS file *resolution*, not *execution order*. Two things +are important to understand: + +1. **Recursion.** Each included SLS is itself processed for its own ``include`` + block before its states are merged into the run. The graph is walked + depth-first, and each SLS is loaded exactly once even if it is referenced + from multiple includes. +2. **Merge order.** States are added to the run in the order in which their + containing SLS files are *first encountered* during this depth-first walk. + The including SLS is processed last so that its states come after the + included SLS files. This is the resolution order, not the execution order. + +Execution order is determined by: + +* :ref:`requisites ` (``require``, ``watch``, ``onchanges``, + ``prereq``, ``listen``, etc.) — these set hard dependencies and override + resolution order. +* The :ref:`order ` global state argument — explicit numeric + ordering. +* The compiler's tie-breaker, which falls back to the resolution order + described above when no requisite or ``order`` applies. + +If you require a specific run order between states defined in different SLS +files, use a requisite. Relying on resolution order is fragile: rearranging +``include`` entries or restructuring a tree of includes can change the +resolved order without changing the YAML you're editing. + +Worked example +-------------- + +Consider the following SLS tree under ``salt://``:: + + top.sls + web/init.sls + web/config.sls + db/init.sls + +``top.sls``: + +.. code-block:: yaml + + base: + '*': + - web + +``web/init.sls``: + +.. code-block:: yaml + + include: + - db + - web.config + + web-pkg: + pkg.installed: + - name: nginx + +``web/config.sls``: + +.. code-block:: yaml + + /etc/nginx/nginx.conf: + file.managed: + - source: salt://web/files/nginx.conf + +``db/init.sls``: + +.. code-block:: yaml + + db-pkg: + pkg.installed: + - name: postgresql + +Salt resolves ``web`` as the top entry. It then walks ``include:`` depth-first: + +1. ``db`` is loaded. ``db-pkg`` is added to the run. +2. ``web.config`` is loaded. ``/etc/nginx/nginx.conf`` is added to the run. +3. The states defined directly in ``web/init.sls`` are added: ``web-pkg``. + +Without requisites the order is ``db-pkg``, ``/etc/nginx/nginx.conf``, +``web-pkg``. If ``web-pkg`` must run before ``/etc/nginx/nginx.conf``, do not +shuffle the ``include`` list; declare a ``require`` instead: + +.. code-block:: yaml + + /etc/nginx/nginx.conf: + file.managed: + - source: salt://web/files/nginx.conf + - require: + - pkg: web-pkg + +Cycles and duplicates +--------------------- + +* A cycle in ``include`` (``a`` includes ``b`` includes ``a``) is permitted at + resolution time because each SLS is loaded at most once. A cycle in + *requisites* is a hard error and is reported by the compiler. +* If two included SLS files both declare the same ID, the compiler raises a + duplicate-ID error. Duplicate IDs are checked before ``exclude`` is applied, + so you cannot use ``exclude`` to silence a duplicate-ID conflict. From 0bd9f64dacfc865a36131fd11995cd120dad34de Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:14:15 -0700 Subject: [PATCH 012/469] Document pillar merge strategies with worked example --- changelog/66733.fixed.md | 1 + doc/topics/pillar/index.rst | 116 ++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 changelog/66733.fixed.md diff --git a/changelog/66733.fixed.md b/changelog/66733.fixed.md new file mode 100644 index 000000000000..0e06dd5184df --- /dev/null +++ b/changelog/66733.fixed.md @@ -0,0 +1 @@ +Added a "Pillar Merge Strategies" section to `doc/topics/pillar/index.rst` summarising every value accepted by `pillar_source_merging_strategy` (`smart`, `recurse`, `aggregate`, `overwrite`, `none`) and how `pillar_merge_lists` and `pillar_includes_override_sls` affect the merged result, with a worked example. diff --git a/doc/topics/pillar/index.rst b/doc/topics/pillar/index.rst index 66e49c091a32..33cb758b6418 100644 --- a/doc/topics/pillar/index.rst +++ b/doc/topics/pillar/index.rst @@ -337,6 +337,122 @@ Since both pillar SLS files contained a ``bind`` key which contained a nested dictionary, the pillar dictionary's ``bind`` key contains the combined contents of both SLS files' ``bind`` keys. +.. _pillar-merge-strategies: + +Pillar Merge Strategies +======================= + +When pillar data comes from multiple sources (multiple SLS files in the +``top.sls``, plus external pillars and pillar includes), Salt must decide +how to combine overlapping keys. The behavior is controlled by two master +options: + +* :conf_master:`pillar_source_merging_strategy` selects how *dictionaries* + from different sources are combined. Allowed values are: + + .. list-table:: + :header-rows: 1 + :widths: 14 86 + + * - Strategy + - Behavior + * - ``smart`` (default) + - Picks ``recurse`` unless the renderer pipeline ends in ``yamlex``, + in which case it picks ``aggregate``. This is what most users want. + * - ``recurse`` + - Recursively merges nested dictionaries. Keys present in both + sources keep both branches; conflicting leaf values use the + later source. + * - ``aggregate`` + - Aggregates values for entries tagged with ``!aggregate`` in the + yamlex renderer. Requires the renderer pipeline to end in + ``yamlex``. + * - ``overwrite`` + - Discards earlier sources whenever a later source declares the + same key. This is the pre-2014.1 behavior. + * - ``none`` + - Does not merge at all. Only the requested environment (and + ``base`` as a fallback) is consulted. + +* :conf_master:`pillar_merge_lists` controls how *lists* are merged when + ``pillar_source_merging_strategy`` is ``recurse`` or ``smart`` (and the + smart-selected strategy is ``recurse``): + + * ``False`` (default): the later source replaces the earlier list. + * ``True``: the later list is appended to the earlier one. Order is + preserved; duplicates are kept. + +Worked example +-------------- + +Given two pillar SLS files merged via ``recurse``: + +``a.sls``: + +.. code-block:: yaml + + web: + vhosts: + - example.com + tls: + cert: /etc/ssl/site.crt + key: /etc/ssl/site.key + +``b.sls``: + +.. code-block:: yaml + + web: + vhosts: + - admin.example.com + tls: + cert: /etc/ssl/site-2024.crt + +With ``pillar_merge_lists: False`` (default) the merged result is: + +.. code-block:: yaml + + web: + vhosts: + - admin.example.com + tls: + cert: /etc/ssl/site-2024.crt + key: /etc/ssl/site.key + +With ``pillar_merge_lists: True`` the merged result is: + +.. code-block:: yaml + + web: + vhosts: + - example.com + - admin.example.com + tls: + cert: /etc/ssl/site-2024.crt + key: /etc/ssl/site.key + +Notice that the ``tls`` dictionary is recursively merged (``key`` is +preserved from ``a.sls``) regardless of ``pillar_merge_lists``; the option +only changes how *lists* are handled. + +Pillar includes +--------------- + +A separate option, :conf_master:`pillar_includes_override_sls`, controls +the ordering between an SLS file and its ``include:`` entries. Since +2017.7.3 the default is to merge all includes together first and then +merge the including SLS on top, so the including SLS wins on conflicts. +Set this option to ``True`` to restore the pre-2017.7.3 behavior, where +the includes are layered on top of the SLS. + +Grain merging +------------- + +Grains follow a simpler rule: grains discovered from grain modules are +combined with grains declared in the minion config, and the minion config +always wins on conflicting keys. Pillar merge strategies do not apply to +grains. + .. _pillar-include: Including Other Pillars From 4ca10edf9e073ce9d746097d43f562a2d5211a7c Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:14:22 -0700 Subject: [PATCH 013/469] Document chained __salt__/__pillar__ availability --- changelog/58420.fixed.md | 1 + doc/topics/development/modules/developing.rst | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 changelog/58420.fixed.md diff --git a/changelog/58420.fixed.md b/changelog/58420.fixed.md new file mode 100644 index 000000000000..d0b341aec8e3 --- /dev/null +++ b/changelog/58420.fixed.md @@ -0,0 +1 @@ +Documented the availability of `__salt__` and `__pillar__` for chained execution-module calls in `doc/topics/development/modules/developing.rst`, including the rule that `__salt__` is fully populated for any function call but is unreliable inside `__virtual__` and at import time. diff --git a/doc/topics/development/modules/developing.rst b/doc/topics/development/modules/developing.rst index 89e10dec561a..d5c3d1eea2b9 100644 --- a/doc/topics/development/modules/developing.rst +++ b/doc/topics/development/modules/developing.rst @@ -232,6 +232,39 @@ functions to be called as they have been set up by the salt loader. When used in runners or outputters, ``__salt__`` references other runner/outputter modules, and not execution modules. +Chained ``__salt__`` calls +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``__salt__`` is a fully populated loader dictionary by the time any +execution module function is called. That means it is safe to call other +execution modules from within an execution module, including transitively: +the call to ``__salt__["pkg.install"]("nginx")`` can itself rely on +``pkg.install`` calling ``__salt__["cmd.run"]`` internally. The execution +loader is reentrant and the same ``__salt__`` instance is shared across +the whole call chain. + +There are two cases where ``__salt__`` is *not* available, both of which +happen *before* the loader has finished populating itself: + +* Inside ``__virtual__``. At this point the module is being decided about, + and other modules may not yet have been loaded. ``__pillar__`` and + ``__grains__`` are available; ``__salt__`` is not reliable. +* Inside module-level code that runs at import time (top-level statements + in the file outside any function). Move such code into the function + bodies or guard it behind a helper that is called from a regular + function. + +Inside ``mod_init`` for state modules, ``__salt__`` is fully available. + +In renderers and pillar modules, ``__salt__`` and ``__pillar__`` are both +available while the render or pillar compilation is in progress. This +makes it safe to call execution modules from a Jinja template +(``{{ salt['cmd.run']('uname -r') }}``) and to read other pillar values +(``{{ pillar.get('mysql:password') }}``). The pillar passed to the +renderer is the pillar as compiled up to that point; do not rely on a +key being present in pillar if it was added later by a different ext +pillar. + __grains__ ---------- From 1e3759487547a81ee3eba2935f1821f7726cf924 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:14:28 -0700 Subject: [PATCH 014/469] Document http.query kwargs and add covering test --- changelog/59930.fixed.md | 1 + salt/modules/http.py | 84 +++++++++++++++--- .../unit/modules/test_http_documented.py | 87 +++++++++++++++++++ 3 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 changelog/59930.fixed.md create mode 100644 tests/pytests/unit/modules/test_http_documented.py diff --git a/changelog/59930.fixed.md b/changelog/59930.fixed.md new file mode 100644 index 000000000000..e8e7de5328e7 --- /dev/null +++ b/changelog/59930.fixed.md @@ -0,0 +1 @@ +Documented the keyword arguments accepted by `http.query` directly in the execution module's docstring (`salt/modules/http.py`), grouping them by request, headers, authentication, TLS, cookies, response decoding, streaming, output capture, form data, transport and error handling. Added `tests/pytests/unit/modules/test_http_documented.py` that asserts every documented kwarg name exists as a real parameter of `salt.utils.http.query` so the documentation cannot silently drift from the implementation. diff --git a/salt/modules/http.py b/salt/modules/http.py index 6252b21dc16d..1421a2468403 100644 --- a/salt/modules/http.py +++ b/salt/modules/http.py @@ -15,16 +15,80 @@ def query(url, **kwargs): """ .. versionadded:: 2015.5.0 - Query a resource, and decode the return data - - Passes through all the parameters described in the - :py:func:`utils.http.query function `: - - .. autofunction:: salt.utils.http.query - - raise_error : True - If ``False``, and if a connection cannot be made, the error will be - suppressed and the body of the return will simply be ``None``. + Query a resource, and decode the return data. + + All keyword arguments are forwarded to + :py:func:`salt.utils.http.query`. The most commonly used kwargs are + summarized below; see the underlying utility for the full reference. + + Request + ``method`` (default ``GET``), ``params`` (query string dict), + ``data`` (request body string), ``data_file`` (path or salt:// URL + to read body from), ``data_render`` / ``data_renderer`` to render + the body through a Salt renderer, ``template_dict`` of values to + expose when rendering. + + Headers + ``header_dict`` (dict of headers), ``header_list`` (list of + ``Name: value`` strings), ``header_file`` (path or salt:// URL), + ``header_render`` / ``header_renderer`` to render headers through a + Salt renderer. + + Authentication + ``username`` and ``password`` for HTTP basic auth, ``auth`` for a + pre-built ``(user, pass)`` tuple, ``cert`` for a client certificate + path or ``(cert, key)`` pair. + + TLS + ``verify_ssl`` (default ``True``), ``ca_bundle`` to point at an + alternate CA bundle. Set ``verify_ssl=False`` only for trusted + development endpoints. + + Cookies and sessions + ``cookies`` to send a cookie jar, ``cookie_jar`` to load/save the + jar from disk, ``cookie_format`` (``lwp`` or ``mozilla``), + ``persist_session`` and ``session_cookie_jar`` to persist a session + across calls. + + Response decoding + ``decode`` (default ``False``) parses the response body using + ``decode_type`` (``auto``, ``json``, ``yaml``, ``xml`` or + ``plain``). ``decode_body`` (default ``True``) controls whether to + decode bytes to text at all. ``text`` returns the raw text body in + the result, ``status`` returns the HTTP status code, ``headers`` + returns response headers. + + Streaming + ``stream`` (default ``False``) streams the response body. + ``streaming_callback`` and ``header_callback`` receive chunks as + they arrive. + + Output capture + ``text_out``, ``headers_out`` and ``decode_out`` are paths to which + the corresponding parts of the response will be written. + + Form data + ``formdata`` (default ``False``) sends a multipart/form-data body. + ``formdata_fieldname`` and ``formdata_filename`` configure the file + part. + + Transport + ``backend`` (``tornado``, ``requests`` or ``urllib2``), + ``agent`` (``User-Agent`` header), ``port`` (used when the URL has + no explicit port), ``handle`` (default ``False``) returns the raw + backend response object. + + Error handling + ``raise_error`` (default ``True``). If ``False``, connection errors + are suppressed and the body of the return will simply be ``None``. + + Sensitive data + ``hide_fields`` is a list of header or form field names whose + values should be redacted in the logged trace output. + + Test mode + ``test`` (default ``False``) and ``test_url`` allow you to dry-run + the request against a fixture URL without making the real call. CLI Example: diff --git a/tests/pytests/unit/modules/test_http_documented.py b/tests/pytests/unit/modules/test_http_documented.py new file mode 100644 index 000000000000..7978f9fa8c04 --- /dev/null +++ b/tests/pytests/unit/modules/test_http_documented.py @@ -0,0 +1,87 @@ +""" +Verify that the kwargs documented in the ``http.query`` execution module +docstring are real keyword arguments of :func:`salt.utils.http.query`. + +If a kwarg is renamed, removed, or replaced, this test fails and the +documentation must be updated to match. +""" + +import inspect + +import pytest + +import salt.utils.http + +# Names that ``salt/modules/http.py``'s docstring promises to forward to +# salt.utils.http.query. Grouped only for readability. +DOCUMENTED_KWARGS = [ + # request + "method", + "params", + "data", + "data_file", + "data_render", + "data_renderer", + "template_dict", + # headers + "header_dict", + "header_list", + "header_file", + "header_render", + "header_renderer", + # authentication + "username", + "password", + "auth", + "cert", + # tls + "verify_ssl", + "ca_bundle", + # cookies and sessions + "cookies", + "cookie_jar", + "cookie_format", + "persist_session", + "session_cookie_jar", + # response decoding + "decode", + "decode_type", + "decode_body", + "text", + "status", + "headers", + # streaming + "stream", + "streaming_callback", + "header_callback", + # output capture + "text_out", + "headers_out", + "decode_out", + # form data + "formdata", + "formdata_fieldname", + "formdata_filename", + # transport + "backend", + "agent", + "port", + "handle", + # error handling + "raise_error", + # sensitive data + "hide_fields", + # test mode + "test", + "test_url", +] + + +@pytest.mark.parametrize("kwarg", DOCUMENTED_KWARGS) +def test_documented_http_query_kwarg_is_real(kwarg): + """Each documented kwarg name must appear in salt.utils.http.query().""" + sig = inspect.signature(salt.utils.http.query) + assert kwarg in sig.parameters, ( + f"http.query docstring references {kwarg!r} but it is not a real " + f"parameter of salt.utils.http.query" + ) From e54bfeadba2060a97c9337e35d52f10a76a19c38 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:14:48 -0700 Subject: [PATCH 015/469] Replace broken slots example with runnable, tested example --- changelog/61073.fixed.md | 1 + doc/topics/slots/index.rst | 53 ++++++++++++++++ .../functional/test_slots_documented.py | 62 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 changelog/61073.fixed.md create mode 100644 tests/pytests/functional/test_slots_documented.py diff --git a/changelog/61073.fixed.md b/changelog/61073.fixed.md new file mode 100644 index 000000000000..3667419ef756 --- /dev/null +++ b/changelog/61073.fixed.md @@ -0,0 +1 @@ +Replaced the broken slots example in `doc/topics/slots/index.rst` with a runnable example using `test.echo` and `grains.get`, and added a documented limitations section. The new functional test `tests/pytests/functional/test_slots_documented.py` renders the example through `state.apply` and asserts the slot-resolved values land in the state arguments. diff --git a/doc/topics/slots/index.rst b/doc/topics/slots/index.rst index 354d44ee2ec9..38b846c18015 100644 --- a/doc/topics/slots/index.rst +++ b/doc/topics/slots/index.rst @@ -68,3 +68,56 @@ Here is an example of result parsing and appending: file.copy: - name: __slot__:salt:user.info(someuser).home ~ /subdirectory - source: salt://somefile + +Runnable example +---------------- + +The following SLS is fully runnable on any minion. It uses ``test.echo`` to +return a string and ``grains.get`` to return a value from grains, then uses the +returned values as state arguments. Because slot evaluation happens just before +the state function is called, the values are resolved at run time rather than +compile time. + +.. code-block:: yaml + + # /srv/salt/slots-example.sls + + write-os-marker: + file.managed: + - name: __slot__:salt:test.echo(/tmp/os_marker) + - contents: __slot__:salt:grains.get(os) ~ "\n" + - makedirs: True + +Applying ``state.apply slots-example`` writes ``/tmp/os_marker`` containing the +value of the ``os`` grain followed by a newline. The same SLS works on every +minion regardless of the grain value because the slot is resolved per minion. + +Result parsing with ``.dictionary`` +----------------------------------- + +When the called execution function returns a dictionary, append +``.`` to drill into the result. Nested keys can be chained with ``.``: + +.. code-block:: yaml + + write-home-marker: + file.managed: + - name: __slot__:salt:user.info(root).home ~ "/marker" + - contents: managed by salt + - makedirs: True + +In this example ``user.info`` returns a dictionary and the slot resolves to the +value of the ``home`` key, with the literal string ``/marker`` appended via the +``~`` operator. + +Limitations +----------- + +* Only execution module functions are supported. The slot syntax must start with + ``__slot__:salt:``. +* Arguments are not quoted and are always treated as strings. To pass a literal + value containing commas or parentheses, use a keyword argument instead. +* If the function call cannot be parsed or the function name is unknown, the + literal slot string is preserved unchanged and a warning is logged. +* If the parsed return is not a string, attempting to append text via ``~`` is + ignored and an error is logged. diff --git a/tests/pytests/functional/test_slots_documented.py b/tests/pytests/functional/test_slots_documented.py new file mode 100644 index 000000000000..3280dd9c69a4 --- /dev/null +++ b/tests/pytests/functional/test_slots_documented.py @@ -0,0 +1,62 @@ +""" +Tests for the documented slots examples in ``doc/topics/slots/index.rst``. + +These tests render the documented SLS samples through ``state.apply`` and +assert the slot-resolved values land in the state arguments. +""" + +import pytest + +pytestmark = [ + pytest.mark.windows_whitelisted, + pytest.mark.core_test, +] + + +@pytest.fixture(scope="module") +def state(modules): + return modules.state + + +def test_documented_slot_in_arg(state, state_tree, tmp_path): + """ + The slot returns a string and that string is used as the state arg. + + Documented example: ``name: __slot__:salt:test.echo()``. + """ + marker = tmp_path / "slots_marker_arg" + sls = f""" + write-arg-marker: + file.managed: + - name: __slot__:salt:test.echo({marker}) + - contents: arg-resolved + - makedirs: True + """ + with pytest.helpers.temp_file("slots_arg.sls", sls, state_tree): + ret = state.sls("slots_arg") + assert ret.failed is False, ret.raw + assert marker.exists(), f"expected {marker} to be created via slot-resolved name" + assert marker.read_text().rstrip() == "arg-resolved" + + +def test_documented_slot_append(state, state_tree, tmp_path): + """ + The slot returns a string and ``~`` appends a literal suffix. + + Documented example: ``__slot__:salt:test.echo() ~ "/suffix"``. + """ + base = tmp_path / "slots_base" + base.mkdir() + expected = base / "appended" + sls = f""" + write-appended-marker: + file.managed: + - name: __slot__:salt:test.echo({base}) ~ "/appended" + - contents: append-resolved + - makedirs: True + """ + with pytest.helpers.temp_file("slots_append.sls", sls, state_tree): + ret = state.sls("slots_append") + assert ret.failed is False, ret.raw + assert expected.exists(), f"expected {expected} to be created via appended slot" + assert expected.read_text().rstrip() == "append-resolved" From 2ca6ee85f4d983f1f72a76610dfc0e0262a321bf Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:14:59 -0700 Subject: [PATCH 016/469] Document salt.state options and orchestration quorum patterns --- changelog/55021.fixed.md | 1 + changelog/60979.fixed.md | 1 + doc/topics/orchestrate/orchestrate_runner.rst | 128 ++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 changelog/55021.fixed.md create mode 100644 changelog/60979.fixed.md diff --git a/changelog/55021.fixed.md b/changelog/55021.fixed.md new file mode 100644 index 000000000000..85eaf14afe2b --- /dev/null +++ b/changelog/55021.fixed.md @@ -0,0 +1 @@ +Added a "salt.state options reference" to `doc/topics/orchestrate/orchestrate_runner.rst` enumerating every option accepted by `salt.states.saltmod.state` (targeting, environment, failure semantics, concurrency, return handling, salt-ssh) grouped by concern. diff --git a/changelog/60979.fixed.md b/changelog/60979.fixed.md new file mode 100644 index 000000000000..466f23d573d6 --- /dev/null +++ b/changelog/60979.fixed.md @@ -0,0 +1 @@ +Documented in `doc/topics/orchestrate/orchestrate_runner.rst` how `salt.state`'s aggregate `result` is computed, how to use `allow_fail` to express "succeed if at least N minions returned ok", and how to compute N dynamically from the matched-minion count. diff --git a/doc/topics/orchestrate/orchestrate_runner.rst b/doc/topics/orchestrate/orchestrate_runner.rst index 11fbab63a04f..c36d4f9d72e9 100644 --- a/doc/topics/orchestrate/orchestrate_runner.rst +++ b/doc/topics/orchestrate/orchestrate_runner.rst @@ -209,6 +209,80 @@ To run a highstate, set ``highstate: True`` in your state config: salt-run state.orchestrate orch.web_setup +salt.state options reference +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``salt.state`` is the most commonly used orchestration step. It fans out a +state run to a set of minions and reports the aggregated result back to the +orchestrator. The following options are accepted; see +:mod:`salt.states.saltmod.state ` for the +canonical reference. + +Targeting + ``tgt`` (required) and ``tgt_type`` (default ``glob``) select which + minions execute the state. + +What to run + Exactly one of ``sls``, ``top``, or ``highstate: True`` must be + supplied. ``sls`` accepts a string or list of SLS files. ``exclude`` + excludes a state or SLS from the run. + +Environment + ``saltenv`` selects the file-server environment; ``pillarenv`` + selects the pillar environment; ``pillar`` injects inline pillar data + for the run. + +Failure semantics + * ``expect_minions`` (default ``True``) — if any targeted minion does + not respond, the orchestrator state fails. + * ``fail_minions`` — list of minion IDs whose failure should not be + treated as a failure of the orchestrator state. + * ``allow_fail`` (default ``0``) — number of minions that may fail + before the orchestrator state reports failure. + * ``failhard`` — propagate Salt's global ``failhard`` setting to the + child run. + * ``test`` — force ``test=True`` or ``test=False`` on the child run, + overriding the orchestrator's own test mode. + +Concurrency and batching + * ``concurrent`` (default ``False``) — allow multiple state runs at + once. Use with care; the child runs are not isolated from each other + on the minion. + * ``batch`` — run in batches, e.g. ``"10%"`` or ``"5"``. + * ``subset`` — randomly select N minions from the matched set. + * ``queue`` — pass ``queue=True`` to the child run. + * ``timeout`` — override the publish timeout for the orchestration + step. + +Return handling + * ``ret`` — one or a list of returner names to which the child run + should send its results. + * ``ret_config`` and ``ret_kwargs`` — override the returner + configuration block or pass per-call kwargs. + +Salt SSH + Set ``ssh: True`` to dispatch the child run through ``salt-ssh``. In + that case ``roster`` selects the roster system. + +Example using failure controls: + +.. code-block:: yaml + + # /srv/salt/orch/rollout.sls + rollout_web: + salt.state: + - tgt: 'web*' + - sls: + - web.config + - batch: 25% + - allow_fail: 2 + - fail_minions: + - web-canary-01 + +The above runs ``web.config`` on all ``web*`` minions in 25% batches, +treats failures on ``web-canary-01`` as expected, and only fails the +orchestrator step if more than two other minions fail. + Runner ^^^^^^ @@ -604,6 +678,60 @@ used to handle their failures in the same way ``salt.state`` jobs did, and this has likewise been corrected. +.. _orchestrate-runner-partial-success: + +Requiring "at least N" successful returns +----------------------------------------- + +``salt.state`` reports a single boolean ``result`` for the orchestration +step that aggregates every targeted minion's individual result. The +aggregation rules are: + +* ``result: True`` if every targeted minion returned a state run whose + states all succeeded. +* ``result: False`` if any targeted minion failed, unless the failures + are tolerated by ``allow_fail`` or excused by ``fail_minions``. + +When you want to express "succeed if at least N minions returned ok", +use ``allow_fail`` with N = (matched - required): + +.. code-block:: yaml + + # /srv/salt/orch/quorum.sls + apply-config: + salt.state: + - tgt: 'role:web' + - tgt_type: grain + - sls: + - web.config + # 5 web minions targeted; succeed if at least 3 return ok. + - allow_fail: 2 + +If you don't know the matched count in advance — for example because the +target glob may match a variable number of minions — you can drive the +threshold from a runner that counts the matches first and templates the +orchestration with the actual N: + +.. code-block:: jinja + + {% set matched = salt['cache.grains'](tgt='role:web', tgt_type='grain') | length %} + {% set required = 3 %} + + apply-config: + salt.state: + - tgt: 'role:web' + - tgt_type: grain + - sls: + - web.config + - allow_fail: {{ [matched - required, 0] | max }} + +For finer-grained control — for example "succeed only if at least two +specific minions returned a non-empty changes dict" — use ``salt.runner`` +to call the :py:func:`saltutil.runner ` or +a custom runner that inspects the return data structure shown in +:ref:`orchestrate-runner-parsing-results-programatically` and sets +``__context__["retcode"]`` accordingly. + Running States on the Master without a Minion ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 9a945a7095c83d7642024325128c08c7ba605489 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:15:08 -0700 Subject: [PATCH 017/469] Clarify slspath/tpldir availability in template contexts --- changelog/41195.fixed.md | 1 + doc/ref/states/vars.rst | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 changelog/41195.fixed.md diff --git a/changelog/41195.fixed.md b/changelog/41195.fixed.md new file mode 100644 index 000000000000..4b1852842303 --- /dev/null +++ b/changelog/41195.fixed.md @@ -0,0 +1 @@ +Documented in `doc/ref/states/vars.rst` that `slspath`, `tpldir`, and friends are render-time variables of the state compiler and are not available inside templates rendered through `file.managed`/`template: jinja`; the correct way to use them in such templates is to pass them via `defaults`/`context`. diff --git a/doc/ref/states/vars.rst b/doc/ref/states/vars.rst index b340028cf61c..aafbef4d3bf4 100644 --- a/doc/ref/states/vars.rst +++ b/doc/ref/states/vars.rst @@ -119,6 +119,41 @@ will return "" {{ slspath }} +When ``slspath`` and ``tpldir`` are populated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``slspath`` and ``tpldir`` are template render-time variables. They are +injected by Salt's state compiler when the SLS template is rendered as a +state file. As a result: + +* They are populated inside any SLS file that is being rendered as state + data (top files, included SLS files, and the SLS being applied). +* They are **not** populated inside templates that are rendered through + ``file.managed`` or ``template: jinja`` for non-state files. In those + contexts the template is rendered by the renderer subsystem, not by + the state compiler, and the state-only template variables are not in + scope. To get the SLS path inside a non-state template, pass it + explicitly via ``defaults`` or ``context``: + + .. code-block:: yaml + + configure-app: + file.managed: + - name: /etc/app.conf + - source: salt://app/files/app.conf.j2 + - template: jinja + - defaults: + sls_dir: {{ slspath }} + + Inside ``app.conf.j2`` the template can then use ``{{ sls_dir }}``. + +* When using a Jinja ``{% include %}`` from within an SLS file, the + included template inherits the current SLS render context, so + ``slspath`` and ``tpldir`` continue to refer to the *including* SLS. + When using Salt's ``include:`` directive at the top of an SLS file to + pull in another SLS, each SLS sees its own ``slspath`` while it is + being rendered. + sls_path -------- From bdf2a63969f4b7c2dd21a141e68d28cc103e3add Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:15:16 -0700 Subject: [PATCH 018/469] Document wheel.key.delete_dict status semantics --- changelog/56208.fixed.md | 1 + salt/wheel/key.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 changelog/56208.fixed.md diff --git a/changelog/56208.fixed.md b/changelog/56208.fixed.md new file mode 100644 index 000000000000..a0207e250201 --- /dev/null +++ b/changelog/56208.fixed.md @@ -0,0 +1 @@ +Documented the actual code path of `wheel.key.delete_dict` in `salt/wheel/key.py`: the function iterates the supplied dict by status (`minions`, `minions_pre`, `minions_rejected`, `minions_denied`) and silently skips entries that are not present under the requested status. To delete a key whose status is unknown, use `wheel.key.delete` with a glob match instead. diff --git a/salt/wheel/key.py b/salt/wheel/key.py index 4883345bfb34..29c9cca54a79 100644 --- a/salt/wheel/key.py +++ b/salt/wheel/key.py @@ -183,10 +183,29 @@ def delete(match): def delete_dict(match): """ - Delete keys based on a dict of keys. Returns a dictionary. + Delete keys based on a dict of keys grouped by key status. Returns a + dictionary describing the keys that remain after the deletion. match - The dictionary of keys to delete. + A dictionary keyed by key status. Recognized statuses are: + + * ``minions`` (accepted) + * ``minions_pre`` (unaccepted / pending) + * ``minions_rejected`` + * ``minions_denied`` + + Each value is a list of minion key names under that status. The + wheel will iterate the dictionary as-is and attempt to remove each + listed key from the directory named by the status. Keys that do + not exist on disk under the requested status are silently + skipped: ``delete_dict`` does **not** look the key up by name, so + passing an unaccepted minion under ``minions`` will simply do + nothing for that key (and the minion's pending key will remain + in place). + + If you want to delete keys regardless of their current status, + either gather the dictionary with :func:`list_match` first, or use + :func:`delete` with a glob match instead. .. code-block:: python @@ -199,6 +218,18 @@ def delete_dict(match): ], }}) {'jid': '20160826201244808521', 'tag': 'salt/wheel/20160826201244808521'} + + Example using more than one status to delete a mix of accepted and + pending keys in one call: + + .. code-block:: python + + >>> wheel.cmd('key.delete_dict', [ + ... { + ... 'minions': ['accepted-1'], + ... 'minions_pre': ['pending-1', 'pending-2'], + ... } + ... ]) """ with salt.key.get_key(__opts__) as skey: return skey.delete_key(match_dict=match) From ce1a05ea5bf9f09046f95091d875a52eb322580c Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 16:15:28 -0700 Subject: [PATCH 019/469] Rewrite standalone-minion description with concrete differences --- changelog/57488.fixed.md | 1 + doc/topics/tutorials/standalone_minion.rst | 67 +++++++++++++++++----- 2 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 changelog/57488.fixed.md diff --git a/changelog/57488.fixed.md b/changelog/57488.fixed.md new file mode 100644 index 000000000000..4c129b78bae6 --- /dev/null +++ b/changelog/57488.fixed.md @@ -0,0 +1 @@ +Rewrote the standalone-minion introduction in `doc/topics/tutorials/standalone_minion.rst` to give a concrete description of what a standalone minion is, when to use one, and the practical differences from a master-connected minion (targeting, file/pillar roots, ext-pillar, mine/jobs availability, two operating modes). diff --git a/doc/topics/tutorials/standalone_minion.rst b/doc/topics/tutorials/standalone_minion.rst index df482e351cd0..7fd623fef408 100644 --- a/doc/topics/tutorials/standalone_minion.rst +++ b/doc/topics/tutorials/standalone_minion.rst @@ -4,24 +4,61 @@ Standalone Minion ================= -Since the Salt minion contains such extensive functionality it can be useful -to run it standalone. A standalone minion can be used to do a number of -things: - -- Use salt-call commands on a system without connectivity to a master -- Masterless States, run states entirely from files local to the minion +A standalone (or *masterless*) Salt minion is a Salt minion installation +that is not connected to a Salt master and runs everything locally. The +same code paths that execute on a normal minion run on a standalone +minion; what changes is the source of configuration, state files, and +pillar data, all of which come from local paths instead of the master's +file server. + +A standalone minion is useful for: + +- Running configuration management on hosts that have no network path to + a Salt master (air-gapped systems, build agents, kiosks, single-server + environments). +- Bootstrapping a system from local SLS files before joining it to a + master (or as part of an image build pipeline). +- Local testing and development of state, pillar, or formula code with + fast feedback via ``salt-call --local`` against checked-out SLS trees. +- Triggering :ref:`reactor ` and :ref:`beacons ` flows + on a host that does not publish events to a master. + +How a standalone minion differs from a master-connected minion: + +- **Targeting is implicit.** ``salt-call`` always operates on the local + host. There is no ``salt`` CLI for fanning out to other minions because + there is no master. +- **File and pillar roots are local.** ``file_roots`` and ``pillar_roots`` + on the minion point at directories on the local filesystem (typically + ``/srv/salt`` and ``/srv/pillar``). The minion does not fetch SLS files + over the wire. +- **External pillars still work.** :ref:`External pillars + ` (for example, gitfs or vault) can still be + configured on a standalone minion, as long as the minion can reach the + external source. +- **No mine, no jobs, no events to the master.** Anything that requires a + master — the mine, multi-minion targeting, master-side returners, the + reactor that runs on the master — is unavailable. Local-only reactors + and engines do work. + +There are two practical ways to operate a standalone minion: + +1. **No daemon, just ``salt-call --local``.** This is the simplest mode. + You do not run the ``salt-minion`` service at all; you invoke + ``salt-call --local `` on demand. Use this when the host + only needs to be configured during provisioning or on a manual cadence. +2. **Running ``salt-minion`` with no master.** When you want beacons, + engines, schedules, or a local reactor running continuously without a + master connection, set :conf_minion:`master_type` to ``disable`` so + the daemon does not attempt to connect to a master. .. note:: - When running Salt in masterless mode, it is not required to run the - salt-minion daemon. By default the salt-minion daemon will attempt to - connect to a master and fail. The salt-call command stands on its own - and does not need the salt-minion daemon. - - As of version 2016.11.0 you can have a running minion (with engines and - beacons) without a master connection. If you wish to run the salt-minion - daemon you will need to set the :conf_minion:`master_type` configuration - setting to be set to 'disable'. + By default the salt-minion daemon will attempt to connect to a master + and fail. The salt-call command stands on its own and does not need + the salt-minion daemon. As of version 2016.11.0 you can run the + salt-minion daemon without a master connection by setting + :conf_minion:`master_type` to ``disable``. From 7ec32d645e52b3c163a458697c1e387788e42607 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 30 Jun 2026 15:12:02 -0700 Subject: [PATCH 020/469] Skip saltutil runner/wheel privilege drop on invalid user state.orchestrate overwrites __opts__["user"] with __user__ (the publishing user, salt.utils.user.get_specific_user(), which returns "sudo_" when salt-run was launched under sudo). The post-#67716 privilege-drop path in saltutil.runner/saltutil.wheel reads that value as the runas target and asks chugid to switch to it, which then raises KeyError from pwd.getpwnam wrapped in CommandExecutionError: Failed to run 'cache.grains' as user 'sudo_alice': KeyError: "getpwnam(): name not found: 'sudo_alice'" Validate the candidate against the passwd database in _master_user_runas and skip the privilege drop when it does not resolve to a real account, falling back to historical in-process behavior. Fixes #69600 --- changelog/69600.fixed.md | 1 + salt/modules/saltutil.py | 20 +++++++++++++ tests/pytests/unit/modules/test_saltutil.py | 32 ++++++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 changelog/69600.fixed.md diff --git a/changelog/69600.fixed.md b/changelog/69600.fixed.md new file mode 100644 index 000000000000..af8611001e61 --- /dev/null +++ b/changelog/69600.fixed.md @@ -0,0 +1 @@ +Fixed ``saltutil.runner`` and ``saltutil.wheel`` raising ``KeyError: "getpwnam(): name not found: 'sudo_'"`` when an orchestration (``salt-run state.orchestrate``) was launched under ``sudo`` and the rendered SLS called ``salt.saltutil.runner`` from Jinja. ``state.orchestrate`` overwrites ``__opts__["user"]`` with the publishing user (``salt.utils.user.get_specific_user()``, which returns ``"sudo_"`` under ``sudo``), and the post-#67716 privilege-drop path then tried to ``chugid`` to that non-existent account. The privilege-drop helper now validates the candidate against the passwd database and skips the drop when the configured ``user`` is not a real account, falling back to the historical in-process behavior. diff --git a/salt/modules/saltutil.py b/salt/modules/saltutil.py index 1e92e80e363b..baad8da1c596 100644 --- a/salt/modules/saltutil.py +++ b/salt/modules/saltutil.py @@ -1774,6 +1774,15 @@ def _master_user_runas(opts): the Salt master runs as the ``salt`` user by default, so those functions would otherwise touch master-owned resources (the git_pillar/gitfs cache, the pki tree, ...) as the wrong user. See #67716. + + The ``user`` value in ``opts`` is not always the master's configured + daemon user: ``state.orchestrate`` overwrites ``__opts__['user']`` with + the publishing user (``salt.utils.user.get_specific_user()``), which + returns ``"sudo_"`` when the call was made under ``sudo``. That + is not a real account, so attempting to drop to it would later raise + ``KeyError`` from ``pwd.getpwnam`` inside ``chugid``. Validate the + candidate against the passwd database and skip the privilege drop when + it does not resolve to a real user. See #69600. """ runas = opts.get("user") if not runas or runas == salt.utils.user.get_user(): @@ -1781,6 +1790,17 @@ def _master_user_runas(opts): # Changing users requires root; otherwise keep the historical behavior. if not hasattr(os, "geteuid") or os.geteuid() != 0: return None + if pwd is not None: + try: + pwd.getpwnam(runas) + except KeyError: + log.debug( + "Not dropping privileges: '%s' is not a real user on this " + "system (likely the publishing user copied into opts by " + "state.orchestrate, e.g. 'sudo_').", + runas, + ) + return None return runas diff --git a/tests/pytests/unit/modules/test_saltutil.py b/tests/pytests/unit/modules/test_saltutil.py index 2ed8fb809eaa..fcf42307cc57 100644 --- a/tests/pytests/unit/modules/test_saltutil.py +++ b/tests/pytests/unit/modules/test_saltutil.py @@ -232,7 +232,12 @@ def cmd(self, name, **kwargs): ({}, 0, "root", None), ), ) -def test_master_user_runas(opts, euid, current_user, expected): +def test_master_user_runas(opts, euid, current_user, expected, monkeypatch): + # The candidate user is validated against the passwd database; stub it + # so the configured ``salt`` user appears to exist on the test host. + monkeypatch.setattr( + saltutil, "pwd", types.SimpleNamespace(getpwnam=lambda user: None) + ) with patch("os.geteuid", return_value=euid), patch( "salt.utils.user.get_user", return_value=current_user ): @@ -398,6 +403,31 @@ def _raise(user): assert os.environ["HOME"] == "/root" +def test_master_user_runas_unknown_user_returns_none(monkeypatch): + """ + When ``opts['user']`` is not a real account on the system, + ``_master_user_runas`` must return ``None`` instead of returning a + name that would later blow up in ``pwd.getpwnam`` inside + ``_client_cmd_as`` / ``chugid`` (#69600). + + Regression: ``state.orchestrate`` overwrites ``__opts__['user']`` + with ``__user__`` (the value of ``salt.utils.user.get_specific_user()``), + which is ``"sudo_"`` whenever ``salt-run`` was launched under + ``sudo``. That name has no passwd entry, so attempting to drop to it + raised ``KeyError: "getpwnam(): name not found: 'sudo_'"`` + wrapped in ``CommandExecutionError``. + """ + + def _raise(user): + raise KeyError(user) + + monkeypatch.setattr(saltutil, "pwd", types.SimpleNamespace(getpwnam=_raise)) + with patch("os.geteuid", return_value=0), patch( + "salt.utils.user.get_user", return_value="root" + ): + assert saltutil._master_user_runas({"user": "sudo_alice"}) is None + + def test_align_runas_environment_without_pwd_is_noop(monkeypatch): """On platforms without the pwd module (Windows) the helper is a no-op.""" monkeypatch.setattr(saltutil, "pwd", None) From 731ec3ad4c4abec410bc87004ca803ab865f8dfe Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 25 Jun 2026 17:22:51 -0400 Subject: [PATCH 021/469] Fix load_yaml AttributeError under the libyaml loader SerializerExtension.load_yaml only guarded against a YAMLError whose problem_mark is absent. PyYAML's libyaml (C) loader populates problem_mark but leaves its buffer as None, so load_yaml took the else branch and passed buffer=None into salt.utils.stringutils.get_context(), which crashed with "AttributeError: 'NoneType' object has no attribute 'splitlines'" instead of raising the intended TemplateRuntimeError. Fall back to the stringified exception when the buffer is missing, and add a regression test. Fixes #69533 --- changelog/69533.fixed.md | 1 + salt/utils/jinja.py | 15 +++++++++++---- .../utils/jinja/test_custom_extensions.py | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 changelog/69533.fixed.md diff --git a/changelog/69533.fixed.md b/changelog/69533.fixed.md new file mode 100644 index 000000000000..e6aa8915e923 --- /dev/null +++ b/changelog/69533.fixed.md @@ -0,0 +1 @@ +Fixed `SerializerExtension.load_yaml` raising `AttributeError` instead of a `TemplateRuntimeError` when YAML parsing fails under PyYAML's libyaml (C) loader, which leaves `problem_mark.buffer` unset. diff --git a/salt/utils/jinja.py b/salt/utils/jinja.py index 384a69b2f462..6c4ff21e067d 100644 --- a/salt/utils/jinja.py +++ b/salt/utils/jinja.py @@ -1186,10 +1186,17 @@ def load_yaml(self, value): # to the stringified version of the exception. msg += str(exc) else: - msg += f"{problem}\n" - msg += salt.utils.stringutils.get_context( - buf, line, marker=" <======================" - ) + if buf is None: + # The libyaml (C) loader populates problem_mark but leaves + # its buffer unset, so there is no source text to render + # context from; fall back to the stringified exception + # rather than crash in get_context. + msg += str(exc) + else: + msg += f"{problem}\n" + msg += salt.utils.stringutils.get_context( + buf, line, marker=" <======================" + ) raise TemplateRuntimeError(msg) except AttributeError: raise TemplateRuntimeError(f"Unable to load yaml from {value}") diff --git a/tests/pytests/unit/utils/jinja/test_custom_extensions.py b/tests/pytests/unit/utils/jinja/test_custom_extensions.py index e1fcf46e9820..f1cd22a4aea2 100644 --- a/tests/pytests/unit/utils/jinja/test_custom_extensions.py +++ b/tests/pytests/unit/utils/jinja/test_custom_extensions.py @@ -25,6 +25,7 @@ from salt.utils.decorators.jinja import JinjaFilter from salt.utils.jinja import SerializerExtension, ensure_sequence_filter from salt.utils.templates import render_jinja_tmpl +from tests.support.mock import patch try: import timelib # pylint: disable=W0611 @@ -1295,3 +1296,21 @@ def test_ifelse(minion_opts, local_salt): dict(opts=minion_opts, saltenv="test", salt=local_salt), ) assert rendered == ("default\n" "fooval\n" "barval\n" "barval\n" "default") + + +def test_load_yaml_handles_marked_error_without_buffer(): + """A YAML error whose problem_mark has no buffer (as produced by the + libyaml C loader) must raise a clean TemplateRuntimeError, not crash.""" + env = Environment(extensions=[SerializerExtension]) + + class _Mark: + line = 0 + buffer = None + + err = salt.utils.yaml.YAMLError() + err.problem = "found unexpected end of stream" + err.problem_mark = _Mark() + + with patch("salt.utils.yaml.safe_load", side_effect=err): + with pytest.raises(exceptions.TemplateRuntimeError): + env.from_string("{{ 'x' | load_yaml }}").render() From cdaaab05319f424bc0664a31ec359c41bba0e301 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 17 Jun 2026 04:15:53 -0700 Subject: [PATCH 022/469] Remove linode-python dependency to silence install SyntaxWarnings (#69455) The unmaintained `linode-python` 1.1.1 package targets the retired Linode API v3 and uses `is not 0` / `is 1` against literals, which Python 3.12+ emits as `SyntaxWarning` from `linode/api.py` lines 293, 348, and 356. On RHEL/Rocky/Oracle Linux 9.x, the salt-common onedir's post-install scriptlet imports the onedir's Python which in turn imports `linode-python` and the warnings surface during every package install/upgrade. Drop `linode-python` from `requirements/static/pkg/linux.txt` and its dependent CI/pkg linux lockfiles so it is no longer installed into the salt-common onedir. `salt.cloud.clouds.linode` already uses the Linode APIv4 over HTTP/JSON (no `linode-python` import), so the package is purely vestigial. This backports #69339 (3006.x) and mirrors #68871 (master/3008.x) to 3007.x. Fixes #69455 --- changelog/69455.removed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/69455.removed.md diff --git a/changelog/69455.removed.md b/changelog/69455.removed.md new file mode 100644 index 000000000000..bbbfe4a94fa0 --- /dev/null +++ b/changelog/69455.removed.md @@ -0,0 +1 @@ +Removed the unmaintained `linode-python` package dependency to stop SyntaxWarnings during install for retired Linode API v3. From 4133687604b35cba969cf125d5ca9f74cef3e6bf Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Thu, 2 Jul 2026 02:25:18 -0700 Subject: [PATCH 023/469] Remove temporary Fedora 40 test skips (#69645) The Fedora 40 skips on test_peer_communication and test_grains_remove_add were added in 5a85699c8b4 (2024-05-17) as a temporary workaround referencing #66539 and #66540 with no root cause captured. Fedora 40 reached end-of-life on 2025-05-13 and the tests now pass without the workaround on 3007.x and master, so the skips can be dropped. Fixes #66540 Fixes #66539 --- changelog/66540.fixed.md | 1 + tests/pytests/integration/master/test_peer.py | 4 +--- tests/pytests/integration/modules/grains/test_append.py | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) create mode 100644 changelog/66540.fixed.md diff --git a/changelog/66540.fixed.md b/changelog/66540.fixed.md new file mode 100644 index 000000000000..e72633d41838 --- /dev/null +++ b/changelog/66540.fixed.md @@ -0,0 +1 @@ +Removed the temporary Fedora 40 skips from ``tests/pytests/integration/master/test_peer.py::test_peer_communication`` and ``tests/pytests/integration/modules/grains/test_append.py::test_grains_remove_add``. Fedora 40 reached end-of-life on 2025-05-13 and the tests now pass without the workaround. diff --git a/tests/pytests/integration/master/test_peer.py b/tests/pytests/integration/master/test_peer.py index a9552060f78b..a702f37195f3 100644 --- a/tests/pytests/integration/master/test_peer.py +++ b/tests/pytests/integration/master/test_peer.py @@ -110,9 +110,7 @@ def peer_salt_minion_3(peer_salt_master): @pytest.mark.parametrize( "source,target", ((x, y) for x in range(1, 4) for y in range(1, 4) if x != y) ) -def test_peer_communication(source, target, request, grains): - if grains["os"] == "Fedora" and grains["osmajorrelease"] >= 40: - pytest.skip(f"Temporary skip on {grains['osfinger']}") +def test_peer_communication(source, target, request): cli = request.getfixturevalue(f"peer_salt_minion_{source}").salt_call_cli() tgt = request.getfixturevalue(f"peer_salt_minion_{target}").id ret = cli.run("publish.publish", tgt, "test.ping") diff --git a/tests/pytests/integration/modules/grains/test_append.py b/tests/pytests/integration/modules/grains/test_append.py index 3634254ed434..0338d93ae51f 100644 --- a/tests/pytests/integration/modules/grains/test_append.py +++ b/tests/pytests/integration/modules/grains/test_append.py @@ -108,10 +108,8 @@ def test_grains_append_val_is_list(salt_call_cli, append_grain): @pytest.mark.timeout_unless_on_windows(300) def test_grains_remove_add( - salt_call_cli, append_grain, wait_for_pillar_refresh_complete, grains + salt_call_cli, append_grain, wait_for_pillar_refresh_complete ): - if grains["os"] == "Fedora" and grains["osmajorrelease"] >= 40: - pytest.skip(f"Temporary skip on {grains['osfinger']}") second_grain = append_grain.value + "-2" ret = salt_call_cli.run("grains.get", append_grain.key) assert ret.returncode == 0 From 138a0bbbe928bd28f4a3ed54c4c326525844e8c2 Mon Sep 17 00:00:00 2001 From: Teddy Andrieux Date: Thu, 2 Jul 2026 11:26:53 +0200 Subject: [PATCH 024/469] fix(x509_v2): unmask pillar values in _get_signing_policy (#69636) Since 3008, pillar.get masks scalar string values by default, so signing policies fetched from pillar came back as '**********' and certificate generation failed with "Bad decrypt - is the password correct?". Pass unmask=True so the policy retains its real values. Fixes: #69253 (cherry picked from commit 27307c5bff7bace965f14bcefffb7fdcb726ca77) Signed-off-by: Teddy Andrieux --- changelog/69253.fixed.md | 1 + salt/modules/x509_v2.py | 4 +- tests/pytests/unit/modules/test_x509_v2.py | 60 ++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 changelog/69253.fixed.md create mode 100644 tests/pytests/unit/modules/test_x509_v2.py diff --git a/changelog/69253.fixed.md b/changelog/69253.fixed.md new file mode 100644 index 000000000000..6afa73d57ac3 --- /dev/null +++ b/changelog/69253.fixed.md @@ -0,0 +1 @@ +Fix `x509.certificate_managed` failing with "Bad decrypt" when the signing policy is sourced from pillar by unmasking pillar values in `x509_v2._get_signing_policy`. diff --git a/salt/modules/x509_v2.py b/salt/modules/x509_v2.py index 53c528805737..099145b97949 100644 --- a/salt/modules/x509_v2.py +++ b/salt/modules/x509_v2.py @@ -2232,7 +2232,9 @@ def _generate_pk(algo="rsa", keysize=None): def _get_signing_policy(name): if name is None: return {} - policies = __salt__["pillar.get"]("x509_signing_policies", {}).get(name) + policies = __salt__["pillar.get"]("x509_signing_policies", {}, unmask=True).get( + name + ) policies = policies or __salt__["config.get"]("x509_signing_policies", {}).get(name) if isinstance(policies, list): dict_ = {} diff --git a/tests/pytests/unit/modules/test_x509_v2.py b/tests/pytests/unit/modules/test_x509_v2.py new file mode 100644 index 000000000000..88258855f45c --- /dev/null +++ b/tests/pytests/unit/modules/test_x509_v2.py @@ -0,0 +1,60 @@ +import pytest + +import salt.modules.x509_v2 as x509_v2 +import salt.utils.secret +from tests.support.mock import MagicMock, patch + +pytestmark = [ + pytest.mark.skipif( + not x509_v2.HAS_CRYPTOGRAPHY, reason="Needs cryptography library" + ), +] + + +@pytest.fixture +def configure_loader_modules(): + return {x509_v2: {"__salt__": {}, "__opts__": {}}} + + +def _pillar_get(masked_pillar): + """Build a fake pillar.get that mirrors salt.modules.pillar.get masking.""" + + def _get(key, default=None, unmask=None, **kwargs): + value = masked_pillar.get(key, default if default is not None else {}) + if unmask: + return salt.utils.secret.expose(value) + return salt.utils.secret.serial(value) + + return _get + + +def test_get_signing_policy_unmasks_pillar_values(): + """ + Regression test for issue #69253: _get_signing_policy must request + unmasked pillar values, otherwise scalar string values get replaced + by the redaction placeholder and signing fails. + """ + policy = { + "signing_private_key": "/etc/pki/ca.key", + "signing_cert": "/etc/pki/ca.crt", + "keyUsage": "critical, cRLSign, keyCertSign", + } + masked_pillar = salt.utils.secret.hide( + {"x509_signing_policies": {"mypolicy": policy}} + ) + + config_get = MagicMock(return_value={}) + with patch.dict( + x509_v2.__salt__, + {"pillar.get": _pillar_get(masked_pillar), "config.get": config_get}, + ): + result = x509_v2._get_signing_policy("mypolicy") + + assert result == policy + for value in result.values(): + assert value != salt.utils.secret.REDACT_PLACEHOLDER + + +def test_get_signing_policy_none_returns_empty(): + with patch.dict(x509_v2.__salt__, {}): + assert x509_v2._get_signing_policy(None) == {} From 55584fa961f348750eecc3dee5208c41ea24c3b0 Mon Sep 17 00:00:00 2001 From: Carry Sauce <11678665+carrysauce@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:56:12 +0300 Subject: [PATCH 025/469] fix timeout for salt-api (#62188) * fix timeout for salt-api * Add changelog and regression test for salt-api timeout fix Addresses @twangboy's CHANGES_REQUESTED on PR #62188. - changelog/62187.fixed.md describes the salt-api hang fix. - test_mk_token_missing_password_returns_empty pins the missing-password /username path: mk_token must return {} instead of raising SaltInvocationError, which previously escaped through the master clear-payload handler and hung salt-api workers for ~3 minutes per bad request. Co-authored-by: carrysauce * Apply reviewer suggestions: separate try/except for format_call, rename changelog to PR number - Give format_call its own try/except block catching SaltInvocationError with a descriptive debug message, as requested by @twangboy - Rename changelog/62187.fixed.md to changelog/62188.fixed.md (PR number, not issue number) --------- Co-authored-by: Alex Donec Co-authored-by: Daniel A. Wozniak Co-authored-by: carrysauce Co-authored-by: Daniel A. Wozniak --- changelog/62188.fixed.md | 1 + salt/auth/__init__.py | 15 ++++++++++--- tests/pytests/unit/test_auth.py | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 changelog/62188.fixed.md diff --git a/changelog/62188.fixed.md b/changelog/62188.fixed.md new file mode 100644 index 000000000000..f06c6070f1c4 --- /dev/null +++ b/changelog/62188.fixed.md @@ -0,0 +1 @@ +Fix salt-api hanging when an eauth `/login` request omits `password` or `username`. `salt.auth.LoadAuth.__auth_call` now catches the `SaltInvocationError` raised by `salt.utils.args.format_call` for malformed payloads and returns `False` instead of letting the exception escape into the ZeroMQ transport, which previously caused the client to wait for the full request retry cycle (~3 minutes) and blocked salt-api workers. diff --git a/salt/auth/__init__.py b/salt/auth/__init__.py index 46edc45f943f..e471c17f22c9 100644 --- a/salt/auth/__init__.py +++ b/salt/auth/__init__.py @@ -129,9 +129,18 @@ def __auth_call(self, load): _valid = ["username", "password", "eauth", "token"] _load = {key: value for (key, value) in load.items() if key in _valid} - fcall = salt.utils.args.format_call( - self.auth[fstr], _load, expected_extra_kws=AUTH_INTERNAL_KEYWORDS - ) + try: + fcall = salt.utils.args.format_call( + self.auth[fstr], _load, expected_extra_kws=AUTH_INTERNAL_KEYWORDS + ) + except salt.exceptions.SaltInvocationError as e: + log.debug( + "Authentication request for eauth '%s' is missing required " + "arguments: %s", + load.get("eauth"), + e, + ) + return False try: if "kwargs" in fcall: return self.auth[fstr](*fcall["args"], **fcall["kwargs"]) diff --git a/tests/pytests/unit/test_auth.py b/tests/pytests/unit/test_auth.py index 59a0e6e34ee9..db5c19b0a89f 100644 --- a/tests/pytests/unit/test_auth.py +++ b/tests/pytests/unit/test_auth.py @@ -1027,3 +1027,43 @@ def test_cve_2021_3244(tmp_path): t_data = auth.get_tok(t_data["token"]) assert not t_data assert not token_file.exists() + + +def test_mk_token_missing_password_returns_empty(tmp_path): + """ + Regression test for #62187. + + A salt-api ``/login`` request whose payload is missing the ``password`` + (or ``username``) argument must not raise out of ``LoadAuth.mk_token``. + Previously ``salt.utils.args.format_call`` was called outside the + ``try``/``except`` in ``LoadAuth.__auth_call``; the resulting + ``SaltInvocationError`` escaped through the master clear-payload handler + and the salt-api worker hung waiting on the ZeroMQ reply, retrying for + ~3 minutes per request and creating a DoS vector. + + The fix catches the exception and returns ``False`` from ``__auth_call`` + just like any other failed credential check, so ``mk_token`` returns an + empty dict and the caller gets an immediate ``401``-equivalent response. + """ + opts = { + "extension_modules": "", + "optimization_order": [0, 1, 2], + "token_expire": 1, + "keep_acl_in_token": False, + "eauth_tokens": "localfs", + "cachedir": str(tmp_path), + "token_expire_user_override": True, + "external_auth": {"auto": {"admin": [".*"]}}, + "eauth_tokens.cache_driver": None, + "eauth_tokens.cluster_id": None, + "cluster_id": None, + "hash_type": "sha256", + } + auth = salt.auth.LoadAuth(opts) + # /login payload missing ``password`` — must return {} (auth failure) + # rather than raise SaltInvocationError("auth takes at least 2 arguments"). + assert auth.mk_token({"eauth": "auto", "username": "admin"}) == {} + # Also covers the case where ``username`` is missing. + assert auth.mk_token({"eauth": "auto", "password": "whatever"}) == {} + # And the case where both are missing. + assert auth.mk_token({"eauth": "auto"}) == {} From 93b1d97e0dc9413e651c98b4d3066a0db785c79e Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Fri, 3 Jul 2026 15:36:33 -0600 Subject: [PATCH 026/469] Fix/69661/3006.x (#69662) * Fix onchanges requisites hard-failing when their target fails A failed onchanges/onchanges_any target was classified the same as a failed require/watch target, causing the dependent state to hard-fail instead of being skipped with result=True per the documented truth table. Also relocates test_slots_documented.py so its `state` fixture can resolve, and adds a regression test locking in prereq's already correct skip-on-failed-target behavior. * Fix inconsistent heading levels in highstate.rst breaking docs build The "Highstate Output" section's eight subsections (state_output, state_verbose, state_output_diff, state_output_pct, state_output_profile, state_tabular, state_compress_ids, Choosing a mode) used "~" underlines, which docutils resolves to heading level 4 since "~" had not appeared earlier in the document. Their parent section is level 2 ("="), so this skipped level 3 entirely, and Sphinx's man-page build (which runs with -W, treating warnings as errors) failed with eight "Inconsistent title style: skip from level 2 to 4" errors, breaking the "Generate MAN Pages" CI job. Change the eight subsection underlines from "~" to "-", matching the level-3 convention already used by every other subsection in this file (e.g. "Top file", "Include declaration"), restoring a consistent level 1/2/3 hierarchy with no skipped level. * Fix undefined :ref: label 'beacon' in standalone_minion.rst The tutorial referenced :ref:`beacons `, but no label named "beacon" exists; the beacons index page defines the label "beacons" (doc/topics/beacons/index.rst:1), matching how every other doc page links to it (e.g. doc/topics/development/modules/index.rst:181). The typo tripped Sphinx's -W (warnings-as-errors) html build with "WARNING: undefined label: 'beacon' [ref.ref]". * Fix IndexError in State.__eval_slot for non-dotted accessor When a slot expression has no dotted post-`)` accessor but has trailing whitespace, `return_get` was a truthy non-empty string that entered the dict-traversal branch and crashed on `split(".", 1)[1]`. Guard the branch on `.` being present instead. Also strip surrounding matching quotes from `~`-appended text so that documented examples like `~ "/suffix"` produce `/suffix`, not `"/suffix"`. Fixes #69661 * Replace macos-14 arm64 runner with macos-15 macos-14 begins deprecation on 2026-07-06 and is unsupported after 2026-11-02 per https://github.com/actions/runner-images/issues/13518 Ref: https://github.com/actions/runner-images/issues/13518 * Fix Windows slot documented tests dropping path backslashes The two tests in tests/pytests/functional/states/test_slots_documented.py embed a tmp_path directly into the SLS source, e.g.:: - name: __slot__:salt:test.echo(D:\a\_temp\...\slots_marker_arg) State.__eval_slot dispatches the ``test.echo(...)`` call through salt.utils.args.parse_function, which is built on top of ``shlex.shlex(posix=True)``. In POSIX mode shlex treats a backslash as a generic escape character, so on Windows every backslash in the path was consumed and the slot resolved to ``D:a_temppytest-of-...slots_marker_arg`` -- a relative path -- so file.managed rejected it with "not an absolute path" and the assertion that the marker file was created failed. Format the paths through ``PurePath.as_posix()`` before splicing them into the SLS. Windows accepts forward-slash separators for filesystem paths, and shlex leaves ``/`` untouched, so the slot now resolves to the same path the assertion checks. Behavior on Linux is unchanged (already forward slashes). --------- Co-authored-by: Daniel A. Wozniak --- .github/workflows/build-deps-ci-action.yml | 2 +- .github/workflows/build-packages.yml | 2 +- .github/workflows/build-salt-onedir.yml | 2 +- changelog/69661.fixed.md | 9 ++++++++ doc/ref/states/highstate.rst | 16 +++++++------- doc/topics/tutorials/standalone_minion.rst | 2 +- salt/state.py | 22 +++++++++++++------ .../requisites/test_documented_truth_table.py | 21 ++++++++++++++++++ .../{ => states}/test_slots_documented.py | 12 ++++++++-- 9 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 changelog/69661.fixed.md rename tests/pytests/functional/{ => states}/test_slots_documented.py (73%) diff --git a/.github/workflows/build-deps-ci-action.yml b/.github/workflows/build-deps-ci-action.yml index ab13ee6c0be5..67f8a0c96011 100644 --- a/.github/workflows/build-deps-ci-action.yml +++ b/.github/workflows/build-deps-ci-action.yml @@ -158,7 +158,7 @@ jobs: macos-dependencies: name: MacOS - runs-on: ${{ matrix.arch == 'x86_64' && 'macos-15-intel' || 'macos-14' }} + runs-on: ${{ matrix.arch == 'x86_64' && 'macos-15-intel' || 'macos-15' }} if: ${{ toJSON(fromJSON(inputs.matrix)['macos']) != '[]' }} timeout-minutes: 90 strategy: diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index d271b6b16a60..a1d77324942d 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -313,7 +313,7 @@ jobs: env: PIP_INDEX_URL: https://pypi.org/simple runs-on: - - ${{ matrix.arch == 'arm64' && 'macos-14' || 'macos-15-intel' }} + - ${{ matrix.arch == 'arm64' && 'macos-15' || 'macos-15-intel' }} steps: - name: Check Package Signing Enabled diff --git a/.github/workflows/build-salt-onedir.yml b/.github/workflows/build-salt-onedir.yml index 6a7e03d972e7..d70840b9a01d 100644 --- a/.github/workflows/build-salt-onedir.yml +++ b/.github/workflows/build-salt-onedir.yml @@ -110,7 +110,7 @@ jobs: matrix: include: ${{ fromJSON(inputs.matrix)['macos'] }} runs-on: - - ${{ matrix.arch == 'arm64' && 'macos-14' || 'macos-15-intel' }} + - ${{ matrix.arch == 'arm64' && 'macos-15' || 'macos-15-intel' }} env: PIP_INDEX_URL: https://pypi.org/simple USE_S3_CACHE: 'false' diff --git a/changelog/69661.fixed.md b/changelog/69661.fixed.md new file mode 100644 index 000000000000..5f1e2a700947 --- /dev/null +++ b/changelog/69661.fixed.md @@ -0,0 +1,9 @@ +Fixed `onchanges`/`onchanges_any` requisites treating a failed target state as a hard +failure. Per the documented requisites truth table, a failed `onchanges` target should +be treated the same as a target with no changes: the dependent state does not run, but +reports `result=True` with empty `changes`, instead of hard-failing with a +"One or more requisite failed" comment. + +Fixed `IndexError` in `State.__eval_slot` when a slot expression has no dotted +post-`)` accessor, and fixed quoted append operands (e.g. `~ "/suffix"`) not having +their surrounding quotes stripped before being concatenated to the slot result. diff --git a/doc/ref/states/highstate.rst b/doc/ref/states/highstate.rst index 3dc1dee5b49f..e00aa3018782 100644 --- a/doc/ref/states/highstate.rst +++ b/doc/ref/states/highstate.rst @@ -348,7 +348,7 @@ controlled by a small set of options that can be set in the master config ``salt-call``). They can also be passed on the command line. state_output -~~~~~~~~~~~~ +------------ ``state_output`` (default ``full``) selects the per-state rendering mode. @@ -378,7 +378,7 @@ The ``state_output`` value can be overridden per command: salt-call state.highstate state_output=mixed_id state_verbose -~~~~~~~~~~~~~ +------------- ``state_verbose`` (default ``True``) controls whether states that succeeded with no changes appear in the output at all. Setting it to ``False`` suppresses @@ -389,7 +389,7 @@ with no changes appear in the output at all. Setting it to ``False`` suppresses salt '*' state.apply state_verbose=False state_output_diff -~~~~~~~~~~~~~~~~~ +----------------- ``state_output_diff`` (default ``False``) is similar to ``state_verbose=False`` but stricter: when set to ``True``, only states whose return contains a @@ -397,26 +397,26 @@ non-empty ``changes`` dictionary are displayed. Successful no-change states are suppressed regardless of their result. state_output_pct -~~~~~~~~~~~~~~~~ +---------------- ``state_output_pct`` (default ``False``) adds ``Success %`` and ``Failure %`` fields to the summary block at the end of the run. state_output_profile -~~~~~~~~~~~~~~~~~~~~ +-------------------- ``state_output_profile`` (default ``True``) controls whether ``Started`` and ``Duration`` are printed for each state. Set to ``False`` for tighter output. state_tabular -~~~~~~~~~~~~~ +------------- When ``state_output`` is one of the ``terse`` modes, ``state_tabular: True`` aligns the columns for easier scanning. Setting it to a string uses that string as the column format. state_compress_ids -~~~~~~~~~~~~~~~~~~ +------------------ ``state_compress_ids`` (default ``False``) consolidates multiple ``names`` under the same ``__id__`` into a single output row, grouped by result. This is @@ -424,7 +424,7 @@ most useful with ``terse_id`` rendering for states that use the ``names`` argument with many entries. Choosing a mode -~~~~~~~~~~~~~~~ +--------------- * Use ``full`` (default) when debugging state development or running a small number of states. diff --git a/doc/topics/tutorials/standalone_minion.rst b/doc/topics/tutorials/standalone_minion.rst index 7fd623fef408..e1192d4bd38a 100644 --- a/doc/topics/tutorials/standalone_minion.rst +++ b/doc/topics/tutorials/standalone_minion.rst @@ -20,7 +20,7 @@ A standalone minion is useful for: master (or as part of an image build pipeline). - Local testing and development of state, pillar, or formula code with fast feedback via ``salt-call --local`` against checked-out SLS trees. -- Triggering :ref:`reactor ` and :ref:`beacons ` flows +- Triggering :ref:`reactor ` and :ref:`beacons ` flows on a host that does not publish events to a master. How a standalone minion differs from a master-connected minion: diff --git a/salt/state.py b/salt/state.py index c495204f8822..b26d8b337694 100644 --- a/salt/state.py +++ b/salt/state.py @@ -2630,7 +2630,7 @@ def __eval_slot(self, slot): return_get = slot_text[slot_text.rindex(")") + 1 :] except ValueError: pass - if return_get: + if "." in (return_get or ""): # remove first period return_get = return_get.split(".", 1)[1].strip() log.debug("Searching slot result %s for %s", slot_return, return_get) @@ -2642,6 +2642,12 @@ def __eval_slot(self, slot): if isinstance(slot_return, str): # Append text to slot string result append_data = " ".join(append_data).strip() + if ( + len(append_data) >= 2 + and append_data[0] == append_data[-1] + and append_data[0] in ('"', "'") + ): + append_data = append_data[1:-1] log.debug("appending to slot result: %s", append_data) slot_return += append_data else: @@ -3057,16 +3063,18 @@ def check_requisite(self, low, running, chunks, pre=False): if run_dict[tag]["result"] is True: req_stats.add("onfail") # At least one state is OK continue - else: - if run_dict[tag]["result"] is False: - req_stats.add("fail") - continue - if r_state.startswith("onchanges"): - if not run_dict[tag]["changes"]: + elif r_state.startswith("onchanges"): + # onchanges is a soft trigger: a failed target is treated + # the same as a target with no changes, not a hard failure. + if run_dict[tag]["result"] is False or not run_dict[tag]["changes"]: req_stats.add("onchanges") else: req_stats.add("onchangesmet") continue + else: + if run_dict[tag]["result"] is False: + req_stats.add("fail") + continue if r_state.startswith("watch") and run_dict[tag]["changes"]: req_stats.add("change") continue diff --git a/tests/pytests/functional/modules/state/requisites/test_documented_truth_table.py b/tests/pytests/functional/modules/state/requisites/test_documented_truth_table.py index 74a53572ddff..b7efd3df17af 100644 --- a/tests/pytests/functional/modules/state/requisites/test_documented_truth_table.py +++ b/tests/pytests/functional/modules/state/requisites/test_documented_truth_table.py @@ -329,3 +329,24 @@ def test_watch_target_failed_skips_watcher(state, state_tree): w = _result(ret, "cmd_|-watcher_|-echo should-not-run_|-run") assert w["result"] is False assert w["changes"] is False + + +# --- prereq ----------------------------------------------------------------- + + +def test_prereq_target_failed(state, state_tree): + """prereq: target's test=True dry run fails -> dependent is skipped (result False).""" + sls = """ + target: + test.fail_without_changes + + dependent: + cmd.run: + - name: echo should-not-run + - prereq: + - test: target + """ + ret = _apply(state, state_tree, sls) + dep = _result(ret, "cmd_|-dependent_|-echo should-not-run_|-run") + assert dep["result"] is False + assert dep["changes"] is False diff --git a/tests/pytests/functional/test_slots_documented.py b/tests/pytests/functional/states/test_slots_documented.py similarity index 73% rename from tests/pytests/functional/test_slots_documented.py rename to tests/pytests/functional/states/test_slots_documented.py index 3280dd9c69a4..971c4be4908c 100644 --- a/tests/pytests/functional/test_slots_documented.py +++ b/tests/pytests/functional/states/test_slots_documented.py @@ -25,10 +25,14 @@ def test_documented_slot_in_arg(state, state_tree, tmp_path): Documented example: ``name: __slot__:salt:test.echo()``. """ marker = tmp_path / "slots_marker_arg" + # Use POSIX-style separators in the SLS so ``salt.utils.args.parse_function`` + # (which is backed by ``shlex(posix=True)``) does not strip backslashes on + # Windows. Both Windows and Linux accept forward-slash paths. + marker_arg = marker.as_posix() sls = f""" write-arg-marker: file.managed: - - name: __slot__:salt:test.echo({marker}) + - name: __slot__:salt:test.echo({marker_arg}) - contents: arg-resolved - makedirs: True """ @@ -48,10 +52,14 @@ def test_documented_slot_append(state, state_tree, tmp_path): base = tmp_path / "slots_base" base.mkdir() expected = base / "appended" + # Use POSIX-style separators in the SLS so ``salt.utils.args.parse_function`` + # (which is backed by ``shlex(posix=True)``) does not strip backslashes on + # Windows. Both Windows and Linux accept forward-slash paths. + base_arg = base.as_posix() sls = f""" write-appended-marker: file.managed: - - name: __slot__:salt:test.echo({base}) ~ "/appended" + - name: __slot__:salt:test.echo({base_arg}) ~ "/appended" - contents: append-resolved - makedirs: True """ From ea3049e66656f9491efac7d008dc73d5070a4b81 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Fri, 3 Jul 2026 15:18:36 -0700 Subject: [PATCH 027/469] Refresh gitfs/git_pillar/s3fs/file_roots docs (#69565) * gitfs walkthrough: drop EOL Ubuntu 14/Debian Wheezy/CentOS 7.3 notes and pin pygit2>=1.13.1, GitPython>=3.1.50 (matches base.txt + CI lockfiles). Adds a GitLab subsection covering deploy tokens, project access tokens, PATs, and SSH deploy keys. * git_pillar docstring: enumerate supported remote URL forms (https://, ssh://, scp-style user@host:path, file://). The scp-style colon is the most common source of 'Failed to resolve address' errors. * s3fs docstring: document s3.location / s3.service_url / s3.https_enable / s3.path_style / s3.verify_ssl with a Regional endpoints section, citing the SigV4 redirect failure mode. * file_roots.rst: new section explaining why /srv/salt is the default. Update netconfig/napalm_network examples to use /srv/salt instead of /etc/salt/states so the example matches the recommendation. * New tests: tests/pytests/functional/fileserver/gitfs/ test_documented_providers.py and tests/pytests/unit/fileserver/test_s3fs_documented_options.py pin the docs to the loader. Closes #62260 Closes #56127 Closes #60809 Closes #60408 Closes #53746 --- changelog/53746.fixed.md | 1 + changelog/56127.fixed.md | 1 + changelog/60408.fixed.md | 1 + changelog/60809.fixed.md | 1 + changelog/62260.fixed.md | 1 + doc/ref/file_server/file_roots.rst | 42 +++ doc/topics/tutorials/gitfs.rst | 255 +++++++++--------- salt/fileserver/s3fs.py | 41 +++ salt/modules/napalm_network.py | 4 +- salt/pillar/git_pillar.py | 34 ++- salt/states/netconfig.py | 4 +- .../functional/fileserver/gitfs/__init__.py | 0 .../gitfs/test_documented_providers.py | 219 +++++++++++++++ .../test_s3fs_documented_options.py | 76 ++++++ 14 files changed, 539 insertions(+), 141 deletions(-) create mode 100644 changelog/53746.fixed.md create mode 100644 changelog/56127.fixed.md create mode 100644 changelog/60408.fixed.md create mode 100644 changelog/60809.fixed.md create mode 100644 changelog/62260.fixed.md create mode 100644 tests/pytests/functional/fileserver/gitfs/__init__.py create mode 100644 tests/pytests/functional/fileserver/gitfs/test_documented_providers.py create mode 100644 tests/pytests/unit/fileserver/test_s3fs_documented_options.py diff --git a/changelog/53746.fixed.md b/changelog/53746.fixed.md new file mode 100644 index 000000000000..51aa5331088b --- /dev/null +++ b/changelog/53746.fixed.md @@ -0,0 +1 @@ +Added a "Where should ``file_roots`` live?" section to ``doc/ref/file_server/file_roots.rst`` explaining why ``/srv/salt`` is the recommended default (FHS, sibling to ``/srv/pillar``, separate from package-managed ``/etc/salt``) and when other paths are reasonable. Updated the ``netconfig.managed`` and ``napalm_network`` docstring examples to use ``/srv/salt`` instead of ``/etc/salt/states`` so the inline example matches the recommendation. diff --git a/changelog/56127.fixed.md b/changelog/56127.fixed.md new file mode 100644 index 000000000000..8e23457c474d --- /dev/null +++ b/changelog/56127.fixed.md @@ -0,0 +1 @@ +Clarified the supported remote URL formats in the ``git_pillar`` module docstring, including the scp-style ``user@host:path`` SSH form and the requirement for the colon between host and path. The walkthrough now lists HTTPS, ``ssh://``, scp-style, and ``file://`` URLs explicitly to avoid the "Failed to resolve address" and "Unable to exchange encryption keys" errors that result from a typo'd host portion. diff --git a/changelog/60408.fixed.md b/changelog/60408.fixed.md new file mode 100644 index 000000000000..8b7527016873 --- /dev/null +++ b/changelog/60408.fixed.md @@ -0,0 +1 @@ +Documented the ``s3.location``, ``s3.service_url``, ``s3.https_enable``, ``s3.path_style``, and ``s3.verify_ssl`` master config options in the ``s3fs`` fileserver module docstring. The new "Regional endpoints" section explains why s3fs may fail with ``No AWSAccessKey was presented`` or a SigV4 region-mismatch error against buckets outside ``us-east-1`` and what setting to use to fix it. A test in ``tests/pytests/unit/fileserver/test_s3fs_documented_options.py`` pins the option names to the loader so the docs cannot silently drift. diff --git a/changelog/60809.fixed.md b/changelog/60809.fixed.md new file mode 100644 index 000000000000..b6a75d2aa1c4 --- /dev/null +++ b/changelog/60809.fixed.md @@ -0,0 +1 @@ +Added a GitLab subsection to the Git Fileserver Backend Walkthrough's Authentication section covering deploy tokens, project access tokens, personal access tokens, and SSH deploy keys. Documents the typical 401 failure modes (expired tokens, missing ``read_repository`` scope) so that operators do not chase Salt-side configuration when the cause is GitLab-side. diff --git a/changelog/62260.fixed.md b/changelog/62260.fixed.md new file mode 100644 index 000000000000..92d4dc2ae93a --- /dev/null +++ b/changelog/62260.fixed.md @@ -0,0 +1 @@ +Refreshed the Git Fileserver Backend Walkthrough to drop EOL platform notes (Ubuntu 14.04, Debian Wheezy, RHEL 7.3-era CFFI quirks) and recommend the pygit2/GitPython versions that match ``requirements/base.txt`` and the CI lockfiles (pygit2 1.13.1+/1.19.2+ and GitPython 3.1.50+). Salt's runtime ``GITPYTHON_MINVER`` / ``PYGIT2_MINVER`` floors are unchanged. diff --git a/doc/ref/file_server/file_roots.rst b/doc/ref/file_server/file_roots.rst index 8622e4905fa4..ff53693c25d0 100644 --- a/doc/ref/file_server/file_roots.rst +++ b/doc/ref/file_server/file_roots.rst @@ -13,6 +13,48 @@ individual environments can span across multiple directory roots to create overlays and to allow for files to be organized in many flexible ways. +.. _file-roots-default-location: + +Where should ``file_roots`` live? +================================= + +The Salt default is: + +.. code-block:: yaml + + file_roots: + base: + - /srv/salt + +``/srv/salt`` is the recommended location because it follows the +`Filesystem Hierarchy Standard`_ ("``/srv`` contains site-specific data which +is served by this system") and keeps state content cleanly separated from +master configuration in ``/etc/salt``. Both pillar (``/srv/pillar``) and the +salt-ssh roster default to the same ``/srv/...`` parent, which makes backups +and version control straightforward. + +Other layouts work, but each has trade-offs: + +* **Putting ``file_roots`` inside ``/etc/salt``** mixes Salt's package-managed + configuration with operator-managed state files. A package upgrade will + not delete the directory, but auditing what changed and excluding it from + configuration management is harder. Use a sibling directory if you need + to keep states under ``/etc``. +* **A path under ``/opt`` or ``/var/lib``** is fine for hand-rolled + deployments. ``/var/lib/salt`` is what you get with ``salt-call --local`` + on a system where ``/srv`` is not writable, and is the default the + minionless installer uses on macOS. +* **Multiple roots** — list more than one directory per environment to + layer files (see :ref:`Directory Overlay `). + +Some examples in the Salt documentation (notably the +:py:func:`netconfig.managed ` state) show +``/etc/salt/states`` purely so the example fits in a single directory tree. +That is illustrative, not a recommendation — production deployments should +prefer ``/srv/salt``. + +.. _Filesystem Hierarchy Standard: https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch03s17.html + Periodic Restarts ================= diff --git a/doc/topics/tutorials/gitfs.rst b/doc/topics/tutorials/gitfs.rst index 0c85b61445bd..71ecefcd90a6 100644 --- a/doc/topics/tutorials/gitfs.rst +++ b/doc/topics/tutorials/gitfs.rst @@ -31,177 +31,102 @@ compatible versions of both are installed, pygit2_ will be preferred. In these cases, GitPython_ can be forced using the :conf_master:`gitfs_provider` parameter in the master config file. +The versions tested in CI and shipped with the Salt onedir packages are: + +* pygit2_ ``>= 1.13.1`` (on Python 3.11+, ``pygit2 >= 1.19.2``), built against + libgit2_ ``>= 1.5``. +* GitPython_ ``>= 3.1.50`` together with the system ``git`` binary. + +These pins live in ``requirements/base.txt`` and ``requirements/static/ci/``. +Salt's import-time check still accepts the very old floor of pygit2_ ``0.20.3`` +and GitPython_ ``0.3`` (see ``GITPYTHON_MINVER`` / ``PYGIT2_MINVER`` in +``salt/utils/gitfs.py``), but only the combinations above are exercised by the +test suite. Older releases are missing fixes for SSH authentication, refspec +handling, and credential helpers, and should not be used in production. + .. note:: - It is recommended to always run the most recent version of any the below - dependencies. Certain features of GitFS may not be available without - the most recent version of the chosen library. + Run the most recent compatible release of whichever provider you choose. .. _pygit2: https://github.com/libgit2/pygit2 .. _GitPython: https://github.com/gitpython-developers/GitPython +.. _libgit2: https://libgit2.org/ +.. _libssh2: https://www.libssh2.org/ pygit2 ------ -The minimum supported version of pygit2_ is 0.20.3. Availability for this -version of pygit2_ is still limited, though the SaltStack team is working to -get compatible versions available for as many platforms as possible. - -For the Fedora/EPEL versions which have a new enough version packaged, the -following command would be used to install pygit2_: +The Salt onedir packages already include a working pygit2_/libgit2_ pair, so on +a onedir install no extra steps are required. For source installs, install the +distro packages where available: .. code-block:: bash - # yum install python-pygit2 + # RHEL / Fedora / Alma / Rocky 8+ (EPEL provides libgit2/python3-pygit2) + # dnf install python3-pygit2 -Provided a valid version is packaged for Debian/Ubuntu (which is not currently -the case), the package name would be the same, and the following command would -be used to install it: + # Debian 11+ / Ubuntu 22.04+ + # apt-get install python3-pygit2 -.. code-block:: bash +If the distro packages are too old, ``pygit2`` can be installed from PyPI. +``pygit2`` is tightly coupled to libgit2_ — the pygit2_ release notes list the +exact libgit2_ ABI it links against, and a mismatch produces import errors at +salt-master start. The simplest recipe on a onedir install is: - # apt-get install python-pygit2 +.. code-block:: bash + # apt-get install libgit2-1.5 # or whatever libgit2-N your distro ships + # salt-pip install 'pygit2>=1.13.1,<1.18' --no-deps -If pygit2_ is not packaged for the platform on which the Master is running, the -pygit2_ website has installation instructions -`here `_. Keep in mind however that -following these instructions will install libgit2_ and pygit2_ without system -packages. Additionally, keep in mind that :ref:`SSH authentication in pygit2 -` requires libssh2_ (*not* libssh) development -libraries to be present before libgit2_ is built. On some Debian-based distros -``pkg-config`` is also required to link libgit2_ with libssh2. +``--no-deps`` keeps ``salt-pip`` from upgrading the bundled cffi. .. note:: - If you are receiving the error "Unsupported URL Protocol" in the Salt Master - log when making a connection using SSH, review the libssh2 details listed - above. - -Additionally, version 0.21.0 of pygit2 introduced a dependency on python-cffi_, -which in turn depends on newer releases of libffi_. Upgrading libffi_ is not -advisable as several other applications depend on it, so on older LTS linux -releases pygit2_ 0.20.3 and libgit2_ 0.20.0 is the recommended combination. + SSH authentication in pygit2 (see :ref:`pygit2-authentication-ssh`) + requires libssh2_ (*not* libssh) to be linked into the libgit2_ build. + Distro libgit2 packages already include libssh2 support. If you are + rebuilding libgit2 from source and see "Unsupported URL Protocol" errors + against ``ssh://`` remotes in the master log, the libgit2 build was made + without libssh2 headers. .. warning:: pygit2_ is actively developed and `frequently makes non-backwards-compatible - API changes`_, even in minor releases. It is not uncommon for pygit2_ - upgrades to result in errors in Salt. Please take care when upgrading - pygit2_, and pay close attention to the changelog_, keeping an eye out for - API changes. Errors can be reported on the `SaltStack issue tracker`_. + API changes`_, even in minor releases. Pin pygit2_ in production, watch + the changelog_ when upgrading, and report breakage on the + `SaltStack issue tracker`_. .. _frequently makes non-backwards-compatible API changes: https://www.pygit2.org/install.html#version-numbers .. _changelog: https://github.com/libgit2/pygit2/blob/master/CHANGELOG.rst .. _SaltStack issue tracker: https://github.com/saltstack/salt/issues -.. _pygit2-install-instructions: http://www.pygit2.org/install.html -.. _libgit2: https://libgit2.org/ -.. _libssh2: https://www.libssh2.org/ -.. _python-cffi: https://pypi.org/project/cffi -.. _libffi: http://sourceware.org/libffi/ - - -RedHat Pygit2 Issues -~~~~~~~~~~~~~~~~~~~~ - -The release of RedHat/CentOS 7.3 upgraded both ``python-cffi`` and -``http-parser``, both of which are dependencies for pygit2_/libgit2_. Both -``pygit2`` and ``libgit2`` packages (which are from the EPEL repository) should -be upgraded to the most recent versions, at least to ``0.24.2``. - -The below errors will show up in the master log if an incompatible -``python-pygit2`` package is installed: - -.. code-block:: text - - 2017-02-10 09:07:34,892 [salt.utils.gitfs ][ERROR ][11211] Import pygit2 failed: CompileError: command 'gcc' failed with exit status 1 - 2017-02-10 09:07:34,907 [salt.utils.gitfs ][ERROR ][11211] gitfs is configured but could not be loaded, are pygit2 and libgit2 installed? - 2017-02-10 09:07:34,907 [salt.utils.gitfs ][CRITICAL][11211] No suitable gitfs provider module is installed. - 2017-02-10 09:07:34,912 [salt.master ][CRITICAL][11211] Master failed pre flight checks, exiting - -The below errors will show up in the master log if an incompatible ``libgit2`` -package is installed: - -.. code-block:: text - - 2017-02-15 18:04:45,211 [salt.utils.gitfs ][ERROR ][6211] Error occurred fetching gitfs remote 'https://foo.com/bar.git': No Content-Type header in response - -A restart of the ``salt-master`` daemon and gitfs cache directory clean up may -be required to allow http(s) repositories to continue to be fetched. - - -Debian Pygit2 Issues -~~~~~~~~~~~~~~~~~~~~ - -The Debian repos currently have older versions of pygit2 (package -``python3-pygit2``). These older versions may have issues using newer SSH keys -(see [this issue](https://github.com/saltstack/salt/issues/61790)). Instead, -``pygit2`` can be installed from Pypi, but you will need a version that -matches the ``libgit2`` version from Debian. This is version 1.6.1. - -.. code-block:: bash - - # apt-get install libgit2 - # salt-pip install pygit2==1.6.1 --no-deps - -Note that the above instructions assume a onedir installation. The need for -`--no-deps` is to prevent the CFFI package from mismatching with Salt. GitPython --------- -GitPython_ 0.3.0 or newer is required to use GitPython for gitfs. For -RHEL-based Linux distros, a compatible version is available in EPEL, and can be -easily installed on the master using yum: +GitPython_ ``>= 3.1.50`` is recommended, matching ``requirements/base.txt`` and +the lockfiles under ``requirements/static/ci/``. Install from distro packages +or from PyPI: .. code-block:: bash - # yum install GitPython + # RHEL / Fedora + # dnf install python3-GitPython -Ubuntu 14.04 LTS and Debian Wheezy (7.x) also have a compatible version packaged: + # Debian / Ubuntu + # apt-get install python3-git -.. code-block:: bash - - # apt-get install python-git + # Onedir install (any platform) + # salt-pip install 'GitPython>=3.1.50' -GitPython_ requires the ``git`` CLI utility to work. If installed from a system -package, then git should already be installed, but if installed via pip_ then -it may still be necessary to install git separately. For MacOS users, -GitPython_ comes bundled in with the Salt installer, but git must still be -installed for it to work properly. Git can be installed in several ways, -including by installing XCode_. +GitPython_ shells out to the ``git`` CLI, so the system ``git`` binary must +also be installed. On macOS, install Xcode_ command-line tools or use Homebrew. -.. _pip: http://www.pip-installer.org/ -.. _XCode: https://developer.apple.com/xcode/ +.. _Xcode: https://developer.apple.com/xcode/ .. warning:: GitPython advises against the use of its library for long-running processes - (such as a salt-master or salt-minion). Please see their warning on potential - leaks of system resources: + (such as a salt-master). See their warning on potential leaks of system + resources: https://github.com/gitpython-developers/GitPython#leakage-of-system-resources. - -.. warning:: - - Keep in mind that if GitPython has been previously installed on the master - using pip (even if it was subsequently uninstalled), then it may still - exist in the build cache (typically ``/tmp/pip-build-root/GitPython``) if - the cache is not cleared after installation. The package in the build cache - will override any requirement specifiers, so if you try upgrading to - version 0.3.2.RC1 by running ``pip install 'GitPython==0.3.2.RC1'`` then it - will ignore this and simply install the version from the cache directory. - Therefore, it may be necessary to delete the GitPython directory from the - build cache in order to ensure that the specified version is installed. - -.. warning:: - - GitPython_ 2.0.9 and newer is not compatible with Python 2.6. If installing - GitPython_ using pip on a machine running Python 2.6, make sure that a - version earlier than 2.0.9 is installed. This can be done on the CLI by - running ``pip install 'GitPython<2.0.9'``, or in a :py:func:`pip.installed - ` state using the following SLS: - - .. code-block:: yaml - - GitPython: - pip.installed: - - name: 'GitPython < 2.0.9' + The Salt fileserver mitigates this by restarting the fileserver worker on + a configurable interval (see :conf_master:`fileserver_interval`). Simple Configuration ==================== @@ -1119,6 +1044,74 @@ to the entry in ``~/.ssh/config`` However, this is generally regarded as insecure, and is not recommended. +.. _gitfs-gitlab: + +GitLab +------ + +GitLab repositories work with the same ``user``/``password`` and SSH +mechanics described above, but the credential to use depends on the +GitLab account type. The Salt master is a service account, so the +recommended options, in decreasing order of preference, are: + +1. **Deploy token** (project- or group-scoped, read-only) — best fit for + gitfs and git_pillar. Create one in GitLab under + *Settings → Repository → Deploy tokens* with the ``read_repository`` + scope. The token's username is the value GitLab shows on creation; + the token itself is the password: + + .. code-block:: yaml + + gitfs_remotes: + - https://gitlab.example.com/group/states.git: + - user: salt-deploy-states + - password: gldt-XXXXXXXXXXXXXXXXXXXX + +2. **Project access token** (project-scoped, configurable role) — useful + when the master must push (for example, for the ``winrepo`` runner). + Username is the token name; password is the token: + + .. code-block:: yaml + + gitfs_remotes: + - https://gitlab.example.com/group/winrepo.git: + - user: salt-winrepo + - password: glpat-XXXXXXXXXXXXXXXXXXXX + +3. **Personal access token** — works, but ties the master's access to a + real user. Authenticate as the token owner: + + .. code-block:: yaml + + gitfs_remotes: + - https://gitlab.example.com/group/repo.git: + - user: my-gitlab-user + - password: glpat-XXXXXXXXXXXXXXXXXXXX + +4. **Deploy key over SSH** — use a passphraseless key pair, add the + public key under *Project → Settings → Repository → Deploy Keys*, and + reference the private key: + + .. code-block:: yaml + + gitfs_remotes: + - git@gitlab.example.com:group/repo.git: + - pubkey: /etc/salt/gitlab_deploy.pub + - privkey: /etc/salt/gitlab_deploy + + This works with both pygit2_ and GitPython_. For GitPython_, only + passphraseless keys are supported (see the GitPython section above). + Add the GitLab host key with + ``salt-call --local ssh.set_known_host hostname=gitlab.example.com`` + first. + +.. note:: + GitLab returns ``401 Unauthorized`` rather than a descriptive error + when a deploy/project token has expired or lacks ``read_repository`` + scope. If gitfs starts logging ``401`` after working previously, + re-check the token's expiry and scopes before changing the Salt + configuration. + .. _gitfs-ssh-fingerprint: Adding the SSH Host Key to the known_hosts File diff --git a/salt/fileserver/s3fs.py b/salt/fileserver/s3fs.py index d3c3d9cd78f0..431cf8d6f42b 100644 --- a/salt/fileserver/s3fs.py +++ b/salt/fileserver/s3fs.py @@ -22,6 +22,47 @@ Alternatively, if on EC2 these credentials can be automatically loaded from instance metadata. +Regional endpoints +================== + +By default ``s3fs`` talks to the global endpoint (``s3.amazonaws.com``) and +relies on AWS to redirect to the bucket's home region. Redirected requests +sometimes fail to carry the SigV4 signature, producing errors such as:: + + No AWSAccessKey was presented + +or:: + + The authorization header is malformed; the region 'us-east-1' is wrong; + expecting 'us-west-2' + +To avoid the redirect, set ``s3.location`` (preferred — passed straight +through to the SigV4 signer) or ``s3.service_url`` (point to a regional or +non-AWS S3 endpoint): + +.. code-block:: yaml + + # Tell s3fs the bucket lives in us-west-2 + s3.location: us-west-2 + + # Alternative: hit the regional endpoint directly + s3.service_url: s3.us-west-2.amazonaws.com + + # Force HTTPS and standard hostname-style URLs (defaults are True/False) + s3.https_enable: true + s3.path_style: false + + # Verify the TLS certificate (default true). Disable only for + # private S3-compatible deployments with self-signed certificates. + s3.verify_ssl: true + +The same options are recognized by the +:py:mod:`s3 ` external pillar. + +Use ``s3.location`` for AWS regions. Use ``s3.service_url`` when targeting an +S3-compatible service such as MinIO, Ceph RGW, or Wasabi; set +``s3.path_style: true`` if the service requires path-style addressing. + This fileserver supports two modes of operation for the buckets: 1. :strong:`A single bucket per environment` diff --git a/salt/modules/napalm_network.py b/salt/modules/napalm_network.py index 0823bf726689..d760d8319516 100644 --- a/salt/modules/napalm_network.py +++ b/salt/modules/napalm_network.py @@ -1651,9 +1651,9 @@ def load_template( file_roots: base: - - /etc/salt/states + - /srv/salt - Placing the template under ``/etc/salt/states/templates/example.jinja``, + Placing the template under ``/srv/salt/templates/example.jinja``, it can be used as ``salt://templates/example.jinja``. Alternatively, for local files, the user can specify the absolute path. If remotely, the source can be retrieved via ``http``, ``https`` or ``ftp``. diff --git a/salt/pillar/git_pillar.py b/salt/pillar/git_pillar.py index 6256e6040eee..d28a22a26b0b 100644 --- a/salt/pillar/git_pillar.py +++ b/salt/pillar/git_pillar.py @@ -65,6 +65,28 @@ See :ref:`here ` for documentation on the git_pillar configuration options and their usage. +Each ``- git:`` entry is a list of remote definitions. Every remote is a +single string that starts with the branch (or tag) name to use as the Pillar +environment, followed by a space, followed by a URL that ``pygit2`` or +GitPython_ can clone. Supported URL forms are the same as for ``git clone`` +on the command line: + +* ``https://gitserver.example.com/group/repo.git`` — HTTPS. Credentials, if + needed, are configured per-remote via ``user`` and ``password`` (see + below). +* ``ssh://git@gitserver.example.com/group/repo.git`` or + ``git@gitserver.example.com:group/repo.git`` — SSH. The scp-style + ``user@host:path`` form is also accepted. Note the ``:`` (colon) before + ``path`` — using ``/`` here is the most common cause of "Failed to resolve + address" or "Unable to exchange encryption keys" errors at master start. +* ``file:///srv/git/repo.git`` — a bare repository on the local filesystem + (handy for testing). + +When a remote requires per-remote configuration (root, env override, auth +credentials, etc.) the URL string must end with a trailing ``:`` and the +options follow as a YAML list. Without any per-remote options, no trailing +colon is needed. + Here is an example git_pillar configuration: .. code-block:: yaml @@ -72,16 +94,16 @@ ext_pillar: - git: # Use 'prod' instead of the branch name 'production' as the environment - - production https://gitserver/git-pillar.git: + - production https://gitserver.example.com/group/git-pillar.git: - env: prod # Use 'dev' instead of the branch name 'develop' as the environment - - develop https://gitserver/git-pillar.git: + - develop https://gitserver.example.com/group/git-pillar.git: - env: dev # No per-remote config parameters (and no trailing colon), 'qa' will # be used as the environment - - qa https://gitserver/git-pillar.git - # SSH key authentication - - master git@other-git-server:pillardata-ssh.git: + - qa https://gitserver.example.com/group/git-pillar.git + # SSH key authentication. Note the ':' (colon) between host and path. + - master git@other-git-server.example.com:group/pillardata-ssh.git: # Pillar SLS files will be read from the 'pillar' subdirectory in # this repository - root: pillar @@ -89,7 +111,7 @@ - pubkey: /path/to/key.pub - passphrase: CorrectHorseBatteryStaple # HTTPS authentication - - master https://other-git-server/pillardata-https.git: + - master https://other-git-server.example.com/group/pillardata-https.git: - user: git - password: CorrectHorseBatteryStaple diff --git a/salt/states/netconfig.py b/salt/states/netconfig.py index 17870ab4afde..677405aa45a2 100644 --- a/salt/states/netconfig.py +++ b/salt/states/netconfig.py @@ -503,9 +503,9 @@ def managed( file_roots: base: - - /etc/salt/states + - /srv/salt - Placing the template under ``/etc/salt/states/templates/example.jinja``, it can be used as + Placing the template under ``/srv/salt/templates/example.jinja``, it can be used as ``salt://templates/example.jinja``. Alternatively, for local files, the user can specify the absolute path. If remotely, the source can be retrieved via ``http``, ``https`` or ``ftp``. diff --git a/tests/pytests/functional/fileserver/gitfs/__init__.py b/tests/pytests/functional/fileserver/gitfs/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/pytests/functional/fileserver/gitfs/test_documented_providers.py b/tests/pytests/functional/fileserver/gitfs/test_documented_providers.py new file mode 100644 index 000000000000..0619c6adb8d5 --- /dev/null +++ b/tests/pytests/functional/fileserver/gitfs/test_documented_providers.py @@ -0,0 +1,219 @@ +""" +Smoke-test the gitfs configurations shown in +``doc/topics/tutorials/gitfs.rst``. + +For each example layout the docs publish we build a local bare repository and +verify that: + +* The fileserver loads with the documented YAML structure (no schema + errors, no exceptions during ``init`` / ``update`` / ``envs``). +* Both the ``pygit2`` and ``gitpython`` providers can serve the same + config (each is skipped if its library is unavailable). +* The branches listed in the doc map to fileserver environments. + +This guards against the docs drifting away from what the gitfs loader will +actually accept. +""" + +import shutil +import subprocess + +import pytest + +import salt.fileserver.gitfs as gitfs +import salt.utils.gitfs as utils_gitfs +from salt.utils.gitfs import GITPYTHON_VERSION, PYGIT2_VERSION + +pytestmark = [ + pytest.mark.slow_test, + pytest.mark.skipif( + shutil.which("git") is None, reason="system git binary required" + ), +] + + +HAS_GITPYTHON = GITPYTHON_VERSION is not None +HAS_PYGIT2 = PYGIT2_VERSION is not None + + +def _run_git(repo, *args): + subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + ) + + +def _seed_repo(repo_dir, branches=("master",), files=None): + """ + Build a bare git repo + working tree at ``repo_dir`` with the given + branches and a couple of top-level files per branch. Returns the bare + repo path that gitfs should be pointed at. + """ + files = files or ("top.sls", "init.sls") + work = repo_dir / "work" + bare = repo_dir / "bare.git" + work.mkdir(parents=True) + _run_git(work, "init", "-q", "-b", branches[0]) + _run_git(work, "config", "user.email", "salt-doc-test@example.invalid") + _run_git(work, "config", "user.name", "Salt Doc Test") + for branch in branches: + if branch != branches[0]: + _run_git(work, "checkout", "-q", "-b", branch) + for name in files: + target = work / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"# {branch}/{name}\n") + _run_git(work, "add", *files) + _run_git(work, "commit", "-q", "-m", f"seed {branch}") + _run_git(work, "clone", "-q", "--bare", str(work), str(bare)) + return bare + + +@pytest.fixture +def base_opts(tmp_path): + """Master opts skeleton matching what gitfs.init() needs.""" + return { + "cachedir": str(tmp_path / "cache"), + "sock_dir": str(tmp_path / "sock"), + "fileserver_backend": ["gitfs"], + "gitfs_remotes": [], + "gitfs_root": "", + "gitfs_base": "master", + "gitfs_fallback": "", + "gitfs_mountpoint": "", + "gitfs_saltenv": [], + "gitfs_saltenv_whitelist": [], + "gitfs_saltenv_blacklist": [], + "gitfs_user": "", + "gitfs_password": "", + "gitfs_insecure_auth": False, + "gitfs_privkey": "", + "gitfs_pubkey": "", + "gitfs_passphrase": "", + "gitfs_refspecs": [ + "+refs/heads/*:refs/remotes/origin/*", + "+refs/tags/*:refs/tags/*", + ], + "gitfs_ssl_verify": True, + "gitfs_disable_saltenv_mapping": False, + "gitfs_ref_types": ["branch", "tag"], + "gitfs_update_interval": 60, + "__role": "master", + "fileserver_events": False, + "transport": "zeromq", + } + + +def _build_gitfs(opts, remotes, provider): + """Construct a fresh GitFS instance, isolating from any cached instances.""" + opts = dict(opts) + opts["gitfs_provider"] = provider + opts["gitfs_remotes"] = list(remotes) + utils_gitfs.GitFS.instance_map.clear() + return utils_gitfs.GitFS( + opts, + opts["gitfs_remotes"], + per_remote_overrides=gitfs.PER_REMOTE_OVERRIDES, + per_remote_only=gitfs.PER_REMOTE_ONLY, + ) + + +@pytest.fixture +def documented_simple_repo(tmp_path): + """A single-branch (master) repo — matches the 'Simple Configuration' + example.""" + return _seed_repo(tmp_path / "simple", branches=("master",)) + + +@pytest.fixture +def documented_multi_env_repo(tmp_path): + """A multi-branch repo — matches the 'Branches, Environments, and Top + Files' example with base/qa/dev branches.""" + return _seed_repo( + tmp_path / "multi_env", + branches=("master", "qa", "dev"), + ) + + +def _provider_params(): + params = [] + if HAS_GITPYTHON: + params.append(pytest.param("gitpython", id="gitpython")) + if HAS_PYGIT2: + params.append(pytest.param("pygit2", id="pygit2")) + if not params: + params.append( + pytest.param( + "missing", + marks=pytest.mark.skip(reason="No gitfs provider available"), + id="no-provider", + ) + ) + return params + + +@pytest.mark.parametrize("provider", _provider_params()) +def test_simple_remote_loads(provider, base_opts, documented_simple_repo): + """The minimal 'fileserver_backend: [gitfs]' walkthrough config loads.""" + gfs = _build_gitfs(base_opts, [f"file://{documented_simple_repo}"], provider) + gfs.update() + envs = gfs.envs(ignore_cache=True) + # Default base branch is 'master' — must appear as an env. + assert "base" in envs + + +@pytest.mark.parametrize("provider", _provider_params()) +def test_multi_env_remote_loads(provider, base_opts, documented_multi_env_repo): + """qa/dev branches map to saltenvs as the walkthrough advertises.""" + gfs = _build_gitfs(base_opts, [f"file://{documented_multi_env_repo}"], provider) + gfs.update() + envs = set(gfs.envs(ignore_cache=True)) + # 'master' is implicitly remapped to 'base'. + assert {"base", "qa", "dev"} <= envs + + +@pytest.mark.parametrize("provider", _provider_params()) +def test_per_remote_root_loads(provider, base_opts, tmp_path): + """The per-remote ``root`` example accepts a list-of-dict layout.""" + repo = _seed_repo( + tmp_path / "rooted", + branches=("master",), + files=("subdir/init.sls", "subdir/top.sls", "README.md"), + ) + gfs = _build_gitfs( + base_opts, + [ + { + f"file://{repo}": [ + {"root": "subdir"}, + {"mountpoint": "salt://overlay"}, + ] + } + ], + provider, + ) + gfs.update() + envs = gfs.envs(ignore_cache=True) + assert "base" in envs + + +@pytest.mark.skipif(not HAS_PYGIT2, reason="auth params only honoured by pygit2") +def test_documented_auth_keys_accepted(base_opts, tmp_path): + """The auth per-remote keys mentioned in the walkthrough are recognised + by the loader. We do not drive a real auth session here — credentials are + only meaningful over HTTPS/SSH, not file:// — but the loader must accept + the documented YAML shape without raising. Auth params are only honoured + by the pygit2 provider, so this test is pygit2-only.""" + repo = _seed_repo(tmp_path / "auth", branches=("master",)) + remotes = [ + { + f"file://{repo}": [ + {"user": "salt-deploy"}, + {"password": "redacted"}, + {"insecure_auth": False}, + ] + } + ] + gfs = _build_gitfs(base_opts, remotes, "pygit2") + gfs.update() diff --git a/tests/pytests/unit/fileserver/test_s3fs_documented_options.py b/tests/pytests/unit/fileserver/test_s3fs_documented_options.py new file mode 100644 index 000000000000..258298427d8c --- /dev/null +++ b/tests/pytests/unit/fileserver/test_s3fs_documented_options.py @@ -0,0 +1,76 @@ +""" +Lock down the s3fs options documented in the s3fs.py module docstring. + +The docstring promises that ``s3.location``, ``s3.service_url``, +``s3.verify_ssl``, ``s3.https_enable``, and ``s3.path_style`` are honoured by +the fileserver. This test verifies that ``salt.fileserver.s3fs._get_s3_key`` +returns each value untouched and passes it on the way down to ``s3.query``. + +If somebody renames or removes one of these option keys without also touching +the documentation, this test fails first. +""" + +import pytest + +import salt.fileserver.s3fs as s3fs + + +@pytest.fixture +def configure_loader_modules(tmp_path): + opts = { + "cachedir": str(tmp_path), + "s3.key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3.keyid": "AKIAIOSFODNN7EXAMPLE", + "s3.service_url": "s3.us-west-2.amazonaws.com", + "s3.location": "us-west-2", + "s3.verify_ssl": False, + "s3.https_enable": True, + "s3.path_style": True, + "s3.buckets": {"base": ["docs-example-bucket"]}, + } + return {s3fs: {"__opts__": opts}} + + +def test_get_s3_key_returns_documented_options(): + """All option keys that the s3fs.py docstring promises are returned.""" + ( + key, + keyid, + service_url, + verify_ssl, + kms_keyid, + location, + path_style, + https_enable, + ) = s3fs._get_s3_key() + + assert key == "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + assert keyid == "AKIAIOSFODNN7EXAMPLE" + assert service_url == "s3.us-west-2.amazonaws.com" + assert verify_ssl is False + assert location == "us-west-2" + assert path_style is True + assert https_enable is True + # kms_keyid is read from a separate aws.kms.keyid opt + assert kms_keyid is None + + +@pytest.mark.parametrize( + "opt_key", + [ + "s3.location", + "s3.service_url", + "s3.verify_ssl", + "s3.https_enable", + "s3.path_style", + ], +) +def test_documented_option_key_is_recognized(opt_key): + """ + Each documented option key must appear in the module's source so that a + silent rename does not slip past the documentation. + """ + import inspect + + source = inspect.getsource(s3fs._get_s3_key) + assert opt_key in source, f"{opt_key!r} no longer read by _get_s3_key" From 3b76f8871065ca644e63d5b8b560b9b4d272a5f8 Mon Sep 17 00:00:00 2001 From: st-man <92167612+st-man@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:26:54 +0300 Subject: [PATCH 028/469] grains: extend os_family mappings for alfaLinux, AlterOS, RED OS (#68715) --- changelog/68715.added.md | 1 + salt/grains/core.py | 4 ++ tests/pytests/unit/grains/test_core.py | 84 ++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 changelog/68715.added.md diff --git a/changelog/68715.added.md b/changelog/68715.added.md new file mode 100644 index 000000000000..d3f02b5f5912 --- /dev/null +++ b/changelog/68715.added.md @@ -0,0 +1 @@ +Added os_family mappings for additional Linux distributions. diff --git a/salt/grains/core.py b/salt/grains/core.py index 609805794f45..ef9ac8f9f3b4 100644 --- a/salt/grains/core.py +++ b/salt/grains/core.py @@ -1884,6 +1884,10 @@ def _derive_os_grain(osfullname, os_id=None): "openSUSE Leap": "Suse", "openSUSE Tumbleweed": "Suse", "SLES_SAP": "Suse", + "alfaLinux": "Suse", + "alfaLinux Rise": "Suse", + "AlterOS": "RedHat", + "RED OS": "RedHat", "Arch ARM": "Arch", "Manjaro": "Arch", "Manjaro ARM": "Arch", diff --git a/tests/pytests/unit/grains/test_core.py b/tests/pytests/unit/grains/test_core.py index d9ea6e5cac64..094229fc2dd7 100644 --- a/tests/pytests/unit/grains/test_core.py +++ b/tests/pytests/unit/grains/test_core.py @@ -5255,3 +5255,87 @@ def test__ps(): "| awk '{ $7=\"\"; print }'" ) } + + +@pytest.mark.skip_unless_on_linux +def test_alfalinux_os_grains(): + _os_release_data = { + "NAME": "alfaLinux", + "PRETTY_NAME": "alfaLinux", + "ID": "alfalinux", + "VERSION_ID": "1", + } + expectation = { + "os": "alfaLinux", + "os_family": "Suse", + "osfullname": "alfaLinux", + "oscodename": "alfaLinux", + "osfinger": "alfaLinux-1", + "osrelease": "1", + "osrelease_info": (1,), + "osmajorrelease": 1, + } + _run_os_grains_tests(_os_release_data, {}, expectation) + + +@pytest.mark.skip_unless_on_linux +def test_alfalinux_rise_os_grains(): + _os_release_data = { + "NAME": "alfaLinux Rise", + "PRETTY_NAME": "alfaLinux Rise", + "ID": "alfalinux-rise", + "VERSION_ID": "1", + } + expectation = { + "os": "alfaLinux Rise", + "os_family": "Suse", + "osfullname": "alfaLinux Rise", + "oscodename": "alfaLinux Rise", + "osfinger": "alfaLinux Rise-1", + "osrelease": "1", + "osrelease_info": (1,), + "osmajorrelease": 1, + } + _run_os_grains_tests(_os_release_data, {}, expectation) + + +@pytest.mark.skip_unless_on_linux +def test_alteros_os_grains(): + _os_release_data = { + "NAME": "AlterOS", + "PRETTY_NAME": "AlterOS", + "ID": "alteros", + "VERSION_ID": "1", + } + expectation = { + "os": "AlterOS", + "os_family": "RedHat", + "osfullname": "AlterOS", + "oscodename": "AlterOS", + "osfinger": "AlterOS-1", + "osrelease": "1", + "osrelease_info": (1,), + "osmajorrelease": 1, + } + _run_os_grains_tests(_os_release_data, {}, expectation) + + +@pytest.mark.skip_unless_on_linux +def test_red_os_os_grains(): + _os_release_data = { + "NAME": "RED OS", + "PRETTY_NAME": "RED OS", + "ID": "redos", + "VERSION_ID": "1", + } + expectation = { + "os": "RED OS", + "os_family": "RedHat", + "osfullname": "RED OS", + "oscodename": "RED OS", + "osfinger": "RED OS-1", + "osrelease": "1", + "osrelease_info": (1,), + "osmajorrelease": 1, + } + _run_os_grains_tests(_os_release_data, {}, expectation) From 725a71c8384fadf8d04842644da00cd437618ed7 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 3 Jul 2026 20:55:30 -0400 Subject: [PATCH 029/469] Fix pkg.list_holds returning empty on dnf5 (parse versionlock.toml with tomllib) (#69607) (#69608) * Fix pkg.list_holds returning empty on dnf5 (onedir lacks 'toml') _list_holds_dnf5 parsed /etc/dnf/versionlock.toml via salt.serializers.tomlmod, which depends on the third-party 'toml' library. 'toml' is a CI-only dependency and is not bundled in the onedir packages, and the onedir ships Python 3.10 (no stdlib tomllib), so the parse failed silently and pkg.list_holds always returned []. With hold: True this made pkg.installed re-hold packages on every run. Parse with the standard-library tomllib (available once the onedir ships Python 3.11 in 3006.27, see #69526), falling back to the toml serializer on older interpreters where it is installed. Fixes #69607 * Add unit tests for yumpkg.version_cmp, group_diff, and list_repos Expand test coverage of salt/modules/yumpkg.py beyond the #69607 change, per the contributor policy of improving coverage in modules a PR touches: - version_cmp: assert it delegates to lowpkg.version_cmp with ignore_epoch - group_diff: assert group members are split into installed/not-installed - list_repos: assert .repo files are parsed, non-repo files skipped, and each repo records its source file --- changelog/69607.fixed.md | 1 + salt/modules/yumpkg.py | 42 +++++--- tests/pytests/unit/modules/test_yumpkg.py | 111 ++++++++++++++++++++++ 3 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 changelog/69607.fixed.md diff --git a/changelog/69607.fixed.md b/changelog/69607.fixed.md new file mode 100644 index 000000000000..9be85ea4b401 --- /dev/null +++ b/changelog/69607.fixed.md @@ -0,0 +1 @@ +Fixed ``pkg.list_holds`` returning an empty list on dnf5 systems even when packages are held. ``_list_holds_dnf5`` parsed ``/etc/dnf/versionlock.toml`` through ``salt.serializers.tomlmod``, which depends on the third-party ``toml`` library that is not bundled in the onedir packages; the parse failed silently and ``pkg.installed`` with ``hold: True`` re-held packages on every run. It now parses with the standard-library ``tomllib`` (available once the onedir ships Python 3.11 in 3006.27, see #69526), falling back to the ``toml`` serializer on older interpreters where it is installed. diff --git a/salt/modules/yumpkg.py b/salt/modules/yumpkg.py index 5beaf2a40e17..f1694e907163 100644 --- a/salt/modules/yumpkg.py +++ b/salt/modules/yumpkg.py @@ -2497,25 +2497,45 @@ def _list_holds_dnf5(full=True): dnf5's ``versionlock list`` writes a structured human-readable format rather than the legacy ``name-epoch:ver-rel.arch.*`` token that - ``_get_hold`` expects, so we read the on-disk configuration directly - via the salt TOML serializer (already a dependency of salt's RPM - tooling). - """ - # Import inside the function so the top-level import graph stays - # unchanged for systems without a TOML library. - import salt.serializers as serializers - import salt.serializers.tomlmod as tomlmod + ``_get_hold`` expects, so we read the on-disk configuration directly. + + .. note:: + Parsing prefers the standard-library :py:mod:`tomllib`, which exists + only on Python 3.11+. The Salt onedir packages ship Python 3.10 through + 3006.26 and do **not** bundle the third-party ``toml`` library (it is a + CI-only dependency), so on those builds neither parser is available and + this returns an empty list. Reliable dnf5 hold reporting therefore + depends on the Python 3.11 bump landing in 3006.27 (see #69526); where + the optional ``toml`` library happens to be installed it is used as a + fallback on older interpreters. + """ + # Prefer the standard-library tomllib (Python 3.11+). Fall back to the + # optional third-party ``toml`` library via the salt serializer on older + # interpreters that have it installed. Imported inside the function so the + # top-level import graph stays unchanged. + try: + import tomllib + + def _read_versionlock(): + with salt.utils.files.fopen(_DNF5_VERSIONLOCK_PATH, "rb") as fp_: + return tomllib.load(fp_) + + except ImportError: + import salt.serializers.tomlmod as tomlmod + + def _read_versionlock(): + with salt.utils.files.fopen(_DNF5_VERSIONLOCK_PATH) as fp_: + return tomlmod.deserialize(fp_) try: - with salt.utils.files.fopen(_DNF5_VERSIONLOCK_PATH) as fp_: - data = tomlmod.deserialize(fp_) + data = _read_versionlock() except OSError: log.debug( "dnf5 versionlock file %s is missing; no holds to report", _DNF5_VERSIONLOCK_PATH, ) return [] - except serializers.DeserializationError as exc: + except Exception as exc: # pylint: disable=broad-except log.warning( "Failed to parse dnf5 versionlock file %s: %s", _DNF5_VERSIONLOCK_PATH, diff --git a/tests/pytests/unit/modules/test_yumpkg.py b/tests/pytests/unit/modules/test_yumpkg.py index 238486953611..fe8f4faf8ed1 100644 --- a/tests/pytests/unit/modules/test_yumpkg.py +++ b/tests/pytests/unit/modules/test_yumpkg.py @@ -2617,6 +2617,117 @@ def test_list_holds_dnf5_missing_versionlock_toml_69181(tmp_path): assert yumpkg.list_holds(full=False) == [] +def test_list_holds_dnf5_parses_without_toml_library(tmp_path): + """ + The Salt onedir packages do not bundle the third-party ``toml`` library + (it is a CI-only dependency), so reading dnf5 holds via + ``salt.serializers.tomlmod`` silently failed and ``list_holds`` always + returned ``[]`` -- causing ``pkg.installed`` with ``hold: True`` to re-hold + on every run. ``_list_holds_dnf5`` must instead parse the versionlock file + with the standard-library ``tomllib`` (Python 3.11+, shipped in the onedir + from 3006.27 per #69526). Verify holds are read even when the ``toml`` + serializer is unavailable/unused. + """ + pytest.importorskip("tomllib") + versionlock_toml = tmp_path / "versionlock.toml" + versionlock_toml.write_text( + 'version = "1.0"\n' + "\n" + "[[packages]]\n" + 'name = "salt-minion"\n' + "\n" + "[[packages.conditions]]\n" + 'key = "evr"\n' + 'comparator = "="\n' + 'value = "3007.14-0"\n' + ) + + patch_versionlock = patch.object(yumpkg, "_check_versionlock", MagicMock()) + patch_yum = patch.object(yumpkg, "_yum", MagicMock(return_value="dnf5")) + patch_path = patch.object(yumpkg, "_DNF5_VERSIONLOCK_PATH", str(versionlock_toml)) + # Fail loudly if the toml-backed serializer is touched at all. + tomlmod_deserialize = MagicMock( + side_effect=AssertionError("must parse via tomllib, not the toml serializer") + ) + patch_serializer = patch( + "salt.serializers.tomlmod.deserialize", tomlmod_deserialize + ) + + with patch_versionlock, patch_yum, patch_path, patch_serializer: + full = yumpkg.list_holds() + names = yumpkg.list_holds(full=False) + + assert full == ["salt-minion-0:3007.14-0.*"] + assert names == ["salt-minion"] + tomlmod_deserialize.assert_not_called() + + +def test_version_cmp_delegates_to_lowpkg(): + """ + pkg.version_cmp is a thin wrapper that must defer the actual comparison to + lowpkg.version_cmp, forwarding the ignore_epoch flag. + """ + cmp_mock = MagicMock(return_value=-1) + with patch.dict(yumpkg.__salt__, {"lowpkg.version_cmp": cmp_mock}): + result = yumpkg.version_cmp("0.2-001", "0.2.0.1-002", ignore_epoch=True) + assert result == -1 + cmp_mock.assert_called_once_with("0.2-001", "0.2.0.1-002", ignore_epoch=True) + + +def test_group_diff(): + """ + pkg.group_diff splits each package type's members into installed and + not-installed buckets based on the currently-installed packages. + """ + group_info_ret = { + "mandatory": ["pkga", "pkgb"], + "optional": ["pkgc"], + "default": ["pkgd"], + "conditional": [], + } + installed = {"pkga": "1.0", "pkgd": "2.0"} + with patch.object( + yumpkg, "list_pkgs", MagicMock(return_value=installed) + ), patch.object(yumpkg, "group_info", MagicMock(return_value=group_info_ret)): + ret = yumpkg.group_diff("MyGroup") + assert ret == { + "mandatory": {"installed": ["pkga"], "not installed": ["pkgb"]}, + "optional": {"installed": [], "not installed": ["pkgc"]}, + "default": {"installed": ["pkgd"], "not installed": []}, + "conditional": {"installed": [], "not installed": []}, + } + + +def test_list_repos(tmp_path): + """ + pkg.list_repos parses every ``*.repo`` file under the basedirs, skips + non-repo files, records each repo's source ``file``, and aggregates repos + across files into a single dict. + """ + repo_dir = tmp_path / "yum.repos.d" + repo_dir.mkdir() + (repo_dir / "base.repo").write_text( + "[base]\nname=Base Repo\nbaseurl=https://example.test/base\nenabled=1\n" + ) + (repo_dir / "extra.repo").write_text( + "[extra]\nname=Extra Repo\nbaseurl=https://example.test/extra\nenabled=0\n" + ) + # Not a .repo file -- must be ignored. + (repo_dir / "notes.txt").write_text("[ignored]\nname=Ignored\n") + + with patch.object( + yumpkg, "_normalize_basedir", MagicMock(return_value=[str(repo_dir)]) + ): + repos = yumpkg.list_repos() + + assert set(repos) == {"base", "extra"} + assert repos["base"]["name"] == "Base Repo" + assert repos["base"]["enabled"] == "1" + assert repos["base"]["file"] == f"{repo_dir}/base.repo" + assert repos["extra"]["enabled"] == "0" + assert repos["extra"]["file"] == f"{repo_dir}/extra.repo" + + def test_get_yum_config_no_config(): with patch("os.path.exists", MagicMock(return_value=False)): with pytest.raises(CommandExecutionError): From 6c92b9814f8ccd0257cadb0e45e2988575c50dc5 Mon Sep 17 00:00:00 2001 From: sujitdb Date: Fri, 3 Jul 2026 18:00:09 -0700 Subject: [PATCH 030/469] Fix Docker 409 when starting vault functional test container (#68961) * Use unique Docker container name for vault functional tests salt_factories.get_container used the fixed name "vault", which causes Docker 409 conflicts when a stale container remains (failed teardown, pytest --lf reruns, or runner reuse). Use random_string("vault-") like other CI container fixtures. Fixes nightly functional failures creating the vault test container. Made-with: Cursor * Add changelog for vault Docker container name fix --------- Co-authored-by: Daniel A. Wozniak --- changelog/68961.fixed.md | 4 ++++ tests/pytests/functional/modules/test_vault.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 changelog/68961.fixed.md diff --git a/changelog/68961.fixed.md b/changelog/68961.fixed.md new file mode 100644 index 000000000000..ada44823313b --- /dev/null +++ b/changelog/68961.fixed.md @@ -0,0 +1,4 @@ +Fixed Docker 409 "name already in use" errors when creating the vault +functional test container by using a unique random container name via +``random_string("vault-")``, preventing conflicts from stale containers +left by interrupted runs or CI runner reuse. diff --git a/tests/pytests/functional/modules/test_vault.py b/tests/pytests/functional/modules/test_vault.py index c836d8246d29..9b07c09dc5bd 100644 --- a/tests/pytests/functional/modules/test_vault.py +++ b/tests/pytests/functional/modules/test_vault.py @@ -4,6 +4,7 @@ import pytest from saltfactories.daemons.container import Container +from saltfactories.utils import random_string import salt.utils.path from tests.support.runtests import RUNTIME_VARS @@ -83,7 +84,7 @@ def vault_container_version(request, salt_factories, vault_port, shell): } factory = salt_factories.get_container( - "vault", + random_string("vault-"), f"ghcr.io/saltstack/salt-ci-containers/vault:{vault_version}", check_ports=[vault_port], container_run_kwargs={ From d9d399bff7bdde88987fe27ec419578c237da190 Mon Sep 17 00:00:00 2001 From: Foklan <34000783+foklan@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:06:26 +0200 Subject: [PATCH 031/469] Fix chocolatey.installed forcing reinstall (#68827) * Fix chocolatey.installed forcing reinstall chocolatey.installed compared variables which are not the same type this behaviour causes chocolatey force package reinstall even when the target version is the same as current. * Add changelog * Add changelog content * Update changelog for chocolatey.installed fix * Add regression test for chocolatey.installed forcing reinstall (#68827) --------- Co-authored-by: AWRZN038 Co-authored-by: Daniel A. Wozniak --- changelog/68827.fixed.md | 1 + salt/states/chocolatey.py | 2 +- .../test_chocolatey_installed_version.py | 51 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 changelog/68827.fixed.md create mode 100644 tests/pytests/unit/states/test_chocolatey_installed_version.py diff --git a/changelog/68827.fixed.md b/changelog/68827.fixed.md new file mode 100644 index 000000000000..791c19f8ddd8 --- /dev/null +++ b/changelog/68827.fixed.md @@ -0,0 +1 @@ +Fixed an issue in chocolatey.installed state where packages were always reinstalled. diff --git a/salt/states/chocolatey.py b/salt/states/chocolatey.py index eba3f8bb2526..131e62920519 100644 --- a/salt/states/chocolatey.py +++ b/salt/states/chocolatey.py @@ -131,7 +131,7 @@ def installed( if name.lower() == pkg.lower(): full_name = pkg - installed_version = pre_install[full_name] + installed_version = pre_install[full_name][0] if version: if salt.utils.versions.compare( diff --git a/tests/pytests/unit/states/test_chocolatey_installed_version.py b/tests/pytests/unit/states/test_chocolatey_installed_version.py new file mode 100644 index 000000000000..dc3a00b326ce --- /dev/null +++ b/tests/pytests/unit/states/test_chocolatey_installed_version.py @@ -0,0 +1,51 @@ +""" +Regression test for chocolatey.installed forcing reinstall (#68827). + +chocolatey.list returns ``{name: [version, ...]}`` (lists per package), +so indexing the dict gave the state a list where a string was expected. +``salt.utils.versions.compare(ver1=[ver], oper="==", ver2=ver)`` then +returned False, the "matches installed version" branch never fired, +and force=True was set causing reinstall every run. +""" + +import pytest + +import salt.modules.chocolatey as chocolatey_mod +import salt.states.chocolatey as chocolatey +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(minion_opts): + minion_opts["test"] = True + return { + chocolatey: { + "__opts__": minion_opts, + "__salt__": {}, + "__context__": {}, + }, + chocolatey_mod: { + "__opts__": minion_opts, + "__context__": {}, + }, + } + + +def test_installed_does_not_reinstall_when_version_matches(): + """ + chocolatey.installed must report "already installed" and not + trigger an install when the requested version matches what + chocolatey.list reports as installed. + """ + list_return = {"vim": ["9.0.1672"]} + install_mock = MagicMock(return_value="installed ok") + salt_dunder = { + "chocolatey.list": MagicMock(return_value=list_return), + "chocolatey.install": install_mock, + } + with patch.dict(chocolatey.__salt__, salt_dunder): + ret = chocolatey.installed(name="vim", version="9.0.1672") + assert ret["result"] is None + assert "is already installed" in ret["comment"] + assert "will be installed over" not in ret["comment"] + install_mock.assert_not_called() From b5c591b14f9a426c9e262442c7f15ed9932cadc5 Mon Sep 17 00:00:00 2001 From: Stepan <51859698+co-cy@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:35:48 -0300 Subject: [PATCH 032/469] Fix pgjsonb returner referencing salt.utils.jid without importing it (#69043) prep_jid() and get_jids() call salt.utils.jid.gen_jid() and salt.utils.jid.format_jid_instance() respectively, but the module removed `import salt.utils.jid` in 3006.x. Both calls work today only because salt.utils.args (transitively pulled in via salt.utils.data) imports salt.utils.jid as a side effect. Any refactor of that import chain silently breaks pgjsonb with: AttributeError: module 'salt.utils' has no attribute 'jid' prep_jid() in particular is called from master.py:_prep_jid() on every job publish, where the caller only handles KeyError/TypeError, so an AttributeError there would prevent the master from publishing jobs. Restore the explicit `import salt.utils.jid` and add behavioural unit tests for prep_jid() and get_jids() (none existed before): jid format, passed_jid pass-through, and end-to-end formatting of jids-table rows through the real salt.utils.jid.format_jid_instance(). Refs: #69042 Co-authored-by: co-cy --- changelog/69042.fixed.md | 4 ++ salt/returners/pgjsonb.py | 1 + tests/pytests/unit/returners/test_pgjsonb.py | 50 ++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 changelog/69042.fixed.md diff --git a/changelog/69042.fixed.md b/changelog/69042.fixed.md new file mode 100644 index 000000000000..a34a7f4b10ec --- /dev/null +++ b/changelog/69042.fixed.md @@ -0,0 +1,4 @@ +Fixed `salt.returners.pgjsonb.prep_jid` and `get_jids` raising +`AttributeError` when the `salt.utils.jid` submodule was not loaded +transitively by another import. The pgjsonb module now imports +`salt.utils.jid` explicitly. diff --git a/salt/returners/pgjsonb.py b/salt/returners/pgjsonb.py index fb043ef1d7ae..1afda19aa8a9 100644 --- a/salt/returners/pgjsonb.py +++ b/salt/returners/pgjsonb.py @@ -167,6 +167,7 @@ import salt.exceptions import salt.returners import salt.utils.data +import salt.utils.jid import salt.utils.job try: diff --git a/tests/pytests/unit/returners/test_pgjsonb.py b/tests/pytests/unit/returners/test_pgjsonb.py index df68fc16ffa8..45a49848ee8c 100644 --- a/tests/pytests/unit/returners/test_pgjsonb.py +++ b/tests/pytests/unit/returners/test_pgjsonb.py @@ -317,3 +317,53 @@ def test_event_return_logs_on_database_error_without_raising(caplog): "failed to store" in r.message and "3 event" in r.message for r in caplog.records ) + + +def test_prep_jid_returns_passed_jid_unchanged(): + """``prep_jid(passed_jid=X)`` returns X verbatim.""" + assert pgjsonb.prep_jid(passed_jid="20260504000000000001") == "20260504000000000001" + + +def test_prep_jid_generates_a_valid_jid_when_none_passed(): + """With no ``passed_jid``, ``prep_jid`` returns Salt's default + 20-character all-digit jid.""" + out = pgjsonb.prep_jid() + assert isinstance(out, str) + assert out.isdigit() + assert len(out) == 20 + + +def test_get_jids_returns_one_formatted_entry_per_row(): + """``get_jids`` reads ``(jid, load)`` rows from the ``jids`` table + and returns ``{jid: format_jid_instance(jid, load)}``.""" + rows = [ + ( + "20260504000000000001", + {"fun": "test.ping", "tgt": "*", "user": "root", "arg": []}, + ), + ( + "20260504000000000002", + { + "fun": "state.apply", + "tgt": "minion-1", + "user": "salt", + "arg": ["highstate"], + }, + ), + ] + cur = MagicMock() + cur.fetchall.return_value = rows + serv = MagicMock() + serv.return_value.__enter__.return_value = cur + + with patch.object(pgjsonb, "_get_serv", serv): + result = pgjsonb.get_jids() + + assert set(result) == {"20260504000000000001", "20260504000000000002"} + assert result["20260504000000000001"]["Function"] == "test.ping" + assert result["20260504000000000001"]["Target"] == "*" + assert result["20260504000000000001"]["User"] == "root" + assert result["20260504000000000002"]["Function"] == "state.apply" + assert result["20260504000000000002"]["Target"] == "minion-1" + assert result["20260504000000000002"]["Arguments"] == ["highstate"] + assert result["20260504000000000002"]["User"] == "salt" From 9cf41137c4af8ee0f5e0ebbe0d418820dc7ff529 Mon Sep 17 00:00:00 2001 From: Stepan <51859698+co-cy@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:36:19 -0300 Subject: [PATCH 033/469] Route pgjsonb database errors through Salt's logger instead of stderr (#69049) `salt.returners.pgjsonb` had eight error-handling sites in `_get_serv`, `_purge_jobs` and `_archive_jobs` that wrote `psycopg2.DatabaseError` messages directly to `sys.stderr` and re-raised: except psycopg2.DatabaseError as err: error = err.args sys.stderr.write(str(error)) cursor.execute("ROLLBACK") raise err On a daemonized master `sys.stderr` is captured by systemd's journal (or worse, /dev/null), bypassing the configured `log_file` / `log_level_logfile` and syslog. Operators that scrape Salt's log file never see these errors, even though the rest of the file already uses `log.error` (e.g. `clean_old_jobs`). The local `error = err.args` assignment is dead -- only `str(err.args)` is written, then the variable is unused. Also drop `import sys`, which becomes unused after the migration. Replace each call with `log.exception(...)` carrying a description of the operation that failed, so the traceback is preserved in the log. Behaviour (rollback, re-raise) is unchanged. The asymmetric `except Exception` block on `_archive_jobs` jids-insert is updated to the same pattern but kept in place (its scope is PR-7, not this PR). Add three behavioural tests with `caplog`: one each for `_get_serv`, `_purge_jobs` and `_archive_jobs` confirming the error reaches Salt's logger, the transaction is rolled back, and the exception propagates. Refs: #69048 Co-authored-by: co-cy --- changelog/69048.fixed.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog/69048.fixed.md diff --git a/changelog/69048.fixed.md b/changelog/69048.fixed.md new file mode 100644 index 000000000000..0b2b9ec18052 --- /dev/null +++ b/changelog/69048.fixed.md @@ -0,0 +1,5 @@ +Fixed `salt.returners.pgjsonb` writing database errors to `sys.stderr` +instead of Salt's logger. Errors from `_get_serv`, `_purge_jobs` and +`_archive_jobs` are now reported via `log.exception`, so they reach +the configured `log_file` / syslog destination on a daemonized master, +including a full traceback. The unused `import sys` is also dropped. From a135a575f9c006b5e50252cc6a414140b92710dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20D=2E=20=C3=81lvaro?= Date: Sat, 4 Jul 2026 22:39:57 +0200 Subject: [PATCH 034/469] =?UTF-8?q?fix(pip):=20normalize=20names=20in=20li?= =?UTF-8?q?st=5Ffreeze=5Fparse=20and=20detect=20modern=20pip=20=E2=80=A6?= =?UTF-8?q?=20(#68784)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pip): normalize names in list_freeze_parse and detect modern pip satisfied message * ref: change string formatting * enh: add PR suggestions --- changelog/68784.fixed.md | 11 ++++ salt/modules/pip.py | 13 ++-- salt/states/pip_state.py | 45 +++++++------- tests/pytests/unit/modules/test_pip.py | 50 +++++++++++++-- tests/pytests/unit/states/test_pip.py | 86 ++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 31 deletions(-) create mode 100644 changelog/68784.fixed.md diff --git a/changelog/68784.fixed.md b/changelog/68784.fixed.md new file mode 100644 index 000000000000..af47fee0e0bf --- /dev/null +++ b/changelog/68784.fixed.md @@ -0,0 +1,11 @@ +Fix `pip.installed` state reinstalling packages on every run even when the +correct version is already present: + +- `pip.list_freeze_parse` now normalizes package names (lowercase, hyphens) + consistent with `pip.list`, so that packages whose `pip freeze` name uses + underscores or mixed case (e.g. `requests_oauthlib`) are correctly detected + as already installed when looked up by their normalized name. +- The post-install check in `pip.installed` now also recognizes + `"Requirement already satisfied:"` (modern pip ≥ 10.0) in addition to the + old `"Requirement already up-to-date:"` message, preventing packages + confirmed as already present from being falsely reported as changed. diff --git a/salt/modules/pip.py b/salt/modules/pip.py index 3809bc6b6a19..e85e93675764 100644 --- a/salt/modules/pip.py +++ b/salt/modules/pip.py @@ -1341,7 +1341,9 @@ def list_freeze_parse( cwd = _pip_bin_env(cwd, bin_env) packages = {} - if prefix is None or "pip".startswith(prefix): + normal_prefix = normalize(prefix) if prefix else None + + if normal_prefix is None or "pip".startswith(normal_prefix): packages["pip"] = version(bin_env, cwd) for line in freeze( @@ -1375,11 +1377,12 @@ def list_freeze_parse( logger.error("Can't parse line '%s'", line) continue - if prefix: - if name.lower().startswith(prefix.lower()): - packages[name] = version_ + normal_name = normalize(name) + if normal_prefix: + if normal_name.startswith(normal_prefix): + packages[normal_name] = version_ else: - packages[name] = version_ + packages[normal_name] = version_ return packages diff --git a/salt/states/pip_state.py b/salt/states/pip_state.py index 94bb741930b1..fc77af52198a 100644 --- a/salt/states/pip_state.py +++ b/salt/states/pip_state.py @@ -19,6 +19,7 @@ """ import logging +import re import sys import types @@ -256,13 +257,10 @@ def _check_pkg_version_format(pkg): ret["result"] = False if not from_vcs and "=" in pkg and "==" not in pkg: ret["comment"] = ( - "Invalid version specification in package {}. '=' is " - "not supported, use '==' instead.".format(pkg) + f"Invalid version specification in package {pkg}. '=' is not supported, use '==' instead." ) return ret - ret["comment"] = "pip raised an exception while parsing '{}': {}".format( - pkg, exc - ) + ret["comment"] = f"pip raised an exception while parsing '{pkg}': {exc}" return ret if install_req is None or install_req.req is None: @@ -339,8 +337,8 @@ def _check_if_installed( and _fulfills_version_spec(pip_list[prefix], version_spec) ) or (not any(version_spec)): ret["result"] = True - ret["comment"] = "Python package {} was already installed".format( - state_pkg_name + ret["comment"] = ( + f"Python package {state_pkg_name} was already installed" ) return ret if force_reinstall is False and upgrade: @@ -386,8 +384,8 @@ def _check_if_installed( return ret if _pep440_version_cmp(pip_list[prefix], desired_version) == 0: ret["result"] = True - ret["comment"] = "Python package {} was already installed".format( - state_pkg_name + ret["comment"] = ( + f"Python package {state_pkg_name} was already installed" ) return ret @@ -915,8 +913,7 @@ def prepro(pkg): ) if editable: comments.append( - "Package will be installed in editable mode (i.e. " - 'setuptools "develop mode") from {}.'.format(editable) + f'Package will be installed in editable mode (i.e. setuptools "develop mode") from {editable}.' ) ret["comment"] = " ".join(comments) return ret @@ -1085,18 +1082,14 @@ def prepro(pkg): ret["changes"]["requirements"] = True if ret["changes"].get("requirements"): comments.append( - "Successfully processed requirements file {}.".format( - requirements - ) + f"Successfully processed requirements file {requirements}." ) else: comments.append("Requirements were already installed.") if editable: comments.append( - "Package successfully installed from VCS checkout {}.".format( - editable - ) + f"Package successfully installed from VCS checkout {editable}." ) ret["changes"]["editable"] = True ret["comment"] = " ".join(comments) @@ -1108,10 +1101,18 @@ def prepro(pkg): already_installed_packages = set() for line in pip_install_call.get("stdout", "").split("\n"): # Output for already installed packages: - # 'Requirement already up-to-date: jinja2 in /usr/local/lib/python2.7/dist-packages\nCleaning up...' - if line.startswith("Requirement already up-to-date: "): - package = line.split(":", 1)[1].split()[0] - already_installed_packages.add(package.lower()) + # modern pip: 'Requirement already satisfied: jinja2 in /usr/local/lib/...' + # old pip: 'Requirement already up-to-date: jinja2 in /usr/local/lib/python2.7/...' + if line.startswith( + ( + "Requirement already satisfied: ", + "Requirement already up-to-date: ", + ) + ): + pkg_str = line.split(":", 1)[1].split()[0] + # Strip version specifier to get just the package name + pkg_name = re.split(r"[=!<>~@]", pkg_str)[0] + already_installed_packages.add(__salt__["pip.normalize"](pkg_name)) for prefix, state_name in target_pkgs: # Case for packages that are not an URL @@ -1138,7 +1139,7 @@ def prepro(pkg): else: if ( prefix in pipsearch - and prefix.lower() not in already_installed_packages + and prefix not in already_installed_packages ): ver = pipsearch[prefix] ret["changes"][f"{prefix}=={ver}"] = "Installed" diff --git a/tests/pytests/unit/modules/test_pip.py b/tests/pytests/unit/modules/test_pip.py index 1fb1e533c686..555df24a933c 100644 --- a/tests/pytests/unit/modules/test_pip.py +++ b/tests/pytests/unit/modules/test_pip.py @@ -1509,8 +1509,8 @@ def test_list_freeze_parse_command(python_binary): use_vt=False, ) assert ret == { - "SaltTesting-dev": "git+git@github.com:s0undt3ch/salt-testing.git@9ed81aa2f918d59d3706e56b18f0782d1ea43bf8", - "M2Crypto": "0.21.1", + "salttesting-dev": "git+git@github.com:s0undt3ch/salt-testing.git@9ed81aa2f918d59d3706e56b18f0782d1ea43bf8", + "m2crypto": "0.21.1", "bbfreeze-loader": "1.1.0", "bbfreeze": "1.1.0", "pip": mock_version, @@ -1559,8 +1559,8 @@ def test_list_freeze_parse_command_with_all(python_binary): use_vt=False, ) assert ret == { - "SaltTesting-dev": "git+git@github.com:s0undt3ch/salt-testing.git@9ed81aa2f918d59d3706e56b18f0782d1ea43bf8", - "M2Crypto": "0.21.1", + "salttesting-dev": "git+git@github.com:s0undt3ch/salt-testing.git@9ed81aa2f918d59d3706e56b18f0782d1ea43bf8", + "m2crypto": "0.21.1", "bbfreeze-loader": "1.1.0", "bbfreeze": "1.1.0", "pip": "9.0.1", @@ -1601,6 +1601,48 @@ def test_list_freeze_parse_command_with_prefix(python_binary): assert ret == {"bbfreeze-loader": "1.1.0", "bbfreeze": "1.1.0"} +def test_list_freeze_parse_normalizes_package_names(python_binary): + """ + list_freeze_parse must return normalized package names (lowercase, hyphens) + consistent with list_(), so that pip_list lookups work correctly regardless + of how the name appears in `pip freeze` output (underscores, mixed case, etc.). + """ + eggs = [ + "requests_oauthlib==1.3.0", + "My_Package==2.0.0", + "Pillow==10.0.0", + ] + mock = MagicMock(return_value={"retcode": 0, "stdout": "\n".join(eggs)}) + with patch.dict(pip.__salt__, {"cmd.run_all": mock}): + with patch("salt.modules.pip.version", MagicMock(return_value="6.1.1")): + ret = pip.list_freeze_parse() + assert ret == { + "requests-oauthlib": "1.3.0", + "my-package": "2.0.0", + "pillow": "10.0.0", + "pip": "6.1.1", + } + + +def test_list_freeze_parse_prefix_matches_normalized_name(python_binary): + """ + list_freeze_parse must match packages by normalized prefix even when the + freeze output uses underscores but the caller uses hyphens (or vice versa). + This ensures _check_if_installed does not produce false negatives. + """ + eggs = [ + "requests_oauthlib==1.3.0", + "requests==2.31.0", + "other_pkg==0.1.0", + ] + mock = MagicMock(return_value={"retcode": 0, "stdout": "\n".join(eggs)}) + with patch.dict(pip.__salt__, {"cmd.run_all": mock}): + with patch("salt.modules.pip.version", MagicMock(return_value="6.1.1")): + # A hyphenated prefix must match an underscore-named package + ret = pip.list_freeze_parse(prefix="requests-oauthlib") + assert ret == {"requests-oauthlib": "1.3.0"} + + def test_list_upgrades_legacy(python_binary): eggs = [ "apache-libcloud (Current: 1.1.0 Latest: 2.2.1 [wheel])", diff --git a/tests/pytests/unit/states/test_pip.py b/tests/pytests/unit/states/test_pip.py index 92061b0263b1..d7c904511567 100644 --- a/tests/pytests/unit/states/test_pip.py +++ b/tests/pytests/unit/states/test_pip.py @@ -71,3 +71,89 @@ def test_issue_64169(caplog): # Confirm that the state continued to install the package as expected. # Only check the 'pkgs' parameter of pip.install assert mock_pip_install.call_args.kwargs["pkgs"] == pkg_to_install + + +def test_already_satisfied_not_reported_as_change(): + """ + When pip outputs 'Requirement already satisfied' (modern pip >= 10) for a + package that ended up in target_pkgs, the state must NOT report it as a + change. Previously only the old 'Requirement already up-to-date' message + was checked, causing the state to always report the package as installed. + """ + pkg_name = "my-package" + pkg_version = "1.0.0" + + mock_pip_list = MagicMock( + side_effect=[ + {}, # pre-cache: empty → package goes to target_pkgs + {}, # _check_if_installed fallback: package not found + {pkg_name: pkg_version}, # post-install verification + ] + ) + mock_pip_version = MagicMock(return_value="24.0.0") + mock_pip_install = MagicMock( + return_value={ + "retcode": 0, + "stdout": f"Requirement already satisfied: {pkg_name} in /path/to/site-packages", + } + ) + + with patch.dict( + pip_state.__salt__, + { + "pip.list": mock_pip_list, + "pip.version": mock_pip_version, + "pip.install": mock_pip_install, + "pip.normalize": pip_module.normalize, + }, + ): + ret = pip_state.installed(name=pkg_name) + + assert ret["result"] is True + # The package was already satisfied — no changes should be reported + assert ( + ret["changes"] == {} + ), "Package reported as 'Requirement already satisfied' must not appear in changes" + + +def test_already_satisfied_with_version_spec_not_reported_as_change(): + """ + When pip outputs 'Requirement already satisfied: pkg==x.y.z ...' (with a + version specifier in the message), the version suffix must be stripped when + checking against already_installed_packages so the package is still + correctly excluded from changes. + """ + pkg_name = "my-package" + pkg_version = "1.0.0" + + mock_pip_list = MagicMock( + side_effect=[ + {}, # pre-cache: empty + {}, # _check_if_installed fallback + {pkg_name: pkg_version}, # post-install verification + ] + ) + mock_pip_version = MagicMock(return_value="24.0.0") + mock_pip_install = MagicMock( + return_value={ + "retcode": 0, + # pip includes the version spec in the satisfied message + "stdout": f"Requirement already satisfied: {pkg_name}=={pkg_version} in /path", + } + ) + + with patch.dict( + pip_state.__salt__, + { + "pip.list": mock_pip_list, + "pip.version": mock_pip_version, + "pip.install": mock_pip_install, + "pip.normalize": pip_module.normalize, + }, + ): + ret = pip_state.installed(name=pkg_name) + + assert ret["result"] is True + assert ( + ret["changes"] == {} + ), "Package with version spec in satisfied message must not appear in changes" From e7732f5b513239a6879f290190aa6516947f6d6c Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 5 Jul 2026 02:55:55 -0400 Subject: [PATCH 035/469] Let virtualenv.create build stdlib venvs with a specific interpreter (#69679) (#69681) * Let virtualenv.create build stdlib venvs with a specific interpreter virtualenv.create's venv path always ran the venv module through sys.executable, which on a onedir minion is Salt's private relenv interpreter, and it rejected the python argument outright. That left no way to build an environment for a distro interpreter on systems whose virtualenv binary is too old to target one (EL8 ships virtualenv 15.1.0 bound to python 3.6 while 3.9/3.11/3.12 are installable alongside). Honour python on the venv path by running ' -m venv', accept a python interpreter directly as venv_bin, and pass prompt through to venv (supported since Python 3.6) instead of rejecting it. The virtualenv-binary path is unchanged, and venv_bin=venv without python keeps using sys.executable. Partially created environments are now removed when the creation command fails, so a failed ' -m venv' (e.g. target interpreter without ensurepip) no longer leaves a bin/python behind that the virtualenv.managed state mistakes for a working environment. The obsolete easy_install/ez_setup bootstrap is skipped for venv-module environments, where ensurepip already provides pip. The state module already forwarded python; its docs are updated to match. The legacy unit test file is migrated to the pytest suite with coverage for the new paths, and functional tests exercise the venv module end to end. Verified on AlmaLinux 8/9/10, Debian 11/12/13 and Ubuntu 22.04/24.04/26.04 containers, including cross-interpreter creation (3.11 minion python building 3.9/3.12/3.14 environments). Fixes #69679 * Skip the stdlib-venv functional tests when ensurepip is unavailable python -m venv bootstraps pip through ensurepip, which is stripped from the salt onedir/relenv interpreter the CI runners use, so the four venv-module tests failed with retcode 1 there. Skip them unless the interpreter has a working ensurepip; they still exercise the code path under any normal interpreter (verified across AlmaLinux/Debian/Ubuntu). --- changelog/69679.added.md | 1 + salt/modules/virtualenv_mod.py | 102 ++- salt/states/virtualenv_mod.py | 13 + .../functional/modules/test_virtualenv_mod.py | 80 ++- .../unit/modules/test_virtualenv_mod.py | 614 ++++++++++++++++++ tests/unit/modules/test_virtualenv_mod.py | 414 ------------ 6 files changed, 794 insertions(+), 430 deletions(-) create mode 100644 changelog/69679.added.md create mode 100644 tests/pytests/unit/modules/test_virtualenv_mod.py delete mode 100644 tests/unit/modules/test_virtualenv_mod.py diff --git a/changelog/69679.added.md b/changelog/69679.added.md new file mode 100644 index 000000000000..4d8fb279c41a --- /dev/null +++ b/changelog/69679.added.md @@ -0,0 +1 @@ +``virtualenv.create`` and the ``virtualenv.managed`` state can now build an environment with a specific interpreter's standard library ``venv`` module: ``venv_bin: venv`` honours the ``python`` argument (running `` -m venv`` instead of always using the interpreter running the minion), and a python interpreter may be passed directly as ``venv_bin``. The ``prompt`` argument is now passed through on the venv path as well, instead of being rejected. This makes it possible to manage e.g. python3.11 environments on EL8, where the distro virtualenv is 15.1.0 bound to python 3.6. diff --git a/salt/modules/virtualenv_mod.py b/salt/modules/virtualenv_mod.py index cd52435e6f51..b9a056cec66f 100644 --- a/salt/modules/virtualenv_mod.py +++ b/salt/modules/virtualenv_mod.py @@ -39,6 +39,21 @@ def __virtual__(): return __virtualname__ +def _is_python_binary(venv_bin): + """ + Return True when venv_bin points at a python interpreter (e.g. + ``python3``, ``/usr/bin/python3.11``, ``pypy3``, ``python.exe``), which + selects environment creation through `` -m venv``. + """ + return bool( + re.fullmatch( + r"(python|pypy)[0-9.]*(\.exe)?", + os.path.basename(venv_bin), + flags=re.IGNORECASE, + ) + ) + + def virtualenv_ver(venv_bin, user=None, **kwargs): """ return virtualenv version if exists @@ -98,7 +113,16 @@ def create( venv_bin The name (and optionally path) of the virtualenv command. This can also be set globally in the minion config file as ``virtualenv.venv_bin``. - Defaults to ``virtualenv``. + Defaults to the first virtualenv binary found in the PATH, falling + back to ``venv`` when none is installed. The special value ``venv`` + selects the + python standard library ``venv`` module instead of a virtualenv + binary; a python interpreter (e.g. ``/usr/bin/python3.11``) may also + be given, in which case the environment is created with + `` -m venv``. + + .. versionchanged:: 3006.28 + A python interpreter is now accepted as ``venv_bin``. system_site_packages : False Passthrough argument given to virtualenv or venv @@ -114,7 +138,16 @@ def create( Passthrough argument given to virtualenv or venv python : None (default) - Passthrough argument given to virtualenv + The python interpreter to create the environment with. With a + virtualenv binary this is passed as ``--python``; with + ``venv_bin: venv`` the environment is created by running + `` -m venv``, so the environment belongs to that + interpreter rather than the one running the Salt minion. + + .. versionchanged:: 3006.28 + With ``venv_bin: venv`` this argument used to be rejected; it + now selects the interpreter that runs ``-m venv``. It remains + unsupported for other venv-style binaries such as ``pyvenv``. extra_search_dir : None (default) Passthrough argument given to virtualenv @@ -123,7 +156,12 @@ def create( Passthrough argument given to virtualenv if True prompt : None (default) - Passthrough argument given to virtualenv if not None + Passthrough argument given to virtualenv or venv if not None + + .. versionchanged:: 3006.28 + Previously rejected when ``venv_bin`` selected the ``venv`` + module; the ``venv`` module has supported ``--prompt`` since + Python 3.6. symlinks : None Passthrough argument given to venv if True @@ -176,12 +214,32 @@ def create( if venv_bin is None: venv_bin = __pillar__.get("venv_bin") or __opts__.get("venv_bin") + # The "venv" magic value and an interpreter passed as venv_bin both + # select the python standard library venv module; any other value + # containing "venv" (e.g. the historical pyvenv script) is run as-is + # but treated as venv for option handling. + venv_via_interpreter = venv_bin == "venv" or _is_python_binary(venv_bin) + if venv_bin == "venv": - cmd = [sys.executable, "-m", "venv"] + interpreter = sys.executable + if python is not None and python.strip() != "": + if not salt.utils.path.which(python): + raise CommandExecutionError(f"Cannot find requested python ({python}).") + interpreter = python + cmd = [interpreter, "-m", "venv"] + elif _is_python_binary(venv_bin): + if python is not None and python.strip() != "": + raise CommandExecutionError( + "Pass the target interpreter either as `venv_bin` or as " + "`python`, not both." + ) + if not salt.utils.path.which(venv_bin): + raise CommandExecutionError(f"Cannot find requested python ({venv_bin}).") + cmd = [venv_bin, "-m", "venv"] else: cmd = [venv_bin] - if "venv" not in venv_bin: + if not venv_via_interpreter and "venv" not in venv_bin: # ----- Stop the user if venv only options are used -----------------> # If any of the following values are not None, it means that the user # is actually passing a True or False value. Stop Him! @@ -238,13 +296,15 @@ def create( # ----- Stop the user if virtualenv only options are being used -----> # If any of the following values are not None, it means that the user # is actually passing a True or False value. Stop Him! - if python is not None and python.strip() != "": + if not venv_via_interpreter and python is not None and python.strip() != "": raise CommandExecutionError( "The `python`(`--python`) option is not supported by '{}'".format( venv_bin ) ) - elif extra_search_dir is not None and extra_search_dir.strip() != "": + elif extra_search_dir is not None and ( + not isinstance(extra_search_dir, str) or extra_search_dir.strip() != "" + ): raise CommandExecutionError( "The `extra_search_dir`(`--extra-search-dir`) option is not " "supported by '{}'".format(venv_bin) @@ -254,18 +314,15 @@ def create( "The `never_download`(`--never-download`) option is not " "supported by '{}'".format(venv_bin) ) - elif prompt is not None and prompt.strip() != "": - raise CommandExecutionError( - "The `prompt`(`--prompt`) option is not supported by '{}'".format( - venv_bin - ) - ) # <---- Stop the user if virtualenv only options are being used ------ if upgrade is True: cmd.append("--upgrade") if symlinks is True: cmd.append("--symlinks") + if prompt is not None and prompt.strip() != "": + # venv has supported --prompt since Python 3.6 + cmd.extend(["--prompt", prompt]) # Common options to virtualenv and venv if clear is True: @@ -277,9 +334,15 @@ def create( cmd.append(path) # Let's create the virtualenv + path_preexisting = os.path.exists(path) ret = __salt__["cmd.run_all"](cmd, runas=user, python_shell=False, **kwargs) if ret["retcode"] != 0: - # Something went wrong. Let's bail out now! + # Something went wrong. Remove a partially created environment so a + # later run (or the virtualenv.managed state, which keys existence + # off bin/python) does not mistake it for a working one, then bail. + if not path_preexisting and os.path.isdir(path): + log.debug("Removing partially created virtualenv %s", path) + shutil.rmtree(path, ignore_errors=True) return ret # Check if distribute and pip are already installed @@ -292,8 +355,17 @@ def create( venv_pip = os.path.join(path, "bin", "pip") venv_setuptools = os.path.join(path, "bin", "easy_install") + # ensurepip already provides pip in venv-module environments, and the + # easy_install/ez_setup bootstrap is long obsolete, so skip it there; + # the get-pip step below is skipped through os.path.exists(venv_pip). + use_venv_module = venv_via_interpreter or "venv" in venv_bin + # Install setuptools - if (pip or distribute) and not os.path.exists(venv_setuptools): + if ( + (pip or distribute) + and not use_venv_module + and not os.path.exists(venv_setuptools) + ): _install_script( "https://bootstrap.pypa.io/ez_setup.py", path, diff --git a/salt/states/virtualenv_mod.py b/salt/states/virtualenv_mod.py index 7472bcdfcc26..0e3125733543 100644 --- a/salt/states/virtualenv_mod.py +++ b/salt/states/virtualenv_mod.py @@ -74,6 +74,13 @@ def managed( venv_bin: virtualenv The name (and optionally path) of the virtualenv command. This can also be set globally in the minion config file as ``virtualenv.venv_bin``. + The special value ``venv`` selects the python standard library + ``venv`` module instead of a virtualenv binary; a python interpreter + (e.g. ``/usr/bin/python3.11``) may also be given, in which case the + environment is created with `` -m venv``. + + .. versionchanged:: 3006.28 + A python interpreter is now accepted as ``venv_bin``. requirements: None Path to a pip requirements file. If the path begins with ``salt://`` @@ -87,6 +94,12 @@ def managed( from a onedir package. You will likely want to specify which python interperter should be used. + .. versionchanged:: 3006.28 + Also honoured with ``venv_bin: venv``: the environment is created + by running `` -m venv``, so distros whose virtualenv + binary is outdated (e.g. EL8) can still build environments for + any installed interpreter. + user: None The user under which to run virtualenv and pip. diff --git a/tests/pytests/functional/modules/test_virtualenv_mod.py b/tests/pytests/functional/modules/test_virtualenv_mod.py index 7d8398e149b8..313ebb4654c4 100644 --- a/tests/pytests/functional/modules/test_virtualenv_mod.py +++ b/tests/pytests/functional/modules/test_virtualenv_mod.py @@ -1,4 +1,6 @@ import shutil +import subprocess +import sys import pytest @@ -6,9 +8,35 @@ pytestmark = [ pytest.mark.slow_test, - pytest.mark.skip_if_binaries_missing(*KNOWN_BINARY_NAMES, check_all=False), ] +# The stdlib venv tests below do not need a virtualenv binary; only the +# tests driving one carry this marker. +requires_virtualenv = pytest.mark.skip_if_binaries_missing( + *KNOWN_BINARY_NAMES, check_all=False +) + + +def _ensurepip_available(): + # ``python -m venv`` bootstraps pip through ensurepip, which is stripped + # from the salt onedir/relenv interpreter used on the CI runners. Skip the + # stdlib-venv tests there; they exercise the same code path fine under any + # interpreter that ships a working ensurepip. + return ( + subprocess.run( + [sys.executable, "-m", "ensurepip", "--version"], + capture_output=True, + check=False, + ).returncode + == 0 + ) + + +requires_ensurepip = pytest.mark.skipif( + not _ensurepip_available(), + reason="stdlib venv creation needs an interpreter with a working ensurepip", +) + @pytest.fixture def venv_dir(tmp_path): @@ -20,6 +48,7 @@ def virtualenv(modules): return modules.virtualenv +@requires_virtualenv def test_create_defaults(virtualenv, venv_dir): """ virtualenv.managed @@ -32,6 +61,7 @@ def test_create_defaults(virtualenv, venv_dir): assert pip_binary.exists() +@requires_virtualenv def test_site_packages(virtualenv, venv_dir, modules): ret = virtualenv.create(str(venv_dir), system_site_packages=True) assert ret @@ -48,6 +78,7 @@ def test_site_packages(virtualenv, venv_dir, modules): assert with_site != without_site +@requires_virtualenv def test_clear(virtualenv, venv_dir, modules): ret = virtualenv.create(str(venv_dir)) assert ret @@ -63,6 +94,7 @@ def test_clear(virtualenv, venv_dir, modules): assert "pep8" not in packages +@requires_virtualenv def test_virtualenv_ver(virtualenv, venv_dir): ret = virtualenv.create(str(venv_dir)) assert ret @@ -71,3 +103,49 @@ def test_virtualenv_ver(virtualenv, venv_dir): ret = virtualenv.virtualenv_ver(str(venv_dir)) assert isinstance(ret, tuple) assert all([isinstance(x, int) for x in ret]) + + +@requires_ensurepip +def test_create_venv_module(virtualenv, venv_dir): + """ + venv_bin="venv" builds the environment with the python standard library + venv module. + """ + ret = virtualenv.create(str(venv_dir), venv_bin="venv") + assert ret + assert ret["retcode"] == 0 + assert (venv_dir / "bin" / "python").exists() + assert (venv_dir / "pyvenv.cfg").exists() + + +@requires_ensurepip +def test_create_venv_module_with_python(virtualenv, venv_dir): + """ + venv_bin="venv" with an explicit python runs ` -m venv`. + """ + ret = virtualenv.create(str(venv_dir), venv_bin="venv", python=sys.executable) + assert ret + assert ret["retcode"] == 0 + assert (venv_dir / "bin" / "python").exists() + + +@requires_ensurepip +def test_create_venv_interpreter_as_venv_bin(virtualenv, venv_dir): + """ + A python interpreter passed as venv_bin also selects the venv module. + """ + ret = virtualenv.create(str(venv_dir), venv_bin=sys.executable) + assert ret + assert ret["retcode"] == 0 + assert (venv_dir / "bin" / "python").exists() + + +@requires_ensurepip +def test_create_venv_module_prompt(virtualenv, venv_dir): + """ + The prompt argument is passed through to the venv module. + """ + ret = virtualenv.create(str(venv_dir), venv_bin="venv", prompt="salty-venv") + assert ret + assert ret["retcode"] == 0 + assert "salty-venv" in (venv_dir / "pyvenv.cfg").read_text() diff --git a/tests/pytests/unit/modules/test_virtualenv_mod.py b/tests/pytests/unit/modules/test_virtualenv_mod.py new file mode 100644 index 000000000000..759bc616dd7f --- /dev/null +++ b/tests/pytests/unit/modules/test_virtualenv_mod.py @@ -0,0 +1,614 @@ +""" +Tests for salt.modules.virtualenv_mod +""" + +import logging +import sys + +import pytest + +import salt.modules.virtualenv_mod as virtualenv_mod +from salt.exceptions import CommandExecutionError +from tests.support.helpers import ForceImportErrorOn +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + base_virtualenv_mock = MagicMock() + base_virtualenv_mock.__version__ = "1.9.1" + return { + virtualenv_mod: { + "__opts__": {"venv_bin": "virtualenv"}, + "_install_script": MagicMock( + return_value={ + "retcode": 0, + "stdout": "Installed script!", + "stderr": "", + } + ), + "sys.modules": {"virtualenv": base_virtualenv_mock}, + } + } + + +@pytest.fixture(autouse=True) +def which_identity(): + # The interpreter/python lookups performed by create() must find whatever + # binary name the tests pass in. + with patch("salt.utils.path.which", lambda exe: exe): + yield + + +def test_issue_6029_deprecated_distribute(caplog): + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", system_site_packages=True, distribute=True) + mock.assert_called_once_with( + ["virtualenv", "--distribute", "--system-site-packages", "/tmp/foo"], + runas=None, + python_shell=False, + ) + + with caplog.at_level(logging.INFO, logger="salt.modules.virtualenv_mod"): + # Let's fake a higher virtualenv version + virtualenv_mock = MagicMock() + virtualenv_mock.__version__ = "1.10rc1" + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + with patch.dict("sys.modules", {"virtualenv": virtualenv_mock}): + virtualenv_mod.create( + "/tmp/foo", system_site_packages=True, distribute=True + ) + mock.assert_called_once_with( + ["virtualenv", "--system-site-packages", "/tmp/foo"], + runas=None, + python_shell=False, + ) + + # Are we logging the deprecation information? + assert ( + "The virtualenv '--distribute' option has been " + "deprecated in virtualenv(>=1.10), as such, the " + "'distribute' option to `virtualenv.create()` has " + "also been deprecated and it's not necessary anymore." + in caplog.messages + ) + + +def test_issue_6030_deprecated_never_download(caplog): + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", never_download=True) + mock.assert_called_once_with( + ["virtualenv", "--never-download", "/tmp/foo"], + runas=None, + python_shell=False, + ) + + with caplog.at_level(logging.INFO, logger="salt.modules.virtualenv_mod"): + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + # Let's fake a higher virtualenv version + virtualenv_mock = MagicMock() + virtualenv_mock.__version__ = "1.10rc1" + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + with patch.dict("sys.modules", {"virtualenv": virtualenv_mock}): + virtualenv_mod.create("/tmp/foo", never_download=True) + mock.assert_called_once_with( + ["virtualenv", "/tmp/foo"], runas=None, python_shell=False + ) + + # Are we logging the deprecation information? + assert ( + "--never-download was deprecated in 1.10.0, " + "but reimplemented in 14.0.0. If this feature is needed, " + "please install a supported virtualenv version." in caplog.messages + ) + + +@pytest.mark.parametrize( + "extra_search_dir", + [ + ["/tmp/bar-1", "/tmp/bar-2", "/tmp/bar-3"], + "/tmp/bar-1,/tmp/bar-2,/tmp/bar-3", + ], + ids=["list", "comma-separated-string"], +) +def test_issue_6031_multiple_extra_search_dirs(extra_search_dir): + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", extra_search_dir=extra_search_dir) + mock.assert_called_once_with( + [ + "virtualenv", + "--extra-search-dir=/tmp/bar-1", + "--extra-search-dir=/tmp/bar-2", + "--extra-search-dir=/tmp/bar-3", + "/tmp/foo", + ], + runas=None, + python_shell=False, + ) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"venv_bin": "virtualenv", "upgrade": True}, + {"venv_bin": "virtualenv", "symlinks": True}, + {"venv_bin": "pyvenv", "python": "python2.7"}, + {"venv_bin": "pyvenv", "never_download": True}, + {"venv_bin": "pyvenv", "extra_search_dir": "/tmp/bar"}, + ], + ids=[ + "virtualenv-upgrade", + "virtualenv-symlinks", + "pyvenv-python", + "pyvenv-never_download", + "pyvenv-extra_search_dir", + ], +) +def test_unapplicable_options(kwargs): + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + with pytest.raises(CommandExecutionError): + virtualenv_mod.create("/tmp/foo", **kwargs) + + +def test_pyvenv_accepts_prompt(): + # Historically the prompt option was rejected on the venv code path, but + # the venv module has supported --prompt since Python 3.6. + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", venv_bin="pyvenv", prompt="PY Prompt") + mock.assert_called_once_with( + ["pyvenv", "--prompt", "PY Prompt", "/tmp/foo"], + runas=None, + python_shell=False, + ) + + +def test_get_virtualenv_version_from_shell(): + with ForceImportErrorOn("virtualenv"): + + # ----- virtualenv binary not available -------------------------> + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + with pytest.raises(CommandExecutionError): + virtualenv_mod.create("/tmp/foo") + # <---- virtualenv binary not available -------------------------- + + # ----- virtualenv binary present but > 0 exit code -------------> + mock = MagicMock( + side_effect=[ + {"retcode": 1, "stdout": "", "stderr": "This is an error"}, + {"retcode": 0, "stdout": ""}, + ] + ) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + with pytest.raises(CommandExecutionError): + virtualenv_mod.create("/tmp/foo", venv_bin="virtualenv") + # <---- virtualenv binary present but > 0 exit code -------------- + + # ----- virtualenv binary returns 1.9.1 as its version ---------> + mock = MagicMock( + side_effect=[ + {"retcode": 0, "stdout": "1.9.1"}, + {"retcode": 0, "stdout": ""}, + ] + ) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", never_download=True) + mock.assert_called_with( + ["virtualenv", "--never-download", "/tmp/foo"], + runas=None, + python_shell=False, + ) + # <---- virtualenv binary returns 1.9.1 as its version ---------- + + # ----- virtualenv binary returns 1.10rc1 as its version -------> + mock = MagicMock( + side_effect=[ + {"retcode": 0, "stdout": "1.10rc1"}, + {"retcode": 0, "stdout": ""}, + ] + ) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", never_download=True) + mock.assert_called_with( + ["virtualenv", "/tmp/foo"], runas=None, python_shell=False + ) + # <---- virtualenv binary returns 1.10rc1 as its version -------- + + +def test_python_argument(): + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", python=sys.executable) + mock.assert_called_once_with( + ["virtualenv", f"--python={sys.executable}", "/tmp/foo"], + runas=None, + python_shell=False, + ) + + +@pytest.mark.parametrize( + "prompt,expected", + [ + ("PY Prompt", "--prompt='PY Prompt'"), + ("'PY' Prompt", "--prompt=''PY' Prompt'"), + ('"PY" Prompt', "--prompt='\"PY\" Prompt'"), + ], + ids=["plain", "single-quotes", "double-quotes"], +) +def test_prompt_argument(prompt, expected): + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", prompt=prompt) + mock.assert_called_once_with( + ["virtualenv", expected, "/tmp/foo"], + runas=None, + python_shell=False, + ) + + +def test_clear_argument(): + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", clear=True) + mock.assert_called_once_with( + ["virtualenv", "--clear", "/tmp/foo"], runas=None, python_shell=False + ) + + +def test_upgrade_argument(): + # We test for pyvenv only because with virtualenv this is an + # unsupported option. + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", venv_bin="pyvenv", upgrade=True) + mock.assert_called_once_with( + ["pyvenv", "--upgrade", "/tmp/foo"], runas=None, python_shell=False + ) + + +def test_symlinks_argument(): + # We test for pyvenv only because with virtualenv this is an + # unsupported option. + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", venv_bin="pyvenv", symlinks=True) + mock.assert_called_once_with( + ["pyvenv", "--symlinks", "/tmp/foo"], runas=None, python_shell=False + ) + + +def test_virtualenv_ver(): + """ + test virtualenv_ver when there is no ImportError + """ + ret = virtualenv_mod.virtualenv_ver(venv_bin="pyvenv") + assert ret == (1, 9, 1) + + +def test_virtualenv_ver_importerror(): + """ + test virtualenv_ver when there is an ImportError + """ + with ForceImportErrorOn("virtualenv"): + mock_ver = MagicMock(return_value={"retcode": 0, "stdout": "1.9.1"}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock_ver}): + ret = virtualenv_mod.virtualenv_ver(venv_bin="pyenv") + assert ret == (1, 9, 1) + + +def test_virtualenv_ver_importerror_cmd_error(): + """ + test virtualenv_ver when there is an ImportError + and virtualenv --version does not return anything + """ + with ForceImportErrorOn("virtualenv"): + mock_ver = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock_ver}): + with pytest.raises(CommandExecutionError): + virtualenv_mod.virtualenv_ver(venv_bin="pyenv") + + +@pytest.mark.parametrize( + "stdout,expected", + [ + ("1.9.2", (1, 9, 2)), + ("1.9rc2", (1, 9)), + ( + "virtualenv 20.0.0 from" + " /home/ch3ll/.pyenv/versions/3.6.4/envs/virtualenv/lib/python3.6/site-packages/virtualenv/__init__.py", + (20, 0, 0), + ), + ("16.7.10", (16, 7, 10)), + ], +) +def test_virtualenv_importerror_ver_output(stdout, expected): + """ + test virtualenv_ver when there is an ImportError + and virtualenv --version returns the various + --versions outputs + """ + with ForceImportErrorOn("virtualenv"): + mock_ver = MagicMock(return_value={"retcode": 0, "stdout": stdout}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock_ver}): + ret = virtualenv_mod.virtualenv_ver(venv_bin="pyenv") + assert ret == expected + + +def test_issue_57734_debian_package(): + virtualenv_mock = MagicMock() + virtualenv_mock.__version__ = "20.0.23+ds" + with patch.dict("sys.modules", {"virtualenv": virtualenv_mock}): + ret = virtualenv_mod.virtualenv_ver(venv_bin="pyenv") + assert ret == (20, 0, 23) + + +def test_issue_57734_debian_package_importerror(): + with ForceImportErrorOn("virtualenv"): + mock_ver = MagicMock( + return_value={ + "retcode": 0, + "stdout": ( + "virtualenv 20.0.23+ds from " + "/usr/lib/python3/dist-packages/virtualenv/__init__.py" + ), + } + ) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock_ver}): + ret = virtualenv_mod.virtualenv_ver(venv_bin="pyenv") + assert ret == (20, 0, 23) + + +def test_venv_module_default_interpreter(): + """ + venv_bin=venv runs the venv module with the interpreter running the minion + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", venv_bin="venv") + mock.assert_called_once_with( + [sys.executable, "-m", "venv", "/tmp/foo"], + runas=None, + python_shell=False, + ) + + +def test_venv_module_with_python(): + """ + venv_bin=venv with python selects the interpreter that runs -m venv + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", venv_bin="venv", python="python3.11") + mock.assert_called_once_with( + ["python3.11", "-m", "venv", "/tmp/foo"], + runas=None, + python_shell=False, + ) + + +def test_venv_module_python_not_found(): + """ + venv_bin=venv with a python that cannot be found raises an error + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + with patch("salt.utils.path.which", MagicMock(return_value=None)): + with pytest.raises(CommandExecutionError): + virtualenv_mod.create("/tmp/foo", venv_bin="venv", python="python3.11") + mock.assert_not_called() + + +def test_interpreter_as_venv_bin(): + """ + A python interpreter passed as venv_bin runs -m venv + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", venv_bin="/usr/bin/python3.11") + mock.assert_called_once_with( + ["/usr/bin/python3.11", "-m", "venv", "/tmp/foo"], + runas=None, + python_shell=False, + ) + + +def test_interpreter_as_venv_bin_with_python_is_ambiguous(): + """ + Passing an interpreter as venv_bin AND a python is rejected as ambiguous + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + with pytest.raises(CommandExecutionError): + virtualenv_mod.create( + "/tmp/foo", venv_bin="/usr/bin/python3.11", python="python3.9" + ) + mock.assert_not_called() + + +def test_interpreter_as_venv_bin_not_found(): + """ + An interpreter passed as venv_bin that cannot be found raises an error + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + with patch("salt.utils.path.which", MagicMock(return_value=None)): + with pytest.raises(CommandExecutionError): + virtualenv_mod.create("/tmp/foo", venv_bin="/usr/bin/python3.11") + mock.assert_not_called() + + +def test_venv_module_prompt(): + """ + venv_bin=venv passes --prompt through to the venv module + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", venv_bin="venv", prompt="My Env") + mock.assert_called_once_with( + [sys.executable, "-m", "venv", "--prompt", "My Env", "/tmp/foo"], + runas=None, + python_shell=False, + ) + + +def test_venv_module_option_ordering(): + """ + venv module options are appended in a stable order + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create( + "/tmp/foo", + venv_bin="venv", + upgrade=True, + symlinks=True, + clear=True, + system_site_packages=True, + ) + mock.assert_called_once_with( + [ + sys.executable, + "-m", + "venv", + "--upgrade", + "--symlinks", + "--clear", + "--system-site-packages", + "/tmp/foo", + ], + runas=None, + python_shell=False, + ) + + +def test_pyvenv_python_still_rejected(): + """ + A non-interpreter venv-style binary (pyvenv) still rejects the python option + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + with pytest.raises(CommandExecutionError): + virtualenv_mod.create("/tmp/foo", venv_bin="pyvenv", python="python3") + mock.assert_not_called() + + +@pytest.mark.parametrize( + "venv_bin,expected", + [ + ("python", True), + ("python3", True), + ("python3.11", True), + ("/usr/bin/python3.10", True), + ("python.exe", True), + pytest.param( + "C:\\Python311\\python.exe", + True, + marks=pytest.mark.skip_unless_on_windows( + reason="os.path.basename() only splits on backslashes on Windows" + ), + ), + ("pypy3", True), + ("pypy", True), + ("virtualenv", False), + ("pyvenv", False), + ("python-config", False), + ("/opt/venvs/virtualenv", False), + ("mypython3", False), + ], +) +def test_is_python_binary(venv_bin, expected): + assert virtualenv_mod._is_python_binary(venv_bin) is expected + + +def test_venv_failure_removes_partial_env(tmp_path): + """ + A failed creation removes the partially created environment, so a later + run (or virtualenv.managed, which keys existence off bin/python) does + not mistake it for a working one. + """ + env_dir = tmp_path / "env" + + def failing_run_all(cmd, **kwargs): + (env_dir / "bin").mkdir(parents=True) + (env_dir / "bin" / "python").touch() + return {"retcode": 1, "stdout": "", "stderr": "ensurepip is not available"} + + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": failing_run_all}): + ret = virtualenv_mod.create(str(env_dir), venv_bin="venv") + assert ret["retcode"] == 1 + assert not env_dir.exists() + + +def test_venv_failure_keeps_preexisting_path(tmp_path): + """ + The failure cleanup never removes a path that already existed before the + creation command ran. + """ + env_dir = tmp_path / "env" + env_dir.mkdir() + marker = env_dir / "precious" + marker.touch() + + mock = MagicMock(return_value={"retcode": 1, "stdout": "", "stderr": "boom"}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + ret = virtualenv_mod.create(str(env_dir), venv_bin="venv", clear=True) + assert ret["retcode"] == 1 + assert marker.exists() + + +def test_venv_extra_search_dir_list_rejected(): + """ + A list-valued extra_search_dir is rejected cleanly on the venv path + instead of raising AttributeError on list.strip(). + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + with pytest.raises(CommandExecutionError): + virtualenv_mod.create( + "/tmp/foo", + venv_bin="venv", + python="python3.11", + extra_search_dir=["/tmp/bar"], + ) + mock.assert_not_called() + + +def test_venv_skips_setuptools_bootstrap(): + """ + venv-module environments never get the obsolete easy_install/ez_setup + bootstrap; ensurepip already provides pip there. + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": "", "stderr": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): + virtualenv_mod.create("/tmp/foo", venv_bin="venv", pip=True) + ez_setup_calls = [ + call + for call in virtualenv_mod._install_script.call_args_list + if "ez_setup" in call[0][0] + ] + assert not ez_setup_calls + + +def test_default_resolution_pillar_overrides_opts(): + """ + With venv_bin unset, the pillar value wins over the minion config value. + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}), patch.dict( + virtualenv_mod.__pillar__, {"venv_bin": "venv"} + ): + virtualenv_mod.create("/tmp/foo") + mock.assert_called_once_with( + [sys.executable, "-m", "venv", "/tmp/foo"], + runas=None, + python_shell=False, + ) diff --git a/tests/unit/modules/test_virtualenv_mod.py b/tests/unit/modules/test_virtualenv_mod.py deleted file mode 100644 index 552a93264b2d..000000000000 --- a/tests/unit/modules/test_virtualenv_mod.py +++ /dev/null @@ -1,414 +0,0 @@ -""" - :codeauthor: Pedro Algarvio (pedro@algarvio.me) - - - tests.unit.modules.virtualenv_test - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -""" - -# Import python libraries - -import sys - -import salt.modules.virtualenv_mod as virtualenv_mod -from salt.exceptions import CommandExecutionError -from tests.support.helpers import ForceImportErrorOn, TstSuiteLoggingHandler -from tests.support.mixins import LoaderModuleMockMixin -from tests.support.mock import MagicMock, patch -from tests.support.unit import TestCase - - -class VirtualenvTestCase(TestCase, LoaderModuleMockMixin): - def setup_loader_modules(self): - base_virtualenv_mock = MagicMock() - base_virtualenv_mock.__version__ = "1.9.1" - patcher = patch("salt.utils.path.which", lambda exe: exe) - patcher.start() - self.addCleanup(patcher.stop) - return { - virtualenv_mod: { - "__opts__": {"venv_bin": "virtualenv"}, - "_install_script": MagicMock( - return_value={ - "retcode": 0, - "stdout": "Installed script!", - "stderr": "", - } - ), - "sys.modules": {"virtualenv": base_virtualenv_mock}, - } - } - - def test_issue_6029_deprecated_distribute(self): - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create( - "/tmp/foo", system_site_packages=True, distribute=True - ) - mock.assert_called_once_with( - ["virtualenv", "--distribute", "--system-site-packages", "/tmp/foo"], - runas=None, - python_shell=False, - ) - - with TstSuiteLoggingHandler() as handler: - # Let's fake a higher virtualenv version - virtualenv_mock = MagicMock() - virtualenv_mock.__version__ = "1.10rc1" - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - with patch.dict("sys.modules", {"virtualenv": virtualenv_mock}): - virtualenv_mod.create( - "/tmp/foo", system_site_packages=True, distribute=True - ) - mock.assert_called_once_with( - ["virtualenv", "--system-site-packages", "/tmp/foo"], - runas=None, - python_shell=False, - ) - - # Are we logging the deprecation information? - self.assertIn( - "INFO:The virtualenv '--distribute' option has been " - "deprecated in virtualenv(>=1.10), as such, the " - "'distribute' option to `virtualenv.create()` has " - "also been deprecated and it's not necessary anymore.", - handler.messages, - ) - - def test_issue_6030_deprecated_never_download(self): - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create("/tmp/foo", never_download=True) - mock.assert_called_once_with( - ["virtualenv", "--never-download", "/tmp/foo"], - runas=None, - python_shell=False, - ) - - with TstSuiteLoggingHandler() as handler: - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - # Let's fake a higher virtualenv version - virtualenv_mock = MagicMock() - virtualenv_mock.__version__ = "1.10rc1" - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - with patch.dict("sys.modules", {"virtualenv": virtualenv_mock}): - virtualenv_mod.create("/tmp/foo", never_download=True) - mock.assert_called_once_with( - ["virtualenv", "/tmp/foo"], runas=None, python_shell=False - ) - - # Are we logging the deprecation information? - self.assertIn( - "INFO:--never-download was deprecated in 1.10.0, " - "but reimplemented in 14.0.0. If this feature is needed, " - "please install a supported virtualenv version.", - handler.messages, - ) - - def test_issue_6031_multiple_extra_search_dirs(self): - extra_search_dirs = ["/tmp/bar-1", "/tmp/bar-2", "/tmp/bar-3"] - - # Passing extra_search_dirs as a list - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create("/tmp/foo", extra_search_dir=extra_search_dirs) - mock.assert_called_once_with( - [ - "virtualenv", - "--extra-search-dir=/tmp/bar-1", - "--extra-search-dir=/tmp/bar-2", - "--extra-search-dir=/tmp/bar-3", - "/tmp/foo", - ], - runas=None, - python_shell=False, - ) - - # Passing extra_search_dirs as comma separated list - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create( - "/tmp/foo", extra_search_dir=",".join(extra_search_dirs) - ) - mock.assert_called_once_with( - [ - "virtualenv", - "--extra-search-dir=/tmp/bar-1", - "--extra-search-dir=/tmp/bar-2", - "--extra-search-dir=/tmp/bar-3", - "/tmp/foo", - ], - runas=None, - python_shell=False, - ) - - def test_unapplicable_options(self): - # ----- Virtualenv using pyvenv options -----------------------------> - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - self.assertRaises( - CommandExecutionError, - virtualenv_mod.create, - "/tmp/foo", - venv_bin="virtualenv", - upgrade=True, - ) - - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - self.assertRaises( - CommandExecutionError, - virtualenv_mod.create, - "/tmp/foo", - venv_bin="virtualenv", - symlinks=True, - ) - # <---- Virtualenv using pyvenv options ------------------------------ - - # ----- pyvenv using virtualenv options -----------------------------> - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict( - virtualenv_mod.__salt__, - {"cmd.run_all": mock, "cmd.which_bin": lambda _: "pyvenv"}, - ): - self.assertRaises( - CommandExecutionError, - virtualenv_mod.create, - "/tmp/foo", - venv_bin="pyvenv", - python="python2.7", - ) - - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - self.assertRaises( - CommandExecutionError, - virtualenv_mod.create, - "/tmp/foo", - venv_bin="pyvenv", - prompt="PY Prompt", - ) - - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - self.assertRaises( - CommandExecutionError, - virtualenv_mod.create, - "/tmp/foo", - venv_bin="pyvenv", - never_download=True, - ) - - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - self.assertRaises( - CommandExecutionError, - virtualenv_mod.create, - "/tmp/foo", - venv_bin="pyvenv", - extra_search_dir="/tmp/bar", - ) - # <---- pyvenv using virtualenv options ------------------------------ - - def test_get_virtualenv_version_from_shell(self): - with ForceImportErrorOn("virtualenv"): - - # ----- virtualenv binary not available -------------------------> - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - self.assertRaises( - CommandExecutionError, - virtualenv_mod.create, - "/tmp/foo", - ) - # <---- virtualenv binary not available -------------------------- - - # ----- virtualenv binary present but > 0 exit code -------------> - mock = MagicMock( - side_effect=[ - {"retcode": 1, "stdout": "", "stderr": "This is an error"}, - {"retcode": 0, "stdout": ""}, - ] - ) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - self.assertRaises( - CommandExecutionError, - virtualenv_mod.create, - "/tmp/foo", - venv_bin="virtualenv", - ) - # <---- virtualenv binary present but > 0 exit code -------------- - - # ----- virtualenv binary returns 1.9.1 as its version ---------> - mock = MagicMock( - side_effect=[ - {"retcode": 0, "stdout": "1.9.1"}, - {"retcode": 0, "stdout": ""}, - ] - ) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create("/tmp/foo", never_download=True) - mock.assert_called_with( - ["virtualenv", "--never-download", "/tmp/foo"], - runas=None, - python_shell=False, - ) - # <---- virtualenv binary returns 1.9.1 as its version ---------- - - # ----- virtualenv binary returns 1.10rc1 as its version -------> - mock = MagicMock( - side_effect=[ - {"retcode": 0, "stdout": "1.10rc1"}, - {"retcode": 0, "stdout": ""}, - ] - ) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create("/tmp/foo", never_download=True) - mock.assert_called_with( - ["virtualenv", "/tmp/foo"], runas=None, python_shell=False - ) - # <---- virtualenv binary returns 1.10rc1 as its version -------- - - def test_python_argument(self): - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create( - "/tmp/foo", - python=sys.executable, - ) - mock.assert_called_once_with( - ["virtualenv", f"--python={sys.executable}", "/tmp/foo"], - runas=None, - python_shell=False, - ) - - def test_prompt_argument(self): - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create("/tmp/foo", prompt="PY Prompt") - mock.assert_called_once_with( - ["virtualenv", "--prompt='PY Prompt'", "/tmp/foo"], - runas=None, - python_shell=False, - ) - - # Now with some quotes on the mix - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create("/tmp/foo", prompt="'PY' Prompt") - mock.assert_called_once_with( - ["virtualenv", "--prompt=''PY' Prompt'", "/tmp/foo"], - runas=None, - python_shell=False, - ) - - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create("/tmp/foo", prompt='"PY" Prompt') - mock.assert_called_once_with( - ["virtualenv", "--prompt='\"PY\" Prompt'", "/tmp/foo"], - runas=None, - python_shell=False, - ) - - def test_clear_argument(self): - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create("/tmp/foo", clear=True) - mock.assert_called_once_with( - ["virtualenv", "--clear", "/tmp/foo"], runas=None, python_shell=False - ) - - def test_upgrade_argument(self): - # We test for pyvenv only because with virtualenv this is un - # unsupported option. - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create("/tmp/foo", venv_bin="pyvenv", upgrade=True) - mock.assert_called_once_with( - ["pyvenv", "--upgrade", "/tmp/foo"], runas=None, python_shell=False - ) - - def test_symlinks_argument(self): - # We test for pyvenv only because with virtualenv this is un - # unsupported option. - mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock}): - virtualenv_mod.create("/tmp/foo", venv_bin="pyvenv", symlinks=True) - mock.assert_called_once_with( - ["pyvenv", "--symlinks", "/tmp/foo"], runas=None, python_shell=False - ) - - def test_virtualenv_ver(self): - """ - test virtualenv_ver when there is no ImportError - """ - ret = virtualenv_mod.virtualenv_ver(venv_bin="pyvenv") - assert ret == (1, 9, 1) - - def test_virtualenv_ver_importerror(self): - """ - test virtualenv_ver when there is an ImportError - """ - with ForceImportErrorOn("virtualenv"): - mock_ver = MagicMock(return_value={"retcode": 0, "stdout": "1.9.1"}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock_ver}): - ret = virtualenv_mod.virtualenv_ver(venv_bin="pyenv") - assert ret == (1, 9, 1) - - def test_virtualenv_ver_importerror_cmd_error(self): - """ - test virtualenv_ver when there is an ImportError - and virtualenv --version does not return anything - """ - with ForceImportErrorOn("virtualenv"): - mock_ver = MagicMock(return_value={"retcode": 0, "stdout": ""}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock_ver}): - with self.assertRaises(CommandExecutionError): - virtualenv_mod.virtualenv_ver(venv_bin="pyenv") - - def test_virtualenv_importerror_ver_output(self): - """ - test virtualenv_ver when there is an ImportError - and virtualenv --version returns the various - --versions outputs - """ - stdout = ( - ("1.9.2", (1, 9, 2)), - ("1.9rc2", (1, 9)), - ( - "virtualenv 20.0.0 from" - " /home/ch3ll/.pyenv/versions/3.6.4/envs/virtualenv/lib/python3.6/site-packages/virtualenv/__init__.py", - (20, 0, 0), - ), - ("16.7.10", (16, 7, 10)), - ) - for stdout, expt in stdout: - with ForceImportErrorOn("virtualenv"): - mock_ver = MagicMock(return_value={"retcode": 0, "stdout": stdout}) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock_ver}): - ret = virtualenv_mod.virtualenv_ver(venv_bin="pyenv") - assert ret == expt - - def test_issue_57734_debian_package(self): - virtualenv_mock = MagicMock() - virtualenv_mock.__version__ = "20.0.23+ds" - with patch.dict("sys.modules", {"virtualenv": virtualenv_mock}): - ret = virtualenv_mod.virtualenv_ver(venv_bin="pyenv") - self.assertEqual(ret, (20, 0, 23)) - - def test_issue_57734_debian_package_importerror(self): - with ForceImportErrorOn("virtualenv"): - mock_ver = MagicMock( - return_value={ - "retcode": 0, - "stdout": ( - "virtualenv 20.0.23+ds from " - "/usr/lib/python3/dist-packages/virtualenv/__init__.py" - ), - } - ) - with patch.dict(virtualenv_mod.__salt__, {"cmd.run_all": mock_ver}): - ret = virtualenv_mod.virtualenv_ver(venv_bin="pyenv") - self.assertEqual(ret, (20, 0, 23)) From 6273172e10ac9b2195946e5c49a07aa0f06575af Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 5 Jul 2026 02:57:41 -0400 Subject: [PATCH 036/469] Harden saltutil.runner/wheel privilege-drop child (#69619) The forked child added in #69240 to run saltutil.runner/saltutil.wheel as the master's configured user had three robustness problems, all on the drop path (root process, master configured as a different real user): - daemon=True stopped runners/wheels that spawn their own processes from working (e.g. an orchestration containing a parallel:True state), with "daemonic processes are not allowed to have children" - queue.get() had no timeout and the child's liveness was never checked, so a child that died before returning a result (OOM kill, os._exit, a segfault in a C extension such as libgit2) hung the caller forever - every child exception was flattened to CommandExecutionError, so the drop path diverged from the in-process path -- in particular wheel()'s "except SaltInvocationError" could never fire Run the child non-daemonic and joined; poll the queue and the child's liveness so a dead child raises instead of hanging; guard the queue against unpicklable payloads; and re-raise the child's original exception type where possible. --- changelog/69618.fixed.md | 1 + salt/modules/saltutil.py | 88 ++++++++++++++++--- tests/pytests/unit/modules/test_saltutil.py | 93 +++++++++++++++++++-- 3 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 changelog/69618.fixed.md diff --git a/changelog/69618.fixed.md b/changelog/69618.fixed.md new file mode 100644 index 000000000000..ec724ead6a92 --- /dev/null +++ b/changelog/69618.fixed.md @@ -0,0 +1 @@ +Fixed the ``saltutil.runner``/``saltutil.wheel`` privilege-drop child (added for #67716) hanging forever when the child died before returning a result (OOM kill, ``os._exit``, or a segfault in a C extension such as libgit2), failing runners/wheels that spawn their own processes such as an orchestration containing a ``parallel: True`` state, and flattening the child's exception type to ``CommandExecutionError`` (which stopped ``saltutil.wheel``'s ``SaltInvocationError`` handling from working). diff --git a/salt/modules/saltutil.py b/salt/modules/saltutil.py index baad8da1c596..dabeae6d9f1a 100644 --- a/salt/modules/saltutil.py +++ b/salt/modules/saltutil.py @@ -11,10 +11,13 @@ import logging import multiprocessing import os +import pickle +import queue import shutil import signal import sys import time +import traceback import urllib.error try: @@ -1854,31 +1857,94 @@ def _client_cmd_as(runas, client, name, cmd_kwargs): privileges to ``runas``, returning its result. Used so master-side functions invoked through ``saltutil.runner``/``saltutil.wheel`` execute as the master's configured user rather than the minion's user. See #67716. + + The child is intentionally **not** daemonized: some runner/wheel functions + spawn their own processes (for example an orchestration whose SLS contains a + ``parallel: True`` state), and a daemonic process is not allowed to have + children. The parent watches the result queue *and* the child's liveness, so + a child that dies before returning a result -- an ``os._exit``, an OOM kill, + or a segfault in a C extension such as libgit2 -- raises a + ``CommandExecutionError`` instead of blocking on ``queue.get()`` forever. + Exceptions raised in the child are re-raised in the parent with their + original type where possible, so callers' ``except`` clauses behave the same + as when the function runs in-process. """ # A fork context is required so the child inherits the already-initialized # client rather than trying to pickle it (as "spawn" would). ctx = multiprocessing.get_context("fork") - queue = ctx.Queue() + result_queue = ctx.Queue() def _run(): try: salt.utils.user.chugid(runas) _align_runas_environment(runas) - queue.put(("ret", client.cmd(name, **cmd_kwargs))) + ret = client.cmd(name, **cmd_kwargs) + except Exception as exc: # pylint: disable=broad-except + tb = traceback.format_exc() + try: + # Guard the put: an unpicklable payload would silently kill the + # Queue feeder thread and hang the parent's get(). + pickle.dumps(exc) + result_queue.put(("exc", exc, tb)) + except Exception: # pylint: disable=broad-except + result_queue.put(("err", f"{exc.__class__.__name__}: {exc}", tb)) + return + try: + pickle.dumps(ret) except Exception as exc: # pylint: disable=broad-except - queue.put(("err", f"{exc.__class__.__name__}: {exc}")) + result_queue.put( + ( + "err", + f"unpicklable return value: {exc.__class__.__name__}: {exc}", + None, + ) + ) + return + result_queue.put(("ret", ret, None)) - proc = ctx.Process(target=_run, daemon=True) + proc = ctx.Process(target=_run, name=f"saltutil-runas-{runas}") proc.start() - try: - status, payload = queue.get() - finally: - proc.join() - if status == "err": + + # Wait for a result, but do not block forever if the child dies without + # putting one on the queue. + payload = None + received = False + while True: + try: + payload = result_queue.get(timeout=1) + received = True + break + except queue.Empty: + if proc.is_alive(): + continue + # The child has exited; drain a result the feeder thread may not + # have flushed at the instant we checked ``is_alive()``. + try: + payload = result_queue.get(timeout=1) + received = True + except queue.Empty: + received = False + break + + proc.join() + + if not received: raise CommandExecutionError( - f"Failed to run '{name}' as user '{runas}': {payload}" + f"Failed to run '{name}' as user '{runas}': the privilege-dropped " + f"child process exited with code {proc.exitcode} before returning a " + "result" ) - return payload + + status, data, tb = payload + if status == "ret": + return data + if tb: + log.debug("Traceback from '%s' run as user '%s':\n%s", name, runas, tb) + if status == "exc": + # Re-raise the original exception so drop-path error handling matches + # the in-process path (e.g. wheel()'s ``except SaltInvocationError``). + raise data + raise CommandExecutionError(f"Failed to run '{name}' as user '{runas}': {data}") def runner( diff --git a/tests/pytests/unit/modules/test_saltutil.py b/tests/pytests/unit/modules/test_saltutil.py index fcf42307cc57..cdace558db9b 100644 --- a/tests/pytests/unit/modules/test_saltutil.py +++ b/tests/pytests/unit/modules/test_saltutil.py @@ -1,3 +1,4 @@ +import multiprocessing import os import pathlib import sys @@ -7,7 +8,7 @@ import salt.modules.saltutil as saltutil from salt.client import LocalClient -from salt.exceptions import CommandExecutionError +from salt.exceptions import CommandExecutionError, SaltInvocationError from tests.support.mock import MagicMock, create_autospec, patch from tests.support.mock import sentinel as s @@ -253,13 +254,20 @@ def test_client_cmd_as_returns_result(): assert result == {"local": True} -def test_client_cmd_as_propagates_error(): - client = _FakeClient(exc=RuntimeError("boom")) - with patch("salt.utils.user.chugid"): - with pytest.raises(CommandExecutionError): - saltutil._client_cmd_as( - "salt", client, "test.ping", {"arg": [], "kwarg": {}} - ) +def test_client_cmd_as_reraises_original_exception_type(): + """ + The privilege-drop path must surface the same exception type the in-process + path would, so callers' ``except`` clauses keep working -- for example + wheel()'s ``except SaltInvocationError``. (Errors that cannot be pickled + back across the process boundary still fall back to CommandExecutionError.) + """ + for exc_type in (RuntimeError, SaltInvocationError): + client = _FakeClient(exc=exc_type("boom")) + with patch("salt.utils.user.chugid"): + with pytest.raises(exc_type): + saltutil._client_cmd_as( + "salt", client, "test.ping", {"arg": [], "kwarg": {}} + ) def test_runner_runs_as_master_user_when_needed(): @@ -345,6 +353,75 @@ def cmd(self, name, **kwargs): assert saltutil._client_cmd_as("nobody", _UidClient(), "x", {}) == target.pw_uid +@pytest.mark.skip_unless_on_linux +def test_client_cmd_as_allows_child_to_spawn_process(): + """ + The privilege-dropped child must be allowed to spawn its own processes -- + e.g. a runner that executes an orchestration containing a ``parallel: True`` + state. A daemonized child raises "daemonic processes are not allowed to have + children"; the child must therefore not be daemonic. + """ + + class _SpawnClient: + functions = {} + + def cmd(self, name, **kwargs): + ctx = multiprocessing.get_context("fork") + grandchild_queue = ctx.Queue() + + def _grandchild(q): + q.put("grandchild-ran") + + proc = ctx.Process(target=_grandchild, args=(grandchild_queue,)) + proc.start() + out = grandchild_queue.get() + proc.join() + return out + + with patch("salt.utils.user.chugid"): + assert ( + saltutil._client_cmd_as("nobody", _SpawnClient(), "x", {}) + == "grandchild-ran" + ) + + +@pytest.mark.skip_unless_on_linux +def test_client_cmd_as_dead_child_raises_instead_of_hanging(): + """ + If the child dies before returning a result (OOM kill, ``os._exit``, a + segfault in a C extension such as libgit2), the parent must raise rather + than block on the queue forever. + """ + + class _DyingClient: + functions = {} + + def cmd(self, name, **kwargs): + os._exit(1) + + with patch("salt.utils.user.chugid"): + with pytest.raises(CommandExecutionError): + saltutil._client_cmd_as("nobody", _DyingClient(), "x", {}) + + +@pytest.mark.skip_unless_on_linux +def test_client_cmd_as_unpicklable_result_raises(): + """ + A return value the child cannot pickle would silently kill the Queue feeder + thread and hang the parent; it must surface as a clear error instead. + """ + + class _UnpicklableClient: + functions = {} + + def cmd(self, name, **kwargs): + return lambda x: x + + with patch("salt.utils.user.chugid"): + with pytest.raises(CommandExecutionError): + saltutil._client_cmd_as("nobody", _UnpicklableClient(), "x", {}) + + @pytest.fixture def _fake_pwd(monkeypatch): """Patch saltutil.pwd so the runas user resolves to a known home.""" From 82d8ec7d3af0e74d3ace5e219e486138a519cbc3 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 5 Jul 2026 03:33:52 -0400 Subject: [PATCH 037/469] Strip publisher __pub_* kwargs in fileserver.update runner (#66793) (#69537) When fileserver.update is invoked through saltutil.runner or an orchestration, the runner client injects publisher metadata into the runner function's kwargs as __pub_* keys (via load_args_and_kwargs). fileserver.update forwarded those kwargs verbatim to the fileserver backends, whose update() signatures (e.g. roots.update(), gitfs.update(remotes=None)) reject unknown keyword arguments, raising: Passed invalid arguments: update() got an unexpected keyword argument '__pub_user' Strip the __pub_* publisher metadata with salt.utils.args.clean_kwargs before forwarding to the backends. This keeps the runner-job user attribution added in #63148 intact (it is applied upstream in the runner client, not via these kwargs). --- changelog/66793.fixed.md | 1 + salt/runners/fileserver.py | 7 ++ tests/pytests/unit/runners/test_fileserver.py | 69 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 changelog/66793.fixed.md diff --git a/changelog/66793.fixed.md b/changelog/66793.fixed.md new file mode 100644 index 000000000000..4984ec348ae4 --- /dev/null +++ b/changelog/66793.fixed.md @@ -0,0 +1 @@ +Fixed the ``fileserver.update`` runner raising ``Passed invalid arguments: update() got an unexpected keyword argument '__pub_user'`` when invoked through ``saltutil.runner`` or an orchestration, by stripping publisher ``__pub_*`` metadata from the kwargs before forwarding them to the fileserver backends. diff --git a/salt/runners/fileserver.py b/salt/runners/fileserver.py index d75d7de0cf46..db28e1bd5000 100644 --- a/salt/runners/fileserver.py +++ b/salt/runners/fileserver.py @@ -3,6 +3,7 @@ """ import salt.fileserver +import salt.utils.args def envs(backend=None, sources=False): @@ -349,6 +350,12 @@ def update(backend=None, **kwargs): salt-run fileserver.update backend=roots,git salt-run fileserver.update backend=git remotes=myrepo,yourrepo """ + # When this runner is invoked through saltutil.runner (or an + # orchestration), the runner client injects publisher metadata into the + # kwargs as ``__pub_*`` keys. Those must not be forwarded to the + # fileserver backends, whose update() signatures reject unknown keyword + # arguments (see #66793). + kwargs = salt.utils.args.clean_kwargs(**kwargs) fileserver = salt.fileserver.Fileserver(__opts__) fileserver.update(back=backend, **kwargs) return True diff --git a/tests/pytests/unit/runners/test_fileserver.py b/tests/pytests/unit/runners/test_fileserver.py index b664a56bb442..ac378948664a 100644 --- a/tests/pytests/unit/runners/test_fileserver.py +++ b/tests/pytests/unit/runners/test_fileserver.py @@ -4,6 +4,7 @@ import pytest +import salt.fileserver import salt.loader import salt.runners.fileserver as fileserver import salt.utils.files @@ -132,3 +133,71 @@ def test_clear_file_list_cache_vcs_limited(cachedir): assert (cachedir / "file_lists" / "roots" / "base.p").exists() assert (cachedir / "file_lists" / "roots" / "dev.p").exists() assert (cachedir / "file_lists" / "roots" / "foo.txt").exists() + + +@pytest.fixture +def mock_fileserver(): + """ + Patch salt.fileserver.Fileserver so update() calls can be inspected + without touching real fileserver backends. + """ + instance = MagicMock() + with patch.object(salt.fileserver, "Fileserver", MagicMock(return_value=instance)): + yield instance + + +def test_update_returns_true(mock_fileserver): + """ + update() returns True and forwards the call to the fileserver backends. + """ + with patch.dict(fileserver.__opts__, {}): + assert fileserver.update() is True + mock_fileserver.update.assert_called_once_with(back=None) + + +def test_update_forwards_backend_and_kwargs(mock_fileserver): + """ + The backend is forwarded as ``back`` and any genuine keyword arguments + are passed through to the fileserver backends unchanged. + """ + with patch.dict(fileserver.__opts__, {}): + assert fileserver.update(backend="git", remotes="myrepo") is True + mock_fileserver.update.assert_called_once_with(back="git", remotes="myrepo") + + +def test_update_strips_pub_kwargs(mock_fileserver): + """ + Regression test for #66793. + + When fileserver.update is invoked through saltutil.runner or an + orchestration, the runner client injects publisher metadata into the + kwargs as ``__pub_*`` keys. Those keys must be stripped before the call + is forwarded to the fileserver backends, whose update() signatures (e.g. + ``roots.update()`` / ``gitfs.update(remotes=None)``) reject unknown + keyword arguments and would otherwise raise + ``TypeError: update() got an unexpected keyword argument '__pub_user'``. + """ + with patch.dict(fileserver.__opts__, {}): + ret = fileserver.update( + backend="git", + remotes="myrepo", + __pub_user="root", + __pub_fun="fileserver.update", + __pub_jid="20240808000000000000", + __pub_pid=12345, + __pub_tgt="salt_master", + ) + assert ret is True + # Only the genuine arguments survive; every __pub_* key is dropped. + mock_fileserver.update.assert_called_once_with(back="git", remotes="myrepo") + + +def test_update_strips_pub_kwargs_without_backend(mock_fileserver): + """ + The publisher metadata is stripped even when no backend is specified, so + a bare ``saltutil.runner fileserver.update`` call succeeds. + """ + with patch.dict(fileserver.__opts__, {}): + ret = fileserver.update(__pub_user="root", __pub_jid="20240808000000000000") + assert ret is True + mock_fileserver.update.assert_called_once_with(back=None) From d0c8373c0917532deec488dab0676cb04965d16f Mon Sep 17 00:00:00 2001 From: Stepan <51859698+co-cy@users.noreply.github.com> Date: Sun, 5 Jul 2026 04:35:10 -0300 Subject: [PATCH 038/469] Add configurable connect_timeout to pgjsonb returner (#69051) `_get_serv` calls `psycopg2.connect()` without `connect_timeout`. When the database is reachable on the network but does not respond (stalled standby, firewall drop, HAProxy mid-failover), the connect call blocks for tens of seconds on the system TCP timeout. Since `event_return` and `clean_old_jobs` both run inside the master event loop, one such stalled connect cascades into delayed events and returners across the whole master. Add `returner.pgjsonb.connect_timeout` (seconds) -- forwarded to `psycopg2.connect(connect_timeout=...)` only when set. Behaviour for existing deployments is unchanged: the kwarg is omitted by default, so libpq's default applies. Operators that need a hard cap opt in explicitly via master config or `--return_kwargs`. `_get_options` now also coerces a string-valued timeout (which can arrive via pillar / environment) to int, mirroring the existing treatment of `port`. Add three behavioural tests: - `test__get_serv_omits_connect_timeout_when_not_configured` pins the backwards-compatible default. - `test__get_serv_passes_connect_timeout_when_configured` verifies the kwarg reaches `psycopg2.connect`. - `test__get_options_coerces_string_connect_timeout_to_int` covers the type-coercion path. Refs: #69050 Co-authored-by: co-cy --- changelog/69050.added.md | 6 +++ salt/returners/pgjsonb.py | 33 +++++++++++--- tests/pytests/unit/returners/test_pgjsonb.py | 47 ++++++++++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 changelog/69050.added.md diff --git a/changelog/69050.added.md b/changelog/69050.added.md new file mode 100644 index 000000000000..ef84a238f26d --- /dev/null +++ b/changelog/69050.added.md @@ -0,0 +1,6 @@ +Added an optional `returner.pgjsonb.connect_timeout` configuration +option (in seconds) for the pgjsonb returner. When set, the value is +forwarded to `psycopg2.connect(connect_timeout=...)` so a stalled +PostgreSQL connect attempt cannot block the master event loop. The +option has no default and the existing connect behaviour is preserved +for deployments that do not set it. diff --git a/salt/returners/pgjsonb.py b/salt/returners/pgjsonb.py index 1afda19aa8a9..dd0dd43f8eea 100644 --- a/salt/returners/pgjsonb.py +++ b/salt/returners/pgjsonb.py @@ -29,6 +29,16 @@ returner.pgjsonb.db: 'salt' returner.pgjsonb.port: 5432 +An optional ``connect_timeout`` (in seconds) caps how long ``psycopg2.connect`` +will wait for a database connection. When unset, ``libpq``'s default applies +(no application-level timeout, only the system TCP timeout). Setting it is +recommended on masters that talk to PostgreSQL through HAProxy or Sentinel +to keep a stalled connect attempt from blocking the master event loop. + +.. code-block:: yaml + + returner.pgjsonb.connect_timeout: 5 + SSL is optional. The defaults are set to None. If you do not want to use SSL, either exclude these options or set them to None. @@ -212,6 +222,7 @@ def _get_options(ret=None): "pass": "pass", "db": "db", "port": "port", + "connect_timeout": "connect_timeout", "sslmode": "sslmode", "sslcert": "sslcert", "sslkey": "sslkey", @@ -230,6 +241,9 @@ def _get_options(ret=None): # Ensure port is an int if "port" in _options: _options["port"] = int(_options["port"]) + # Coerce connect_timeout when set: pillar / env may deliver it as a string. + if _options.get("connect_timeout") is not None: + _options["connect_timeout"] = int(_options["connect_timeout"]) return _options @@ -247,14 +261,19 @@ def _get_serv(ret=None, commit=False): for k, v in _options.items() if k in ["sslmode", "sslcert", "sslkey", "sslrootcert", "sslcrl"] } - conn = psycopg2.connect( - host=_options.get("host"), - port=_options.get("port"), - dbname=_options.get("db"), - user=_options.get("user"), - password=_options.get("pass"), + connect_kwargs = { + "host": _options.get("host"), + "port": _options.get("port"), + "dbname": _options.get("db"), + "user": _options.get("user"), + "password": _options.get("pass"), **ssl_options, - ) + } + # Only pass connect_timeout when configured; omitting it preserves + # libpq's default behaviour for existing deployments. + if _options.get("connect_timeout") is not None: + connect_kwargs["connect_timeout"] = _options["connect_timeout"] + conn = psycopg2.connect(**connect_kwargs) except psycopg2.OperationalError as exc: raise salt.exceptions.SaltMasterError( f"pgjsonb returner could not connect to database: {exc}" diff --git a/tests/pytests/unit/returners/test_pgjsonb.py b/tests/pytests/unit/returners/test_pgjsonb.py index 45a49848ee8c..bff51b98d360 100644 --- a/tests/pytests/unit/returners/test_pgjsonb.py +++ b/tests/pytests/unit/returners/test_pgjsonb.py @@ -367,3 +367,50 @@ def test_get_jids_returns_one_formatted_entry_per_row(): assert result["20260504000000000002"]["Target"] == "minion-1" assert result["20260504000000000002"]["Arguments"] == ["highstate"] assert result["20260504000000000002"]["User"] == "salt" + + +def _enter_get_serv(connect_mock): + """Enter ``_get_serv`` once with a mocked ``psycopg2.connect`` and a + minimal fake connection, so the body opens the connection and we can + inspect the kwargs the caller passed to ``connect``.""" + fake_conn = MagicMock() + fake_conn.server_version = 90500 + connect_mock.return_value = fake_conn + with patch("psycopg2.connect", connect_mock): + with pgjsonb._get_serv(): + pass + + +@pytest.mark.skipif(not pgjsonb.HAS_PG, reason="psycopg2 not installed") +def test__get_serv_omits_connect_timeout_when_not_configured(): + """Existing deployments must keep their current connect behaviour: + when no ``connect_timeout`` is configured, the kwarg is not passed to + ``psycopg2.connect`` at all so libpq's default (no app-level timeout) + still applies.""" + connect = MagicMock() + with patch.object(pgjsonb, "_get_options", return_value={}): + _enter_get_serv(connect) + assert "connect_timeout" not in connect.call_args.kwargs + + +@pytest.mark.skipif(not pgjsonb.HAS_PG, reason="psycopg2 not installed") +def test__get_serv_passes_connect_timeout_when_configured(): + """When ``connect_timeout`` is configured, it is forwarded to + ``psycopg2.connect`` verbatim.""" + connect = MagicMock() + with patch.object(pgjsonb, "_get_options", return_value={"connect_timeout": 5}): + _enter_get_serv(connect) + assert connect.call_args.kwargs["connect_timeout"] == 5 + + +def test__get_options_coerces_string_connect_timeout_to_int(): + """A string ``connect_timeout`` (as it can arrive from pillar or env) + is coerced to int so ``psycopg2.connect`` does not get a string.""" + with patch.object( + pgjsonb.salt.returners, + "get_returner_options", + return_value={"connect_timeout": "5", "port": "5432"}, + ): + opts = pgjsonb._get_options() + assert opts["connect_timeout"] == 5 + assert isinstance(opts["connect_timeout"], int) From d1434ade21b92883993ffb8e5c3d0b92e0ca27b8 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 5 Jul 2026 17:42:24 -0400 Subject: [PATCH 039/469] Unmask pillar values in execution-module consumers on 3008 (#69712) Since 3008, pillar.get masks scalar string values by default, so modules that read pillar and use the value operationally were receiving the redaction placeholder instead of the real data. Pass unmask=True at each genuine consumer call site, matching the file.managed contents_pillar path and the x509_v2 _get_signing_policy fix (#69636): - gpg: signing/encryption passphrase (create_key, delete_key, export_key, sign, encrypt, decrypt) - debuild_pkgbuild / rpmbuild_pkgbuild: signing passphrase and public and private key names - x509 / ssh_pki: signing policies - tls: certificate extension definitions - oracle: database connection data (show_dbs consumed by run_query) - pyobjects: the Map renderer merge pillar Display-only reads (oracle show_dbs no-arg, show_pillar) are left masked by design. The pyobjects renderer pillar()/SaltObject shortcuts have the same masking exposure and are left for a separate render-context fix. Refs #69711 --- changelog/69711.fixed.md | 1 + salt/modules/debuild_pkgbuild.py | 6 +- salt/modules/gpg.py | 12 +- salt/modules/oracle.py | 4 +- salt/modules/rpmbuild_pkgbuild.py | 6 +- salt/modules/ssh_pki.py | 2 +- salt/modules/tls.py | 18 ++- salt/modules/x509.py | 2 +- salt/utils/pyobjects.py | 8 +- .../unit/modules/test_debuild_pkgbuild.py | 104 ++++++++++++++ tests/pytests/unit/modules/test_gpg.py | 131 ++++++++++++++++++ tests/pytests/unit/modules/test_oracle.py | 43 ++++++ .../unit/modules/test_rpmbuild_pkgbuild.py | 93 +++++++++++++ tests/pytests/unit/modules/test_ssh_pki.py | 66 +++++++++ tests/pytests/unit/modules/test_tls_unmask.py | 84 +++++++++++ tests/pytests/unit/modules/test_x509.py | 62 +++++++++ tests/pytests/unit/utils/test_pyobjects.py | 48 +++++++ 17 files changed, 670 insertions(+), 20 deletions(-) create mode 100644 changelog/69711.fixed.md create mode 100644 tests/pytests/unit/modules/test_debuild_pkgbuild.py create mode 100644 tests/pytests/unit/modules/test_rpmbuild_pkgbuild.py create mode 100644 tests/pytests/unit/modules/test_ssh_pki.py create mode 100644 tests/pytests/unit/modules/test_tls_unmask.py create mode 100644 tests/pytests/unit/modules/test_x509.py diff --git a/changelog/69711.fixed.md b/changelog/69711.fixed.md new file mode 100644 index 000000000000..522654a659c0 --- /dev/null +++ b/changelog/69711.fixed.md @@ -0,0 +1 @@ +Fixed several execution modules reading pillar values without ``unmask=True`` on 3008 and later, where ``pillar.get`` masks by default, so they received the redaction placeholder (``**********``) instead of the real value: ``gpg`` and the deb/rpm pkgbuild modules (signing passphrase and key names), ``x509`` and ``ssh_pki`` (signing policies), ``tls`` (certificate extensions), ``oracle`` (connection data), and the pyobjects ``Map`` renderer (merge pillar). diff --git a/salt/modules/debuild_pkgbuild.py b/salt/modules/debuild_pkgbuild.py index cc5217b7b724..b6ccf673a25b 100644 --- a/salt/modules/debuild_pkgbuild.py +++ b/salt/modules/debuild_pkgbuild.py @@ -721,10 +721,10 @@ def make_repo( # import_keys pkg_pub_key_file = "{}/{}".format( - gnupghome, __salt__["pillar.get"]("gpg_pkg_pub_keyname", None) + gnupghome, __salt__["pillar.get"]("gpg_pkg_pub_keyname", None, unmask=True) ) pkg_priv_key_file = "{}/{}".format( - gnupghome, __salt__["pillar.get"]("gpg_pkg_priv_keyname", None) + gnupghome, __salt__["pillar.get"]("gpg_pkg_priv_keyname", None, unmask=True) ) if pkg_pub_key_file is None or pkg_priv_key_file is None: @@ -809,7 +809,7 @@ def make_repo( if use_passphrase: _check_repo_gpg_phrase_utils() - phrase = __salt__["pillar.get"]("gpg_passphrase") + phrase = __salt__["pillar.get"]("gpg_passphrase", unmask=True) cmd = ( "/usr/lib/gnupg2/gpg-preset-passphrase --verbose --preset --passphrase" ' "{}" {}'.format(phrase, local_keygrip_to_use) diff --git a/salt/modules/gpg.py b/salt/modules/gpg.py index 74c2d856a286..148540e71fbb 100644 --- a/salt/modules/gpg.py +++ b/salt/modules/gpg.py @@ -593,7 +593,7 @@ def create_key( create_params["expire_date"] = expire_date if use_passphrase: - gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase") + gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase", unmask=True) if not gpg_passphrase: ret["res"] = False ret["message"] = "gpg_passphrase not available in pillar." @@ -703,7 +703,7 @@ def delete_key( def __delete_key(fingerprint, secret, use_passphrase): if secret and use_passphrase: - gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase") + gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase", unmask=True) if not gpg_passphrase: return "gpg_passphrase not available in pillar." else: @@ -1017,7 +1017,7 @@ def export_key( keyids = keyids.split(",") if secret and use_passphrase: - gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase") + gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase", unmask=True) if not gpg_passphrase: raise SaltInvocationError("gpg_passphrase not available in pillar.") result = gpg.export_keys(keyids, secret, passphrase=gpg_passphrase) @@ -1372,7 +1372,7 @@ def sign( """ if use_passphrase: - gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase") + gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase", unmask=True) if not gpg_passphrase: raise SaltInvocationError("gpg_passphrase not available in pillar.") else: @@ -1671,7 +1671,7 @@ def encrypt( """ ret = {"res": True, "comment": ""} if sign and use_passphrase: - gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase") + gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase", unmask=True) if not gpg_passphrase: raise SaltInvocationError("gpg_passphrase not available in pillar.") else: @@ -1776,7 +1776,7 @@ def decrypt( """ ret = {"res": True, "comment": ""} if use_passphrase: - gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase") + gpg_passphrase = __salt__["pillar.get"]("gpg_passphrase", unmask=True) if not gpg_passphrase: raise SaltInvocationError("gpg_passphrase not available in pillar.") else: diff --git a/salt/modules/oracle.py b/salt/modules/oracle.py index bb79063533fc..721f7539410f 100644 --- a/salt/modules/oracle.py +++ b/salt/modules/oracle.py @@ -159,7 +159,9 @@ def show_dbs(*dbs): log.debug("get dbs from pillar: %s", dbs) result = {} for db in dbs: - result[db] = __salt__["pillar.get"]("oracle:dbs:" + db) + # run_query() connects with the uri from this data, so the + # credentials must not be masked + result[db] = __salt__["pillar.get"]("oracle:dbs:" + db, unmask=True) return result else: pillar_dbs = __salt__["pillar.get"]("oracle:dbs") diff --git a/salt/modules/rpmbuild_pkgbuild.py b/salt/modules/rpmbuild_pkgbuild.py index 135afab975c9..6fdfc4201d5a 100644 --- a/salt/modules/rpmbuild_pkgbuild.py +++ b/salt/modules/rpmbuild_pkgbuild.py @@ -238,10 +238,10 @@ def _get_gpg_key_resources(keyid, env, use_passphrase, gnupghome, runas): if keyid is not None: # import_keys pkg_pub_key_file = "{}/{}".format( - gnupghome, __salt__["pillar.get"]("gpg_pkg_pub_keyname", None) + gnupghome, __salt__["pillar.get"]("gpg_pkg_pub_keyname", None, unmask=True) ) pkg_priv_key_file = "{}/{}".format( - gnupghome, __salt__["pillar.get"]("gpg_pkg_priv_keyname", None) + gnupghome, __salt__["pillar.get"]("gpg_pkg_priv_keyname", None, unmask=True) ) if pkg_pub_key_file is None or pkg_priv_key_file is None: @@ -301,7 +301,7 @@ def _get_gpg_key_resources(keyid, env, use_passphrase, gnupghome, runas): ) if use_passphrase: - phrase = __salt__["pillar.get"]("gpg_passphrase") + phrase = __salt__["pillar.get"]("gpg_passphrase", unmask=True) if use_gpg_agent: _check_repo_gpg_phrase_utils() cmd = ( diff --git a/salt/modules/ssh_pki.py b/salt/modules/ssh_pki.py index 598eb4621d33..801bef94d066 100644 --- a/salt/modules/ssh_pki.py +++ b/salt/modules/ssh_pki.py @@ -833,7 +833,7 @@ def _generate_pk(algo="rsa", keysize=None): def _get_signing_policy(name): if name is None: return {} - policies = __salt__["pillar.get"]("ssh_signing_policies", {}).get(name) + policies = __salt__["pillar.get"]("ssh_signing_policies", {}, unmask=True).get(name) policies = policies or __salt__["config.get"]("ssh_signing_policies", {}).get(name) return policies or {} diff --git a/salt/modules/tls.py b/salt/modules/tls.py index a32bd165c39d..4bd8119d9c10 100644 --- a/salt/modules/tls.py +++ b/salt/modules/tls.py @@ -1081,7 +1081,11 @@ def get_extensions(cert_type): cert_type = "server" try: - ext["common"] = __salt__["pillar.get"]("tls.extensions:common", False) + # unmask=True: extension values are written into CSRs/certificates, + # so the real strings are needed, not the masked placeholders. + ext["common"] = __salt__["pillar.get"]( + "tls.extensions:common", False, unmask=True + ) except NameError as err: log.debug(err) @@ -1095,7 +1099,9 @@ def get_extensions(cert_type): } try: - ext["server"] = __salt__["pillar.get"]("tls.extensions:server", False) + ext["server"] = __salt__["pillar.get"]( + "tls.extensions:server", False, unmask=True + ) except NameError as err: log.debug(err) @@ -1109,7 +1115,9 @@ def get_extensions(cert_type): } try: - ext["client"] = __salt__["pillar.get"]("tls.extensions:client", False) + ext["client"] = __salt__["pillar.get"]( + "tls.extensions:client", False, unmask=True + ) except NameError as err: log.debug(err) @@ -1125,7 +1133,9 @@ def get_extensions(cert_type): # possible user-defined profile or a typo if cert_type not in ext: try: - ext[cert_type] = __salt__["pillar.get"](f"tls.extensions:{cert_type}") + ext[cert_type] = __salt__["pillar.get"]( + f"tls.extensions:{cert_type}", unmask=True + ) except NameError as e: log.debug( "pillar, tls:extensions:%s not available or " diff --git a/salt/modules/x509.py b/salt/modules/x509.py index 5a40eff67a9c..08ebc7549d1a 100644 --- a/salt/modules/x509.py +++ b/salt/modules/x509.py @@ -297,7 +297,7 @@ def _parse_openssl_crl(crl_filename): def _get_signing_policy(name): - policies = __salt__["pillar.get"]("x509_signing_policies", None) + policies = __salt__["pillar.get"]("x509_signing_policies", None, unmask=True) if policies: signing_policy = policies.get(name) if signing_policy: diff --git a/salt/utils/pyobjects.py b/salt/utils/pyobjects.py index 9afbac8ef7bc..8f12591f39df 100644 --- a/salt/utils/pyobjects.py +++ b/salt/utils/pyobjects.py @@ -377,7 +377,13 @@ def __set_attributes__(cls): attrs.update(match_attrs) if hasattr(cls, "merge"): - pillar = Map.__salt__["pillar.get"](cls.merge) + # The merged values become Map class attributes that are used + # operationally in rendered states, so the real pillar values + # are needed here, not the masked placeholders. The pyobjects + # renderer does not run under the mask_pillar=False context + # that string-template renderers (jinja, mako, ...) get from + # salt.utils.templates.wrap_tmpl_func. + pillar = Map.__salt__["pillar.get"](cls.merge, unmask=True) if pillar: attrs.update(pillar) diff --git a/tests/pytests/unit/modules/test_debuild_pkgbuild.py b/tests/pytests/unit/modules/test_debuild_pkgbuild.py new file mode 100644 index 000000000000..bf730ccbadb1 --- /dev/null +++ b/tests/pytests/unit/modules/test_debuild_pkgbuild.py @@ -0,0 +1,104 @@ +""" +Tests for salt.modules.debuild_pkgbuild +""" + +import pytest + +import salt.modules.debuild_pkgbuild as debuild_pkgbuild +import salt.utils.secret +from tests.support.mock import MagicMock, patch + +pytestmark = [ + pytest.mark.skip_on_windows(reason="deb-only module"), +] + +GPG_PILLAR = { + "gpg_pkg_pub_keyname": "gpg_pkg_key.pub", + "gpg_pkg_priv_keyname": "gpg_pkg_key.pem", + "gpg_passphrase": "sup3r_s3cr3t", +} + + +@pytest.fixture +def configure_loader_modules(): + return { + debuild_pkgbuild: { + "__grains__": {"os": "Debian", "osmajorrelease": 11}, + } + } + + +def _masking_pillar_get(key, default=None, **kwargs): + """ + Mimic 3008 pillar.get masking: scalar strings are redacted unless the + caller passes unmask=True. + """ + value = GPG_PILLAR.get(key, default) + if kwargs.get("unmask"): + return salt.utils.secret.expose(value) + return salt.utils.secret.serial(value) + + +def test_make_repo_unmasks_gpg_pillar_values(tmp_path): + """ + make_repo must read the gpg key filenames and passphrase with + unmask=True, otherwise gpg-preset-passphrase and gpg.import_key + get fed the redact placeholder. + """ + repodir = tmp_path / "repo" + repodir.mkdir() + gnupghome = tmp_path / "gpgkeys" + gnupghome.mkdir() + # older-gnupg path: agent info file must exist and be readable + (gnupghome / "gpg-agent-info-salt").write_text( + "GPG_AGENT_INFO=/run/user/0/gnupg/S.gpg-agent:0:1\n" + ) + + import_key_mock = MagicMock(return_value=True) + list_keys_mock = MagicMock( + return_value=[ + { + "keyid": "AAAAAAAA07123E1F", + "fingerprint": "1234567890ABCDEF1234567890ABCDEF07123E1F", + "uids": ["Packaging Key "], + } + ] + ) + retcode_mock = MagicMock(return_value=0) + salt_dunder = { + "pillar.get": _masking_pillar_get, + "gpg.import_key": import_key_mock, + "gpg.list_keys": list_keys_mock, + "cmd.retcode": retcode_mock, + "cmd.run": MagicMock(return_value=""), + "file.file_exists": MagicMock(return_value=True), + } + + with patch.dict(debuild_pkgbuild.__salt__, salt_dunder), patch.object( + debuild_pkgbuild, "_check_repo_sign_utils_support", MagicMock(return_value=True) + ), patch.object( + debuild_pkgbuild, "_check_repo_gpg_phrase_utils", MagicMock(return_value=True) + ): + debuild_pkgbuild.make_repo( + str(repodir), + keyid="07123E1F", + use_passphrase=True, + gnupghome=str(gnupghome), + ) + + # key files imported into gpg must carry the real pillar filenames + imported = [call.kwargs["filename"] for call in import_key_mock.call_args_list] + assert f"{gnupghome}/gpg_pkg_key.pub" in imported + assert f"{gnupghome}/gpg_pkg_key.pem" in imported + for filename in imported: + assert salt.utils.secret.REDACT_PLACEHOLDER not in filename + + # gpg-preset-passphrase must be invoked with the real passphrase + preset_cmds = [ + call.args[0] + for call in retcode_mock.call_args_list + if "gpg-preset-passphrase" in call.args[0] + ] + assert len(preset_cmds) == 1 + assert GPG_PILLAR["gpg_passphrase"] in preset_cmds[0] + assert salt.utils.secret.REDACT_PLACEHOLDER not in preset_cmds[0] diff --git a/tests/pytests/unit/modules/test_gpg.py b/tests/pytests/unit/modules/test_gpg.py index 332f13f78955..bd6651963417 100644 --- a/tests/pytests/unit/modules/test_gpg.py +++ b/tests/pytests/unit/modules/test_gpg.py @@ -16,6 +16,7 @@ import pytest import salt.modules.gpg as gpg +import salt.utils.secret from tests.support.mock import MagicMock, Mock, call, patch pytest.importorskip("gnupg") @@ -1185,3 +1186,133 @@ def test_get_user_gnupghome_respects_shell_env_setup(user, envvar): ): res = gpg._get_user_gnupghome(user) assert res == expected + + +@pytest.fixture +def masking_pillar_mock(): + """ + Fake pillar.get that behaves like the 3008 masking machinery: scalar + string values come back redacted unless the caller passes unmask=True. + """ + + def _pillar_get(key, default=None, **kwargs): + if kwargs.get("unmask"): + return salt.utils.secret.expose(GPG_TEST_KEY_PASSPHRASE) + return salt.utils.secret.serial(GPG_TEST_KEY_PASSPHRASE) + + return MagicMock(side_effect=_pillar_get) + + +def test_create_key_unmasks_pillar_passphrase(masking_pillar_mock, tmp_path): + """ + gpg.create_key must pass the real pillar passphrase to gen_key_input, + not the masking placeholder. + """ + user_info = MagicMock( + return_value={"name": "salt", "home": str(tmp_path), "uid": 1000, "gid": 1000} + ) + with patch("salt.modules.gpg._create_gpg") as create: + create.return_value.gen_key_input.return_value = "%commit\n" + create.return_value.gen_key.return_value.fingerprint = "F" * 40 + with patch.dict( + gpg.__salt__, + { + "pillar.get": masking_pillar_mock, + "config.option": MagicMock(return_value="salt"), + "user.info": user_info, + }, + ): + ret = gpg.create_key(use_passphrase=True, gnupghome=str(tmp_path)) + assert ret["res"] is True + passed = create.return_value.gen_key_input.call_args.kwargs["passphrase"] + assert passed == GPG_TEST_KEY_PASSPHRASE + assert passed != salt.utils.secret.REDACT_PLACEHOLDER + + +def test_delete_key_unmasks_pillar_passphrase(masking_pillar_mock): + """ + gpg.delete_key must pass the real pillar passphrase to delete_keys, + not the masking placeholder. + """ + fingerprint = "F" * 40 + key = {"fingerprint": fingerprint} + with patch("salt.modules.gpg._create_gpg") as create: + create.return_value.delete_keys.return_value = "ok" + with patch.object(gpg, "get_key", return_value=key), patch.object( + gpg, "get_secret_key", return_value=key + ): + with patch.dict(gpg.__salt__, {"pillar.get": masking_pillar_mock}): + ret = gpg.delete_key(fingerprint=fingerprint, delete_secret=True) + assert ret["res"] is True + create.return_value.delete_keys.assert_any_call( + fingerprint, True, passphrase=GPG_TEST_KEY_PASSPHRASE + ) + + +def test_export_key_unmasks_pillar_passphrase(masking_pillar_mock): + """ + gpg.export_key must pass the real pillar passphrase to export_keys, + not the masking placeholder. + """ + with patch("salt.modules.gpg._create_gpg") as create: + create.return_value.export_keys.return_value = "exported key data" + with patch.dict(gpg.__salt__, {"pillar.get": masking_pillar_mock}): + ret = gpg.export_key(keyids="ABCDEF01", secret=True, use_passphrase=True) + assert ret["res"] is True + create.return_value.export_keys.assert_called_once_with( + ["ABCDEF01"], True, passphrase=GPG_TEST_KEY_PASSPHRASE + ) + + +def test_sign_unmasks_pillar_passphrase(masking_pillar_mock): + """ + gpg.sign must pass the real pillar passphrase to gnupg's sign, + not the masking placeholder. + """ + with patch("salt.modules.gpg._create_gpg") as create: + create.return_value.sign.return_value.data = b"signed" + with patch.dict(gpg.__salt__, {"pillar.get": masking_pillar_mock}): + ret = gpg.sign(keyid="ABCDEF01", text="foo", use_passphrase=True) + assert ret == b"signed" + create.return_value.sign.assert_called_once_with( + "foo", keyid="ABCDEF01", passphrase=GPG_TEST_KEY_PASSPHRASE + ) + + +def test_encrypt_unmasks_pillar_passphrase(masking_pillar_mock): + """ + gpg.encrypt with sign=True must pass the real pillar passphrase to + gnupg's encrypt, not the masking placeholder. + """ + with patch("salt.modules.gpg._create_gpg") as create: + result = create.return_value.encrypt.return_value + result.ok = True + result.data = b"encrypted" + with patch.dict(gpg.__salt__, {"pillar.get": masking_pillar_mock}): + ret = gpg.encrypt( + text="foo", + recipients="person@example.com", + sign=True, + use_passphrase=True, + ) + assert ret["res"] is True + passed = create.return_value.encrypt.call_args.kwargs["passphrase"] + assert passed == GPG_TEST_KEY_PASSPHRASE + assert passed != salt.utils.secret.REDACT_PLACEHOLDER + + +def test_decrypt_unmasks_pillar_passphrase(masking_pillar_mock): + """ + gpg.decrypt must pass the real pillar passphrase to gnupg's decrypt, + not the masking placeholder. + """ + with patch("salt.modules.gpg._create_gpg") as create: + result = create.return_value.decrypt.return_value + result.ok = True + result.data = b"decrypted" + with patch.dict(gpg.__salt__, {"pillar.get": masking_pillar_mock}): + ret = gpg.decrypt(text="foo", use_passphrase=True) + assert ret["res"] is True + create.return_value.decrypt.assert_called_once_with( + "foo", passphrase=GPG_TEST_KEY_PASSPHRASE + ) diff --git a/tests/pytests/unit/modules/test_oracle.py b/tests/pytests/unit/modules/test_oracle.py index 4aa8318d9b7c..68eebfa9ad07 100644 --- a/tests/pytests/unit/modules/test_oracle.py +++ b/tests/pytests/unit/modules/test_oracle.py @@ -9,6 +9,8 @@ import pytest import salt.modules.oracle as oracle +import salt.utils.data +import salt.utils.secret from tests.support.mock import MagicMock, patch @@ -62,6 +64,47 @@ def test_show_pillar(): assert oracle.show_pillar("item") == "a" +def _masking_pillar_get(pillar): + """ + Build a fake pillar.get that behaves like the real 3008 one: values are + run through salt.utils.secret.serial() (strings redacted) unless + unmask=True, in which case they are expose()d to plain values. + """ + hidden = salt.utils.secret.hide(pillar) + + def fake_pillar_get(key, default=None, *args, unmask=None, **kwargs): + value = salt.utils.data.traverse_dict_and_list(hidden, key, default) + if unmask: + return salt.utils.secret.expose(value) + return salt.utils.secret.serial(value) + + return fake_pillar_get + + +def test_show_dbs_returns_unmasked_uri(): + """ + show_dbs(db) must return the real connection uri, not the redact + placeholder, because run_query() feeds it to _connect(). + """ + real_uri = "scott/tiger@oradb1:1521/orcl" + fake_get = _masking_pillar_get({"oracle": {"dbs": {"my_db": {"uri": real_uri}}}}) + with patch.dict(oracle.__salt__, {"pillar.get": fake_get}): + assert oracle.show_dbs("my_db") == {"my_db": {"uri": real_uri}} + + +def test_run_query_connects_with_unmasked_uri(): + """ + run_query() must pass the real (unmasked) uri to _connect(). + """ + real_uri = "scott/tiger@oradb1:1521/orcl" + fake_get = _masking_pillar_get({"oracle": {"dbs": {"my_db": {"uri": real_uri}}}}) + with patch.dict(oracle.__salt__, {"pillar.get": fake_get}): + with patch.object(oracle, "_connect", MagicMock()) as mock_connect: + oracle.run_query("my_db", "select 1 from dual") + mock_connect.assert_called_once_with(real_uri) + assert salt.utils.secret.REDACT_PLACEHOLDER not in mock_connect.call_args.args[0] + + def test_show_env(): """ Test for Show Environment used by Oracle Client diff --git a/tests/pytests/unit/modules/test_rpmbuild_pkgbuild.py b/tests/pytests/unit/modules/test_rpmbuild_pkgbuild.py new file mode 100644 index 000000000000..c3dc8286e4f6 --- /dev/null +++ b/tests/pytests/unit/modules/test_rpmbuild_pkgbuild.py @@ -0,0 +1,93 @@ +""" +Tests for salt.modules.rpmbuild_pkgbuild +""" + +import pytest + +import salt.modules.rpmbuild_pkgbuild as rpmbuild_pkgbuild +import salt.utils.secret +from tests.support.mock import MagicMock, patch + +pytestmark = [ + pytest.mark.skip_on_windows(reason="rpm-only module"), +] + +GPG_PILLAR = { + "gpg_pkg_pub_keyname": "gpg_pkg_key.pub", + "gpg_pkg_priv_keyname": "gpg_pkg_key.pem", + "gpg_passphrase": "sup3r_s3cr3t", +} + + +@pytest.fixture +def configure_loader_modules(): + return { + rpmbuild_pkgbuild: { + "__grains__": {"os_family": "RedHat", "osmajorrelease": 7}, + } + } + + +def _masking_pillar_get(key, default=None, **kwargs): + """ + Mimic 3008 pillar.get masking: scalar strings are redacted unless the + caller passes unmask=True. + """ + value = GPG_PILLAR.get(key, default) + if kwargs.get("unmask"): + return salt.utils.secret.expose(value) + return salt.utils.secret.serial(value) + + +def test_get_gpg_key_resources_unmasks_pillar_values(): + """ + _get_gpg_key_resources must read the gpg key filenames and passphrase + with unmask=True, otherwise gpg gets fed the redact placeholder. + """ + import_key_mock = MagicMock(return_value=True) + list_keys_mock = MagicMock( + return_value=[ + { + "keyid": "AAAAAAAA07123E1F", + "fingerprint": "1234567890ABCDEF1234567890ABCDEF07123E1F", + "uids": ["Packaging Key "], + } + ] + ) + retcode_mock = MagicMock(return_value=0) + salt_dunder = { + "pillar.get": _masking_pillar_get, + "gpg.import_key": import_key_mock, + "gpg.list_keys": list_keys_mock, + "cmd.retcode": retcode_mock, + "cmd.run": MagicMock(return_value=""), + } + + with patch.dict(rpmbuild_pkgbuild.__salt__, salt_dunder): + use_gpg_agent, local_keyid, define_gpg_name, phrase = ( + rpmbuild_pkgbuild._get_gpg_key_resources( + keyid="07123E1F", + env={}, + use_passphrase=True, + gnupghome="/etc/salt/gpgkeys", + runas="root", + ) + ) + + assert use_gpg_agent is False + assert local_keyid == "AAAAAAAA07123E1F" + + # the passphrase handed back for signing must be the real value + assert phrase == GPG_PILLAR["gpg_passphrase"] + assert salt.utils.secret.REDACT_PLACEHOLDER not in phrase + + # key files imported into gpg must carry the real pillar filenames + imported = [call.kwargs["filename"] for call in import_key_mock.call_args_list] + assert "/etc/salt/gpgkeys/gpg_pkg_key.pub" in imported + assert "/etc/salt/gpgkeys/gpg_pkg_key.pem" in imported + for filename in imported: + assert salt.utils.secret.REDACT_PLACEHOLDER not in filename + + # rpm --import must reference the real public key file + rpm_import_cmd = retcode_mock.call_args_list[0].args[0] + assert rpm_import_cmd == "rpm --import /etc/salt/gpgkeys/gpg_pkg_key.pub" diff --git a/tests/pytests/unit/modules/test_ssh_pki.py b/tests/pytests/unit/modules/test_ssh_pki.py new file mode 100644 index 000000000000..b5567f6250a8 --- /dev/null +++ b/tests/pytests/unit/modules/test_ssh_pki.py @@ -0,0 +1,66 @@ +import pytest + +import salt.modules.ssh_pki as ssh_pki +import salt.utils.secret +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return {ssh_pki: {"__salt__": {}, "__opts__": {}}} + + +def _pillar_get(masked_pillar): + """Build a fake pillar.get that mirrors salt.modules.pillar.get masking.""" + + def _get(key, default=None, unmask=None, **kwargs): + value = masked_pillar.get(key, default) + if unmask: + return salt.utils.secret.expose(value) + return salt.utils.secret.serial(value) + + return _get + + +def test_get_signing_policy_unmasks_pillar_values(): + """ + Regression test for issue #69711: _get_signing_policy must request + unmasked pillar values, otherwise scalar string values get replaced + by the redaction placeholder and signing fails. + """ + policy = { + "signing_private_key": "/etc/pki/ssh/ca.key", + "ttl": "30d", + "allowed_valid_principals": ["web.example.com"], + } + masked_pillar = salt.utils.secret.hide( + {"ssh_signing_policies": {"mypolicy": policy}} + ) + + config_get = MagicMock(return_value={}) + with patch.dict( + ssh_pki.__salt__, + {"pillar.get": _pillar_get(masked_pillar), "config.get": config_get}, + ): + result = ssh_pki._get_signing_policy("mypolicy") + + assert result == policy + assert result["signing_private_key"] != salt.utils.secret.REDACT_PLACEHOLDER + + +def test_get_signing_policy_none_returns_empty(): + with patch.dict(ssh_pki.__salt__, {}): + assert ssh_pki._get_signing_policy(None) == {} + + +def test_get_signing_policy_falls_back_to_config(): + masked_pillar = salt.utils.secret.hide({}) + policy = {"signing_private_key": "/etc/pki/ssh/ca.key"} + config_get = MagicMock(return_value={"mypolicy": policy}) + with patch.dict( + ssh_pki.__salt__, + {"pillar.get": _pillar_get(masked_pillar), "config.get": config_get}, + ): + result = ssh_pki._get_signing_policy("mypolicy") + + assert result == policy diff --git a/tests/pytests/unit/modules/test_tls_unmask.py b/tests/pytests/unit/modules/test_tls_unmask.py new file mode 100644 index 000000000000..32b483f75c9d --- /dev/null +++ b/tests/pytests/unit/modules/test_tls_unmask.py @@ -0,0 +1,84 @@ +""" +Pillar masking regression tests for salt.modules.tls. + +These live outside test_tls.py because that module is skipped wholesale when +pyOpenSSL no longer ships the X509Extension API, while tls.get_extensions +itself only needs X509_EXT_ENABLED and must keep unmasking pillar values on +every pyOpenSSL version. +""" + +import pytest + +import salt.modules.tls as tls +import salt.utils.secret +from tests.support.mock import patch + + +@pytest.fixture +def configure_loader_modules(): + return {tls: {}} + + +def _masking_pillar_get(pillar_data): + """ + Build a pillar.get fake that behaves like the 3008 masking-aware + implementation: values are redacted with salt.utils.secret.serial unless + the caller passes unmask=True. + """ + + def fake_pillar_get(key, default=None, *args, **kwargs): + value = pillar_data.get(key, default) + if kwargs.get("unmask"): + return salt.utils.secret.expose(value) + return salt.utils.secret.serial(value) + + return fake_pillar_get + + +def test_get_extensions_unmasks_pillar_values(): + """ + get_extensions must pass unmask=True so the real extension strings from + pillar (not REDACT_PLACEHOLDER) end up in the CSR/cert definitions. + """ + pillar_data = { + "tls.extensions:common": { + "csr": {"basicConstraints": "CA:FALSE"}, + "cert": {"subjectKeyIdentifier": "hash"}, + }, + "tls.extensions:server": { + "csr": {"extendedKeyUsage": "serverAuth"}, + "cert": {}, + }, + "tls.extensions:client": { + "csr": {"extendedKeyUsage": "clientAuth"}, + "cert": {}, + }, + } + with patch.dict(tls.__dict__, {"X509_EXT_ENABLED": True}), patch.dict( + tls.__salt__, {"pillar.get": _masking_pillar_get(pillar_data)} + ): + ext = tls.get_extensions("server") + assert ext["csr"]["basicConstraints"] == "CA:FALSE" + assert ext["csr"]["extendedKeyUsage"] == "serverAuth" + assert ext["cert"]["subjectKeyIdentifier"] == "hash" + assert salt.utils.secret.REDACT_PLACEHOLDER not in repr(ext) + + +def test_get_extensions_unmasks_custom_cert_type_pillar_values(): + """ + User-defined cert_type profiles read from tls.extensions:{cert_type} must + also be unmasked before being merged into the extension set. + """ + pillar_data = { + "tls.extensions:vpnclient": { + "csr": {"keyUsage": "nonRepudiation"}, + "cert": {"nsComment": "Salt generated VPN client certificate"}, + }, + } + with patch.dict(tls.__dict__, {"X509_EXT_ENABLED": True}), patch.dict( + tls.__salt__, {"pillar.get": _masking_pillar_get(pillar_data)} + ): + ext = tls.get_extensions("vpnclient") + assert ext["csr"]["keyUsage"] == "nonRepudiation" + assert ext["cert"]["nsComment"] == "Salt generated VPN client certificate" + assert salt.utils.secret.REDACT_PLACEHOLDER not in repr(ext) diff --git a/tests/pytests/unit/modules/test_x509.py b/tests/pytests/unit/modules/test_x509.py new file mode 100644 index 000000000000..9758faeee713 --- /dev/null +++ b/tests/pytests/unit/modules/test_x509.py @@ -0,0 +1,62 @@ +import pytest + +import salt.modules.x509 as x509 +import salt.utils.secret +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return {x509: {"__salt__": {}, "__opts__": {}}} + + +def _pillar_get(masked_pillar): + """Build a fake pillar.get that mirrors salt.modules.pillar.get masking.""" + + def _get(key, default=None, unmask=None, **kwargs): + value = masked_pillar.get(key, default) + if unmask: + return salt.utils.secret.expose(value) + return salt.utils.secret.serial(value) + + return _get + + +def test_get_signing_policy_unmasks_pillar_values(): + """ + Regression test for issue #69711: _get_signing_policy must request + unmasked pillar values, otherwise scalar string values get replaced + by the redaction placeholder and signing fails. + """ + policy = { + "signing_private_key": "/etc/pki/ca.key", + "signing_cert": "/etc/pki/ca.crt", + "keyUsage": "critical, cRLSign, keyCertSign", + } + masked_pillar = salt.utils.secret.hide( + {"x509_signing_policies": {"mypolicy": policy}} + ) + + config_get = MagicMock(return_value={}) + with patch.dict( + x509.__salt__, + {"pillar.get": _pillar_get(masked_pillar), "config.get": config_get}, + ): + result = x509._get_signing_policy("mypolicy") + + assert result == policy + for value in result.values(): + assert value != salt.utils.secret.REDACT_PLACEHOLDER + + +def test_get_signing_policy_falls_back_to_config(): + masked_pillar = salt.utils.secret.hide({}) + policy = {"signing_private_key": "/etc/pki/ca.key"} + config_get = MagicMock(return_value={"mypolicy": policy}) + with patch.dict( + x509.__salt__, + {"pillar.get": _pillar_get(masked_pillar), "config.get": config_get}, + ): + result = x509._get_signing_policy("mypolicy") + + assert result == policy diff --git a/tests/pytests/unit/utils/test_pyobjects.py b/tests/pytests/unit/utils/test_pyobjects.py index e95a0195f45c..f082eb6060c6 100644 --- a/tests/pytests/unit/utils/test_pyobjects.py +++ b/tests/pytests/unit/utils/test_pyobjects.py @@ -61,3 +61,51 @@ def test_opts_and_sls_access(pyobjects_template): ), ] ) + + +def test_map_merge_pillar_values_are_unmasked(): + """ + Map ``merge`` pillar reads happen at class-definition (render) time, + outside the mask_pillar=False context that string-template renderers + get from salt.utils.templates.wrap_tmpl_func, so the read must pass + unmask=True to receive real pillar values instead of the redact + placeholder. See issue #69711. + """ + import salt.utils.pyobjects as pyobjects_utils + import salt.utils.secret + + pillar_data = {"nginx:lookup": {"package": "nginx-full", "api_token": "hunter2"}} + + def fake_pillar_get(key, default=None, unmask=None, **kwargs): + # Mimic salt.modules.pillar.get masking semantics under the + # default mask_pillar=True context. + value = pillar_data.get(key, default) + if unmask is None: + unmask = not salt.utils.secret.mask_pillar.get() + if unmask: + return salt.utils.secret.expose(value) + return salt.utils.secret.serial(value) + + orig_salt = pyobjects_utils.Map.__salt__ + # Pin the contextvar to its default (masked) so the test is + # deterministic regardless of what earlier tests did. + token = salt.utils.secret.mask_pillar.set(True) + pyobjects_utils.Map.__salt__ = { + "grains.filter_by": MagicMock(), + "grains.item": MagicMock(return_value={}), + "pillar.get": fake_pillar_get, + } + try: + + class Nginx(pyobjects_utils.Map): + merge = "nginx:lookup" + + assert Nginx.package == "nginx-full" + assert Nginx.api_token == "hunter2" + assert salt.utils.secret.REDACT_PLACEHOLDER not in ( + Nginx.package, + Nginx.api_token, + ) + finally: + pyobjects_utils.Map.__salt__ = orig_salt + salt.utils.secret.mask_pillar.reset(token) From 681828476bb79902155daf9f3b4a602ca2cbdcbd Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 5 Jul 2026 17:59:21 -0400 Subject: [PATCH 040/469] Fix etcd cache ls() to return a bank's immediate children (#69616) (#69617) etcd_cache.ls() recursed through the whole bank subtree and returned the flattened leaf key names instead of the bank's immediate children. The minion data cache stores each minion under minions/, so cache.list("minions") returned ['data', 'mine', ...] instead of the minion IDs, and grain (-G), pillar, range, and compound targeting matched no minions with cache: etcd. Return only the immediate children, matching localfs and the other cache backends. Remove _walk(); fold its empty-folder self-reference guard (#57377) into ls(). --- changelog/69616.fixed.md | 1 + salt/cache/etcd_cache.py | 54 ++++++------ tests/pytests/functional/cache/test_etcd.py | 22 +++++ tests/pytests/unit/cache/test_etcd_cache.py | 92 +++++++++++++++------ 4 files changed, 117 insertions(+), 52 deletions(-) create mode 100644 changelog/69616.fixed.md diff --git a/changelog/69616.fixed.md b/changelog/69616.fixed.md new file mode 100644 index 000000000000..1188f7686315 --- /dev/null +++ b/changelog/69616.fixed.md @@ -0,0 +1 @@ +Fixed the etcd cache ``ls`` returning nested leaf key names for a bank instead of the bank's immediate children. It now returns only the direct children of the bank, matching the ``localfs`` cache, so grain (``-G``) targeting works with ``cache: etcd``. diff --git a/salt/cache/etcd_cache.py b/salt/cache/etcd_cache.py index fefa582264fc..64664ef011a7 100644 --- a/salt/cache/etcd_cache.py +++ b/salt/cache/etcd_cache.py @@ -49,9 +49,10 @@ cache: etcd -In Phosphorus, ls/list was changed to always return the final name in the path. -This should only make a difference if you were directly using ``ls`` on paths -that were more or less nested than, for example: ``1/2/3/4``. +``ls``/``list`` returns the immediate entries stored in a bank (the direct +children of the bank path), matching the behavior of the other cache backends +(e.g. ``localfs``). This is what the master relies on to enumerate cached +minions via ``cache.list("minions")``. .. _`Etcd documentation`: https://github.com/coreos/etcd .. _`python-etcd documentation`: http://python-etcd.readthedocs.io/en/latest/ @@ -189,38 +190,21 @@ def flush(bank, key=None): raise SaltCacheError(f"There was an error removing the key, {etcd_key}: {exc}") -def _walk(r): - """ - Recursively walk dirs. Return flattened list of keys. - r: etcd.EtcdResult - """ - if not r.dir: - if r.key.endswith(_tstamp_suffix): - return [] - else: - return [r.key.rsplit("/", 1)[-1]] - - keys = [] - for c in client.read(r.key).children: - # An empty etcd folder lists itself as its only child; without this - # guard _walk would recurse on the same key until it exhausts the - # recursion limit (see #57377). - if c.key == r.key: - log.debug('Empty folder found: "%s"', r.key) - break - keys.extend(_walk(c)) - return keys - - def ls(bank): """ Return an iterable object containing all entries stored in the specified bank. + + Only the immediate children of the bank are returned -- the bank's own + keys and any sub-banks -- matching the behavior of the other cache + backends such as ``localfs``. In particular this is what lets the master + enumerate cached minions via ``cache.list("minions")``, where each minion + is stored under its own ``minions/`` sub-bank. """ _init_client() path = f"{path_prefix}/{bank}" try: - return _walk(client.read(path)) + result = client.read(path) except etcd.EtcdKeyNotFound: return [] except Exception as exc: # pylint: disable=broad-except @@ -228,6 +212,22 @@ def ls(bank): f'There was an error getting the key "{bank}": {exc}' ) from exc + keys = [] + for child in result.children: + # A leaf key and an empty directory both list themselves as their + # only child; skip that self-reference so an empty/leaf bank lists as + # empty and the bank is never echoed as one of its own entries + # (see #57377). + if child.key == result.key: + continue + name = child.key.rsplit("/", 1)[-1] + # store() writes a companion timestamp entry next to each key; it is + # internal bookkeeping, not a cache entry, so don't surface it. + if name.endswith(_tstamp_suffix): + continue + keys.append(name) + return keys + def contains(bank, key): """ diff --git a/tests/pytests/functional/cache/test_etcd.py b/tests/pytests/functional/cache/test_etcd.py index e69dcba84318..7276017b2ff6 100644 --- a/tests/pytests/functional/cache/test_etcd.py +++ b/tests/pytests/functional/cache/test_etcd.py @@ -48,3 +48,25 @@ def cache(minion_opts, etcd_port): def test_caching(subtests, cache): run_common_cache_tests(subtests, cache) + + +def test_list_returns_immediate_children(cache): + """ + The master stores minion data under a per-minion sub-bank + (``cache.store("minions/", "data", ...)``). Listing the ``minions`` + bank must return the minion IDs -- the immediate children of the bank -- + not the nested ``data``/``mine`` leaf key names. Regression test for grain + (``-G``) targeting matching no minions with ``cache: etcd``. + """ + cache.flush("minions") + try: + for minion_id in ("web01", "db01"): + cache.store(f"minions/{minion_id}", "data", {"grains": {"id": minion_id}}) + cache.store(f"minions/{minion_id}", "mine", {}) + assert sorted(cache.list("minions")) == ["db01", "web01"] + for minion_id in ("web01", "db01"): + assert cache.fetch(f"minions/{minion_id}", "data") == { + "grains": {"id": minion_id} + } + finally: + cache.flush("minions") diff --git a/tests/pytests/unit/cache/test_etcd_cache.py b/tests/pytests/unit/cache/test_etcd_cache.py index 98b3d51921c5..eeab1b4fff76 100644 --- a/tests/pytests/unit/cache/test_etcd_cache.py +++ b/tests/pytests/unit/cache/test_etcd_cache.py @@ -176,52 +176,94 @@ def test_flush_error(client): etcd_cache.flush("bank", "key") -# --- _walk ------------------------------------------------------------------- +# --- ls ---------------------------------------------------------------------- -def test_walk_leaf_key(client): - leaf = FakeResult(key="/salt/cache/bank/minion", dir=False) - assert etcd_cache._walk(leaf) == ["minion"] +def test_ls(client): + minion = FakeResult(key="/salt/cache/bank/minion", dir=False) + client.read.return_value = FakeResult( + key="/salt/cache/bank", dir=True, children=[minion] + ) + assert etcd_cache.ls("bank") == ["minion"] -def test_walk_skips_timestamp_keys(client): - leaf = FakeResult(key="/salt/cache/bank/minion.tstamp", dir=False) - assert etcd_cache._walk(leaf) == [] +def test_ls_returns_immediate_children_not_nested_leaf_names(client): + """ + Regression test: the minion data cache stores each minion under its own + ``minions/`` sub-bank (with ``data``/``mine`` leaf keys inside). + ``ls("minions")`` must return the minion IDs -- the immediate children of + the bank -- not the leaf key names from the nested sub-banks. ls() used to + recurse and return ``["data", "data", ...]``, which broke grain (``-G``) + targeting because the master could not enumerate the cached minions. + """ + tree = { + "/salt/cache/minions": FakeResult( + key="/salt/cache/minions", + dir=True, + children=[ + FakeResult(key="/salt/cache/minions/web01", dir=True), + FakeResult(key="/salt/cache/minions/db01", dir=True), + ], + ), + # ls() must NOT descend into these sub-banks. They are wired up so that + # a reintroduced recursion would (wrongly) surface the leaf names and + # fail this test. + "/salt/cache/minions/web01": FakeResult( + key="/salt/cache/minions/web01", + dir=True, + children=[ + FakeResult(key="/salt/cache/minions/web01/data", dir=False), + FakeResult(key="/salt/cache/minions/web01/data.tstamp", dir=False), + ], + ), + "/salt/cache/minions/db01": FakeResult( + key="/salt/cache/minions/db01", + dir=True, + children=[ + FakeResult(key="/salt/cache/minions/db01/data", dir=False), + FakeResult(key="/salt/cache/minions/db01/data.tstamp", dir=False), + ], + ), + } + client.read.side_effect = lambda key: tree[key] + assert sorted(etcd_cache.ls("minions")) == ["db01", "web01"] -def test_walk_directory(client): - minion = FakeResult(key="/salt/cache/bank/minion", dir=False) - tstamp = FakeResult(key="/salt/cache/bank/minion.tstamp", dir=False) +def test_ls_filters_timestamp_siblings(client): + """ + A flat bank stores each key next to a ```` timestamp entry. + The timestamp entries are internal bookkeeping and must not be listed. + """ + children = [ + FakeResult(key="/salt/cache/grains/web01", dir=False), + FakeResult(key="/salt/cache/grains/web01.tstamp", dir=False), + ] client.read.return_value = FakeResult( - key="/salt/cache/bank", dir=True, children=[minion, tstamp] + key="/salt/cache/grains", dir=True, children=children ) - bank = FakeResult(key="/salt/cache/bank", dir=True) - assert etcd_cache._walk(bank) == ["minion"] + assert etcd_cache.ls("grains") == ["web01"] -def test_walk_empty_folder_does_not_recurse(client): +def test_ls_empty_dir_returns_empty(client): """ Regression test for #57377: an empty etcd folder lists itself as its only - child, which previously caused _walk to recurse until it hit the recursion - limit and raised a SaltCacheError. + child. ls() must skip that self-reference and return an empty list without + recursing. """ self_ref = FakeResult(key="/salt/cache/bank", dir=True) client.read.return_value = FakeResult( key="/salt/cache/bank", dir=True, children=[self_ref] ) - bank = FakeResult(key="/salt/cache/bank", dir=True) - assert etcd_cache._walk(bank) == [] - - -# --- ls ---------------------------------------------------------------------- + assert etcd_cache.ls("bank") == [] -def test_ls(client): - minion = FakeResult(key="/salt/cache/bank/minion", dir=False) +def test_ls_preserves_dotted_ids(client): + """A minion id containing dots must survive intact (split on "/" only).""" + child = FakeResult(key="/salt/cache/minions/db01.example.com", dir=True) client.read.return_value = FakeResult( - key="/salt/cache/bank", dir=True, children=[minion] + key="/salt/cache/minions", dir=True, children=[child] ) - assert etcd_cache.ls("bank") == ["minion"] + assert etcd_cache.ls("minions") == ["db01.example.com"] def test_ls_missing_returns_empty(client): From ef776292c79d587d94528eca46af920137049f63 Mon Sep 17 00:00:00 2001 From: Zain Asif <87968213+zain-asif-dev@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:01:02 +0500 Subject: [PATCH 041/469] Fix restrictive keyring file permissions in pkg.add_repo_key (#69678) * Fix restrictive keyring file permissions in pkg.add_repo_key When aptkey=False, add_repo_key() writes GPG keyring files to /usr/share/keyrings/ (or /etc/apt/keyrings/) either by copying a decrypted key with shutil.copyfile(), or by having gpg write the keyring directly via --recv-keys. Neither path explicitly sets the resulting file's permissions, so the mode ends up governed by the process umask. On systems hardened with a restrictive umask (e.g. 077), this leaves the keyring readable only by root, which breaks apt-get update with "NO_PUBKEY" errors, since apt reads keyrings as the unprivileged _apt user. Explicitly chmod the resulting keyring file to 0644 in both code paths so it stays readable regardless of umask, matching what apt-secure(8) expects of keyring files. Fixes #66731 Signed-off-by: Zain Asif * Fix pylint blacklisted-function violation in aptpkg test Use salt.utils.files.set_umask instead of calling os.umask directly in the regression test added for issue 66731, per the project's pylint policy for umask handling. Signed-off-by: Zain Asif --------- Signed-off-by: Zain Asif --- changelog/66731.fixed.md | 1 + salt/modules/aptpkg.py | 24 +++++++- tests/pytests/unit/modules/test_aptpkg.py | 70 +++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 changelog/66731.fixed.md diff --git a/changelog/66731.fixed.md b/changelog/66731.fixed.md new file mode 100644 index 000000000000..25f61891c5dc --- /dev/null +++ b/changelog/66731.fixed.md @@ -0,0 +1 @@ +``pkg.add_repo_key``/``pkgrepo.managed`` (with ``aptkey: False``) now write keyring files under ``/usr/share/keyrings/`` or ``/etc/apt/keyrings/`` with world-readable permissions (0644), regardless of the process umask. Previously, on systems hardened with a restrictive umask (e.g. 077), the keyring file ended up readable only by root, causing ``apt-get update`` to fail with ``NO_PUBKEY`` errors since the unprivileged ``_apt`` user could no longer read it. diff --git a/salt/modules/aptpkg.py b/salt/modules/aptpkg.py index 8392e3867878..506784436e77 100644 --- a/salt/modules/aptpkg.py +++ b/salt/modules/aptpkg.py @@ -2422,6 +2422,10 @@ def add_repo_key( aptkey = False cmd = ["apt-key"] kwargs = {} + # NOTE: Populated below only for the ``not aptkey`` + ``keyserver`` + # branch, so that the keyring file gpg writes can be chmod'd to be + # world-readable afterwards (see the matching os.chmod() call below). + keyring_file = None # If the keyid is provided or determined, check it against the existing # repo key ids to determine whether it needs to be imported. @@ -2451,7 +2455,16 @@ def add_repo_key( keyfile = key.name if keyfile.endswith(".decrypted"): keyfile = keyfile[:-10] - shutil.copyfile(str(key), str(keydir / keyfile)) + dest = keydir / keyfile + shutil.copyfile(str(key), str(dest)) + # NOTE: shutil.copyfile() does not copy permission bits, so the + # destination file's mode is subject to the process umask. On + # systems hardened with a restrictive umask (e.g. 077), this + # left the keyring unreadable by the unprivileged _apt user, + # causing "NO_PUBKEY" errors on the next apt-get update. Force + # a sane, world-readable mode to match what apt-secure(8) + # expects of keyring files. + os.chmod(str(dest), 0o644) return True else: cmd.extend(["add", cached_source_path]) @@ -2473,11 +2486,12 @@ def add_repo_key( "You must define the name of the key file to save the key. See keyfile argument" ) return False + keyring_file = keydir / keyfile cmd = [ "gpg", "--no-default-keyring", "--keyring", - keydir / keyfile, + keyring_file, "--keyserver", keyserver, "--recv-keys", @@ -2496,6 +2510,12 @@ def add_repo_key( cmd_ret = _call_apt(cmd, **kwargs) if cmd_ret["retcode"] == 0: + if keyring_file is not None: + # NOTE: gpg creates keyring files subject to the process umask, + # which can leave them unreadable by the unprivileged _apt + # user on systems with a restrictive umask. See the longer + # explanation above the other os.chmod() call in this function. + os.chmod(str(keyring_file), 0o644) return True log.error("Unable to add repo key: %s", cmd_ret["stderr"]) return False diff --git a/tests/pytests/unit/modules/test_aptpkg.py b/tests/pytests/unit/modules/test_aptpkg.py index cfeff9e4d1d3..d228680b9cfa 100644 --- a/tests/pytests/unit/modules/test_aptpkg.py +++ b/tests/pytests/unit/modules/test_aptpkg.py @@ -3,6 +3,7 @@ import logging import os import pathlib +import stat import textwrap from collections import OrderedDict @@ -10,6 +11,7 @@ import salt.modules.aptpkg as aptpkg import salt.modules.pkg_resource as pkg_resource +import salt.utils.files import salt.utils.path from salt.exceptions import ( CommandExecutionError, @@ -423,6 +425,74 @@ def test_add_repo_key_ascii_armored_asc_keeps_armor_68464(tmp_path): assert dest.read_text() == armored_payload +def test_add_repo_key_copied_key_is_world_readable(tmp_path): + """ + Regression test for #66731. + + ``shutil.copyfile()`` (used to write the keyring file when ``path`` is + given and ``aptkey=False``) does not copy permission bits, so the + resulting mode depends on the process umask. On systems hardened with + a restrictive umask (e.g. 077), this left the keyring unreadable by + the unprivileged ``_apt`` user, breaking ``apt-get update`` with + ``NO_PUBKEY`` errors. The keyring file must always end up + world-readable (0o644), regardless of the umask in effect. + """ + keydir = tmp_path / "keyrings" + keydir.mkdir() + cached = tmp_path / "cached-test.gpg" + cached.write_bytes(b"\x99\x01\x04not-actually-a-key") + + with salt.utils.files.set_umask(0o077): + with patch.dict( + aptpkg.__salt__, {"cp.cache_file": MagicMock(return_value=str(cached))} + ), patch("salt.modules.aptpkg.get_repo_keys", MagicMock(return_value={})): + ret = aptpkg.add_repo_key( + path="salt://files/test.gpg", + aptkey=False, + keydir=keydir, + keyfile="test.gpg", + ) + + assert ret is True + dest = keydir / "test.gpg" + assert dest.is_file() + assert stat.S_IMODE(dest.stat().st_mode) == 0o644 + + +def test_add_repo_key_keyserver_chmods_keyring_file(tmp_path): + """ + Regression test for #66731. + + When ``aptkey=False`` and a ``keyserver`` is used, ``gpg`` itself + creates the destination keyring file, which is likewise subject to + the process umask. The resulting file must be chmod'd to 0o644 after + a successful ``gpg --recv-keys``. + """ + keydir = tmp_path / "keyrings" + keydir.mkdir() + + cmd_run_all = MagicMock(return_value={"retcode": 0, "stdout": "OK"}) + with patch.dict( + aptpkg.__salt__, + { + "cmd.run_all": cmd_run_all, + "config.get": MagicMock(return_value=False), + }, + ), patch("salt.modules.aptpkg.get_repo_keys", MagicMock(return_value={})), patch( + "salt.modules.aptpkg.os.chmod" + ) as chmod_mock: + ret = aptpkg.add_repo_key( + keyserver="keyserver.ubuntu.com", + keyid="FBB75451", + keyfile="test-key.gpg", + aptkey=False, + keydir=keydir, + ) + + assert ret is True + chmod_mock.assert_called_once_with(str(keydir / "test-key.gpg"), 0o644) + + def test_decrypt_key_skips_dearmor_for_asc_destination_68464(tmp_path): """ Regression test for #68464. From d52324a596ef648de2a39468b1e7ec160c2fb89d Mon Sep 17 00:00:00 2001 From: Stepan <51859698+co-cy@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:04:45 -0300 Subject: [PATCH 042/469] Stop pgjsonb purge / archive from orphaning recent salt_returns rows (#69061) `_purge_jobs` and `_archive_jobs` selected jids using: delete from jids where jid in ( select distinct jid from salt_returns where alter_time < %s ) The subquery returned the jid as soon as ANY single salt_returns row for that jid was older than the cutoff. For long-running jobs whose minions answer at staggered times -- a slow orchestrate run, an SSH target with a 2-hour command, a syndic batch -- the predicate fired on the first old return and dropped the parent jids row, but the recent salt_returns rows for the same jid stayed behind in the source table. They were now orphans: * `salt-run jobs.lookup_jid` could no longer reconstruct the load. * The archive copy held parent rows without their recent returns. * Any code that JOINs salt_returns to jids saw a broken parent edge. Rewrite both predicates as an `EXISTS` / `NOT EXISTS` antijoin: delete from jids j where exists ( select 1 from salt_returns r where r.jid = j.jid ) and not exists ( select 1 from salt_returns r where r.jid = j.jid and r.alter_time >= %s ) A jids row is now removed only when every salt_returns row for that jid is older than the cutoff, so partial-return jobs keep their parent until they fully expire. Same change in `_archive_jobs` for the jids_archive insert. `EXISTS` is preferred over `IN (SELECT distinct ...)`: it short- circuits on the first hit, uses `idx_salt_returns_jid` for per-row probes, and avoids `NOT IN`'s NULL gotcha. Since `jids` is typically much smaller than `salt_returns`, the planner picks an antijoin via the jid index, which is at least as fast as the original seq scan under the old predicate. This PR fixes the data-integrity issue (orphan returns) only. It does NOT address the slower leak of jids rows that never received any salt_returns at all -- a job published against an offline target leaves an immortal jids row -- because closing that needs either a `jids.created_at` schema migration with backwards-compat detection or jid-format parsing in Python. Both warrant their own discussion; tracked as a separate follow-up. Add two behavioural tests asserting the rewritten predicate is issued (`NOT EXISTS` and `alter_time >= %s`, with a guard against regressing to the old `alter_time < %s` form) for both `_purge_jobs` and `_archive_jobs`. Depends on #69049 (which routed pgjsonb errors through Salt's logger and structured the catch blocks the new code re-uses); this PR builds on that branch and should be merged after it. Refs: #69060 Co-authored-by: co-cy --- changelog/69060.fixed.md | 8 +++ salt/returners/pgjsonb.py | 31 ++++++++--- tests/pytests/unit/returners/test_pgjsonb.py | 56 ++++++++++++++++++++ 3 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 changelog/69060.fixed.md diff --git a/changelog/69060.fixed.md b/changelog/69060.fixed.md new file mode 100644 index 000000000000..a54802b3195f --- /dev/null +++ b/changelog/69060.fixed.md @@ -0,0 +1,8 @@ +Fixed `salt.returners.pgjsonb._purge_jobs` and `_archive_jobs` deleting +or archiving the parent `jids` row as soon as a single `salt_returns` +row for that jid was older than the cutoff, even when newer rows for +the same jid existed. For long-running jobs whose minions answer at +staggered times, this orphaned the recent `salt_returns` rows in the +source table and produced an inconsistent archive. The predicate now +keeps the parent until every `salt_returns` row for the jid is older +than the cutoff (`EXISTS ... AND NOT EXISTS ...` antijoin). diff --git a/salt/returners/pgjsonb.py b/salt/returners/pgjsonb.py index dd0dd43f8eea..aaf5869768dd 100644 --- a/salt/returners/pgjsonb.py +++ b/salt/returners/pgjsonb.py @@ -497,9 +497,21 @@ def _purge_jobs(timestamp): """ with _get_serv() as cursor: try: + # Purge a jids row only when every salt_returns row for that jid + # is older than the cutoff. The previous predicate + # ("delete from jids where jid in (select distinct jid from + # salt_returns where alter_time < %s)") fired as soon as ONE + # old return existed, leaving recent returns from the same jid + # orphaned in salt_returns once the parent was deleted -- a + # data-integrity bug for any long-running job whose minions + # answer at staggered times. sql = ( - "delete from jids where jid in (select distinct jid from salt_returns" - " where alter_time < %s)" + "delete from jids j where exists (" + " select 1 from salt_returns r where r.jid = j.jid" + ") and not exists (" + " select 1 from salt_returns r" + " where r.jid = j.jid and r.alter_time >= %s" + ")" ) cursor.execute(sql, (timestamp,)) cursor.execute("COMMIT") @@ -556,11 +568,18 @@ def _archive_jobs(timestamp): raise try: + # Mirror the predicate used in _purge_jobs: archive a jids row + # only when every salt_returns row for that jid is older than + # the cutoff. Otherwise the archive ends up holding parent + # rows whose recent salt_returns rows were left behind in the + # source table. sql = ( - "insert into {} select * from {} where jid in (select distinct jid from" - " salt_returns where alter_time < %s)".format( - target_tables["jids"], "jids" - ) + "insert into {target} select * from jids j where exists (" + " select 1 from salt_returns r where r.jid = j.jid" + ") and not exists (" + " select 1 from salt_returns r" + " where r.jid = j.jid and r.alter_time >= %s" + ")".format(target=target_tables["jids"]) ) cursor.execute(sql, (timestamp,)) cursor.execute("COMMIT") diff --git a/tests/pytests/unit/returners/test_pgjsonb.py b/tests/pytests/unit/returners/test_pgjsonb.py index bff51b98d360..6a1bdb05eb37 100644 --- a/tests/pytests/unit/returners/test_pgjsonb.py +++ b/tests/pytests/unit/returners/test_pgjsonb.py @@ -414,3 +414,59 @@ def test__get_options_coerces_string_connect_timeout_to_int(): opts = pgjsonb._get_options() assert opts["connect_timeout"] == 5 assert isinstance(opts["connect_timeout"], int) + + +def _capture_jids_predicate(executed_calls, marker): + """Return the parameterised SQL string from the first call whose text + contains ``marker`` (e.g. ``"delete from jids"`` or ``"insert into"``).""" + for call_ in executed_calls: + if not call_.args: + continue + sql = call_.args[0] + if isinstance(sql, str) and marker in sql: + return sql + raise AssertionError( + f"no execute call contained {marker!r}; " + f"saw: {[c.args for c in executed_calls]}" + ) + + +def test__purge_jobs_keeps_jids_with_any_recent_salt_returns_row(): + """Regression for the orphan-returns bug: ``_purge_jobs`` must delete + a jids row only when every salt_returns row for that jid is older + than the cutoff. The previous predicate fired as soon as one old + return existed, which left recent returns from the same jid orphaned + in salt_returns once the parent was deleted.""" + cursor = MagicMock() + serv = MagicMock() + serv.return_value.__enter__.return_value = cursor + + with patch.object(pgjsonb, "_get_serv", serv): + pgjsonb._purge_jobs("2026-01-01") + + sql = _capture_jids_predicate(cursor.execute.call_args_list, "delete from jids") + # Antijoin: keep the row if any recent salt_returns row exists for it. + assert "not exists" in sql.lower() + assert "alter_time >= %s" in sql + # Defence against regressing to the old predicate. + assert "alter_time < %s" not in sql + + +def test__archive_jobs_keeps_jids_with_any_recent_salt_returns_row(): + """Mirror of the purge test for the archive path. The archive INSERT + into ``jids_archive`` must use the same antijoin predicate so that it + does not pick up parent rows whose recent returns were left behind in + the source table.""" + cursor = MagicMock() + serv = MagicMock() + serv.return_value.__enter__.return_value = cursor + + with patch.object(pgjsonb, "_get_serv", serv): + pgjsonb._archive_jobs("2026-01-01") + + sql = _capture_jids_predicate( + cursor.execute.call_args_list, "insert into jids_archive" + ) + assert "not exists" in sql.lower() + assert "alter_time >= %s" in sql + assert "alter_time < %s" not in sql From 2b8129e89f1e3fc0cdf6f1723e23902e8d073b1f Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Sun, 5 Jul 2026 19:17:43 -0700 Subject: [PATCH 043/469] Add Python 3.9 upper caps for deps that drop 3.9 on their next release (#69714) Dependabot bumps the shared floor for these packages to a version that no longer supports Python 3.9 (jaraco.functools 4.5.0, jaraco.context 6.1.2, msgpack 1.2.1, opentelemetry 1.43.0 / exporter-prometheus 0.64b0, pyopenssl 26.3.0, virtualenv 21.5.1, xmldiff 3.0, xxhash 3.8.0, zipp 4.1.0). With a single unmarked floor the py3.9 lock targets become unresolvable and every grouped pip-updates PR fails pre-commit/lint/build. Split each into a python_version < '3.10' branch capped at the last 3.9-compatible release plus an open py>=3.10 branch, mirroring the existing cryptography/aiohttp/urllib3 splits. Dependabot then only bumps the uncapped py>=3.10 line and leaves 3.9 resolvable. No lock changes are needed now (uv already backtracks py3.9 to these versions); this only future-proofs the input floors. --- requirements/base.txt | 43 +++++++++++++++++++++-------- requirements/static/ci/common.txt | 6 ++-- requirements/static/pkg/freebsd.txt | 3 +- requirements/static/pkg/linux.txt | 3 +- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index fe0b2d2c3df4..b34dc2184dee 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -29,8 +29,12 @@ immutables>=0.21; python_version < '3.7' # py3.10, and let py>=3.11 use the existing 8.7+ floor. importlib-metadata>=3.3.0,<9.0.0; python_version < '3.10' importlib-metadata>=8.7.0; python_version >= '3.10' -jaraco.functools>=4.4.0 -jaraco.context>=6.1.1 +# jaraco.functools 4.5.0 and jaraco.context 6.1.2 drop Python 3.9; keep the +# last 3.9-compatible releases there and let py>=3.10 float forward. +jaraco.functools>=4.4.0,<4.5.0; python_version < '3.10' +jaraco.functools>=4.4.0; python_version >= '3.10' +jaraco.context>=6.1.1,<6.1.2; python_version < '3.10' +jaraco.context>=6.1.1; python_version >= '3.10' jaraco.text>=4.2.0 Jinja2>=3.1.6 jmespath>=1.1.0 @@ -39,17 +43,27 @@ lxml>=6.1.1; sys_platform == 'win32' MarkupSafe>=3.0.3 more-itertools>=10.8.0,<11.0.0; python_version < '3.10' more-itertools>=11.1.0; python_version >= '3.10' -msgpack>=1.1.2 ; python_version < '3.13' +# msgpack 1.2.1 drops Python 3.9; keep the last 3.9-compatible release there. +msgpack>=1.1.2,<1.2.1 ; python_version < '3.10' +msgpack>=1.1.2 ; python_version >= '3.10' and python_version < '3.13' msgpack>=1.1.0 ; python_version >= '3.13' # multidict 6.0.4 fails to source-build under clang 17+ with strict int/pointer # conversion checks (macOS 15 onedir builds compile from sdist via # --no-binary=:all:). 6.6+ fixed the C source compatibility. multidict>=6.6.0 -opentelemetry-api>=1.41.1 -opentelemetry-sdk>=1.41.1 -opentelemetry-exporter-otlp-proto-http>=1.41.1 -opentelemetry-exporter-prometheus>=0.62b1 -xxhash>=3.7.0 +# opentelemetry 1.43.0 (and exporter-prometheus 0.64b0) drop Python 3.9; keep +# the last 3.9-compatible releases there and let py>=3.10 float forward. +opentelemetry-api>=1.41.1,<1.43.0; python_version < '3.10' +opentelemetry-api>=1.41.1; python_version >= '3.10' +opentelemetry-sdk>=1.41.1,<1.43.0; python_version < '3.10' +opentelemetry-sdk>=1.41.1; python_version >= '3.10' +opentelemetry-exporter-otlp-proto-http>=1.41.1,<1.43.0; python_version < '3.10' +opentelemetry-exporter-otlp-proto-http>=1.41.1; python_version >= '3.10' +opentelemetry-exporter-prometheus>=0.62b1,<0.64b0; python_version < '3.10' +opentelemetry-exporter-prometheus>=0.62b1; python_version >= '3.10' +# xxhash 3.8.0 drops Python 3.9; keep the last 3.9-compatible release there. +xxhash>=3.7.0,<3.8.0; python_version < '3.10' +xxhash>=3.7.0; python_version >= '3.10' # Packaging 24.1 imports annotations from __future__ which breaks salt ssh # tests on target hosts with older python versions. packaging>=26.2; python_version < '3.11' @@ -64,7 +78,10 @@ pycparser>=3.0; python_version >= '3.10' # ships cp3X-win32 wheels. pymssql>=2.2.1,<=2.3.11; sys_platform == 'win32' and python_version < '3.11' pymssql==2.3.11; sys_platform == 'win32' and python_version >= '3.11' -pyopenssl>=26.2.0 +# pyopenssl 26.3.0 requires cryptography>=49 which drops Python 3.9; keep the +# last 3.9-compatible release there and let py>=3.10 float forward. +pyopenssl>=26.2.0,<26.3.0; python_version < '3.10' +pyopenssl>=26.2.0; python_version >= '3.10' python-dateutil>=2.9.0.post0 python-gnupg>=0.5.6 pythonnet>=3.0.1; sys_platform == 'win32' and python_version < '3.11' @@ -88,7 +105,9 @@ truststore>=0.10.0; python_version >= "3.10" # (CVE-2025-66418, CVE-2026-21441). urllib3>=1.26.20,<2.0.0; python_version < '3.10' urllib3>=2.7.0; python_version >= '3.10' -virtualenv>=21.4.2 +# virtualenv 21.5.1 drops Python 3.9; keep the last 3.9-compatible release there. +virtualenv>=21.4.2,<21.5.1; python_version < '3.10' +virtualenv>=21.4.2; python_version >= '3.10' # Transitive of virtualenv; some uv resolver caches pin a stale 3.25 # version that conflicts with the CI floor of 3.29.1 on Python 3.10+. filelock>=3.29.1; python_version >= '3.10' @@ -96,4 +115,6 @@ filelock>=3.19.1,<3.29.0; python_version < '3.10' vultr>=1.0.1 wmi>=1.5.1; sys_platform == 'win32' xmltodict>=1.0.4; sys_platform == 'win32' -zipp>=3.23.1 +# zipp 4.1.0 drops Python 3.9; keep the last 3.9-compatible release there. +zipp>=3.23.1,<4.1.0; python_version < '3.10' +zipp>=3.23.1; python_version >= '3.10' diff --git a/requirements/static/ci/common.txt b/requirements/static/ci/common.txt index aeee2508504d..6f9ec9a480d1 100644 --- a/requirements/static/ci/common.txt +++ b/requirements/static/ci/common.txt @@ -68,12 +68,14 @@ toml # vcert 0.18.x adds hard pins on cryptography, pynacl, and six that # conflict with every other CI requirement; stay on 0.9.x. vcert~=0.9.0; sys_platform != 'win32' -virtualenv>=21.4.2 +virtualenv>=21.4.2,<21.5.1; python_version < '3.10' +virtualenv>=21.4.2; python_version >= '3.10' watchdog>=6.0.0 websocket-client>=1.9.0 # werkzeug is a dependency of moto werkzeug>=3.1.8 -xmldiff>=2.7.0 +xmldiff>=2.7.0,<3.0; python_version < '3.10' +xmldiff>=2.7.0; python_version >= '3.10' # Available template libraries that can be used genshi>=0.7.11 cheetah3>=3.2.6.post1 diff --git a/requirements/static/pkg/freebsd.txt b/requirements/static/pkg/freebsd.txt index 65a3c076fad3..93cb3f61ea14 100644 --- a/requirements/static/pkg/freebsd.txt +++ b/requirements/static/pkg/freebsd.txt @@ -6,7 +6,8 @@ cryptography>=46.0.7,<48.0.0; python_version < '3.10' cryptography>=48.0.0; python_version >= '3.10' pycparser>=2.23; python_version < '3.10' pycparser>=3.0; python_version >= '3.10' -pyopenssl>=26.2.0 +pyopenssl>=26.2.0,<26.3.0; python_version < '3.10' +pyopenssl>=26.2.0; python_version >= '3.10' python-dateutil>=2.9.0.post0 python-gnupg>=0.5.6 setproctitle>=1.3.7 diff --git a/requirements/static/pkg/linux.txt b/requirements/static/pkg/linux.txt index d8c65a5a76c1..66f04fb284bf 100644 --- a/requirements/static/pkg/linux.txt +++ b/requirements/static/pkg/linux.txt @@ -7,7 +7,8 @@ cherrypy>=18.10.0 cheroot>=11.1.2 pycparser>=2.23; python_version < '3.10' pycparser>=3.0; python_version >= '3.10' -pyopenssl>=26.2.0 +pyopenssl>=26.2.0,<26.3.0; python_version < '3.10' +pyopenssl>=26.2.0; python_version >= '3.10' python-dateutil>=2.9.0.post0 python-gnupg>=0.5.6 rpm-vercmp From c3f7f91bfb86f386f4fe917042110c30ad5a484e Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Sun, 5 Jul 2026 19:18:22 -0700 Subject: [PATCH 044/469] Add Python 3.9 upper caps for deps that drop 3.9 on their next release (#69715) Dependabot bumps the shared floor for these packages to a version that no longer supports Python 3.9 (jaraco.functools 4.5.0, jaraco.context 6.1.2, msgpack 1.2.1, opentelemetry 1.43.0 / exporter-prometheus 0.64b0, pyopenssl 26.3.0, virtualenv 21.5.1, xmldiff 3.0, xxhash 3.8.0, zipp 4.1.0). With a single unmarked floor the py3.9 lock targets become unresolvable and every grouped pip-updates PR fails pre-commit/lint/build. Split each into a python_version < '3.10' branch capped at the last 3.9-compatible release plus an open py>=3.10 branch, mirroring the existing cryptography/aiohttp/urllib3 splits. Dependabot then only bumps the uncapped py>=3.10 line and leaves 3.9 resolvable. No lock changes are needed now (uv already backtracks py3.9 to these versions); this only future-proofs the input floors. --- requirements/base.txt | 43 +++++++++++++++++++++-------- requirements/static/ci/common.txt | 6 ++-- requirements/static/pkg/freebsd.txt | 3 +- requirements/static/pkg/linux.txt | 3 +- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index fe0b2d2c3df4..b34dc2184dee 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -29,8 +29,12 @@ immutables>=0.21; python_version < '3.7' # py3.10, and let py>=3.11 use the existing 8.7+ floor. importlib-metadata>=3.3.0,<9.0.0; python_version < '3.10' importlib-metadata>=8.7.0; python_version >= '3.10' -jaraco.functools>=4.4.0 -jaraco.context>=6.1.1 +# jaraco.functools 4.5.0 and jaraco.context 6.1.2 drop Python 3.9; keep the +# last 3.9-compatible releases there and let py>=3.10 float forward. +jaraco.functools>=4.4.0,<4.5.0; python_version < '3.10' +jaraco.functools>=4.4.0; python_version >= '3.10' +jaraco.context>=6.1.1,<6.1.2; python_version < '3.10' +jaraco.context>=6.1.1; python_version >= '3.10' jaraco.text>=4.2.0 Jinja2>=3.1.6 jmespath>=1.1.0 @@ -39,17 +43,27 @@ lxml>=6.1.1; sys_platform == 'win32' MarkupSafe>=3.0.3 more-itertools>=10.8.0,<11.0.0; python_version < '3.10' more-itertools>=11.1.0; python_version >= '3.10' -msgpack>=1.1.2 ; python_version < '3.13' +# msgpack 1.2.1 drops Python 3.9; keep the last 3.9-compatible release there. +msgpack>=1.1.2,<1.2.1 ; python_version < '3.10' +msgpack>=1.1.2 ; python_version >= '3.10' and python_version < '3.13' msgpack>=1.1.0 ; python_version >= '3.13' # multidict 6.0.4 fails to source-build under clang 17+ with strict int/pointer # conversion checks (macOS 15 onedir builds compile from sdist via # --no-binary=:all:). 6.6+ fixed the C source compatibility. multidict>=6.6.0 -opentelemetry-api>=1.41.1 -opentelemetry-sdk>=1.41.1 -opentelemetry-exporter-otlp-proto-http>=1.41.1 -opentelemetry-exporter-prometheus>=0.62b1 -xxhash>=3.7.0 +# opentelemetry 1.43.0 (and exporter-prometheus 0.64b0) drop Python 3.9; keep +# the last 3.9-compatible releases there and let py>=3.10 float forward. +opentelemetry-api>=1.41.1,<1.43.0; python_version < '3.10' +opentelemetry-api>=1.41.1; python_version >= '3.10' +opentelemetry-sdk>=1.41.1,<1.43.0; python_version < '3.10' +opentelemetry-sdk>=1.41.1; python_version >= '3.10' +opentelemetry-exporter-otlp-proto-http>=1.41.1,<1.43.0; python_version < '3.10' +opentelemetry-exporter-otlp-proto-http>=1.41.1; python_version >= '3.10' +opentelemetry-exporter-prometheus>=0.62b1,<0.64b0; python_version < '3.10' +opentelemetry-exporter-prometheus>=0.62b1; python_version >= '3.10' +# xxhash 3.8.0 drops Python 3.9; keep the last 3.9-compatible release there. +xxhash>=3.7.0,<3.8.0; python_version < '3.10' +xxhash>=3.7.0; python_version >= '3.10' # Packaging 24.1 imports annotations from __future__ which breaks salt ssh # tests on target hosts with older python versions. packaging>=26.2; python_version < '3.11' @@ -64,7 +78,10 @@ pycparser>=3.0; python_version >= '3.10' # ships cp3X-win32 wheels. pymssql>=2.2.1,<=2.3.11; sys_platform == 'win32' and python_version < '3.11' pymssql==2.3.11; sys_platform == 'win32' and python_version >= '3.11' -pyopenssl>=26.2.0 +# pyopenssl 26.3.0 requires cryptography>=49 which drops Python 3.9; keep the +# last 3.9-compatible release there and let py>=3.10 float forward. +pyopenssl>=26.2.0,<26.3.0; python_version < '3.10' +pyopenssl>=26.2.0; python_version >= '3.10' python-dateutil>=2.9.0.post0 python-gnupg>=0.5.6 pythonnet>=3.0.1; sys_platform == 'win32' and python_version < '3.11' @@ -88,7 +105,9 @@ truststore>=0.10.0; python_version >= "3.10" # (CVE-2025-66418, CVE-2026-21441). urllib3>=1.26.20,<2.0.0; python_version < '3.10' urllib3>=2.7.0; python_version >= '3.10' -virtualenv>=21.4.2 +# virtualenv 21.5.1 drops Python 3.9; keep the last 3.9-compatible release there. +virtualenv>=21.4.2,<21.5.1; python_version < '3.10' +virtualenv>=21.4.2; python_version >= '3.10' # Transitive of virtualenv; some uv resolver caches pin a stale 3.25 # version that conflicts with the CI floor of 3.29.1 on Python 3.10+. filelock>=3.29.1; python_version >= '3.10' @@ -96,4 +115,6 @@ filelock>=3.19.1,<3.29.0; python_version < '3.10' vultr>=1.0.1 wmi>=1.5.1; sys_platform == 'win32' xmltodict>=1.0.4; sys_platform == 'win32' -zipp>=3.23.1 +# zipp 4.1.0 drops Python 3.9; keep the last 3.9-compatible release there. +zipp>=3.23.1,<4.1.0; python_version < '3.10' +zipp>=3.23.1; python_version >= '3.10' diff --git a/requirements/static/ci/common.txt b/requirements/static/ci/common.txt index aeee2508504d..6f9ec9a480d1 100644 --- a/requirements/static/ci/common.txt +++ b/requirements/static/ci/common.txt @@ -68,12 +68,14 @@ toml # vcert 0.18.x adds hard pins on cryptography, pynacl, and six that # conflict with every other CI requirement; stay on 0.9.x. vcert~=0.9.0; sys_platform != 'win32' -virtualenv>=21.4.2 +virtualenv>=21.4.2,<21.5.1; python_version < '3.10' +virtualenv>=21.4.2; python_version >= '3.10' watchdog>=6.0.0 websocket-client>=1.9.0 # werkzeug is a dependency of moto werkzeug>=3.1.8 -xmldiff>=2.7.0 +xmldiff>=2.7.0,<3.0; python_version < '3.10' +xmldiff>=2.7.0; python_version >= '3.10' # Available template libraries that can be used genshi>=0.7.11 cheetah3>=3.2.6.post1 diff --git a/requirements/static/pkg/freebsd.txt b/requirements/static/pkg/freebsd.txt index 65a3c076fad3..93cb3f61ea14 100644 --- a/requirements/static/pkg/freebsd.txt +++ b/requirements/static/pkg/freebsd.txt @@ -6,7 +6,8 @@ cryptography>=46.0.7,<48.0.0; python_version < '3.10' cryptography>=48.0.0; python_version >= '3.10' pycparser>=2.23; python_version < '3.10' pycparser>=3.0; python_version >= '3.10' -pyopenssl>=26.2.0 +pyopenssl>=26.2.0,<26.3.0; python_version < '3.10' +pyopenssl>=26.2.0; python_version >= '3.10' python-dateutil>=2.9.0.post0 python-gnupg>=0.5.6 setproctitle>=1.3.7 diff --git a/requirements/static/pkg/linux.txt b/requirements/static/pkg/linux.txt index d8c65a5a76c1..66f04fb284bf 100644 --- a/requirements/static/pkg/linux.txt +++ b/requirements/static/pkg/linux.txt @@ -7,7 +7,8 @@ cherrypy>=18.10.0 cheroot>=11.1.2 pycparser>=2.23; python_version < '3.10' pycparser>=3.0; python_version >= '3.10' -pyopenssl>=26.2.0 +pyopenssl>=26.2.0,<26.3.0; python_version < '3.10' +pyopenssl>=26.2.0; python_version >= '3.10' python-dateutil>=2.9.0.post0 python-gnupg>=0.5.6 rpm-vercmp From 0c74a593e4a30d24aee7981ba3588378fb143f49 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 14:51:46 -0700 Subject: [PATCH 045/469] Fix PrintableDict folding long multi-line strings across YAML lines (#69663) The #30690 rework switched PrintableDict to emit string values containing newlines as YAML double-quoted scalars via yaml.safe_dump(). safe_dump() folds double-quoted scalars at ~80 columns by default, so any multi-line string longer than that got real newlines inserted mid-scalar. Once the folded representation was Jinja-interpolated inside a YAML block scalar (e.g. `- contents: | \n {{ data }}`), the folded continuation line dedented below the block scalar indentation and rendering failed with "could not find expected ':'". Pass width=2**31-1 to safe_dump() so the emitted scalar always stays on a single physical line, preserving the intent of the #30690 fix and letting the value round-trip through the YAML parser as originally. Fixes #69658 --- changelog/69658.fixed.md | 4 +++ salt/utils/jinja.py | 12 ++++++-- tests/pytests/unit/utils/jinja/test_jinja.py | 30 +++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 changelog/69658.fixed.md diff --git a/changelog/69658.fixed.md b/changelog/69658.fixed.md new file mode 100644 index 000000000000..f301c12b54c3 --- /dev/null +++ b/changelog/69658.fixed.md @@ -0,0 +1,4 @@ +Fixed SLS rendering failure when a Jinja-interpolated ``PrintableDict`` value +contained a multi-line string longer than ~80 columns inside a YAML block +scalar. The YAML double-quoted scalar emitted for such values is no longer +folded across physical lines. diff --git a/salt/utils/jinja.py b/salt/utils/jinja.py index 6c4ff21e067d..3686c237288b 100644 --- a/salt/utils/jinja.py +++ b/salt/utils/jinja.py @@ -245,9 +245,17 @@ def _yaml_safe_repr(value): # safe_dump always emits a trailing newline; strip it. default_style='"' # forces a double-quoted scalar which encodes newlines as the YAML \n # escape sequence that the YAML parser will decode back to a real - # newline. + # newline. width=2**31-1 disables PyYAML's default line-folding at + # ~80 columns; folding would introduce real newlines inside the + # scalar, which breaks YAML block-scalar interpolation via Jinja + # (see issue #69658). return ( - salt.utils.yaml.safe_dump(value, default_style='"', default_flow_style=True) + salt.utils.yaml.safe_dump( + value, + default_style='"', + default_flow_style=True, + width=2**31 - 1, + ) .rstrip("\n") .rstrip("...") .rstrip("\n") diff --git a/tests/pytests/unit/utils/jinja/test_jinja.py b/tests/pytests/unit/utils/jinja/test_jinja.py index 9e1b33c2ff0e..0b6e04fd6020 100644 --- a/tests/pytests/unit/utils/jinja/test_jinja.py +++ b/tests/pytests/unit/utils/jinja/test_jinja.py @@ -3,7 +3,7 @@ """ import salt.utils.dateutils # pylint: disable=unused-import -from salt.utils.jinja import Markup, indent, tojson +from salt.utils.jinja import Markup, PrintableDict, indent, tojson def test_tojson(): @@ -38,3 +38,31 @@ def test_tojson_should_ascii_sort_keys_when_told(): actual = tojson(data, sort_keys=True) assert actual == expected + + +def test_printabledict_long_multiline_str_not_folded_issue_69658(): + """ + Regression test for issue #69658. + + ``PrintableDict.__str__`` emits string values containing newlines as + YAML double-quoted scalars via ``yaml.safe_dump()`` (see #30690). + ``safe_dump()`` folds double-quoted scalars at ~80 columns by default, + which inserts real newlines into the emitted scalar. When the resulting + representation is interpolated into a YAML state file via Jinja inside + a ``|``/``|-`` block scalar, the folded continuation lines break the + document and rendering fails with ``could not find expected ':'``. + + The emitted representation must therefore stay on a single physical + line even for long strings so it can be safely interpolated inside a + block scalar. + """ + long_value = ( + "ServerName my-very-long-hostname.example.com and more words " + "to exceed eighty columns\n" + "ServerAlias alias.example.com\n" + ) + rendered = str(PrintableDict({"conf": long_value})) + # The rendered dict must be a single physical line: any newline + # inside it would be a fold-point that breaks YAML block-scalar + # interpolation. + assert "\n" not in rendered, rendered From 5d3092961082b9317d7518632e14a58c0d47a44c Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 14:52:17 -0700 Subject: [PATCH 046/469] Fix stale session key in master cluster with shared cachedir (#69673) In a Salt Master Cluster with a shared cachedir (e.g. GlusterFS), each master keeps its own in-memory ``sessions`` cache but the ``sessions/`` file is shared on disk. When a peer master rotated the shared key file, the current master's ``session_key`` kept returning the stale in-memory key for the remainder of its ``publish_session`` window because the "cache is still fresh" short-circuit did not check the on-disk mtime. Minions that had authenticated against a peer (and therefore held the new key) then failed to decrypt request-server replies with ``salt.exceptions.AuthenticationError: message authentication failed`` -- typically observed roughly every ``publish_session`` (24h) after a master restart. Stat the shared file on the memory-cache-hit branch of ``session_key`` and invalidate the cached entry when the on-disk mtime is newer than what we cached. Fall through to the existing read-from-disk path, which repopulates the cache with the fresh tuple. The stat only runs on cache hits, so the single-master fast path is essentially unchanged. The helper is duplicated across ``ReqServerChannel``, ``MessageServerChannel`` and ``AuthFuncs``; all three copies are patched identically. Fixes #69193 --- changelog/69193.fixed.md | 1 + salt/channel/server.py | 30 +++++++++++-- salt/master.py | 15 ++++++- .../channel/test_req_server_channel.py | 42 +++++++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 changelog/69193.fixed.md diff --git a/changelog/69193.fixed.md b/changelog/69193.fixed.md new file mode 100644 index 000000000000..6691c3aec6d5 --- /dev/null +++ b/changelog/69193.fixed.md @@ -0,0 +1 @@ +Fixed `salt.exceptions.AuthenticationError: message authentication failed` errors seen roughly every `publish_session` interval on minions in a Salt Master Cluster with a shared cachedir (e.g. GlusterFS). Each master's in-memory `sessions` cache is now invalidated when a peer master rotates the shared `sessions/` file, so the request-server no longer serves stale session keys after another master has rotated them on disk. diff --git a/salt/channel/server.py b/salt/channel/server.py index 4dd55d8400dd..f42e85210de4 100644 --- a/salt/channel/server.py +++ b/salt/channel/server.py @@ -211,11 +211,22 @@ def session_key(self, minion): Returns a session key for the given minion id. """ now = time.time() + path = pathlib.Path(self.opts["cachedir"]) / "sessions" / minion if minion in self.sessions: if now - self.sessions[minion][0] < self.opts["publish_session"]: - return self.sessions[minion][1] + # Master cluster deployments share ``sessions/`` + # on a shared filesystem so a peer master's rotation must + # invalidate our in-memory cache. Comparing the file + # mtime against the mtime we cached catches that case + # without penalising the single-master fast path -- the + # ``stat`` is cheap and only runs on cache hits. + try: + disk_mtime = path.stat().st_mtime + except FileNotFoundError: + disk_mtime = None + if disk_mtime is not None and disk_mtime <= self.sessions[minion][0]: + return self.sessions[minion][1] - path = pathlib.Path(self.opts["cachedir"]) / "sessions" / minion try: if now - path.stat().st_mtime > self.opts["publish_session"]: salt.crypt.Crypticle.write_key(path) @@ -771,11 +782,22 @@ def session_key(self, minion): Returns a session key for the given minion id. """ now = time.time() + path = pathlib.Path(self.opts["cachedir"]) / "sessions" / minion if minion in self.sessions: if now - self.sessions[minion][0] < self.opts["publish_session"]: - return self.sessions[minion][1] + # Master cluster deployments share ``sessions/`` + # on a shared filesystem so a peer master's rotation must + # invalidate our in-memory cache. Comparing the file + # mtime against the mtime we cached catches that case + # without penalising the single-master fast path -- the + # ``stat`` is cheap and only runs on cache hits. + try: + disk_mtime = path.stat().st_mtime + except FileNotFoundError: + disk_mtime = None + if disk_mtime is not None and disk_mtime <= self.sessions[minion][0]: + return self.sessions[minion][1] - path = pathlib.Path(self.opts["cachedir"]) / "sessions" / minion try: if now - path.stat().st_mtime > self.opts["publish_session"]: salt.crypt.Crypticle.write_key(path) diff --git a/salt/master.py b/salt/master.py index 4c17d5a8c8f1..ba71d1df6e44 100644 --- a/salt/master.py +++ b/salt/master.py @@ -3109,11 +3109,22 @@ def session_key(self, minion): Returns a session key for the given minion id. """ now = time.time() + path = pathlib.Path(self.opts["cachedir"]) / "sessions" / minion if minion in self.sessions: if now - self.sessions[minion][0] < self.opts["publish_session"]: - return self.sessions[minion][1] + # Master cluster deployments share ``sessions/`` + # on a shared filesystem so a peer master's rotation must + # invalidate our in-memory cache. Comparing the file + # mtime against the mtime we cached catches that case + # without penalising the single-master fast path -- the + # ``stat`` is cheap and only runs on cache hits. + try: + disk_mtime = path.stat().st_mtime + except FileNotFoundError: + disk_mtime = None + if disk_mtime is not None and disk_mtime <= self.sessions[minion][0]: + return self.sessions[minion][1] - path = pathlib.Path(self.opts["cachedir"]) / "sessions" / minion try: if now - path.stat().st_mtime > self.opts["publish_session"]: salt.crypt.Crypticle.write_key(path) diff --git a/tests/pytests/functional/channel/test_req_server_channel.py b/tests/pytests/functional/channel/test_req_server_channel.py index cdc00c4b44ac..694e0ab4ada2 100644 --- a/tests/pytests/functional/channel/test_req_server_channel.py +++ b/tests/pytests/functional/channel/test_req_server_channel.py @@ -21,6 +21,7 @@ import salt.channel.server import salt.crypt import salt.master +import salt.utils.files import salt.utils.stringutils log = logging.getLogger(__name__) @@ -273,6 +274,47 @@ def test_session_keys_are_unique_per_minion(req_server): assert len({a, b, c}) == 3 +def test_session_key_refreshes_when_peer_master_rotated_file(req_server): + """ + Regression test for #69193. + + In a master cluster (shared PKI + cachedir on a shared filesystem + such as GlusterFS), each master keeps its own ``self.sessions`` + in-memory cache but the ``sessions/`` file is shared. When + peer master ``B`` rotates the on-disk key, master ``A``'s cached + ``(mtime, key)`` entry becomes stale but the "cache is still fresh" + check ``now - self.sessions[minion][0] < publish_session`` continues + to serve the old key -- causing minions that authenticated against + ``B`` (and therefore hold the new key) to fail decryption of + request-server replies from ``A`` with + ``salt.exceptions.AuthenticationError: message authentication + failed``. + + ``session_key`` must invalidate the in-memory cache when the file + mtime on disk is newer than the mtime we cached, and re-read the + fresh key from disk. + """ + original = req_server.session_key("minionA") + path = pathlib.Path(req_server.opts["cachedir"]) / "sessions" / "minionA" + cached_mtime = req_server.sessions["minionA"][0] + + # Simulate a peer master rotating the file: overwrite the on-disk + # key with a different value and bump its mtime forward. Do NOT + # touch the in-memory cache -- that's what the buggy master would + # keep serving. + new_key = salt.crypt.Crypticle.generate_key_string() + assert new_key != original + with salt.utils.files.fopen(path, "w") as fp: + fp.write(new_key) + newer = cached_mtime + 5 + os.utime(path, (newer, newer)) + + assert req_server.session_key("minionA") == new_key + # And the in-memory cache is now aligned with the on-disk value. + assert req_server.sessions["minionA"][1] == new_key + assert req_server.sessions["minionA"][0] == newer + + async def test_handle_message_rejects_non_dict(req_server, io_loop): """ A non-dict payload must be rejected with the standard ``bad From cb098940aadc61086d7e2e589a4580a8fb693513 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 14:52:37 -0700 Subject: [PATCH 047/469] Fix HighState/State leaking fileclient on init failure (#69637) (#69675) When ``HighState.__init__`` or ``State.__init__`` raised after allocating their fileclient (e.g. ``BaseHighState.__init__`` failing during ``master_opts()``, or ``_gather_pillar()`` failing during pillar compilation), the caller never received the instance and therefore never called ``.destroy()``. The fileclient's ZeroMQ ``RequestClient`` was finalized during garbage collection with ``_closing = False``, tripping the ``TransportWarning: Unclosed transport!`` warning that PR #65559 added. Wrap both constructors' post-allocation bodies in ``try/except`` that destroys the freshly-allocated fileclient before re-raising. Also close the temporary ``Pillar`` object built by ``State._gather_pillar()`` in a ``try/finally`` so its channel doesn't rely on ``__del__`` ordering at shutdown. Fixes #69637 --- changelog/69637.fixed.md | 1 + salt/state.py | 150 +++++++++++++----- .../state/test_highstate_transport_cleanup.py | 119 ++++++++++++++ 3 files changed, 226 insertions(+), 44 deletions(-) create mode 100644 changelog/69637.fixed.md create mode 100644 tests/pytests/unit/state/test_highstate_transport_cleanup.py diff --git a/changelog/69637.fixed.md b/changelog/69637.fixed.md new file mode 100644 index 000000000000..351245299a85 --- /dev/null +++ b/changelog/69637.fixed.md @@ -0,0 +1 @@ +Fixed `HighState` and `State` init leaking their fileclient (and its ZeroMQ transport) when a later step in the constructor raises, which produced `TransportWarning: Unclosed transport!` messages during `salt-call state.apply`. diff --git a/salt/state.py b/salt/state.py index b26d8b337694..5a2829d2c054 100644 --- a/salt/state.py +++ b/salt/state.py @@ -791,36 +791,45 @@ def __init__( else: self.file_client = salt.fileclient.get_file_client(self.opts) self.preserve_file_client = False - self.proxy = proxy - self._pillar_override = pillar_override - if pillar_enc is not None: - try: - pillar_enc = pillar_enc.lower() - except AttributeError: - pillar_enc = str(pillar_enc).lower() - self._pillar_enc = pillar_enc - log.debug("Gathering pillar data for state run") - if initial_pillar and not self._pillar_override: - self.opts["pillar"] = initial_pillar - else: - # Compile pillar data - self.opts["pillar"] = self._gather_pillar() - # Reapply overrides on top of compiled pillar - if self._pillar_override: - self.opts["pillar"] = salt.utils.dictupdate.merge( - self.opts["pillar"], - self._pillar_override, - self.opts.get("pillar_source_merging_strategy", "smart"), - self.opts.get("renderer", "yaml"), - self.opts.get("pillar_merge_lists", False), - ) - log.debug("Finished gathering pillar data for state run") - if context is None: - self.state_con = {} - else: - self.state_con = context - self.state_con["fileclient"] = self.file_client - self.load_modules() + # If any of the calls below raise, destroy the file client we just + # allocated so its transport doesn't get finalized without close() + # (issue #69637 -- ``Unclosed transport!`` TransportWarning during + # interpreter shutdown). + try: + self.proxy = proxy + self._pillar_override = pillar_override + if pillar_enc is not None: + try: + pillar_enc = pillar_enc.lower() + except AttributeError: + pillar_enc = str(pillar_enc).lower() + self._pillar_enc = pillar_enc + log.debug("Gathering pillar data for state run") + if initial_pillar and not self._pillar_override: + self.opts["pillar"] = initial_pillar + else: + # Compile pillar data + self.opts["pillar"] = self._gather_pillar() + # Reapply overrides on top of compiled pillar + if self._pillar_override: + self.opts["pillar"] = salt.utils.dictupdate.merge( + self.opts["pillar"], + self._pillar_override, + self.opts.get("pillar_source_merging_strategy", "smart"), + self.opts.get("renderer", "yaml"), + self.opts.get("pillar_merge_lists", False), + ) + log.debug("Finished gathering pillar data for state run") + if context is None: + self.state_con = {} + else: + self.state_con = context + self.state_con["fileclient"] = self.file_client + self.load_modules() + except Exception: + if not self.preserve_file_client: + self._destroy_fileclient_on_init_failure() + raise self.active = set() self.mod_init = set() self.pre = {} @@ -834,6 +843,31 @@ def __init__( # Fix for Issue #30971: Track processed SLS files to handle empty SLS files self._processed_sls_files = set() + def _destroy_fileclient_on_init_failure(self): + """ + Best-effort teardown for ``self.file_client`` when the constructor is + unwinding due to an exception (issue #69637). + + ``RemoteClient`` exposes ``destroy()``; ``FSChan`` / older fileclients + expose ``close()``. Swallow errors -- the caller re-raises the + original exception. + """ + try: + file_client = self.file_client + except AttributeError: + return + try: + teardown = getattr(file_client, "destroy", None) + if teardown is None: + teardown = getattr(file_client, "close", None) + if teardown is not None: + teardown() + except Exception: # pylint: disable=broad-except + log.debug( + "Error while destroying State file client after failed init", + exc_info=True, + ) + def _match_global_state_conditions(self, full, state, name): """ Return ``None`` if global state conditions are met. Otherwise, pass a @@ -914,7 +948,19 @@ def _gather_pillar(self): pillar_override=self._pillar_override, pillarenv=self.opts.get("pillarenv"), ) - return pillar.compile_pillar() + try: + return pillar.compile_pillar() + finally: + # Explicitly release the pillar's channel/transport. Relying on + # ``__del__`` for cleanup during interpreter shutdown can trip + # ``Unclosed transport!`` warnings (see #69637) because the + # transport may be finalized before the pillar or its channel. + destroy = getattr(pillar, "destroy", None) + if destroy is not None: + try: + destroy() + except Exception: # pylint: disable=broad-except + log.debug("Error while destroying pillar", exc_info=True) def _mod_init(self, low): """ @@ -5067,19 +5113,35 @@ def __init__( else: self.client = salt.fileclient.get_file_client(self.opts) self.preserve_client = False - BaseHighState.__init__(self, opts) - self.state = State( - self.opts, - pillar_override, - jid, - pillar_enc, - proxy=proxy, - context=context, - mocked=mocked, - loader=loader, - initial_pillar=initial_pillar, - file_client=self.client, - ) + # If any of the calls below raise, destroy the file client we just + # allocated so its transport doesn't get finalized without close() + # (issue #69637 -- ``Unclosed transport!`` TransportWarning during + # interpreter shutdown). + try: + BaseHighState.__init__(self, opts) + self.state = State( + self.opts, + pillar_override, + jid, + pillar_enc, + proxy=proxy, + context=context, + mocked=mocked, + loader=loader, + initial_pillar=initial_pillar, + file_client=self.client, + ) + except Exception: + if not self.preserve_client: + try: + self.client.destroy() + except Exception: # pylint: disable=broad-except + log.debug( + "Error while destroying HighState file client after " + "failed init", + exc_info=True, + ) + raise self.matchers = salt.loader.matchers(self.opts) self.proxy = proxy diff --git a/tests/pytests/unit/state/test_highstate_transport_cleanup.py b/tests/pytests/unit/state/test_highstate_transport_cleanup.py new file mode 100644 index 000000000000..f41aa25a8ab9 --- /dev/null +++ b/tests/pytests/unit/state/test_highstate_transport_cleanup.py @@ -0,0 +1,119 @@ +""" +Regression tests for issue #69637. + +If ``HighState.__init__`` (or ``State.__init__``) allocates a fileclient and +then a later step in the same constructor raises, the caller never gets the +``HighState``/``State`` instance and therefore never calls ``destroy()``. The +allocated fileclient's transport is finalized during garbage collection with +``_closing = False``, which trips the ``TransportWarning: Unclosed +transport!`` warning added by PR #65559. + +These tests exercise the failure path and assert that the fileclient's +``destroy()`` is invoked before the exception propagates. +""" + +import pytest + +import salt.state +from tests.support import mock + +pytestmark = [ + pytest.mark.core_test, +] + + +@pytest.fixture +def minimal_opts(tmp_path): + return { + "id": "test-minion", + "__role": "minion", + "cachedir": str(tmp_path / "cache"), + "extension_modules": str(tmp_path / "ext_mods"), + "file_client": "remote", + "file_roots": {"base": []}, + "pillar_roots": {"base": []}, + "state_top": "salt://top.sls", + "renderer": "yaml_jinja", + "renderer_whitelist": [], + "renderer_blacklist": [], + "grains": {}, + "pillar": {}, + "pillarenv": None, + "saltenv": "base", + "state_events": False, + "state_verbose": True, + "pillar_cache": False, + "master_type": "str", + "master": "127.0.0.1", + "master_uri": "tcp://127.0.0.1:44506", + "transport": "zeromq", + } + + +def test_highstate_init_failure_destroys_fileclient(minimal_opts): + """ + If ``BaseHighState.__init__`` (called from ``HighState.__init__``) raises, + the fileclient allocated seconds earlier must be destroyed rather than + leaked to garbage collection. + + Regression test for issue #69637. + """ + mock_client = mock.MagicMock() + with mock.patch( + "salt.fileclient.get_file_client", return_value=mock_client + ), mock.patch.object( + salt.state.BaseHighState, "__init__", side_effect=RuntimeError("boom") + ): + with pytest.raises(RuntimeError, match="boom"): + salt.state.HighState(minimal_opts) + mock_client.destroy.assert_called_once() + + +def test_highstate_init_success_does_not_destroy_fileclient(minimal_opts): + """ + In the success case the fileclient must remain owned by the HighState so + that ``HighState.destroy()`` can close it later. This test guards the + happy path so the exception-safety change doesn't accidentally double- + destroy. + """ + mock_client = mock.MagicMock() + with mock.patch( + "salt.fileclient.get_file_client", return_value=mock_client + ), mock.patch.object( + salt.state.BaseHighState, "__init__", return_value=None + ), mock.patch.object( + salt.state, "State", return_value=mock.MagicMock() + ), mock.patch( + "salt.loader.matchers" + ): + hs = salt.state.HighState(minimal_opts) + # Constructor succeeded — the client is now owned by hs. + assert hs.client is mock_client + assert hs.preserve_client is False + mock_client.destroy.assert_not_called() + # Explicit destroy still works. + hs.destroy() + mock_client.destroy.assert_called_once() + + +def test_state_init_failure_destroys_fileclient(minimal_opts): + """ + If ``State.__init__`` raises after allocating a fileclient (e.g. during + pillar rendering), that fileclient must be destroyed. + + Regression test for issue #69637. + """ + mock_client = mock.MagicMock() + with mock.patch( + "salt.fileclient.get_file_client", return_value=mock_client + ), mock.patch.object( + salt.state.State, + "_gather_pillar", + side_effect=RuntimeError("pillar boom"), + ): + with pytest.raises(RuntimeError, match="pillar boom"): + salt.state.State(minimal_opts) + # State prefers destroy() but falls back to close() if not available. + assert ( + mock_client.destroy.called or mock_client.close.called + ), "State did not tear down its fileclient after init failure" From ba1318795f7ca4f913c501d723206eef831ac0a1 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 15:07:21 -0700 Subject: [PATCH 048/469] Fix salt.utils.systemd for Python 3.6 salt-ssh targets (#69677) salt-ssh's thin advertises Python 3.0+ as a supported target interpreter (see salt/utils/thin.py _get_supported_py_config, which writes py3:3:0 into the thin's supported-versions manifest), which means salt/utils/systemd.py must remain importable and callable on Python 3.6 targets. Two call sites in that module use subprocess.run(..., capture_output=True); capture_output was added in Python 3.7, so those sites raise TypeError the moment they execute on a 3.6 remote -- which breaks systemd status probing and PID-to-service lookup on stock RHEL 8 salt-ssh targets. Rewrite both call sites to use the equivalent stdout=subprocess.PIPE, stderr=subprocess.PIPE form. Behaviour on Python 3.7+ is identical. pyupgrade's --py310-plus rule unconditionally rewrites the stdout=/stderr=PIPE form back to capture_output=True, so add salt/utils/systemd.py to the pyupgrade exclude list in .pre-commit-config.yaml alongside the existing ssh_py_shim.py entry. Regression tests assert both call sites are invoked without the capture_output kwarg and with explicit stdout=/stderr=PIPE. Fixes #68778 --- .pre-commit-config.yaml | 7 +++++ changelog/68778.fixed.md | 1 + salt/utils/systemd.py | 14 ++++++++-- tests/unit/utils/test_systemd.py | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 changelog/68778.fixed.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 303673ee548b..c6feb81ed0ef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2244,6 +2244,11 @@ repos: - id: pyupgrade name: Upgrade code to Py3.10+ args: [--py310-plus, --keep-mock] + # salt/utils/systemd.py is bundled in the salt-ssh thin (which + # advertises 3.0+ target Pythons via salt/utils/thin.py py3:3:0), + # so it must stay importable/callable on Python 3.6 targets. + # pyupgrade's --py310-plus rewrites stdout=/stderr=PIPE to + # capture_output=True, which is 3.7+; exclude it here. See #68778. exclude: > (?x)^( salt/client/ssh/ssh_py_shim.py @@ -2251,6 +2256,8 @@ repos: salt/client/ssh/wrapper/pillar.py | salt/ext/.*\.py + | + salt/utils/systemd.py )$ - repo: https://github.com/saltstack/pre-commit-remove-import-headers diff --git a/changelog/68778.fixed.md b/changelog/68778.fixed.md new file mode 100644 index 000000000000..7caaca6f062e --- /dev/null +++ b/changelog/68778.fixed.md @@ -0,0 +1 @@ +Fixed `salt.utils.systemd` using `subprocess.run(capture_output=True)`, which is Python 3.7+, so the module remains importable and callable on the Python 3.6 targets that salt-ssh's thin still advertises support for. Replaced with the equivalent `stdout=subprocess.PIPE`/`stderr=subprocess.PIPE` form in `status()` and `_pid_to_service_systemctl()`. diff --git a/salt/utils/systemd.py b/salt/utils/systemd.py index df7509cd4378..45247d332d29 100644 --- a/salt/utils/systemd.py +++ b/salt/utils/systemd.py @@ -90,10 +90,17 @@ def status(context=None): return context[contextkey] elif context is not None: raise SaltInvocationError("context must be a dictionary if passed") + # Use stdout=/stderr=PIPE rather than capture_output so this module + # remains importable/callable on the Python 3.6 interpreters that + # salt-ssh still advertises as supported target Pythons (see + # salt/utils/thin.py py3:3:0 and #68778). capture_output is 3.7+. + # NOTE: this file is excluded from pyupgrade in .pre-commit-config.yaml + # so that the rewrite back to capture_output=True is suppressed. proc = subprocess.run( ["systemctl", "status"], check=False, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, ) ret = ( b"Failed to get D-Bus connection: No such file or directory" not in proc.stderr @@ -175,7 +182,10 @@ def _pid_to_service_systemctl(pid): systemd_cmd, check=True, text=True, - capture_output=True, + # See status() above: capture_output is 3.7+, but salt-ssh's + # thin advertises 3.0+ as a supported target Python (#68778). + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, ) status_json = salt.utils.json.find_json(systemd_output.stdout) except (ValueError, subprocess.CalledProcessError): diff --git a/tests/unit/utils/test_systemd.py b/tests/unit/utils/test_systemd.py index a68aa186d6a3..ca6b030acd43 100644 --- a/tests/unit/utils/test_systemd.py +++ b/tests/unit/utils/test_systemd.py @@ -362,3 +362,48 @@ class DBusException(Exception): dbus_mock.GetUnitByPID = Mock(site_effect=dbus_mock.DBusException) with patch("salt.utils.systemd.dbus", dbus_mock): assert _systemd.pid_to_service(99999) is None + + def test_status_does_not_use_capture_output_kwarg(self): + """ + Regression test for #68778. + + salt-ssh's thin advertises Python 3.0+ as a supported target + interpreter (see ``salt/utils/thin.py`` ``py3:3:0``), which means + ``salt.utils.systemd`` must import and run on Python 3.6 targets + (e.g. stock RHEL 8 system Python). ``subprocess.run``'s + ``capture_output`` keyword was added in Python 3.7, so any call + site that passes it raises ``TypeError`` on 3.6. Assert that + ``status()`` uses the equivalent ``stdout=PIPE, stderr=PIPE`` + form instead of ``capture_output=True``. + """ + run_mock = Mock(return_value=Mock(stderr=b"")) + with patch("salt.utils.systemd.subprocess.run", run_mock): + _systemd.status({}) + run_mock.assert_called_once() + _, kwargs = run_mock.call_args + assert "capture_output" not in kwargs, ( + "salt.utils.systemd.status() must not use capture_output=; " + "it is Python 3.7+ only and salt-ssh targets can run 3.6." + ) + assert kwargs.get("stdout") is subprocess.PIPE + assert kwargs.get("stderr") is subprocess.PIPE + + @patch("salt.utils.systemd.dbus", False) + def test_pid_to_service_systemctl_does_not_use_capture_output_kwarg(self): + """ + Regression test for #68778. See ``test_status_does_not_use_capture_output_kwarg`` + for background — the same constraint applies to + ``_pid_to_service_systemctl``. + """ + run_mock = Mock(return_value=Mock(stdout='{"_SYSTEMD_UNIT":"foo.service"}')) + with patch("salt.utils.systemd.subprocess.run", run_mock): + _systemd.pid_to_service(1234) + run_mock.assert_called_once() + _, kwargs = run_mock.call_args + assert "capture_output" not in kwargs, ( + "salt.utils.systemd._pid_to_service_systemctl() must not use " + "capture_output=; it is Python 3.7+ only and salt-ssh targets " + "can run 3.6." + ) + assert kwargs.get("stdout") is subprocess.PIPE + assert kwargs.get("stderr") is subprocess.PIPE From 27088da0568e09e4f4e59df6cbd9e76ebd19cc7c Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 15:07:36 -0700 Subject: [PATCH 049/469] Revoke Salt eauth token on salt-api Logout (#69672) `Logout.POST` in `salt/netapi/rest_cherrypy/app.py` only expired the CherryPy session cookie and regenerated the server-side session id: def POST(self): cherrypy.lib.sessions.expire() # set client-side to expire cherrypy.session.regenerate() # replace server-side with new return {"return": "Your token has been cleared"} The underlying Salt eauth token -- the value stored in the configured `eauth_tokens` backend (localfs / redis / rediscluster) and what the user actually authenticates with via `X-Auth-Token: ` -- was never revoked. The token outlived the cookie by `token_expire` (12 hours by default) and any party that had observed the token (logs, shell history, leaked cookie) could keep using it as a bearer credential until natural expiry, even after the user explicitly logged out. Read the session token before expiring the cookie and call `salt.auth.LoadAuth(self.opts).rm_token(salt_token)` to invalidate it in the backend. If the backend is unreachable (Redis down, NFS hung, etc.), log the failure and continue -- the cookie still gets expired, so the user-visible flow always succeeds. Operators see the failure in the master log and can investigate; tokens that linger because of a backend outage will still expire on schedule. Add three behavioural tests in `tests/pytests/unit/netapi/cherrypy/ test_logout.py`: - `test_logout_revokes_salt_token_via_loadauth` -- headline regression. - `test_logout_skips_rm_token_when_no_session_token` -- no token in session means nothing to revoke; do not crash. - `test_logout_completes_when_token_backend_raises` -- cookie still expires and POST returns success when the backend errors. Fixes #69067 Co-authored-by: co-cy --- changelog/69067.fixed.md | 12 +++ salt/netapi/rest_cherrypy/app.py | 27 +++++- .../unit/netapi/cherrypy/test_logout.py | 94 +++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 changelog/69067.fixed.md create mode 100644 tests/pytests/unit/netapi/cherrypy/test_logout.py diff --git a/changelog/69067.fixed.md b/changelog/69067.fixed.md new file mode 100644 index 000000000000..c2a78ab4ff7a --- /dev/null +++ b/changelog/69067.fixed.md @@ -0,0 +1,12 @@ +Fixed `salt-api`'s `Logout` endpoint not revoking the underlying Salt +eauth token. `Logout.POST` only expired the CherryPy session cookie +and regenerated the server-side session id, leaving the Salt token in +the configured `eauth_tokens` backend (localfs/redis/etc.) valid until +its `token_expire` (12 hours by default). Anyone who had observed the +token value could keep using it as a bearer credential through +`X-Auth-Token: ` even after the user thought they had logged +out. The endpoint now calls `salt.auth.LoadAuth(self.opts).rm_token` +on the session token before expiring the cookie, so logout actually +invalidates the bearer credential. If the token backend is +unreachable the failure is logged and the cookie is still expired, +so the user-visible logout flow always completes. diff --git a/salt/netapi/rest_cherrypy/app.py b/salt/netapi/rest_cherrypy/app.py index ca3d85cd2fc0..00d5fc249814 100644 --- a/salt/netapi/rest_cherrypy/app.py +++ b/salt/netapi/rest_cherrypy/app.py @@ -1954,8 +1954,33 @@ class Logout(LowDataAdapter): def POST(self): # pylint: disable=arguments-differ """ - Destroy the currently active session and expire the session cookie + Destroy the currently active session, expire the session cookie, + and revoke the underlying Salt eauth token so the bearer + credential cannot be re-used until ``token_expire`` has elapsed. """ + # Revoke the Salt eauth token. ``cherrypy.lib.sessions.expire()`` + # below only clears the browser cookie and the server-side + # CherryPy session; the Salt token in the configured + # ``eauth_tokens`` backend (localfs/redis/etc.) outlives both by + # ``token_expire`` (12h by default), and any party that has + # observed the token value can keep using it as a bearer + # credential until then. + salt_token = cherrypy.session.get("token") + if salt_token: + try: + salt.auth.LoadAuth(self.opts).rm_token(salt_token) + except Exception: # pylint: disable=broad-except + # If the token backend is unreachable (e.g. Redis down) + # finish the logout from the client's point of view + # anyway -- the cookie still gets expired below. The + # operator sees the failure in the master log and can + # investigate. + logger.exception( + "Logout: failed to revoke Salt eauth token; " + "the cookie has been expired but the token may " + "still be valid in the eauth_tokens backend until " + "its expiry." + ) cherrypy.lib.sessions.expire() # set client-side to expire cherrypy.session.regenerate() # replace server-side with new diff --git a/tests/pytests/unit/netapi/cherrypy/test_logout.py b/tests/pytests/unit/netapi/cherrypy/test_logout.py new file mode 100644 index 000000000000..a1d91a2c6a20 --- /dev/null +++ b/tests/pytests/unit/netapi/cherrypy/test_logout.py @@ -0,0 +1,94 @@ +from types import SimpleNamespace + +import pytest + +import salt.netapi.rest_cherrypy.app as cherrypy_app +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return {cherrypy_app: {}} + + +class _MockNetapiClient: + """Stand-in for ``salt.netapi.NetapiClient`` so ``LowDataAdapter`` + can be instantiated under unit-test conditions without trying to + bring up the real client (which expects a populated ``opts`` dict).""" + + def __init__(self, *args, **kwargs): + pass + + +def _build_cherrypy_mock(session_token="cafebabe"): + """Build a minimal ``cherrypy`` stand-in that records calls so tests + can assert on ``cherrypy.lib.sessions.expire()`` and on the value the + handler reads from ``cherrypy.session``.""" + sessions = SimpleNamespace(expire=MagicMock(name="sessions.expire")) + session = MagicMock(name="session") + session.get = MagicMock( + side_effect=lambda key, default=None: {"token": session_token}.get(key, default) + ) + session.regenerate = MagicMock(name="session.regenerate") + + return SimpleNamespace( + config={"saltopts": {}, "apiopts": {}}, + session=session, + lib=SimpleNamespace(sessions=sessions), + ) + + +def test_logout_revokes_salt_token_via_loadauth(): + """``Logout.POST`` must call ``LoadAuth.rm_token()`` so + the underlying eauth bearer credential is invalidated; otherwise the + Salt token outlives the cookie by ``token_expire`` (12h default) and + can be replayed by anyone who observed it.""" + cherrypy_mock = _build_cherrypy_mock(session_token="deadbeef") + fake_loadauth = MagicMock(name="LoadAuth_instance") + fake_loadauth_cls = MagicMock(name="LoadAuth_class", return_value=fake_loadauth) + + with patch("salt.netapi.rest_cherrypy.app.cherrypy", cherrypy_mock): + with patch("salt.netapi.NetapiClient", _MockNetapiClient): + with patch("salt.auth.LoadAuth", fake_loadauth_cls): + cherrypy_app.Logout().POST() + + fake_loadauth.rm_token.assert_called_once_with("deadbeef") + cherrypy_mock.lib.sessions.expire.assert_called_once() + cherrypy_mock.session.regenerate.assert_called_once() + + +def test_logout_skips_rm_token_when_no_session_token(): + """If the session has no ``token`` key (already-cleared session, or + never logged in), Logout must not attempt to revoke -- skip cleanly + and expire the cookie regardless.""" + cherrypy_mock = _build_cherrypy_mock() + cherrypy_mock.session.get = MagicMock(return_value=None) + fake_loadauth_cls = MagicMock(name="LoadAuth_class") + + with patch("salt.netapi.rest_cherrypy.app.cherrypy", cherrypy_mock): + with patch("salt.netapi.NetapiClient", _MockNetapiClient): + with patch("salt.auth.LoadAuth", fake_loadauth_cls): + cherrypy_app.Logout().POST() + + fake_loadauth_cls.assert_not_called() + cherrypy_mock.lib.sessions.expire.assert_called_once() + + +def test_logout_completes_when_token_backend_raises(): + """If the eauth_tokens backend is unreachable (e.g. Redis down) and + ``rm_token`` raises, Logout must still expire the cookie and + return success -- the backend failure is logged but does not abort + the user-visible logout flow.""" + cherrypy_mock = _build_cherrypy_mock(session_token="cafebabe") + fake_loadauth = MagicMock(name="LoadAuth_instance") + fake_loadauth.rm_token.side_effect = RuntimeError("redis is down") + fake_loadauth_cls = MagicMock(name="LoadAuth_class", return_value=fake_loadauth) + + with patch("salt.netapi.rest_cherrypy.app.cherrypy", cherrypy_mock): + with patch("salt.netapi.NetapiClient", _MockNetapiClient): + with patch("salt.auth.LoadAuth", fake_loadauth_cls): + result = cherrypy_app.Logout().POST() + + fake_loadauth.rm_token.assert_called_once_with("cafebabe") + cherrypy_mock.lib.sessions.expire.assert_called_once() + assert result == {"return": "Your token has been cleared"} From 57b857375ce15325e35c4bc179a4c5952cf03e42 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 15:07:49 -0700 Subject: [PATCH 050/469] Drop RUNTIME_VARS from thin pytest unit tests (#69668) Replace tests.support.runtests.RUNTIME_VARS references in the salt.utils.thin unit tests with: - tests.conftest.CODE_DIR for the salt repo root / SALT_CODE_DIR paths - pytest's tmp_path fixture for the fake-libs directory - tempfile.gettempdir() for the mocked fake-cwd os.getcwd() return value Addresses s0undt3ch's review feedback on #65373. --- changelog/65373.fixed.md | 1 + tests/pytests/unit/utils/test_thin.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 changelog/65373.fixed.md diff --git a/changelog/65373.fixed.md b/changelog/65373.fixed.md new file mode 100644 index 000000000000..5d30e2a4453b --- /dev/null +++ b/changelog/65373.fixed.md @@ -0,0 +1 @@ +Modernized `tests/pytests/unit/utils/test_thin.py` to use the `tmp_path` fixture and `tests.conftest.CODE_DIR` instead of `RUNTIME_VARS`, addressing review feedback on #65373. diff --git a/tests/pytests/unit/utils/test_thin.py b/tests/pytests/unit/utils/test_thin.py index a0719aa05f49..d505ce00548e 100644 --- a/tests/pytests/unit/utils/test_thin.py +++ b/tests/pytests/unit/utils/test_thin.py @@ -31,9 +31,9 @@ sys.modules["backports"] = backports from salt.utils import thin from salt.utils.stringutils import to_bytes as bts +from tests.conftest import CODE_DIR from tests.support.helpers import TstSuiteLoggingHandler, VirtualEnv from tests.support.mock import MagicMock, patch -from tests.support.runtests import RUNTIME_VARS def patch_if(condition, *args, **kwargs): @@ -50,13 +50,13 @@ def inner(func): class ThinTestContext: - def __init__(self): + def __init__(self, tmp_path): self.jinja_fp = os.path.dirname(jinja2.__file__) self.ext_conf = { "test": { "py-version": [2, 7], - "path": RUNTIME_VARS.SALT_CODE_DIR, + "path": str(CODE_DIR / "salt"), "dependencies": {"jinja2": self.jinja_fp}, } } @@ -68,7 +68,7 @@ def __init__(self): os.path.join("salt", "payload.py"), os.path.join("jinja2", "__init__.py"), ] - lib_root = os.path.join(RUNTIME_VARS.TMP, "fake-libs") + lib_root = str(tmp_path / "fake-libs") self.fake_libs = { "distro": os.path.join(lib_root, "distro"), "jinja2": os.path.join(lib_root, "jinja2"), @@ -77,7 +77,7 @@ def __init__(self): "msgpack": os.path.join(lib_root, "msgpack"), } - code_dir = pathlib.Path(RUNTIME_VARS.CODE_DIR).resolve() + code_dir = CODE_DIR.resolve() self.exp_ret = { "distro": str(code_dir / "distro.py"), "jinja2": str(code_dir / "jinja2"), @@ -108,8 +108,8 @@ def cleanup(self): @pytest.fixture -def thin_ctx(): - ctx = ThinTestContext() +def thin_ctx(tmp_path): + ctx = ThinTestContext(tmp_path) try: yield ctx finally: @@ -944,7 +944,7 @@ def test_gen_thin_control_files_written_py3(thin_ctx): @patch("salt.utils.thin.zipfile", MagicMock()) @patch( "salt.utils.thin.os.getcwd", - MagicMock(return_value=os.path.join(RUNTIME_VARS.TMP, "fake-cwd")), + MagicMock(return_value=os.path.join(tempfile.gettempdir(), "fake-cwd")), ) @patch("salt.utils.thin.os.chdir", MagicMock()) @patch("salt.utils.thin.os.close", MagicMock()) @@ -1012,7 +1012,7 @@ def test_gen_thin_main_content_files_written_py3(thin_ctx): @patch("salt.utils.thin.zipfile", MagicMock()) @patch( "salt.utils.thin.os.getcwd", - MagicMock(return_value=os.path.join(RUNTIME_VARS.TMP, "fake-cwd")), + MagicMock(return_value=os.path.join(tempfile.gettempdir(), "fake-cwd")), ) @patch("salt.utils.thin.os.chdir", MagicMock()) @patch("salt.utils.thin.os.close", MagicMock()) From 526449e93347c4962b4580057e846b8201e17356 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 15:08:15 -0700 Subject: [PATCH 051/469] Fix slack.post_message failing with legacy_custom_bots_deprecated (#69667) Slack deprecated the ability for classic/custom-bot apps to override the sender's display name and icon via chat.postMessage on 2025-03-31. Every request that includes username/icon_url/icon_emoji from such an app is now rejected with legacy_custom_bots_deprecated. The slack_notify module always set parameters["username"] = from_name and forwarded it verbatim in the URL-encoded request body, and from_name was a required positional argument. The state module made the same required-field choice at its own layer. Together these guaranteed the module was 100% broken against modern Slack apps. Make from_name and icon truly optional at both the module and state layers. Only include the deprecated fields in the request body when the caller explicitly supplies them (for back-compat with SLS/CLI callers that expect the values to still round-trip), and log a deprecation warning in that case. See https://api.slack.com/changelog/2024-09-legacy-custom-bots-classic-apps-deprecation Fixes #67948 --- changelog/67948.fixed.md | 1 + salt/modules/slack_notify.py | 56 +++++++++++++---- salt/states/slack.py | 24 ++++--- tests/pytests/unit/modules/test_slack.py | 79 ++++++++++++++++++++++-- tests/pytests/unit/states/test_slack.py | 49 +++++++++++---- 5 files changed, 172 insertions(+), 37 deletions(-) create mode 100644 changelog/67948.fixed.md diff --git a/changelog/67948.fixed.md b/changelog/67948.fixed.md new file mode 100644 index 000000000000..41e21d688ea1 --- /dev/null +++ b/changelog/67948.fixed.md @@ -0,0 +1 @@ +Fixed the `slack.post_message` execution module and state so calls no longer fail with `legacy_custom_bots_deprecated`. The `from_name` and `icon` arguments are now optional and, when omitted, the deprecated `username` / `icon_url` fields are no longer forwarded to Slack's `chat.postMessage` API. Configure the display name and icon in the Slack app settings instead. diff --git a/salt/modules/slack_notify.py b/salt/modules/slack_notify.py index 87ad64108e27..163e8d75ea61 100644 --- a/salt/modules/slack_notify.py +++ b/salt/modules/slack_notify.py @@ -161,7 +161,7 @@ def find_user(name, api_key=None): def post_message( channel, message, - from_name, + from_name=None, api_key=None, icon=None, attachments=None, @@ -173,11 +173,30 @@ def post_message( .. versionchanged:: 3003 Added `attachments` and `blocks` kwargs + .. versionchanged:: 3006.28 + ``from_name`` is now optional. Slack deprecated the ability for + classic/custom-bot apps to override the bot's display name and icon + via the ``chat.postMessage`` API on March 31, 2025 (see + https://api.slack.com/changelog/2024-09-legacy-custom-bots-classic-apps-deprecation). + When ``from_name`` or ``icon`` is provided, Slack now rejects the + request with ``legacy_custom_bots_deprecated``. Omit both to send + with the bot's configured Slack app identity. + :param channel: The channel name, either will work. :param message: The message to send to the Slack channel. - :param from_name: Specify who the message is from. + :param from_name: Deprecated. Formerly the ``username`` override for + the sent message. Slack rejects this for modern + apps; configure the display name in the Slack app + settings instead. Passing this value now logs a + warning and is only forwarded to Slack for + backward compatibility. :param api_key: The Slack api key, if not specified in the configuration. - :param icon: URL to an image to use as the icon for this message + :param icon: Deprecated. Formerly the ``icon_url`` override for + the sent message. Slack rejects this for modern + apps; configure the icon in the Slack app settings + instead. Passing this value now logs a warning and + is only forwarded to Slack for backward + compatibility. :param attachments: Any attachments to be sent with the message. :param blocks: Any blocks to be sent with the message. :return: Boolean if message was sent successfully. @@ -186,7 +205,7 @@ def post_message( .. code-block:: bash - salt '*' slack.post_message channel="Development Room" message="Build is done" from_name="Build Server" + salt '*' slack.post_message channel="Development Room" message="Build is done" """ if not api_key: @@ -206,24 +225,39 @@ def post_message( ) channel = f"#{channel}" - if not from_name: - log.error("from_name is a required option.") - if not message: log.error("message is a required option.") - if not from_name: - log.error("from_name is a required option.") - parameters = { "channel": channel, - "username": from_name, "text": message, "attachments": attachments or [], "blocks": blocks or [], } + # Slack deprecated the ability for classic/custom-bot apps to override + # the display name and icon via ``chat.postMessage`` on 2025-03-31. + # Only include the overrides when the caller explicitly asked for + # them; otherwise Slack rejects the request with + # ``legacy_custom_bots_deprecated``. See issue #67948. + if from_name: + log.warning( + "The 'from_name' argument to slack.post_message is deprecated. " + "Slack no longer accepts a 'username' override on chat.postMessage " + "for modern apps; configure the display name in your Slack app " + "settings instead. See " + "https://api.slack.com/changelog/2024-09-legacy-custom-bots-classic-apps-deprecation" + ) + parameters["username"] = from_name + if icon is not None: + log.warning( + "The 'icon' argument to slack.post_message is deprecated. " + "Slack no longer accepts an 'icon_url' override on chat.postMessage " + "for modern apps; configure the icon in your Slack app settings " + "instead. See " + "https://api.slack.com/changelog/2024-09-legacy-custom-bots-classic-apps-deprecation" + ) parameters["icon_url"] = icon # Slack wants the body on POST to be urlencoded. diff --git a/salt/states/slack.py b/salt/states/slack.py index df271f2be1cf..377be14c1acc 100644 --- a/salt/states/slack.py +++ b/salt/states/slack.py @@ -49,6 +49,14 @@ def post_message(name, **kwargs): - message: 'This state was executed successfully.' - api_key: peWcBiMOS9HrZG15peWcBiMOS9HrZG15 + .. versionchanged:: 3006.28 + ``from_name`` is now optional. Slack deprecated the ability for + classic/custom-bot apps to override the bot's display name and + icon via ``chat.postMessage`` on March 31, 2025 (see + https://api.slack.com/changelog/2024-09-legacy-custom-bots-classic-apps-deprecation). + Omit ``from_name`` and ``icon`` and configure the display name + and icon in the Slack app settings instead. + The following parameters are required: api_key parameters: @@ -58,9 +66,6 @@ def post_message(name, **kwargs): channel The channel to send the message to. Can either be the ID or the name. - from_name - The name of that is to be shown in the "from" field. - message The message that is to be sent to the Slack channel. @@ -70,8 +75,15 @@ def post_message(name, **kwargs): The api key for Slack to use for authentication, if not specified in the configuration options of master or minion. + from_name + Deprecated. Formerly the name shown in the "from" field. + Slack rejects this for modern apps; configure the display + name in the Slack app settings instead. + icon - URL to an image to use as the icon for this message + Deprecated. Formerly a URL to an image to use as the icon for + this message. Slack rejects this for modern apps; configure + the icon in the Slack app settings instead. webhook parameters: name @@ -124,10 +136,6 @@ def post_message(name, **kwargs): ret["comment"] = "Slack channel is missing." return ret - if api_key and not kwargs.get("from_name"): - ret["comment"] = "Slack from name is missing." - return ret - if not kwargs.get("message"): ret["comment"] = "Slack message is missing." return ret diff --git a/tests/pytests/unit/modules/test_slack.py b/tests/pytests/unit/modules/test_slack.py index 941960614777..4020bce0f3de 100644 --- a/tests/pytests/unit/modules/test_slack.py +++ b/tests/pytests/unit/modules/test_slack.py @@ -2,6 +2,7 @@ Tests for salt.modules.slack module """ +import logging import urllib.parse import pytest @@ -21,11 +22,12 @@ def test_post_message(): """ slack_query = MagicMock(return_value={"res": True}) - # bare minimum + # bare minimum - from_name is now optional and, when omitted, the + # deprecated `username` field must not be sent (Slack rejects it with + # legacy_custom_bots_deprecated, see issue #67948). with patch("salt.utils.slack.query", slack_query): message_params = { "channel": "fake_channel", - "from_name": "salt server", "message": "test message", "api_key": "xxx-xx-xxx", } @@ -38,7 +40,6 @@ def test_post_message(): data=urllib.parse.urlencode( { "channel": "#fake_channel", - "username": "salt server", "text": "test message", "attachments": [], "blocks": [], @@ -51,7 +52,6 @@ def test_post_message(): with patch("salt.utils.slack.query", slack_query): message_params = { "channel": "fake_channel", - "from_name": "salt server", "message": "test message", "api_key": "xxx-xx-xxx", "attachments": [{"text": "And heres an attachment!"}], @@ -71,7 +71,6 @@ def test_post_message(): data=urllib.parse.urlencode( { "channel": "#fake_channel", - "username": "salt server", "text": "test message", "attachments": [{"text": "And heres an attachment!"}], "blocks": [ @@ -84,3 +83,73 @@ def test_post_message(): ), opts=slack_notify.__opts__, ) + + +def test_post_message_legacy_from_name_preserved_with_warning(caplog): + """ + Regression test for #67948. + + When a caller explicitly passes ``from_name``/``icon`` (the legacy + Slack custom-bot fields), the values must still be forwarded to Slack + for backward compatibility, but a deprecation warning must be logged. + """ + slack_query = MagicMock(return_value={"res": True}) + + with patch("salt.utils.slack.query", slack_query), caplog.at_level( + logging.WARNING, logger="salt.modules.slack_notify" + ): + message_params = { + "channel": "fake_channel", + "message": "test message", + "from_name": "salt server", + "icon": "https://example.com/icon.png", + "api_key": "xxx-xx-xxx", + } + assert slack_notify.post_message(**message_params) + slack_query.assert_called_with( + function="message", + api_key="xxx-xx-xxx", + method="POST", + header_dict={"Content-Type": "application/x-www-form-urlencoded"}, + data=urllib.parse.urlencode( + { + "channel": "#fake_channel", + "text": "test message", + "attachments": [], + "blocks": [], + "username": "salt server", + "icon_url": "https://example.com/icon.png", + } + ), + opts=slack_notify.__opts__, + ) + assert any( + "from_name" in rec.getMessage() and "deprecated" in rec.getMessage() + for rec in caplog.records + ) + assert any( + "icon" in rec.getMessage() and "deprecated" in rec.getMessage() + for rec in caplog.records + ) + + +def test_post_message_omits_username_when_from_name_absent(): + """ + Regression test for #67948. + + Ensure the deprecated ``username`` field is not present in the + request body when the caller does not provide ``from_name``. Slack + rejects calls that include ``username`` from classic/custom-bot + apps with ``legacy_custom_bots_deprecated`` since 2025-03-31. + """ + slack_query = MagicMock(return_value={"res": True}) + with patch("salt.utils.slack.query", slack_query): + assert slack_notify.post_message( + channel="fake_channel", + message="hi", + api_key="xxx-xx-xxx", + ) + call_kwargs = slack_query.call_args.kwargs + body = call_kwargs["data"] + assert "username=" not in body + assert "icon_url=" not in body diff --git a/tests/pytests/unit/states/test_slack.py b/tests/pytests/unit/states/test_slack.py index a9b59f495922..995ae5945fc8 100644 --- a/tests/pytests/unit/states/test_slack.py +++ b/tests/pytests/unit/states/test_slack.py @@ -71,19 +71,6 @@ def test_post_message_apikey(): == ret ) - comt = "Slack from name is missing." - ret.update({"comment": comt, "result": False}) - assert ( - slack.post_message( - name, - channel=channel, - from_name=None, - message=message, - api_key=api_key, - ) - == ret - ) - comt = "Slack message is missing." ret.update({"comment": comt, "result": False}) assert ( @@ -113,6 +100,42 @@ def test_post_message_apikey(): ) +def test_post_message_no_from_name_67948(): + """ + Regression test for #67948. + + Ensure the state accepts a call without ``from_name``. Slack rejects + the deprecated ``username`` override on ``chat.postMessage`` from + classic/custom-bot apps with ``legacy_custom_bots_deprecated`` since + 2025-03-31, so the state must no longer require ``from_name``. + """ + name = "slack-message" + channel = "#general" + message = "This state was executed successfully." + api_key = "xoxp-XXXXXXXXXX-XXXXXXXXXX-XXXXXXXXXX-XXXXXX" + + with patch.dict(slack.__opts__, {"test": False}): + mock = MagicMock(return_value=True) + with patch.dict(slack.__salt__, {"slack.post_message": mock}): + ret = slack.post_message( + name, + channel=channel, + message=message, + api_key=api_key, + ) + assert ret == { + "name": name, + "changes": {}, + "result": True, + "comment": f"Sent message: {name}", + } + # The state must not forward a ``from_name`` when the caller + # did not supply one; the underlying module drops the + # deprecated ``username`` field in that case. + call_kwargs = mock.call_args.kwargs + assert call_kwargs.get("from_name") is None + + def test_post_message_webhook(): """ Test to send a message to a Slack channel using an webhook. From ea1b29d7689f371188e675500f15939b31a8a534 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 6 Jul 2026 18:09:59 -0400 Subject: [PATCH 052/469] Add per-file #jinja2: header to override Jinja environment options (fixes #35398) (#69532) * Add per-file #jinja2: header to override Jinja env options Setting jinja_env / jinja_sls_env (or the deprecated jinja_trim_blocks / jinja_lstrip_blocks) on the master changes Jinja rendering for *every* template, which breaks third-party formulas written for the defaults (saltstack/salt#35398). Add a per-file "#jinja2:" header containing a JSON object of Jinja environment options, so an individual template can opt in or out without touching the global configuration: #jinja2: {"trim_blocks": true, "lstrip_blocks": true} The header is honored on the first line, or on the line immediately following a renderer shebang (e.g. "#!jinja|yaml") -- the shebang is not stripped before the jinja renderer runs, so it occupies line one, much like a PEP 263 coding cookie may sit on line two below a "#!" line. The recognized line is removed before rendering; a "#jinja2:" line anywhere else is left as content. Malformed or non-object payloads are ignored with a warning, and the per-file options override the global ones. This revives the approach from the closed PR #63746, rebased onto the current jinja_env machinery, with shebang-aware detection and line handling that is consistent across LF/CRLF/CR endings. Fixes #35398 * Expand unit coverage of salt.utils.jinja and salt.utils.templates Beyond the new #jinja2 header feature, add unit tests for adjacent under-covered code in the jinja/templates modules, per Salt's expectation that contributions also raise overall project coverage. - jinja filters: to_bool, indent, regex_search/match/replace, the match/equalto jinja tests, union/intersect/difference/ symmetric_difference/lst_avg, method_call, tojson, skip_filter. - SerializerExtension: the load_yaml/json/text and import_* tags and filter forms, the yaml/json/xml format filters (incl. recurse_tree / normalize_iter), dict_to_sls_yaml_params, and serializer error paths. - templates.py jinja error handling: render_jinja_tmpl raising SaltRenderError for undefined variables (StrictUndefined) and syntax errors, exercising _get_jinja_error/_message/_line/_slug, the allow_undefined path, and generate_sls_context. - templates.py render funcs: the py() renderer (string/file modes, kwargs, dunders, error/no-run cases) and wrap_tmpl_func/render_tmpl edge paths (from_str, file and file-like input, empty template, sls context merge). Raises coverage of salt/utils/jinja.py 77% -> 94% and salt/utils/templates.py 66% -> 76%. * Fix wrong expectations in expanded jinja/templates test coverage Four tests in the coverage expansion encoded assumed behavior instead of actual behavior and never passed: - test_regex_search_no_group / test_regex_match_no_group assumed a groupless pattern returns the matched text as a one-tuple. The filters return match.groups(), which is an empty tuple when the pattern has no capture groups. The tests now assert that (documenting the falsy-tuple wart rather than changing long-standing filter behavior). - test_regex_search_multiline had the same problem; it now uses a capture group so the multiline flag's effect is actually observable, and also asserts the non-multiline case returns None. - test_generate_sls_context_non_sls_file asserted a fallback branch that does not exist. When the template path cannot be reconciled with the sls name, generate_sls_context logs "Failed to determine proper template path" and keeps the full template path as tplfile; the test now asserts the real (warning) behavior including the full 8-key context. All 218 tests across the five touched test files now pass. * Strengthen coverage tests flagged by mutation review A mutation-testing review of the expanded coverage found five tests that passed without actually proving what they claimed: - test_fileopts_not_at_top_is_ignored compared two identical renders of the same template (a tautology). It now renders the body without the trailing header as an independent reference and asserts the output is that reference plus the verbatim header line, which fails if the non-top header is ever honored. - test_get_iter's dict case iterated keys, so the StrictUndefined value could never be exposed regardless of behavior. It now uses a values iterator, which would surface the StrictUndefined if the implementation consumed iterators. - test_load_yaml_filter_non_string_raises documented only TemplateRuntimeError, but under the libyaml C loader safe_load raises TypeError, which load_yaml does not convert. The docstring now states the split behavior the assertion tuple actually covers. - test_render_tmplpath_missing_include_raises accepted any SaltRenderError, including the "no loader" failure that occurs when the FileSystemLoader wiring is absent. It now matches the loader's search-path message, which only appears when the loader is wired. - test_render_tmpl_file_like_bytes_decoded_by_renderer asserted a POSIX-only outcome: on Windows the newline-normalization branch raises TypeError on bytes output and the wrapper returns result=False. Renamed to describe the pass-through behavior and skipped on Windows, matching the sibling test_wrap_tmpl_func.py. All 218 tests across the five files pass. * Update versionadded to 3006.28 after the retarget to 3006.x This PR originally targeted master as a 3009.0 feature; the marker was left behind when the branch was rebased onto 3006.x. The next available 3006.x release is 3006.28. --- changelog/35398.fixed.md | 1 + doc/ref/configuration/master.rst | 8 + salt/utils/jinja.py | 55 +++ salt/utils/templates.py | 56 +++ .../utils/jinja/test_custom_extensions.py | 251 +++++++++++++ .../utils/jinja/test_jinja_custom_filters.py | 294 ++++++++++++++- .../utils/jinja/test_jinja_file_options.py | 353 ++++++++++++++++++ .../unit/utils/templates/test_jinja.py | 164 +++++++- .../unit/utils/templates/test_render_funcs.py | 244 ++++++++++++ 9 files changed, 1422 insertions(+), 4 deletions(-) create mode 100644 changelog/35398.fixed.md create mode 100644 tests/pytests/unit/utils/jinja/test_jinja_file_options.py create mode 100644 tests/pytests/unit/utils/templates/test_render_funcs.py diff --git a/changelog/35398.fixed.md b/changelog/35398.fixed.md new file mode 100644 index 000000000000..2d2a2d26c574 --- /dev/null +++ b/changelog/35398.fixed.md @@ -0,0 +1 @@ +Added a per-file ``#jinja2:`` header that overrides Jinja environment options (such as ``trim_blocks`` and ``lstrip_blocks``) for a single template, so individual states or third-party formulas can opt in or out without changing the global ``jinja_env``/``jinja_sls_env`` settings (which apply to every template). The header takes a JSON object and is honored on the first line, or on the line immediately following a renderer shebang (e.g. ``#!jinja|yaml``). diff --git a/doc/ref/configuration/master.rst b/doc/ref/configuration/master.rst index 74bda344bd43..819b41dcf79d 100644 --- a/doc/ref/configuration/master.rst +++ b/doc/ref/configuration/master.rst @@ -2530,6 +2530,14 @@ To set the options for sls templates use :conf_master:`jinja_sls_env`. The `Jinja2 Environment documentation `_ is the official source for the default values. Not all the options listed in the jinja documentation can be overridden using :conf_master:`jinja_env` or :conf_master:`jinja_sls_env`. +.. note:: + + :conf_master:`jinja_env` and :conf_master:`jinja_sls_env` apply to **every** + template, so changing them can break unrelated states or third-party + formulas that were written for the defaults. To set Jinja environment + options for a single template instead, add a ``#jinja2:`` header to that + template (see :ref:`Jinja Environment Configuration Override `). + The default options are: .. code-block:: yaml diff --git a/salt/utils/jinja.py b/salt/utils/jinja.py index 3686c237288b..951575b3fc93 100644 --- a/salt/utils/jinja.py +++ b/salt/utils/jinja.py @@ -1029,7 +1029,62 @@ class SerializerExtension(Extension): - changes: true - warnings: OMG! Stuff is happening! + .. _jinja-fileopts: + + **Jinja Environment Configuration Override** + + .. versionadded:: 3006.28 + + A header can be added to a jinja (or jinja|yaml, etc.) template to override + the jinja environment configuration for that template only. This lets an + individual file -- notably a third-party formula -- opt in or out of + options such as ``trim_blocks`` and ``lstrip_blocks`` without changing the + global :conf_master:`jinja_env` / :conf_master:`jinja_sls_env` settings, + which would otherwise force the same options onto every template and can + break unrelated states or formulas. + + The header is a single line beginning with ``#jinja2:`` followed by a JSON + object whose keys are `Jinja2 Environment`_ settings. It is honored on the + first line of the template, or on the line immediately following a renderer + shebang (e.g. ``#!jinja|yaml``), since the shebang is not stripped before + the jinja renderer runs. The recognized header line is removed before + rendering; a ``#jinja2:`` line anywhere else in the template is left + untouched. + + For example: + + .. code-block:: jinja + + #jinja2: {"lstrip_blocks": true, "trim_blocks": true} + thing: + {% for n in range(1, 6) %} + - some thing {{ n }} + {% endfor %} + + or, combined with a renderer shebang: + + .. code-block:: jinja + + #!jinja|yaml + #jinja2: {"lstrip_blocks": true, "trim_blocks": true} + thing: + {% for n in range(1, 6) %} + - some thing {{ n }} + {% endfor %} + + both render as: + + .. code-block:: yaml + + thing: + - some thing 1 + - some thing 2 + - some thing 3 + - some thing 4 + - some thing 5 + .. _`import tag`: https://jinja.palletsprojects.com/en/2.11.x/templates/#import + .. _`Jinja2 Environment`: https://jinja.palletsprojects.com/en/stable/api/#jinja2.Environment ''' tags = { diff --git a/salt/utils/templates.py b/salt/utils/templates.py index 28976979b3ec..b7f7fc1231dc 100644 --- a/salt/utils/templates.py +++ b/salt/utils/templates.py @@ -5,6 +5,7 @@ import codecs import importlib.machinery import importlib.util +import json import logging import os import sys @@ -428,11 +429,66 @@ def opt_jinja_env_helper(opts, optname): else: log.warning("Jinja2 environment %s is not recognized", k) + def parse_jinja_file_opts(tmplstr): + # Honor a per-file "#jinja2:" header so an individual template + # (notably a third-party formula) can override jinja environment + # options for itself, without the global jinja_env/jinja_sls_env + # settings forcing the same options onto every template. The + # header value is a JSON object, e.g.: + # #jinja2: {"trim_blocks": true, "lstrip_blocks": true} + # It is recognized on the first line, or on the line immediately + # following a renderer shebang (e.g. "#!jinja|yaml"): the shebang + # is not stripped before this renderer runs, so it occupies line + # one. This mirrors how a PEP 263 coding cookie may sit on line + # two below a "#!" line. The header line is removed from the + # template before rendering. + jinja2_override = "#jinja2:" + # keepends=True keeps the line terminators, so detection and + # removal share one consistent notion of a line across "\n", + # "\r\n" and lone "\r"; rejoining the remaining lines splices out + # exactly the header line and preserves every other byte. + lines = tmplstr.splitlines(keepends=True) + if not lines: + return tmplstr + idx = 0 + # A renderer shebang ("#!jinja|yaml"), but not an interpreter + # path ("#!/bin/sh"), may legitimately occupy the first line. + if lines[0].startswith("#!") and not lines[0].startswith("#!/"): + idx = 1 + if idx >= len(lines) or not lines[idx].startswith(jinja2_override): + return tmplstr + payload = lines[idx][len(jinja2_override) :] + if not payload.strip(): + # A bare "#jinja2:" with no options is not an override. + return tmplstr + try: + jdata = json.loads(payload) + except ValueError: + log.warning( + "Ignoring malformed '#jinja2:' header in template: %s", + lines[idx].rstrip("\r\n"), + ) + return tmplstr + if not isinstance(jdata, dict): + log.warning( + "Ignoring '#jinja2:' header that is not a JSON object: %s", + lines[idx].rstrip("\r\n"), + ) + return tmplstr + opt_jinja_env_helper(jdata, "jinja_fileopts") + del lines[idx] + return "".join(lines) + if "sls" in context and context["sls"] != "": opt_jinja_env_helper(opt_jinja_sls_env, "jinja_sls_env") else: opt_jinja_env_helper(opt_jinja_env, "jinja_env") + # Per-file "#jinja2:" header overrides the global jinja_env / + # jinja_sls_env options for this template only (see salt.utils.jinja + # for the documented header format). + tmplstr = parse_jinja_file_opts(tmplstr) + if opts.get("allow_undefined", False): jinja_env = jinja2.sandbox.SandboxedEnvironment(**env_args) else: diff --git a/tests/pytests/unit/utils/jinja/test_custom_extensions.py b/tests/pytests/unit/utils/jinja/test_custom_extensions.py index f1cd22a4aea2..bca91fa437e3 100644 --- a/tests/pytests/unit/utils/jinja/test_custom_extensions.py +++ b/tests/pytests/unit/utils/jinja/test_custom_extensions.py @@ -1298,6 +1298,257 @@ def test_ifelse(minion_opts, local_salt): assert rendered == ("default\n" "fooval\n" "barval\n" "barval\n" "default") +def test_serialize_yaml_flow_style_false(): + """ + The `yaml` filter with flow_style False renders block-style YAML. + """ + env = Environment(extensions=[SerializerExtension]) + data = OrderedDict([("a", 1), ("b", [1, 2])]) + rendered = env.from_string("{{ data|yaml(False) }}").render(data=data) + assert rendered == "a: 1\nb:\n- 1\n- 2" + # Round-trips back to the original structure. + assert salt.utils.yaml.safe_load(rendered) == {"a": 1, "b": [1, 2]} + + +def test_serialize_yaml_flow_style_true_default(): + """ + The `yaml` filter defaults to flow_style True (single-line output). + """ + env = Environment(extensions=[SerializerExtension]) + data = OrderedDict([("a", 1), ("b", [1, 2])]) + rendered = env.from_string("{{ data|yaml }}").render(data=data) + assert rendered == "{a: 1, b: [1, 2]}" + + +def test_serialize_yaml_scalar_strips_document_end(): + """ + The `yaml` filter strips the trailing YAML document-end marker for scalars. + """ + env = Environment(extensions=[SerializerExtension]) + assert env.from_string("{{ data|yaml }}").render(data="hello") == "hello" + assert env.from_string("{{ data|yaml }}").render(data=42) == "42" + assert "\n..." not in env.from_string("{{ data|yaml }}").render(data="hello") + + +def test_serialize_json_sort_keys_and_indent(): + """ + The `json` filter sorts keys by default and honors the indent argument. + """ + env = Environment(extensions=[SerializerExtension]) + # Keys are sorted by default regardless of insertion order. + rendered = env.from_string("{{ data|json }}").render( + data=OrderedDict([("b", 2), ("a", 1)]) + ) + assert rendered == '{"a": 1, "b": 2}' + # sort_keys=False preserves insertion order. + rendered = env.from_string("{{ data|json(sort_keys=False) }}").render( + data=OrderedDict([("b", 2), ("a", 1)]) + ) + assert rendered == '{"b": 2, "a": 1}' + # indent produces multi-line, pretty-printed output. + rendered = env.from_string("{{ data|json(indent=2) }}").render(data={"a": 1}) + assert rendered == '{\n "a": 1\n}' + + +def test_serialize_xml_dict_attributes_and_list_children(): + """ + The `xml` filter renders scalar dict values as attributes and list values as + repeated child elements. + """ + env = Environment(extensions=[SerializerExtension]) + data = OrderedDict([("foo", True), ("bar", 42), ("baz", [1, 2, 3]), ("qux", 2.0)]) + rendered = env.from_string('{{ {"root_node": data}|xml }}').render(data=data) + assert rendered == ( + '\n' + '\n' + " 1\n" + " 2\n" + " 3\n" + "\n" + ) + + +def test_serialize_xml_nested_dict_child(): + """ + The `xml` filter recurses into nested dict values as nested child elements. + """ + env = Environment(extensions=[SerializerExtension]) + data = OrderedDict( + [ + ( + "parent", + OrderedDict([("name", "p"), ("child", OrderedDict([("name", "c")]))]), + ) + ] + ) + rendered = env.from_string("{{ data|xml }}").render(data=data) + assert rendered == ( + '\n' + '\n' + ' \n' + "\n" + ) + + +def test_serialize_xml_list_of_dicts_repeats_tag(): + """ + The `xml` filter repeats a tag once per dict when its value is a list of dicts. + """ + env = Environment(extensions=[SerializerExtension]) + data = OrderedDict( + [ + ( + "servers", + OrderedDict( + [ + ( + "server", + [ + OrderedDict([("name", "a")]), + OrderedDict([("name", "b")]), + ], + ) + ] + ), + ) + ] + ) + rendered = env.from_string("{{ data|xml }}").render(data=data) + assert rendered == ( + '\n' + "\n" + ' \n' + ' \n' + "\n" + ) + + +def test_serialize_xml_scalar_raises(): + """ + The `xml` filter raises TemplateRuntimeError when given a non-dict/list value. + """ + env = Environment(extensions=[SerializerExtension]) + with pytest.raises(exceptions.TemplateRuntimeError): + env.from_string("{{ data|xml }}").render(data="just a string") + + +def test_load_yaml_filter_non_string_raises(): + """ + The `load_yaml` filter rejects a non-string value. The exact exception + depends on the YAML loader: the pure-Python loader raises an + AttributeError that load_yaml converts to TemplateRuntimeError, while the + libyaml C loader raises TypeError, which load_yaml does not currently + catch and therefore escapes the filter as-is. + """ + env = Environment(extensions=[SerializerExtension]) + with pytest.raises((TypeError, exceptions.TemplateRuntimeError)): + env.from_string("{{ data|load_yaml }}").render(data={"foo": "bar"}) + + +def test_load_json_filter_bad_quotes_raises(): + """ + The `load_json` filter raises TemplateRuntimeError on single-quoted JSON. + """ + env = Environment(extensions=[SerializerExtension]) + with pytest.raises(exceptions.TemplateRuntimeError): + env.from_string("{{ data|load_json }}").render(data="{'foo': 'bar'}") + + +def test_load_json_filter_non_string_raises(): + """ + The `load_json` filter raises TemplateRuntimeError on a non-string value. + """ + env = Environment(extensions=[SerializerExtension]) + with pytest.raises(exceptions.TemplateRuntimeError): + env.from_string("{{ data|load_json }}").render(data=[1, 2, 3]) + + +def test_load_text_filter(): + """ + The `load_text` filter returns the input string unchanged. + """ + env = Environment(extensions=[SerializerExtension]) + rendered = env.from_string('{{ "plain text here"|load_text }}').render() + assert rendered == "plain text here" + + +def test_load_text_block_tag(): + """ + The `{% load_text as %}` block tag captures its body as a string variable. + """ + env = Environment(extensions=[SerializerExtension]) + source = "{% load_text as txt %}Hello World{% endload %}{{ txt }}" + rendered = env.from_string(source).render() + assert rendered == "Hello World" + + +def test_load_yaml_block_tag(): + """ + The `{% load_yaml as %}` block tag deserializes its body to a YAML structure. + """ + env = Environment(extensions=[SerializerExtension]) + source = "{% load_yaml as d %}foo: bar{% endload %}{{ d.foo }}" + rendered = env.from_string(source).render() + assert rendered == "bar" + + +def test_load_json_block_tag(): + """ + The `{% load_json as %}` block tag deserializes its body to a JSON structure. + """ + env = Environment(extensions=[SerializerExtension]) + source = '{% load_json as d %}{"k": "v"}{% endload %}{{ d.k }}' + rendered = env.from_string(source).render() + assert rendered == "v" + + +def test_import_text_template(): + """ + The `{% import_text %}` tag exposes an external file's contents as a string. + """ + loader = DictLoader({"mytext": "imported text content"}) + env = Environment(extensions=[SerializerExtension], loader=loader) + rendered = env.from_string('{% import_text "mytext" as doc %}{{ doc }}').render() + assert rendered == "imported text content" + + +def test_import_yaml_value_access(): + """ + The `{% import_yaml %}` tag deserializes an external YAML file for attribute access. + """ + loader = DictLoader({"yml": "a: 1\nb: two"}) + env = Environment(extensions=[SerializerExtension], loader=loader) + rendered = env.from_string('{% import_yaml "yml" as doc %}{{ doc.b }}').render() + assert rendered == "two" + + +def test_import_json_value_access(): + """ + The `{% import_json %}` tag deserializes an external JSON file for attribute access. + """ + loader = DictLoader({"jsn": '{"a": 1, "b": "two"}'}) + env = Environment(extensions=[SerializerExtension], loader=loader) + rendered = env.from_string('{% import_json "jsn" as doc %}{{ doc.b }}').render() + assert rendered == "two" + + +def test_dict_to_sls_yaml_params_flow_style(): + """ + The `dict_to_sls_yaml_params` filter renders block-style by default and flow-style on request. + """ + env = Environment(extensions=[SerializerExtension]) + # Default flow_style is False -> block-style single-key list entry. + rendered = env.from_string("{{ d|dict_to_sls_yaml_params }}").render( + data=None, d=OrderedDict([("name", "x")]) + ) + assert rendered == "- name: x" + # flow_style=True -> single-line list of single-key dicts. + rendered = env.from_string( + "{{ d|dict_to_sls_yaml_params(flow_style=True) }}" + ).render(d=OrderedDict([("name", "x")])) + assert rendered == "[{name: x}]" + + def test_load_yaml_handles_marked_error_without_buffer(): """A YAML error whose problem_mark has no buffer (as produced by the libyaml C loader) must raise a clean TemplateRuntimeError, not crash.""" diff --git a/tests/pytests/unit/utils/jinja/test_jinja_custom_filters.py b/tests/pytests/unit/utils/jinja/test_jinja_custom_filters.py index b71b2a42cfb8..bf8fbe5a807c 100644 --- a/tests/pytests/unit/utils/jinja/test_jinja_custom_filters.py +++ b/tests/pytests/unit/utils/jinja/test_jinja_custom_filters.py @@ -90,7 +90,9 @@ def test_get_iter(): len(jinja._get_strict_undefined(iter([None, "\0", StrictUndefined(), False]))) == 0 ) - assert len(jinja._get_strict_undefined(iter({1: StrictUndefined()}))) == 0 + # A values iterator would expose the StrictUndefined if it were consumed + # (a plain dict iterator only yields keys, which could never expose it). + assert len(jinja._get_strict_undefined(iter({1: StrictUndefined()}.values()))) == 0 def test_full(): @@ -345,3 +347,293 @@ def test_tojson(): def test_python(): _render_fail(PYTHON_SLS_ERROR) assert _render(PYTHON_SLS) == PYTHON_SLS_RIGHT + + +def test_to_bool_none(): + """None always returns False.""" + assert jinja.to_bool(None) is False + + +def test_to_bool_already_bool(): + """Booleans are returned unchanged.""" + assert jinja.to_bool(True) is True + assert jinja.to_bool(False) is False + + +def test_to_bool_strings(): + """Only yes/1/true (any case) are truthy strings.""" + assert jinja.to_bool("yes") is True + assert jinja.to_bool("YES") is True + assert jinja.to_bool("True") is True + assert jinja.to_bool("TRUE") is True + assert jinja.to_bool("1") is True + assert jinja.to_bool("no") is False + assert jinja.to_bool("false") is False + assert jinja.to_bool("False") is False + assert jinja.to_bool("0") is False + assert jinja.to_bool("anything else") is False + assert jinja.to_bool("") is False + + +def test_to_bool_ints(): + """Integers are truthy only when greater than zero.""" + assert jinja.to_bool(5) is True + assert jinja.to_bool(1) is True + assert jinja.to_bool(0) is False + assert jinja.to_bool(-3) is False + + +def test_to_bool_non_hashable_uses_length(): + """Non-hashable values fall back to a length check.""" + assert jinja.to_bool([1, 2]) is True + assert jinja.to_bool([0]) is True + assert jinja.to_bool([]) is False + assert jinja.to_bool({"a": 1}) is True + assert jinja.to_bool({}) is False + + +def test_to_bool_unknown_hashable(): + """An unrecognized hashable type (tuple) returns False.""" + assert jinja.to_bool((1, 2)) is False + assert jinja.to_bool(()) is False + + +def test_indent_default_width(): + """Subsequent lines are indented by the default width of 4.""" + assert jinja.indent("a\nb") == "a\n b" + + +def test_indent_custom_width(): + """The width argument controls the indentation size.""" + assert jinja.indent("a\nb", width=2) == "a\n b" + + +def test_indent_first(): + """first=True also indents the first line.""" + assert jinja.indent("a\nb", width=2, first=True) == " a\n b" + + +def test_indent_blank(): + """blank=True indents blank lines as well.""" + assert jinja.indent("a\n\nb", width=2, blank=True) == "a\n \n b" + + +def test_indent_no_blank_skips_empty_lines(): + """Without blank, empty lines stay empty rather than getting whitespace.""" + assert jinja.indent("a\n\nb", width=2) == "a\n\n b" + + +def test_indent_indentfirst_deprecated(): + """The deprecated indentfirst argument still maps onto first.""" + with pytest.warns(DeprecationWarning): + assert jinja.indent("a\nb", width=2, indentfirst=True) == " a\n b" + + +def test_regex_search_no_match_returns_none(): + """A non-matching pattern returns None.""" + assert jinja.regex_search("abc", "xyz") is None + + +def test_regex_search_no_group(): + """The filter returns ``match.groups()``, so a successful match with no + capture groups yields an empty (falsy) tuple, not the matched text.""" + assert jinja.regex_search("abcd", "bc") == () + + +def test_regex_search_groups_ignorecase(): + """Groups are returned and ignorecase makes the match case-insensitive.""" + assert jinja.regex_search("abcd", "^(.*)BC(.*)$", ignorecase=True) == ("a", "d") + + +def test_regex_search_multiline(): + """multiline lets ^ and $ anchor to line boundaries.""" + assert jinja.regex_search("foo\nbar", "^(bar)$", multiline=True) == ("bar",) + assert jinja.regex_search("foo\nbar", "^(bar)$") is None + + +def test_regex_match_no_match_returns_none(): + """match anchors at the start; a mid-string pattern returns None.""" + assert jinja.regex_match("abc", "bc") is None + + +def test_regex_match_no_group(): + """Like regex_search, a match with no capture groups returns an empty + (falsy) tuple because the filter returns ``match.groups()``.""" + assert jinja.regex_match("abcd", "ab") == () + + +def test_regex_match_groups_ignorecase(): + """Groups are returned with ignorecase honored.""" + assert jinja.regex_match("abcd", "^(.*)BC(.*)$", ignorecase=True) == ("a", "d") + + +def test_regex_replace_basic(): + """Whitespace runs are replaced with the given value.""" + assert jinja.regex_replace("lets replace spaces", r"\s+", "__") == ( + "lets__replace__spaces" + ) + + +def test_regex_replace_ignorecase(): + """ignorecase lets the pattern match regardless of case.""" + assert jinja.regex_replace("Hello WORLD", "world", "X", ignorecase=True) == ( + "Hello X" + ) + + +def test_regex_replace_multiline(): + """multiline anchors ^ at each line start for replacement.""" + assert jinja.regex_replace("a\nb", "^", "> ", multiline=True) == "> a\n> b" + + +def test_test_match_true_false(): + """test_match returns True only when the pattern matches at the start.""" + assert jinja.test_match("abc", "^a") is True + assert jinja.test_match("abc", "^z") is False + + +def test_test_match_ignorecase(): + """ignorecase makes test_match case-insensitive.""" + assert jinja.test_match("ABC", "^a", ignorecase=True) is True + assert jinja.test_match("ABC", "^a") is False + + +def test_test_match_multiline(): + """multiline does not affect a leading match anchor for test_match.""" + assert jinja.test_match("foo\nbar", "^bar", multiline=True) is False + assert jinja.test_match("foo\nbar", "^foo", multiline=True) is True + + +def test_test_equalto(): + """test_equalto compares two values for equality.""" + assert jinja.test_equalto(1, 1) is True + assert jinja.test_equalto(1, 2) is False + assert jinja.test_equalto("salt", "salt") is True + + +def test_match_is_test_via_render(): + """The 'is match' jinja test produces the expected result when rendered.""" + env = jinja2.Environment(extensions=[jinja.SerializerExtension]) + env.tests["match"] = jinja.test_match + tmpl = env.from_string("{{ 'abc' is match('^a') }}|{{ 'abc' is match('^z') }}") + assert tmpl.render() == "True|False" + + +def test_match_is_test_ignorecase_via_render(): + """The 'is match' jinja test honors the ignorecase keyword when rendered.""" + env = jinja2.Environment(extensions=[jinja.SerializerExtension]) + env.tests["match"] = jinja.test_match + tmpl = env.from_string("{{ 'ABC' is match('^a', ignorecase=True) }}") + assert tmpl.render() == "True" + + +def test_equalto_is_test_via_render(): + """The 'is equalto' jinja test produces the expected result when rendered.""" + env = jinja2.Environment(extensions=[jinja.SerializerExtension]) + env.tests["equalto"] = jinja.test_equalto + tmpl = env.from_string("{{ 1 is equalto(1) }}|{{ 1 is equalto(2) }}") + assert tmpl.render() == "True|False" + + +def test_union_hashable_strings(): + """Two hashable inputs produce a set union.""" + assert jinja.union("abc", "cde") == {"a", "b", "c", "d", "e"} + + +def test_union_lists_preserve_order(): + """Lists are not hashable, so order is preserved and duplicates dropped.""" + assert jinja.union([1, 2, 3, 4], [2, 4, 6]) == [1, 2, 3, 4, 6] + + +def test_intersect_hashable_strings(): + """Two hashable inputs produce a set intersection.""" + assert jinja.intersect("abc", "bcd") == {"b", "c"} + + +def test_intersect_lists_preserve_order(): + """Lists return the order-preserving intersection.""" + assert jinja.intersect([1, 2, 3, 4], [2, 4, 6]) == [2, 4] + + +def test_difference_hashable_strings(): + """Two hashable inputs produce a set difference.""" + assert jinja.difference("abc", "bc") == {"a"} + + +def test_difference_lists_preserve_order(): + """Lists return the order-preserving difference.""" + assert jinja.difference([1, 2, 3, 4], [2, 4, 6]) == [1, 3] + + +def test_symmetric_difference_hashable_strings(): + """Two hashable inputs produce a set symmetric difference.""" + assert jinja.symmetric_difference("abc", "cde") == {"a", "b", "d", "e"} + + +def test_symmetric_difference_lists(): + """Lists return the order-preserving symmetric difference.""" + assert jinja.symmetric_difference([1, 2, 3, 4], [2, 4, 6]) == [1, 3, 6] + + +def test_lst_avg_list(): + """A list (non-hashable) averages its elements as a float.""" + result = jinja.lst_avg([1, 2, 3, 4]) + assert result == 2.5 + assert isinstance(result, float) + + +def test_lst_avg_single_value(): + """A single hashable value is cast straight to float.""" + result = jinja.lst_avg(5) + assert result == 5.0 + assert isinstance(result, float) + + +def test_method_call_with_args(): + """method_call invokes the named method with the supplied args.""" + assert jinja.method_call("foo bar", "split") == ["foo", "bar"] + assert jinja.method_call("foo,bar", "split", ",") == ["foo", "bar"] + assert jinja.method_call("FOO", "lower") == "foo" + + +def test_method_call_missing_method_returns_none(): + """An unknown method name falls back to a no-op returning None.""" + assert jinja.method_call("x", "does_not_exist") is None + + +def test_tojson_default_order(): + """tojson keeps insertion order by default (no implicit sort_keys).""" + assert jinja.tojson({"b": 2, "a": 1}) == '{"b": 2, "a": 1}' + + +def test_tojson_sort_keys(): + """sort_keys=True sorts the keys in the output.""" + assert jinja.tojson({"b": 2, "a": 1}, sort_keys=True) == '{"a": 1, "b": 2}' + + +def test_tojson_escapes_html_chars(): + """HTML-sensitive characters are escaped to their unicode forms.""" + assert jinja.tojson('') == '"\\u003ca href=\\"x\\"\\u003e"' + + +def test_tojson_non_ascii_passthrough(): + """ensure_ascii=False leaves non-ASCII characters intact.""" + assert jinja.tojson("☃", ensure_ascii=False) == '"☃"' + + +def test_tojson_indent(): + """The indent option is forwarded to the JSON serializer.""" + assert jinja.tojson([1, 2], indent=2) == "[\n 1,\n 2\n]" + + +def test_tojson_strict_undefined_short_circuits(): + """A StrictUndefined input is returned as StrictUndefined, not serialized.""" + result = jinja.tojson(StrictUndefined(name="missing")) + assert isinstance(result, StrictUndefined) + + +def test_skip_filter(): + """skip_filter always renders an empty string regardless of input.""" + assert jinja.skip_filter("foo") == "" + assert jinja.skip_filter(None) == "" + assert jinja.skip_filter([1, 2, 3]) == "" diff --git a/tests/pytests/unit/utils/jinja/test_jinja_file_options.py b/tests/pytests/unit/utils/jinja/test_jinja_file_options.py new file mode 100644 index 000000000000..6ad33c6a8a87 --- /dev/null +++ b/tests/pytests/unit/utils/jinja/test_jinja_file_options.py @@ -0,0 +1,353 @@ +""" +Tests for the per-file ``#jinja2:`` Jinja environment override header +implemented in salt.utils.templates.render_jinja_tmpl. +""" + +import logging +import os + +import pytest + +# dateutils is needed so that the strftime jinja filter is loaded +import salt.utils.dateutils # pylint: disable=unused-import +import salt.utils.files # pylint: disable=unused-import +import salt.utils.json # pylint: disable=unused-import +import salt.utils.stringutils # pylint: disable=unused-import +import salt.utils.yaml # pylint: disable=unused-import +from salt.utils.templates import render_jinja_tmpl + + +@pytest.fixture +def minion_opts(tmp_path, minion_opts): + minion_opts.update( + { + "cachedir": str(tmp_path / "jinja-template-cache"), + "file_buffer_size": 1048576, + "file_client": "local", + "file_ignore_regex": None, + "file_ignore_glob": None, + "file_roots": {"test": [str(tmp_path / "templates")]}, + "pillar_roots": {"test": [str(tmp_path / "templates")]}, + "fileserver_backend": ["roots"], + "hash_type": "md5", + "extension_modules": os.path.join( + os.path.dirname(os.path.abspath(__file__)), "extmods" + ), + } + ) + return minion_opts + + +@pytest.fixture +def local_salt(): + return { + "myvar": "zero", + "mylist": [0, 1, 2, 3], + } + + +# A body that produces visibly different whitespace depending on whether +# trim_blocks / lstrip_blocks are enabled. +BODY = """\ +#lets count +{% for i in range(3) %} + {% if i == 1 %} +1337 + {% endif %} +{{ i }} +{% endfor %} +""" + + +def _render(opts, local_salt, template, sls=""): + context = {"opts": opts, "saltenv": "test", "salt": local_salt} + if sls: + context["sls"] = sls + return render_jinja_tmpl(template, context) + + +def test_fileopts_match_global_jinja_env(minion_opts, local_salt): + """ + A ``#jinja2:`` header must produce exactly the same result as setting the + equivalent options globally via jinja_env -- it is the same machinery, + just scoped to one file. + """ + reference = _render( + {**minion_opts, "jinja_env": {"trim_blocks": True, "lstrip_blocks": True}}, + local_salt, + BODY, + ) + with_header = _render( + {**minion_opts}, + local_salt, + '#jinja2: {"trim_blocks": true, "lstrip_blocks": true}\n' + BODY, + ) + assert with_header == reference + # And the header line itself must not leak into the output. + assert "#jinja2:" not in with_header + + +def test_fileopts_override_global(minion_opts, local_salt): + """ + The per-file header wins over a conflicting global jinja_env setting -- a + formula can opt OUT of options the operator enabled globally. + """ + # Global turns trimming on; the file turns it back off. + opts = {**minion_opts, "jinja_env": {"trim_blocks": True, "lstrip_blocks": True}} + file_off = _render( + opts, + local_salt, + '#jinja2: {"trim_blocks": false, "lstrip_blocks": false}\n' + BODY, + ) + # Equivalent to rendering the bare body with no trimming at all. + no_trim = _render({**minion_opts}, local_salt, BODY) + assert file_off == no_trim + + +def test_fileopts_applies_in_sls_context(minion_opts, local_salt): + """ + The header is honored in the sls render path (jinja_sls_env) too, not just + the plain jinja_env path. + """ + reference = _render( + {**minion_opts, "jinja_sls_env": {"trim_blocks": True, "lstrip_blocks": True}}, + local_salt, + BODY, + sls="some.state", + ) + with_header = _render( + {**minion_opts}, + local_salt, + '#jinja2: {"trim_blocks": true, "lstrip_blocks": true}\n' + BODY, + sls="some.state", + ) + assert with_header == reference + + +def test_fileopts_after_renderer_shebang(minion_opts, local_salt): + """ + When a renderer shebang occupies line 1, the header is honored on line 2. + The shebang itself is left untouched (it is not stripped before the jinja + renderer runs); only the header line is removed. + """ + shebang = "#!jinja|yaml\n" + reference = _render( + {**minion_opts, "jinja_env": {"trim_blocks": True, "lstrip_blocks": True}}, + local_salt, + shebang + BODY, + ) + with_header = _render( + {**minion_opts}, + local_salt, + shebang + '#jinja2: {"trim_blocks": true, "lstrip_blocks": true}\n' + BODY, + ) + assert with_header == reference + # The shebang survives; the #jinja2 header does not. + assert with_header.startswith("#!jinja|yaml") + assert "#jinja2:" not in with_header + + +def test_fileopts_shebang_without_header_is_untouched(minion_opts, local_salt): + """ + A shebang with no following ``#jinja2:`` header applies no options and + leaves the template (shebang included) unchanged. + """ + shebang = "#!jinja|yaml\n" + out = _render({**minion_opts}, local_salt, shebang + BODY) + plain = _render({**minion_opts}, local_salt, BODY) + assert out == shebang + plain + + +def test_fileopts_interpreter_path_is_not_treated_as_shebang(minion_opts, local_salt): + """ + A ``#!/path`` interpreter line is not a renderer shebang, so the header is + only looked for on line 1 (which here is the ``#!/`` line) -- meaning a + header on line 2 is NOT honored. + """ + template = ( + "#!/usr/bin/env something\n" + '#jinja2: {"trim_blocks": true, "lstrip_blocks": true}\n' + BODY + ) + out = _render({**minion_opts}, local_salt, template) + # Not recognized: the header line is left in place and no trimming applied. + assert '#jinja2: {"trim_blocks": true, "lstrip_blocks": true}' in out + + +def test_fileopts_not_at_top_is_ignored(minion_opts, local_salt): + """ + A ``#jinja2:`` line below the top of the file is treated as ordinary + content: left in place, with no options applied. + """ + template = BODY + '#jinja2: {"trim_blocks": true, "lstrip_blocks": true}\n' + out = _render({**minion_opts}, local_salt, template) + # The rendered body must be byte-identical to rendering BODY alone with + # the default environment (proving no trimming was applied), with the + # header line passed through verbatim as ordinary trailing content. + reference = _render({**minion_opts}, local_salt, BODY) + assert out == reference + '#jinja2: {"trim_blocks": true, "lstrip_blocks": true}\n' + + +def test_fileopts_malformed_json_is_ignored(minion_opts, local_salt, caplog): + """ + A header whose payload is not valid JSON is left in place, no options are + applied, and a warning is logged. + """ + template = "#jinja2: {this is not valid json}\n" + BODY + with caplog.at_level(logging.WARNING, logger="salt.utils.templates"): + out = _render({**minion_opts}, local_salt, template) + assert "#jinja2: {this is not valid json}" in out + assert any("malformed '#jinja2:'" in rec.message for rec in caplog.records) + + +def test_fileopts_non_object_json_is_ignored(minion_opts, local_salt, caplog): + """ + A header whose JSON is valid but not an object (e.g. a list) is ignored + with a warning rather than crashing. + """ + template = '#jinja2: ["trim_blocks", "lstrip_blocks"]\n' + BODY + with caplog.at_level(logging.WARNING, logger="salt.utils.templates"): + out = _render({**minion_opts}, local_salt, template) + assert '#jinja2: ["trim_blocks", "lstrip_blocks"]' in out + assert any("not a JSON object" in rec.message for rec in caplog.records) + + +def test_fileopts_unrecognized_key_warns_and_renders(minion_opts, local_salt, caplog): + """ + An unknown Jinja environment option is skipped with a warning; rendering + still succeeds and the header line is removed. + """ + template = '#jinja2: {"not_a_real_jinja_option": true}\n' + BODY + with caplog.at_level(logging.WARNING, logger="salt.utils.templates"): + out = _render({**minion_opts}, local_salt, template) + assert "#jinja2:" not in out + assert any("is not recognized" in rec.message for rec in caplog.records) + + +def test_fileopts_single_option(minion_opts, local_salt): + """ + A header may set just one option; it must match enabling only that option + globally (proving individual options flow through, not just the pair). + """ + reference = _render( + {**minion_opts, "jinja_env": {"trim_blocks": True}}, + local_salt, + BODY, + ) + with_header = _render( + {**minion_opts}, + local_salt, + '#jinja2: {"trim_blocks": true}\n' + BODY, + ) + assert with_header == reference + + +def test_fileopts_lone_cr_body_preserved(minion_opts, local_salt): + """ + Regression: a lone-CR (classic-Mac) template with a recognized header must + not be silently discarded. The header is removed and the body survives. + """ + template = '#jinja2: {"trim_blocks": true}\rkept_a: 1\rkept_b: 2\r' + out = _render({**minion_opts}, local_salt, template) + assert "#jinja2:" not in out + assert "kept_a: 1" in out + assert "kept_b: 2" in out + + +def test_fileopts_crlf_body_preserved(minion_opts, local_salt): + """ + A CRLF template with a recognized header keeps the body and drops only the + header line. + """ + template = '#jinja2: {"trim_blocks": true}\r\nkept_a: 1\r\nkept_b: 2\r\n' + out = _render({**minion_opts}, local_salt, template) + assert "#jinja2:" not in out + assert "kept_a: 1" in out + assert "kept_b: 2" in out + + +def test_fileopts_lone_cr_after_shebang_preserved(minion_opts, local_salt): + """ + Regression: shebang on line 1 + header on line 2 with lone-CR endings must + keep both the shebang and the body. + """ + template = '#!jinja|yaml\r#jinja2: {"trim_blocks": true}\rkept_a: 1\r' + out = _render({**minion_opts}, local_salt, template) + assert out.startswith("#!jinja|yaml") + assert "#jinja2:" not in out + assert "kept_a: 1" in out + + +def test_fileopts_header_is_whole_file(minion_opts, local_salt): + """ + A header that is the entire file (no body, no trailing newline) renders to + nothing without error. + """ + out = _render({**minion_opts}, local_salt, '#jinja2: {"trim_blocks": true}') + assert out == "" + + +def test_fileopts_header_after_shebang_no_trailing_newline(minion_opts, local_salt): + """ + Shebang + header with no trailing newline keeps the shebang and removes the + header. + """ + out = _render( + {**minion_opts}, + local_salt, + '#!jinja|yaml\n#jinja2: {"trim_blocks": true}', + ) + assert out.startswith("#!jinja|yaml") + assert "#jinja2:" not in out + + +def test_fileopts_scalar_json_ignored(minion_opts, local_salt, caplog): + """ + A header whose JSON is a scalar (not an object) is ignored with a warning. + """ + template = "#jinja2: 5\n" + BODY + with caplog.at_level(logging.WARNING, logger="salt.utils.templates"): + out = _render({**minion_opts}, local_salt, template) + assert "#jinja2: 5" in out + assert any("not a JSON object" in rec.message for rec in caplog.records) + + +def test_fileopts_empty_payload_ignored(minion_opts, local_salt, caplog): + """ + A bare ``#jinja2:`` with no payload is not an override: it is left in place + and is not treated as malformed JSON. + """ + template = "#jinja2:\n" + BODY + with caplog.at_level(logging.WARNING, logger="salt.utils.templates"): + out = _render({**minion_opts}, local_salt, template) + assert "#jinja2:" in out + assert not any("malformed" in rec.message for rec in caplog.records) + + +def test_fileopts_only_first_header_consumed(minion_opts, local_salt): + """ + Only the top header is consumed; a second ``#jinja2:`` line is left as + ordinary content. + """ + template = ( + '#jinja2: {"trim_blocks": true}\n' '#jinja2: {"lstrip_blocks": true}\n' + BODY + ) + out = _render({**minion_opts}, local_salt, template) + assert out.count("#jinja2:") == 1 + assert '#jinja2: {"lstrip_blocks": true}' in out + + +def test_fileopts_indented_header_ignored(minion_opts, local_salt): + """ + A header indented by leading whitespace is treated as content (the anchor + is byte 0 of the line, matching Ansible). + """ + template = ' #jinja2: {"trim_blocks": true}\n' + BODY + out = _render({**minion_opts}, local_salt, template) + assert "#jinja2:" in out + + +def test_fileopts_empty_template(minion_opts, local_salt): + """ + An empty template renders to empty without error. + """ + assert _render({**minion_opts}, local_salt, "") == "" diff --git a/tests/pytests/unit/utils/templates/test_jinja.py b/tests/pytests/unit/utils/templates/test_jinja.py index 4133cae7f354..9e1b834a52a4 100644 --- a/tests/pytests/unit/utils/templates/test_jinja.py +++ b/tests/pytests/unit/utils/templates/test_jinja.py @@ -2,14 +2,15 @@ Tests for salt.utils.templates """ +import logging import re - from collections import OrderedDict + import pytest + from salt.exceptions import SaltRenderError from salt.loader.context import LoaderContext -from salt.utils.templates import render_jinja_tmpl - +from salt.utils.templates import generate_sls_context, render_jinja_tmpl from tests.support.mock import patch @@ -143,3 +144,160 @@ def capture_init(self, opts, *args, **kwargs): render_jinja_tmpl("OK", render_context) # If the fix is in place the loader sees a plain dict. assert seen["opts_type"] is dict, seen + + +def test_render_undefined_raises_render_error(render_context): + """An undefined variable under StrictUndefined raises SaltRenderError.""" + with pytest.raises(SaltRenderError) as excinfo: + render_jinja_tmpl("{{ undefined_var }}", render_context) + assert str(excinfo.value).startswith("Jinja variable 'undefined_var' is undefined") + + +def test_render_undefined_reports_line_number(render_context): + """The undefined-variable error reports the line of the offending variable.""" + tmpl = "first\nsecond\n{{ missing }}" + with pytest.raises(SaltRenderError) as excinfo: + render_jinja_tmpl(tmpl, render_context) + exc = excinfo.value + assert exc.line_num == 3 + assert str(exc).splitlines()[0] == "Jinja variable 'missing' is undefined; line 3" + + +def test_render_undefined_includes_context_marker(render_context): + """The undefined error embeds the source line with the position marker.""" + marker = " <======================" + with pytest.raises(SaltRenderError) as excinfo: + render_jinja_tmpl("{{ missing }}", render_context) + message = str(excinfo.value) + assert "{{ missing }}" + marker in message + + +def test_render_syntax_error_raises_render_error(render_context): + """A Jinja syntax error raises SaltRenderError tagged as a syntax error.""" + with pytest.raises(SaltRenderError) as excinfo: + render_jinja_tmpl("{% if %}", render_context) + assert str(excinfo.value).startswith("Jinja syntax error:") + + +def test_render_syntax_error_reports_line_number(render_context): + """A multi-line template's syntax error reports the offending line number.""" + tmpl = "line1\n{% if %}\nline3" + with pytest.raises(SaltRenderError) as excinfo: + render_jinja_tmpl(tmpl, render_context) + assert excinfo.value.line_num == 2 + + +def test_render_allow_undefined_returns_empty(render_context): + """With allow_undefined set, an undefined variable renders as empty, not an error.""" + render_context["opts"]["allow_undefined"] = True + res = render_jinja_tmpl("a{{ undefined_var }}b", render_context) + assert res == "ab" + + +def test_render_tmplpath_filesystem_include(render_context, tmp_path): + """A non-saltenv tmplpath sets up a FileSystemLoader so includes resolve.""" + included = tmp_path / "inc.txt" + included.write_text("INCLUDED") + res = render_jinja_tmpl( + '{% include "inc.txt" %}', + render_context, + tmplpath=str(tmp_path / "main.sls"), + ) + assert res == "INCLUDED" + + +def test_render_tmplpath_missing_include_raises(render_context, tmp_path): + """A missing include through the FileSystemLoader raises SaltRenderError. + Matching on the loader's search-path message proves the include was + resolved through the FileSystemLoader (a missing loader would produce + "no loader for this environment specified" instead).""" + with pytest.raises( + SaltRenderError, match=r"'does_not_exist\.txt' not found in search path" + ): + render_jinja_tmpl( + '{% include "does_not_exist.txt" %}', + render_context, + tmplpath=str(tmp_path / "main.sls"), + ) + + +def test_generate_sls_context_sls_file(): + """A standard .sls template yields the directory-based context values.""" + ctx = generate_sls_context("/srv/salt/foo/bar.sls", "foo.bar") + assert ctx == { + "tplpath": "/srv/salt/foo/bar.sls", + "tplfile": "foo/bar.sls", + "tpldir": "foo", + "tpldot": "foo", + "slspath": "foo", + "slsdotpath": "foo", + "slscolonpath": "foo", + "sls_path": "foo", + } + + +def test_generate_sls_context_init_sls(): + """An init.sls template maps to its containing directory.""" + ctx = generate_sls_context("/srv/salt/foo/init.sls", "foo") + assert ctx["tplfile"] == "foo/init.sls" + assert ctx["tpldir"] == "foo" + assert ctx["slspath"] == "foo" + + +def test_generate_sls_context_nested_sls(): + """A nested .sls path produces slash/dot/colon/underscore separated forms.""" + ctx = generate_sls_context("/srv/salt/a/b/c.sls", "a.b.c") + assert ctx["tpldir"] == "a/b" + assert ctx["tpldot"] == "a.b" + assert ctx["slscolonpath"] == "a:b" + assert ctx["sls_path"] == "a_b" + assert ctx["slsdotpath"] == "a.b" + + +def test_generate_sls_context_top_level_sls(): + """A top-level .sls (no directory) yields '.' tpldir and empty sls paths.""" + ctx = generate_sls_context("/srv/salt/foo.sls", "foo") + assert ctx["tpldir"] == "." + assert ctx["tpldot"] == "" + assert ctx["slspath"] == "" + assert ctx["slscolonpath"] == "" + assert ctx["sls_path"] == "" + + +def test_generate_sls_context_non_sls_file(caplog): + """A template path that cannot be reconciled with the sls name logs a + warning and keeps the full template path as tplfile (the root cannot be + stripped, so all derived path variables carry the full path too).""" + with caplog.at_level(logging.WARNING): + ctx = generate_sls_context("/srv/salt/foo/bar.txt", "foo.bar") + assert "Failed to determine proper template path" in caplog.text + assert ctx == { + "tplpath": "/srv/salt/foo/bar.txt", + "tplfile": "/srv/salt/foo/bar.txt", + "tpldir": "/srv/salt/foo", + "tpldot": ".srv.salt.foo", + "slspath": "/srv/salt/foo", + "slsdotpath": ".srv.salt.foo", + "slscolonpath": ":srv:salt:foo", + "sls_path": "_srv_salt_foo", + } + + +def test_generate_sls_context_no_tmplpath(): + """With no tmplpath, only the sls-derived path variables are returned.""" + ctx = generate_sls_context(None, "foo.bar") + assert "tplpath" not in ctx + assert ctx == { + "slspath": "foo/bar", + "slsdotpath": "foo.bar", + "slscolonpath": "foo:bar", + "sls_path": "foo_bar", + } + + +def test_generate_sls_context_empty_sls(): + """An empty sls with a tmplpath strips the template down to its basename.""" + ctx = generate_sls_context("/srv/salt/foo/bar.sls", "") + assert ctx["tplfile"] == "bar.sls" + assert ctx["tpldir"] == "." + assert ctx["slspath"] == "" diff --git a/tests/pytests/unit/utils/templates/test_render_funcs.py b/tests/pytests/unit/utils/templates/test_render_funcs.py new file mode 100644 index 000000000000..5c4c5b73a2d3 --- /dev/null +++ b/tests/pytests/unit/utils/templates/test_render_funcs.py @@ -0,0 +1,244 @@ +""" +Unit tests for the py() renderer and render_tmpl edge paths in +salt.utils.templates. +""" + +import os + +import pytest + +import salt.utils.files +from salt.utils.templates import py as render_py_tmpl +from salt.utils.templates import wrap_tmpl_func + + +class EchoRender: + """Minimal render_str callable that returns the template string unchanged.""" + + def __call__(self, tplstr, context, tmplpath=None): + self.tplstr = tplstr + self.context = context + self.tmplpath = tmplpath + return tplstr + + +@pytest.fixture +def render_context(): + """Minimal context satisfying render_tmpl's opts/saltenv asserts.""" + return {"opts": {"cachedir": "/D", "__cli": "salt"}, "saltenv": "base"} + + +def _write_py_module(tmp_path, name, body): + """Write a python template module to disk and return its path.""" + sfn = tmp_path / name + sfn.write_text(body) + return str(sfn) + + +def test_py_missing_file_returns_empty_dict(tmp_path): + """py() returns an empty dict when the source file does not exist.""" + missing = str(tmp_path / "does_not_exist.py") + assert render_py_tmpl(missing) == {} + + +def test_py_run_string_true_returns_data_directly(tmp_path): + """py() with string=True returns run()'s value as data without writing a file.""" + sfn = _write_py_module( + tmp_path, "tmpl_str.py", "def run():\n return 'hello world'\n" + ) + result = render_py_tmpl(sfn, string=True) + assert result == {"result": True, "data": "hello world"} + + +def test_py_run_string_false_writes_tempfile(tmp_path): + """py() with string=False writes run()'s output to a temp file and returns its path.""" + sfn = _write_py_module( + tmp_path, "tmpl_file.py", "def run():\n return 'file contents'\n" + ) + result = render_py_tmpl(sfn, string=False) + assert result["result"] is True + written = result["data"] + assert os.path.isfile(written) + try: + with salt.utils.files.fopen(written, encoding="utf-8") as fh: + assert fh.read() == "file contents" + finally: + os.remove(written) + + +def test_py_run_default_string_false_writes_tempfile(tmp_path): + """py() defaults to string=False, writing output to a temp file.""" + sfn = _write_py_module( + tmp_path, "tmpl_default.py", "def run():\n return 'default mode'\n" + ) + result = render_py_tmpl(sfn) + assert result["result"] is True + written = result["data"] + assert os.path.isfile(written) + try: + with salt.utils.files.fopen(written, encoding="utf-8") as fh: + assert fh.read() == "default mode" + finally: + os.remove(written) + + +def test_py_run_uses_passed_kwargs_as_module_attrs(tmp_path): + """py() sets passed kwargs as module attributes available to run().""" + body = "def run():\n return color + '-' + str(count)\n" + sfn = _write_py_module(tmp_path, "tmpl_kwargs.py", body) + result = render_py_tmpl(sfn, string=True, color="blue", count=3) + assert result == {"result": True, "data": "blue-3"} + + +def test_py_run_sets_dunder_builtins_when_saltenv_present(tmp_path): + """py() exposes saltenv/pillar/etc as __env__/__pillar__ dunders to run().""" + body = "def run():\n return __env__ + ':' + __pillar__['k']\n" + sfn = _write_py_module(tmp_path, "tmpl_dunder.py", body) + result = render_py_tmpl( + sfn, + string=True, + saltenv="base", + salt={}, + grains={}, + pillar={"k": "v"}, + opts={}, + ) + assert result == {"result": True, "data": "base:v"} + + +def test_py_run_raises_returns_failure_with_traceback(tmp_path): + """py() catches exceptions raised in run() and returns result=False plus traceback.""" + sfn = _write_py_module( + tmp_path, "tmpl_raise.py", "def run():\n raise ValueError('boom')\n" + ) + result = render_py_tmpl(sfn, string=True) + assert result["result"] is False + assert "ValueError" in result["data"] + assert "boom" in result["data"] + + +def test_py_module_without_run_returns_failure(tmp_path): + """py() returns a failure result when the module defines no run() function.""" + sfn = _write_py_module(tmp_path, "tmpl_norun.py", "x = 1\n") + result = render_py_tmpl(sfn, string=True) + assert result["result"] is False + assert "AttributeError" in result["data"] + + +def test_render_tmpl_from_str_to_str(render_context): + """render_tmpl renders an in-memory string and returns the rendered data.""" + wrapped = wrap_tmpl_func(EchoRender()) + res = wrapped("template body", from_str=True, to_str=True, context=render_context) + assert res == {"result": True, "data": "template body"} + + +def test_render_tmpl_from_str_writes_file(render_context): + """render_tmpl with to_str=False writes rendered output to a temp file.""" + wrapped = wrap_tmpl_func(EchoRender()) + res = wrapped("disk body", from_str=True, context=render_context) + assert res["result"] is True + written = res["data"] + assert os.path.isfile(written) + try: + with salt.utils.files.fopen(written, encoding="utf-8") as fh: + assert fh.read() == "disk body" + finally: + os.remove(written) + + +def test_render_tmpl_reads_file_path(tmp_path, render_context): + """render_tmpl reads template content from a file path when from_str is False.""" + tplfile = tmp_path / "tmpl.txt" + tplfile.write_text("from file") + render = EchoRender() + wrapped = wrap_tmpl_func(render) + res = wrapped(str(tplfile), to_str=True, context=render_context) + assert res == {"result": True, "data": "from file"} + assert render.tplstr == "from file" + + +def test_render_tmpl_file_like_input(render_context): + """render_tmpl reads and closes a file-like template source.""" + import io + + class ClosableStringIO(io.StringIO): + closed_flag = False + + def close(self): + type(self).closed_flag = True + super().close() + + src = ClosableStringIO("from file-like") + wrapped = wrap_tmpl_func(EchoRender()) + res = wrapped(src, to_str=True, context=render_context) + assert res == {"result": True, "data": "from file-like"} + assert ClosableStringIO.closed_flag is True + + +def test_render_tmpl_empty_template(render_context): + """render_tmpl handles an empty template string, returning empty data.""" + wrapped = wrap_tmpl_func(EchoRender()) + res = wrapped("", from_str=True, to_str=True, context=render_context) + assert res == {"result": True, "data": ""} + + +def test_render_tmpl_sls_context_merged(tmp_path): + """render_tmpl merges generate_sls_context output into the render context.""" + slsfile = tmp_path / "foo" / "bar.sls" + slsfile.parent.mkdir() + slsfile.write_text("body") + context = {"opts": {}, "saltenv": "base", "sls": "foo.bar"} + render = EchoRender() + wrapped = wrap_tmpl_func(render) + res = wrapped(str(slsfile), to_str=True, context=context, tmplpath=str(slsfile)) + assert res["result"] is True + # generate_sls_context computed values get merged into the context the + # renderer sees. + assert render.context["slspath"] == "foo" + assert render.context["sls_path"] == "foo" + assert render.context["tplfile"] == "foo/bar.sls" + + +def test_render_tmpl_explicit_context_overrides_kwargs(render_context): + """render_tmpl lets explicit context overwrite values passed as **kws.""" + render = EchoRender() + wrapped = wrap_tmpl_func(render) + context = dict(render_context) + context["shared"] = "from_context" + res = wrapped( + "body", + from_str=True, + to_str=True, + context=context, + shared="from_kws", + ) + assert res["result"] is True + assert render.context["shared"] == "from_context" + + +def test_render_tmpl_bytes_input_treated_as_file_like(render_context): + """render_tmpl treats a non-str template source as file-like, raising on bytes.""" + wrapped = wrap_tmpl_func(EchoRender()) + # bytes is not a str, so render_tmpl falls into the file-like branch and + # calls tmplsrc.read() before the try/except guard; plain bytes has no + # .read(), so the AttributeError propagates out of render_tmpl. + with pytest.raises(AttributeError): + wrapped(b"raw bytes", from_str=False, to_str=True, context=render_context) + + +@pytest.mark.skip_on_windows( + reason="the Windows newline-normalization branch cannot handle bytes " + "renderer output (os.linesep.join over bytes raises TypeError)" +) +def test_render_tmpl_file_like_bytes_passed_through_undecoded(render_context): + """render_tmpl reads a file-like source returning bytes and hands it to + the renderer undecoded; the bytes come back as-is in the result.""" + import io + + src = io.BytesIO(b"byte body") + render = EchoRender() + wrapped = wrap_tmpl_func(render) + res = wrapped(src, from_str=False, to_str=True, context=render_context) + # EchoRender returns the raw bytes it received; to_str path wraps it as data. + assert res == {"result": True, "data": b"byte body"} + assert render.tplstr == b"byte body" From 7bb93f5a5ba4ac63b6464bdfd90e22a526e9a8b6 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 15:11:54 -0700 Subject: [PATCH 053/469] Fix vt.setwinsize passing negative ioctl request on Python 3.14 (#69707) salt.utils.vt.setwinsize carried a pexpect-era workaround that sign-flipped the macOS TIOCSWINSZ constant (2148037735) to the negative literal -2146929561, working around an old CPython signed-cast quirk in fcntl.ioctl. Python 3.14 rejects negative ioctl request values outright ("[Errno 25] Inappropriate ioctl for device"), which broke salt-ssh on the 3008.x macOS onedir: setwinsize runs inside every spawned pty child's preexec_fn, so subprocess swallows the child-side OSError and reports only "Failed to spawn the VT: Exception occurred in preexec_fn" for every target. termios.TIOCSWINSZ / TIOCGWINSZ exist on every POSIX platform this code path reaches (the surrounding try/except ImportError only excludes Windows, which never calls into setwinsize/getwinsize). Drop the getattr fallback and the sign-flip block and pass the termios constants through unchanged. Fixes #69705 --- changelog/69705.fixed.md | 1 + salt/utils/vt.py | 19 +++++--- tests/pytests/unit/utils/test_vt.py | 69 +++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 changelog/69705.fixed.md diff --git a/changelog/69705.fixed.md b/changelog/69705.fixed.md new file mode 100644 index 000000000000..fbdc8fb57ddd --- /dev/null +++ b/changelog/69705.fixed.md @@ -0,0 +1 @@ +Fixed `salt.utils.vt.setwinsize` and `getwinsize` to pass `termios.TIOCSWINSZ`/`TIOCGWINSZ` through to `fcntl.ioctl` unchanged, instead of sign-flipping the macOS value to a negative literal. Python 3.14 rejects negative ioctl request values with `Errno 25`, which broke `salt-ssh` on the 3008.x macOS onedir because `setwinsize` runs inside every spawned pty child's `preexec_fn`. diff --git a/salt/utils/vt.py b/salt/utils/vt.py index 068678612016..300c09be10ea 100644 --- a/salt/utils/vt.py +++ b/salt/utils/vt.py @@ -74,13 +74,18 @@ def setwinsize(child, rows=80, cols=80): Thank you for the shortcut PEXPECT """ # pylint: disable=used-before-assignment - TIOCSWINSZ = getattr(termios, "TIOCSWINSZ", -2146929561) - if TIOCSWINSZ == 2148037735: - # Same bits, but with sign. - TIOCSWINSZ = -2146929561 # Note, assume ws_xpixel and ws_ypixel are zero. + # + # Historical note: this used to fall back to a negative literal + # (-2146929561) when ``termios.TIOCSWINSZ`` compared equal to the + # unsigned macOS value 2148037735, working around an old CPython + # signed-cast quirk in ``fcntl.ioctl``. Python 3.14 rejects negative + # ``request`` values outright (Errno 25 "Inappropriate ioctl for + # device"), which broke salt-ssh on the 3008.x macOS onedir. The + # ``termios`` constant is authoritative on every supported platform, + # so pass it through unchanged. packed = struct.pack(b"HHHH", rows, cols, 0, 0) - fcntl.ioctl(child, TIOCSWINSZ, packed) + fcntl.ioctl(child, termios.TIOCSWINSZ, packed) def getwinsize(child): @@ -90,9 +95,9 @@ def getwinsize(child): Thank you for the shortcut PEXPECT """ - TIOCGWINSZ = getattr(termios, "TIOCGWINSZ", 1074295912) + # pylint: disable=used-before-assignment packed = struct.pack(b"HHHH", 0, 0, 0, 0) - ioctl = fcntl.ioctl(child, TIOCGWINSZ, packed) + ioctl = fcntl.ioctl(child, termios.TIOCGWINSZ, packed) return struct.unpack(b"HHHH", ioctl)[0:2] diff --git a/tests/pytests/unit/utils/test_vt.py b/tests/pytests/unit/utils/test_vt.py index 692bbf76f616..78be53aea7f6 100644 --- a/tests/pytests/unit/utils/test_vt.py +++ b/tests/pytests/unit/utils/test_vt.py @@ -5,6 +5,7 @@ import pytest import salt.utils.vt as vt +from tests.support.mock import patch @pytest.mark.skip_on_windows(reason="salt.utils.vt.Terminal doesn't have _spawn.") @@ -30,6 +31,74 @@ def test_isalive_no_child(): assert aliveness is False +@pytest.mark.skip_on_windows(reason="setwinsize/getwinsize are POSIX-only.") +def test_setwinsize_passes_termios_constant_unchanged(): + """ + Regression test for #69705. + + ``setwinsize`` used to sign-flip the macOS value of ``TIOCSWINSZ`` + (``2148037735``) to a negative int (``-2146929561``) as a workaround + for an old CPython signed-cast quirk. Python 3.14 rejects negative + ``request`` arguments to ``fcntl.ioctl`` outright, which broke + ``salt-ssh`` on the 3008.x macOS onedir because ``setwinsize`` runs + inside the ``preexec_fn`` of every spawned pty child. + + The fix is to pass ``termios.TIOCSWINSZ`` through untouched. This + test simulates the macOS constant and asserts the value handed to + ``fcntl.ioctl`` matches ``termios.TIOCSWINSZ`` exactly (and is not + negative). + """ + mac_tiocswinsz = 2148037735 + captured = [] + + def fake_ioctl(fd, req, packed): + captured.append(req) + return b"\x00" * 8 + + with patch.object( + vt.termios, "TIOCSWINSZ", mac_tiocswinsz, create=True + ), patch.object(vt.fcntl, "ioctl", side_effect=fake_ioctl): + vt.setwinsize(0, 24, 80) + + assert captured, "fcntl.ioctl was not called" + assert captured[0] == mac_tiocswinsz, ( + f"setwinsize passed {captured[0]!r} to fcntl.ioctl; expected " + f"{mac_tiocswinsz!r} (termios.TIOCSWINSZ, unchanged). Python 3.14 " + "rejects negative ioctl request values." + ) + assert captured[0] > 0, "ioctl request must not be negative on Python 3.14+" + + +@pytest.mark.skip_on_windows(reason="setwinsize/getwinsize are POSIX-only.") +def test_getwinsize_passes_termios_constant_unchanged(): + """ + Regression test for #69705 (``getwinsize`` companion). + + ``getwinsize`` had a similar hard-coded negative fallback for + ``TIOCGWINSZ``. Make sure the ``termios`` constant is passed to + ``fcntl.ioctl`` unchanged, so no negative value can reach the kernel + on Python 3.14+. + """ + import struct as _struct + import termios as _termios + + captured = [] + + def fake_ioctl(fd, req, packed): + captured.append(req) + return _struct.pack(b"HHHH", 24, 80, 0, 0) + + with patch.object(vt.fcntl, "ioctl", side_effect=fake_ioctl): + vt.getwinsize(0) + + assert captured, "fcntl.ioctl was not called" + assert captured[0] == _termios.TIOCGWINSZ, ( + f"getwinsize passed {captured[0]!r} to fcntl.ioctl; expected " + f"{_termios.TIOCGWINSZ!r} (termios.TIOCGWINSZ, unchanged)." + ) + assert captured[0] > 0, "ioctl request must not be negative on Python 3.14+" + + @pytest.mark.parametrize("test_cmd", ["echo", "ls"]) @pytest.mark.skip_on_windows() def test_log_sanitize(test_cmd, caplog): From 1a72e4002c5adcbbe55d2276b0abb74272f03577 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 15:12:22 -0700 Subject: [PATCH 054/469] Fix metadata grain IMDSv2 token support (#65233) (#69643) * Account for situation where the metadata grain fails because the AWS environment requires an authentication token to query the metadata URL. * Add changelog for #65233 IMDSv2 token support Fixes #65233 --------- Co-authored-by: Gareth J. Greenaway --- changelog/65233.fixed.md | 1 + salt/grains/metadata.py | 53 ++++++- tests/pytests/unit/grains/test_metadata.py | 172 ++++++++++++++++++++- 3 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 changelog/65233.fixed.md diff --git a/changelog/65233.fixed.md b/changelog/65233.fixed.md new file mode 100644 index 000000000000..5aed6d865122 --- /dev/null +++ b/changelog/65233.fixed.md @@ -0,0 +1 @@ +Fixed the ``metadata`` grain module to send an ``X-aws-ec2-metadata-token`` header when the EC2 Instance Metadata Service requires IMDSv2, preventing silent grain-load failures on AMIs that enforce token-based metadata access. diff --git a/salt/grains/metadata.py b/salt/grains/metadata.py index bd7798a023f1..29c2d37e0c02 100644 --- a/salt/grains/metadata.py +++ b/salt/grains/metadata.py @@ -36,16 +36,55 @@ def __virtual__(): if result != 0: return False if http.query(os.path.join(HOST, "latest/"), status=True).get("status") != 200: - return False + # Initial connection failed, might need a token + _refresh_token() + if ( + http.query( + os.path.join(HOST, "latest/"), + status=True, + header_dict={ + "X-aws-ec2-metadata-token": __context__["metadata_aws_token"] + }, + ).get("status") + != 200 + ): + return False return True +def _refresh_token(): + __context__["metadata_aws_token"] = http.query( + os.path.join(HOST, "latest/api/token"), + method="PUT", + header_dict={"X-aws-ec2-metadata-token-ttl-seconds": "21600"}, + ).get("body") + + def _search(prefix="latest/"): """ Recursively look up all grains in the metadata server """ ret = {} - linedata = http.query(os.path.join(HOST, prefix), headers=True) + if "metadata_aws_token" in __context__: + if ( + http.query( + os.path.join(HOST, "latest/"), + status=True, + header_dict={ + "X-aws-ec2-metadata-token": __context__["metadata_aws_token"] + }, + ).get("status") + != 200 + ): + _refresh_token() + + linedata = http.query( + os.path.join(HOST, prefix), + header_dict={"X-aws-ec2-metadata-token": __context__["metadata_aws_token"]}, + headers=True, + ) + else: + linedata = http.query(os.path.join(HOST, prefix), headers=True) if "body" not in linedata: return ret body = salt.utils.stringutils.to_unicode(linedata["body"]) @@ -76,7 +115,15 @@ def _search(prefix="latest/"): key, value = line.split("=") ret[value] = _search(prefix=os.path.join(prefix, key)) else: - retdata = http.query(os.path.join(HOST, prefix, line)).get("body", None) + if "metadata_aws_token" in __context__: + retdata = http.query( + os.path.join(HOST, prefix, line), + header_dict={ + "X-aws-ec2-metadata-token": __context__["metadata_aws_token"] + }, + ).get("body", None) + else: + retdata = http.query(os.path.join(HOST, prefix, line)).get("body", None) # (gtmanfred) This try except block is slightly faster than # checking if the string starts with a curly brace if isinstance(retdata, bytes): diff --git a/tests/pytests/unit/grains/test_metadata.py b/tests/pytests/unit/grains/test_metadata.py index 1bba86770f78..84f32b08fe60 100644 --- a/tests/pytests/unit/grains/test_metadata.py +++ b/tests/pytests/unit/grains/test_metadata.py @@ -17,6 +17,14 @@ [CRITICAL] Failed to load grains defined in grain file metadata.metadata ... KeyError: 'headers' + +Regression coverage for #65233: on AWS instances that enforce IMDSv2, the +metadata service returns HTTP 401 for any request that does not carry an +``X-aws-ec2-metadata-token`` header. The grain now PUTs to +``latest/api/token`` on that 401, caches the token in ``__context__``, and +sends it with every subsequent metadata query. + +:codeauthor: :email: `Gareth J. Greenaway ` """ import logging @@ -25,11 +33,22 @@ import salt.grains.metadata as metadata import salt.utils.http as http -from tests.support.mock import create_autospec, patch +from tests.support.mock import MagicMock, create_autospec, patch log = logging.getLogger(__name__) +class MockSocketClass: + def __init__(self, *args, **kwargs): + pass + + def settimeout(self, *args, **kwargs): + pass + + def connect_ex(self, *args, **kwargs): + return 0 + + @pytest.fixture def configure_loader_modules(): return {metadata: {"__opts__": {"metadata_server_grains": "True"}}} @@ -94,7 +113,7 @@ def test_user_data_with_equals_is_returned_verbatim(): result = metadata.metadata() assert "user-data" in result, result - # Verbatim — no splitting on "=", no key/value mangling. + # Verbatim - no splitting on "=", no key/value mangling. assert result["user-data"] == user_data_body # And specifically, the ``=`` characters in the payload must survive. assert "FOO=bar" in result["user-data"] @@ -162,7 +181,7 @@ def test_equals_lines_other_than_user_data_still_parse_via_splitter(): "headers": {"Content-Type": "text/plain"}, }, "http://169.254.169.254/latest/meta-data/iam/security-credentials/": { - # "alias=role" — the "=" branch must still fire for this. + # "alias=role" - the "=" branch must still fire for this. "body": "myrole-user-data=role-arn-suffix", "headers": {"Content-Type": "text/plain"}, }, @@ -193,7 +212,7 @@ def test_equals_lines_other_than_user_data_still_parse_via_splitter(): def test_search_handles_error_response_without_headers_65184(): """ Regression for #65184: a recursive ``http.query`` call that returns an - error-shaped response (``body`` present, ``headers`` absent — the shape + error-shaped response (``body`` present, ``headers`` absent - the shape produced by the tornado backend on HTTPError since 3006.3) must not crash ``_search()`` with ``KeyError: 'headers'``. @@ -285,3 +304,148 @@ def test_search_octet_stream_still_returns_body_verbatim(): # Body returned verbatim, not wrapped in a dict. assert result == "raw-octet-stream-payload" + + +def test_metadata_search(): + def mock_http( + url="", + method="GET", + headers=False, + header_list=None, + header_dict=None, + status=False, + ): + metadata_vals = { + "http://169.254.169.254/latest/api/token": { + "body": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX==", + "status": 200, + "headers": {}, + }, + "http://169.254.169.254/latest/": { + "body": "meta-data", + "headers": {}, + }, + "http://169.254.169.254/latest/meta-data/": { + "body": "ami-id\nami-launch-index\nami-manifest-path\nhostname", + "headers": {}, + }, + "http://169.254.169.254/latest/meta-data/ami-id": { + "body": "ami-xxxxxxxxxxxxxxxxx", + "headers": {}, + }, + "http://169.254.169.254/latest/meta-data/ami-launch-index": { + "body": "0", + "headers": {}, + }, + "http://169.254.169.254/latest/meta-data/ami-manifest-path": { + "body": "(unknown)", + "headers": {}, + }, + "http://169.254.169.254/latest/meta-data/hostname": { + "body": "ip-xx-x-xx-xx.us-west-2.compute.internal", + "headers": {}, + }, + } + + return metadata_vals[url] + + with patch( + "salt.utils.http.query", + create_autospec(http.query, autospec=True, side_effect=mock_http), + ): + ret = metadata.metadata() + assert ret == { + "meta-data": { + "ami-id": "ami-xxxxxxxxxxxxxxxxx", + "ami-launch-index": "0", + "ami-manifest-path": "(unknown)", + "hostname": "ip-xx-x-xx-xx.us-west-2.compute.internal", + } + } + + with patch.dict( + metadata.__context__, + { + "metadata_aws_token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX==" + }, + ): + with patch( + "salt.utils.http.query", + create_autospec(http.query, autospec=True, side_effect=mock_http), + ): + ret = metadata.metadata() + assert ret == { + "meta-data": { + "ami-id": "ami-xxxxxxxxxxxxxxxxx", + "ami-launch-index": "0", + "ami-manifest-path": "(unknown)", + "hostname": "ip-xx-x-xx-xx.us-west-2.compute.internal", + } + } + + +def test_metadata_refresh_token(): + with patch( + "salt.utils.http.query", + create_autospec( + http.query, + autospec=True, + return_value={ + "body": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX==", + }, + ), + ): + metadata._refresh_token() + assert "metadata_aws_token" in metadata.__context__ + assert ( + metadata.__context__["metadata_aws_token"] + == "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX==" + ) + + +def test_metadata_virtual(): + with patch("socket.socket", MagicMock(return_value=MockSocketClass())): + with patch( + "salt.utils.http.query", + create_autospec( + http.query, + autospec=True, + return_value={"error": "[Errno -2] Name or service not known"}, + ), + ): + assert metadata.__virtual__() is False + + with patch( + "salt.utils.http.query", + create_autospec( + http.query, + autospec=True, + return_value={ + "body": "dynamic\nmeta-data\nuser-data", + "status": 200, + }, + ), + ): + assert metadata.__virtual__() is True + + with patch( + "salt.utils.http.query", + create_autospec( + http.query, + autospec=True, + side_effect=[ + { + "body": "", + "status": 401, + }, + { + "body": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX==", + }, + { + "body": "dynamic\nmeta-data\nuser-data", + "status": 200, + }, + ], + ), + ): + assert metadata.__virtual__() is True From 0ef44211c8d3f25575945abf088c00865d28188d Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 15:15:03 -0700 Subject: [PATCH 055/469] Fix minion-driven RPM upgrade deadlock and SIGKILL (#69656) (#69664) * Fix minion-driven RPM upgrade deadlock and SIGKILL The RPM %pre minion scriptlet's blocking systemctl stop salt-minion.service deadlocks when the upgrade is driven by the running minion itself (via pkg.installed / pkg.install from a state run): the stop waits for every process in the KillMode=mixed cgroup to exit, including the salt worker running the state, which is waiting on dnf, which is waiting on %pre. After TimeoutStopSec, systemd SIGKILLs the whole cgroup and the state run's return is lost. Orchestrated minion upgrades cannot work at all. %pre minion now detects the self-upgrade case by walking the scriptlet's parent process chain looking for salt-minion.service in /proc//cgroup and, in that case, skips the in-scriptlet stop and drops a /tmp/.salt-minion-self-upgrade marker. %post minion honours that marker to skip the try-restart that would otherwise interrupt the still-running state run. %posttrans minion's start is a no-op against the still-active unit, so the state completes and the FAQ cmd.run bg: True pattern performs the actual restart in a detached child. Also corrected the FAQ entry: the shipped unit sets KillMode=mixed, not KillMode=process as the old wording claimed. Fixes #69656 * Address review: remove duplicate Windows conditional in FAQ example Both branches of the `{%- if grains['kernel'] == 'Windows' %}` block contained identical content, so remove the conditional and leave a single unconditional `- name:` line. --- changelog/69656.fixed.md | 1 + doc/faq.rst | 29 +- pkg/rpm/salt.spec | 60 ++- .../unit/pkg/test_rpm_minion_scriptlets.py | 346 +++++++++++++++++- 4 files changed, 402 insertions(+), 34 deletions(-) create mode 100644 changelog/69656.fixed.md diff --git a/changelog/69656.fixed.md b/changelog/69656.fixed.md new file mode 100644 index 000000000000..9d190bb11f48 --- /dev/null +++ b/changelog/69656.fixed.md @@ -0,0 +1 @@ +Fixed minion-driven RPM upgrades getting SIGKILLed mid-transaction. The ``%pre minion`` scriptlet's blocking ``systemctl stop salt-minion.service`` deadlocked when the upgrade was driven by the running minion itself (via ``pkg.installed`` or ``pkg.install``): the stop waited for every process in the ``KillMode=mixed`` cgroup to exit, including the salt worker executing the state, which was waiting on ``dnf``, which was waiting on ``%pre``. After ``TimeoutStopSec`` systemd SIGKILLed the whole cgroup and the state run's return was lost. ``%pre minion`` now walks the scriptlet's parent process chain, detects when the transaction was initiated from inside ``salt-minion.service``, and skips the in-scriptlet stop; ``%post`` and ``%posttrans`` leave the still-running minion alone so the state completes normally and the ``cmd.run bg: True`` restart pattern from the FAQ can perform the actual restart in a detached child. diff --git a/doc/faq.rst b/doc/faq.rst index a6fedafec5d5..f42c7623a89e 100644 --- a/doc/faq.rst +++ b/doc/faq.rst @@ -261,12 +261,21 @@ What is the best way to restart a Salt Minion daemon using Salt after upgrade? ------------------------------------------------------------------------------ Updating the ``salt-minion`` package requires a restart of the ``salt-minion`` -service. When the minion runs as a child of ``systemd`` and the shipped -``salt-minion.service`` unit (which sets ``KillMode=process``) is in use, the -package install scriptlets issue ``systemctl try-restart salt-minion.service`` -and the in-flight state run survives because only the supervisor process is -signaled. In that environment, no special FAQ workaround is needed for an -upgrade triggered by ``pkg.installed``. +service. On systemd systems the shipped ``salt-minion.service`` unit sets +``KillMode=mixed``, and the RPM's ``%pre`` scriptlet issues a blocking +``systemctl stop salt-minion.service`` so ownership-restoration ``chown`` +calls do not race a live minion. On its own that stop would deadlock a +minion-driven upgrade -- the stop waits for every process in the cgroup to +exit, including the salt worker running the state; the worker is blocked in +``dnf``; ``dnf`` is blocked in ``%pre`` -- and after ``TimeoutStopSec`` +systemd would SIGKILL the whole cgroup, losing the state return +(issue #69656). Starting with 3006.28 the ``%pre minion`` scriptlet detects +this "minion is upgrading itself" case (by walking the scriptlet's parent +process chain and looking for ``salt-minion.service`` in the cgroup) and +skips the blocking stop; ``%post`` and ``%posttrans`` then leave the still- +running minion alone. The state run's ``pkg.installed`` returns normally, +and the FAQ pattern below performs the actual restart in a detached child +after the state completes. The remainder of this entry covers the cases that still need explicit handling: @@ -299,18 +308,14 @@ so the restart runs detached from the state run: Restart Salt Minion: cmd.run: - {%- if grains['kernel'] == 'Windows' %} - name: 'salt-call --local service.restart salt-minion' - {%- else %} - - name: 'salt-call --local service.restart salt-minion' - {%- endif %} - bg: True - onchanges: - pkg: Upgrade Salt Minion ``--local`` keeps the call self-contained so the restart does not depend on a -master round-trip. ``bg: True`` forks the ``salt-call`` process; combined with -``KillMode=process`` in the systemd unit, the running state and its return +master round-trip. ``bg: True`` forks the ``salt-call`` process so it survives +the parent ``salt-minion`` service restart; the running state and its return to the master are not interrupted. Restart from the master diff --git a/pkg/rpm/salt.spec b/pkg/rpm/salt.spec index 72689fea8d10..35d0ff240235 100644 --- a/pkg/rpm/salt.spec +++ b/pkg/rpm/salt.spec @@ -511,6 +511,37 @@ if [ -f /etc/sysconfig/salt-minion-setup ]; then . /etc/sysconfig/salt-minion-setup fi +# Detect whether the current RPM transaction was initiated from within +# the ``salt-minion.service`` control group -- i.e. a running minion is +# driving its own upgrade via ``pkg.installed`` / ``pkg.install``. In +# that case a blocking ``systemctl stop salt-minion.service`` below +# deadlocks: the stop waits for every process in the (KillMode=mixed) +# cgroup to exit, including the salt worker running this transaction; +# the worker is blocked in dnf; dnf is blocked in ``%pre``; ``%pre`` is +# blocked in ``systemctl stop``. After ``TimeoutStopSec`` elapses, +# systemd SIGKILLs the whole cgroup -- including the salt job -- and +# the state run's return is lost. See issue #69656. +# +# Walk the PPID chain from the scriptlet's parent (dnf) up to init and +# check each ancestor's cgroup: ``yumpkg`` wraps dnf in +# ``systemd-run --scope`` which detaches dnf's own cgroup from +# ``salt-minion.service``, but the process-tree relationship is +# preserved and eventually reaches the salt worker, which is still +# under ``salt-minion.service``. +_salt_minion_upgrade_from_running_minion() { + _pid=$PPID + _count=0 + while [ -n "$_pid" ] && [ "$_pid" != "1" ] && [ "$_pid" != "0" ] && [ "$_count" -lt 40 ]; do + if [ -r "/proc/$_pid/cgroup" ] \ + && grep -q 'salt-minion\.service' "/proc/$_pid/cgroup" 2>/dev/null; then + return 0 + fi + _pid=$(awk '/^PPid:/{print $2}' "/proc/$_pid/status" 2>/dev/null) + _count=$((_count + 1)) + done + return 1 +} + if [ $1 -gt 1 ] ; then # Upgrade: detect and save current ownership. # @@ -522,7 +553,19 @@ if [ $1 -gt 1 ] ; then if /bin/systemctl is-active --quiet salt-minion.service 2>/dev/null; then touch /tmp/.salt-minion-upgrade-was-active fi - /bin/systemctl stop salt-minion.service >/dev/null 2>&1 || : + if _salt_minion_upgrade_from_running_minion; then + # Minion is upgrading itself. Skip the blocking stop -- it would + # deadlock the transaction and cause systemd to SIGKILL the job. + # ``%post`` and ``%posttrans minion`` will honor the marker + # dropped here and leave the running minion alone so its state + # run returns cleanly; the FAQ ``cmd.run bg: True`` pattern then + # restarts the minion after the state completes. See #69656. + touch /tmp/.salt-minion-self-upgrade + touch /tmp/.salt-minion-upgrade-was-active + echo "salt-minion: skipping in-scriptlet stop; upgrade is driven by the running minion (issue #69656)" >&2 + else + /bin/systemctl stop salt-minion.service >/dev/null 2>&1 || : + fi # Check if minion config specifies a non-root user. The configured # user in /etc/salt/minion (or a drop-in under /etc/salt/minion.d) @@ -722,7 +765,14 @@ if [ $1 -gt 1 ] ; then # Create marker file to tell %posttrans this was an upgrade touch /tmp/.salt-minion-upgrade-ownership.done fi - /bin/systemctl try-restart salt-minion.service >/dev/null 2>&1 || : + # ``try-restart`` would interrupt a self-upgrade driven by the + # running minion -- the state run would die mid-transaction. Skip + # it when ``%pre minion`` detected that case; ``%posttrans minion`` + # (and the FAQ ``cmd.run bg: True`` pattern) restart the service + # after the transaction completes. See issue #69656. + if [ ! -f /tmp/.salt-minion-self-upgrade ]; then + /bin/systemctl try-restart salt-minion.service >/dev/null 2>&1 || : + fi else # Initial installation /bin/systemctl preset salt-minion.service >/dev/null 2>&1 || : @@ -907,12 +957,18 @@ fi # unit was previously active. The marker file is dropped in ``%pre # minion`` only when ``is-active`` was true at the start of the # upgrade transaction. See issue #69605. +# +# In the self-upgrade case (issue #69656) the minion is *still* +# running here -- ``%pre`` skipped the stop -- so ``systemctl start`` +# is a no-op. The FAQ ``cmd.run bg: True`` pattern in the state that +# drove this transaction restarts the minion once the state returns. if [ -f /tmp/.salt-minion-upgrade-was-active ]; then /bin/systemctl start salt-minion.service >/dev/null 2>&1 || : rm -f /tmp/.salt-minion-upgrade-was-active else /bin/systemctl try-restart salt-minion.service >/dev/null 2>&1 || : fi +rm -f /tmp/.salt-minion-self-upgrade %preun diff --git a/tests/pytests/unit/pkg/test_rpm_minion_scriptlets.py b/tests/pytests/unit/pkg/test_rpm_minion_scriptlets.py index 399ac8edc002..414cb98b70bc 100644 --- a/tests/pytests/unit/pkg/test_rpm_minion_scriptlets.py +++ b/tests/pytests/unit/pkg/test_rpm_minion_scriptlets.py @@ -1,23 +1,44 @@ """ -Regression tests for the RPM ``%pre minion`` / ``%posttrans minion`` -scriptlets. - -The ``%pre minion`` scriptlet unconditionally stops the running minion -service on upgrade so the ownership-restoration chowns in ``%post`` / -``%posttrans`` don't race a live process. The historical -``%post`` / ``%posttrans`` scriptlets only called -``systemctl try-restart salt-minion.service``, which by design is a -no-op when the unit is inactive. The combination silently broke RPM -upgrades on every EL host: the minion was stopped by ``%pre`` and never -started again, leaving operators with no automatic recovery short of -logging into each host. See https://github.com/saltstack/salt/issues/69605. - -This file is a *static audit* of ``pkg/rpm/salt.spec``. It runs in -ordinary unit-test CI - no rpmbuild, no systemd, no fixtures - so the -guard kicks in on every PR rather than only in the packaging matrix. +Regression tests for the RPM ``%pre minion`` / ``%post minion`` / +``%posttrans minion`` scriptlets. + +Two long-standing packaging bugs are guarded here. + +1. Issue #69605: The ``%pre minion`` scriptlet unconditionally stops the + running minion service on upgrade so the ownership-restoration chowns + in ``%post`` / ``%posttrans`` don't race a live process. The historical + ``%post`` / ``%posttrans`` scriptlets only called + ``systemctl try-restart salt-minion.service``, which by design is a + no-op when the unit is inactive. The combination silently broke RPM + upgrades on every EL host: the minion was stopped by ``%pre`` and + never started again, leaving operators with no automatic recovery + short of logging into each host. + +2. Issue #69656: When the upgrade is driven by the *running minion* (via + ``pkg.installed`` from a state run), the blocking ``systemctl stop`` + in ``%pre`` deadlocks. The stop waits for every process in the + ``KillMode=mixed`` cgroup to exit, including the salt worker running + the state, which is waiting on ``dnf``, which is waiting on ``%pre``. + After ``TimeoutStopSec`` systemd SIGKILLs the whole cgroup, the state + return is lost, and orchestrated minion upgrades cannot work at all. + ``%pre minion`` now detects the self-upgrade case (by walking the + scriptlet's parent process chain) and skips the stop; ``%post`` and + ``%posttrans`` then honour a ``.salt-minion-self-upgrade`` marker to + leave the still-running minion alone. The FAQ's ``cmd.run bg: True`` + pattern performs the actual restart in a detached child after the + state returns. + +This file is a *static audit* of ``pkg/rpm/salt.spec`` plus a bash-level +functional test of the ``_salt_minion_upgrade_from_running_minion`` +helper. Both run in ordinary unit-test CI - no rpmbuild, no systemd, no +fixtures - so the guard kicks in on every PR rather than only in the +packaging matrix. """ import re +import shutil +import subprocess +import sys from pathlib import Path import pytest @@ -42,20 +63,73 @@ def _extract_scriptlet(spec_text, directive): return match.group(1) +def _strip_shell_comments(text): + """ + Remove ``#``-style comments from a shell scriptlet body so subsequent + substring searches don't false-positive against explanatory prose. We + only strip lines whose first non-whitespace character is ``#`` and + trailing ``# ...`` comments on ordinary lines; the crude form is enough + for the audit checks in this file. + """ + stripped_lines = [] + for line in text.splitlines(): + # Full-line comment. + if re.match(r"^\s*#", line): + continue + # Trailing comment on an otherwise-live line. Avoid stripping ``#`` + # inside single-quoted strings because the scriptlet uses phrases + # like ``echo '...issue #69656...'``. + in_single = False + out = [] + i = 0 + while i < len(line): + ch = line[i] + if ch == "'" and not in_single: + in_single = True + elif ch == "'" and in_single: + in_single = False + elif ch == "#" and not in_single: + break + out.append(ch) + i += 1 + stripped_lines.append("".join(out)) + return "\n".join(stripped_lines) + + @pytest.fixture(scope="module") def spec_text(): assert SPEC_FILE.is_file(), f"spec file missing: {SPEC_FILE}" return SPEC_FILE.read_text(encoding="utf-8") -def test_pre_minion_records_was_active_before_stop(spec_text): +@pytest.fixture(scope="module") +def pre_minion_body(spec_text): + return _extract_scriptlet(spec_text, "%pre minion") + + +@pytest.fixture(scope="module") +def pre_minion_body_no_comments(pre_minion_body): + return _strip_shell_comments(pre_minion_body) + + +@pytest.fixture(scope="module") +def post_minion_body(spec_text): + return _extract_scriptlet(spec_text, "%post minion") + + +@pytest.fixture(scope="module") +def posttrans_minion_body(spec_text): + return _extract_scriptlet(spec_text, "%posttrans minion") + + +def test_pre_minion_records_was_active_before_stop(pre_minion_body_no_comments): """ ``%pre minion`` must record the unit's pre-upgrade active state before invoking ``systemctl stop``. Otherwise ``%posttrans`` has no way to know whether the service should be brought back up. See https://github.com/saltstack/salt/issues/69605. """ - body = _extract_scriptlet(spec_text, "%pre minion") + body = pre_minion_body_no_comments stop_idx = body.find("systemctl stop salt-minion.service") assert stop_idx != -1, ( "%pre minion no longer stops salt-minion.service on upgrade. " @@ -75,7 +149,7 @@ def test_pre_minion_records_was_active_before_stop(spec_text): ) -def test_posttrans_minion_starts_when_was_active(spec_text): +def test_posttrans_minion_starts_when_was_active(posttrans_minion_body): """ ``%posttrans minion`` must use ``systemctl start`` (not just ``try-restart``) when the ``%pre`` scriptlet recorded that the unit @@ -83,7 +157,7 @@ def test_posttrans_minion_starts_when_was_active(spec_text): inactive unit, so on its own it cannot recover from the deliberate stop in ``%pre``. See https://github.com/saltstack/salt/issues/69605. """ - body = _extract_scriptlet(spec_text, "%posttrans minion") + body = posttrans_minion_body # The scriptlet must reference the marker file dropped by %pre. assert "salt-minion-upgrade-was-active" in body, ( "%posttrans minion does not consult the pre-upgrade-active " @@ -99,3 +173,235 @@ def test_posttrans_minion_starts_when_was_active(spec_text): "unit is inactive and cannot recover from %pre's stop. See " "issue #69605." ) + + +# --------------------------------------------------------------------------- +# Issue #69656 -- self-upgrade guard. +# --------------------------------------------------------------------------- + + +def test_pre_minion_guards_stop_with_self_upgrade_detection( + pre_minion_body_no_comments, +): + """ + ``%pre minion`` must not unconditionally invoke ``systemctl stop + salt-minion.service`` on upgrade -- that deadlocks a minion-driven + upgrade and causes systemd to SIGKILL the state run. The scriptlet + must first check whether the transaction was initiated from inside + ``salt-minion.service`` (self-upgrade case) and, in that case, skip + the stop. See https://github.com/saltstack/salt/issues/69656. + """ + body = pre_minion_body_no_comments + assert "_salt_minion_upgrade_from_running_minion" in body, ( + "%pre minion is missing the " + "_salt_minion_upgrade_from_running_minion helper that detects a " + "self-upgrade. Without it the blocking systemctl stop deadlocks " + "and systemd SIGKILLs the state run. See issue #69656." + ) + # The stop must be inside an ``else`` branch of the self-upgrade + # guard, not at top level. Match the fenced structure explicitly. + guard = re.search( + r"if\s+_salt_minion_upgrade_from_running_minion.*?" + r"else\s+.*?systemctl\s+stop\s+salt-minion\.service.*?fi", + body, + re.DOTALL, + ) + assert guard is not None, ( + "%pre minion does not fence ``systemctl stop salt-minion.service`` " + "behind the self-upgrade detection helper. The stop must live in " + "the ``else`` branch of ``if " + "_salt_minion_upgrade_from_running_minion; then ... else ... fi``. " + "See issue #69656." + ) + + +def test_pre_minion_drops_self_upgrade_marker(pre_minion_body_no_comments): + """ + When ``%pre minion`` skips the stop it must drop a marker file so + ``%post`` and ``%posttrans`` know to leave the still-running minion + alone; otherwise a subsequent ``try-restart`` would kill the state + run driving the upgrade. See issue #69656. + """ + assert "/tmp/.salt-minion-self-upgrade" in pre_minion_body_no_comments, ( + "%pre minion does not drop the /tmp/.salt-minion-self-upgrade " + "marker in the self-upgrade branch; %post's try-restart would " + "then kill the still-running state run. See issue #69656." + ) + + +def test_post_minion_skips_restart_on_self_upgrade(post_minion_body): + """ + ``%post minion`` runs ``systemctl try-restart salt-minion.service`` + on upgrade. That would interrupt a self-upgrade -- the running state + would be killed. The scriptlet must skip the ``try-restart`` when + ``%pre`` left the ``.salt-minion-self-upgrade`` marker. See issue + #69656. + """ + body = post_minion_body + # ``%post minion`` must reference the self-upgrade marker. + assert "/tmp/.salt-minion-self-upgrade" in body, ( + "%post minion does not consult the self-upgrade marker; a " + "``systemctl try-restart`` here kills the state run driving the " + "upgrade. See issue #69656." + ) + # And it must fence the try-restart behind that marker check. + stripped = _strip_shell_comments(body) + guard = re.search( + r"if\s+\[\s+!\s+-f\s+/tmp/\.salt-minion-self-upgrade\s+\]\s*;\s*then" + r".*?systemctl\s+try-restart\s+salt-minion\.service.*?fi", + stripped, + re.DOTALL, + ) + assert guard is not None, ( + "%post minion does not fence ``systemctl try-restart`` behind " + "the /tmp/.salt-minion-self-upgrade guard. See issue #69656." + ) + + +def test_posttrans_minion_cleans_self_upgrade_marker(posttrans_minion_body): + """ + ``%posttrans minion`` must remove the ``/tmp/.salt-minion-self-upgrade`` + marker so it does not leak across subsequent transactions. See issue + #69656. + """ + assert re.search( + r"rm\s+-f\s+/tmp/\.salt-minion-self-upgrade", posttrans_minion_body + ), ( + "%posttrans minion does not remove /tmp/.salt-minion-self-upgrade; " + "the marker will leak into the next upgrade transaction. See " + "issue #69656." + ) + + +# --------------------------------------------------------------------------- +# Functional test of the shell helper against a fake /proc tree. +# --------------------------------------------------------------------------- + + +def _extract_helper_function(spec_text): + """ + Return the shell source of ``_salt_minion_upgrade_from_running_minion`` + from ``%pre minion``, isolated so we can source it directly. + """ + match = re.search( + r"(_salt_minion_upgrade_from_running_minion\(\)\s*\{.*?\n\})", + spec_text, + re.DOTALL, + ) + assert match is not None, ( + "_salt_minion_upgrade_from_running_minion helper missing from " + "pkg/rpm/salt.spec. See issue #69656." + ) + return match.group(1) + + +def _make_proc(tmpdir, entries): + """ + Build a fake ``/proc`` layout under ``tmpdir`` for the supplied + ``entries`` list. Each entry is ``(pid, ppid, cgroup_text)``. Returns + the fake proc root path. + """ + proc = tmpdir / "proc" + proc.mkdir() + for pid, ppid, cgroup in entries: + pid_dir = proc / str(pid) + pid_dir.mkdir() + (pid_dir / "status").write_text(f"Name:\tfoo\nPPid:\t{ppid}\n") + (pid_dir / "cgroup").write_text(cgroup) + return proc + + +def _run_helper(spec_text, tmp_path, ppid, entries): + """ + Source the helper against a fake ``/proc`` tree and return its exit + status. We rewrite the hardcoded ``/proc`` path to point at the + fake tree and set ``$PPID`` by using a subshell that starts with + the requested pid on its walk. + """ + helper = _extract_helper_function(spec_text) + fake_proc = _make_proc(tmp_path, entries) + # Patch the helper to read from ``$FAKE_PROC`` instead of ``/proc``. + helper_patched = helper.replace("/proc/", "${FAKE_PROC}/") + # Fake the ``$PPID`` bash builtin: it is read-only in bash so we + # rewrite the reference in the helper to a variable we control. + helper_patched = helper_patched.replace("_pid=$PPID", "_pid=$PPID_OVERRIDE") + script = f""" +set -e +FAKE_PROC={fake_proc} +PPID_OVERRIDE={ppid} +{helper_patched} +_salt_minion_upgrade_from_running_minion && echo YES || echo NO +""" + bash = shutil.which("bash") + if bash is None: # pragma: no cover + pytest.skip("bash not available") + result = subprocess.run( + [bash, "-c", script], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, ( + f"helper harness failed: stdout={result.stdout!r} " f"stderr={result.stderr!r}" + ) + return result.stdout.strip().splitlines()[-1] + + +@pytest.mark.skipif( + sys.platform != "linux", + reason="bash-executable /proc walk test only runs on Linux", +) +def test_helper_detects_ancestor_in_salt_minion_cgroup(spec_text, tmp_path): + """ + Simulate the salt-driven upgrade path: dnf (parent of scriptlet) + was spawned by a salt worker that is still inside + ``salt-minion.service``. The helper must walk up and report YES. + """ + # Fake tree: pid 100 (scriptlet's parent, dnf) -> pid 200 + # (systemd-run, still in the transient scope) -> pid 300 (salt + # worker, in salt-minion.service). The helper starts at $PPID=100. + entries = [ + (100, 200, "0::/system.slice/run-r1234.scope\n"), + (200, 300, "0::/system.slice/run-r1234.scope\n"), + (300, 1, "0::/system.slice/salt-minion.service\n"), + ] + assert _run_helper(spec_text, tmp_path, 100, entries) == "YES" + + +@pytest.mark.skipif( + sys.platform != "linux", + reason="bash-executable /proc walk test only runs on Linux", +) +def test_helper_reports_no_when_run_from_root_shell(spec_text, tmp_path): + """ + A regular administrator invocation (``dnf upgrade salt-minion`` from + a user session, or a cron job) must NOT match the self-upgrade + detection. The stop is still required to keep the ownership + restoration safe. Fake a chain that never enters + ``salt-minion.service``. + """ + entries = [ + (100, 200, "0::/user.slice/user-1000.slice/session-3.scope\n"), + (200, 1, "0::/user.slice/user-1000.slice/session-3.scope\n"), + ] + assert _run_helper(spec_text, tmp_path, 100, entries) == "NO" + + +@pytest.mark.skipif( + sys.platform != "linux", + reason="bash-executable /proc walk test only runs on Linux", +) +def test_helper_reports_no_when_ppid_chain_reaches_pid1(spec_text, tmp_path): + """ + A short PPID chain that terminates at pid 1 without hitting + ``salt-minion.service`` must return NO. Regression guard against + the walk mistaking ``init`` for a match. + """ + entries = [ + ( + 100, + 1, + "0::/init.scope\n", + ), + ] + assert _run_helper(spec_text, tmp_path, 100, entries) == "NO" From 464f15918ef341670ab33549472f9187ab5717fe Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 6 Jul 2026 18:17:00 -0400 Subject: [PATCH 056/469] Unmask pillar values in file.serialize and file.decode (#69709) (#69710) * Unmask pillar values in file.serialize and file.decode Since 3008, pillar.get masks scalar string values by default, so file.serialize (dataset_pillar) and file.decode (contents_pillar) were serializing/decoding the redaction placeholder into the managed file instead of the real values. Pass unmask=True at both call sites, matching the file.managed contents_pillar path and the _get_signing_policy fix in #69636. Fixes #69709 * Add inverse regression tests for serialize/decode pillar unmasking The PR's existing tests already exercise file.serialize and file.decode directly with production-shaped dataset_pillar/contents_pillar arguments against an unmask-aware pillar.get fake, so the unmask=True flag is pinned. These tests add the inverse guards: a directly supplied dataset must not be routed through pillar.get at all, and a missing contents_pillar key must still return the positional False default so 'Pillar data not found.' is raised instead of writing anything. Both pass with and without the fix, guarding against overcorrection. --- changelog/69709.fixed.md | 1 + salt/states/file.py | 10 ++- .../unit/states/file/test_filestate.py | 69 ++++++++++++++++ .../unit/states/file/test_serialize.py | 81 +++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 changelog/69709.fixed.md diff --git a/changelog/69709.fixed.md b/changelog/69709.fixed.md new file mode 100644 index 000000000000..564a864924a6 --- /dev/null +++ b/changelog/69709.fixed.md @@ -0,0 +1 @@ +Fixed file.serialize (dataset_pillar) and file.decode (contents_pillar) writing the pillar redaction placeholder (``**********``) into the managed file instead of the real values on 3008 and later, where pillar.get masks by default. diff --git a/salt/states/file.py b/salt/states/file.py index d16b6d021906..03f0b8d52383 100644 --- a/salt/states/file.py +++ b/salt/states/file.py @@ -8762,7 +8762,10 @@ def serialize( return _error(ret, "Only one of 'dataset' and 'dataset_pillar' is permitted") if dataset_pillar: - dataset = __salt__["pillar.get"](dataset_pillar) + # Since 3008, pillar.get masks scalar string values by default; pass + # unmask=True so the real values are serialized into the file instead + # of the redaction placeholder, matching file.managed contents_pillar. + dataset = __salt__["pillar.get"](dataset_pillar, unmask=True) if dataset is None: return _error(ret, "Neither 'dataset' nor 'dataset_pillar' was defined") @@ -9255,7 +9258,10 @@ def decode( elif encoded_data: content = encoded_data elif contents_pillar: - content = __salt__["pillar.get"](contents_pillar, False) + # Since 3008, pillar.get masks scalar string values by default; pass + # unmask=True so the decoded data written to the file is the real + # value rather than the redaction placeholder. + content = __salt__["pillar.get"](contents_pillar, False, unmask=True) if content is False: raise CommandExecutionError("Pillar data not found.") else: diff --git a/tests/pytests/unit/states/file/test_filestate.py b/tests/pytests/unit/states/file/test_filestate.py index 8a6951aafdd5..7f1922b61f2b 100644 --- a/tests/pytests/unit/states/file/test_filestate.py +++ b/tests/pytests/unit/states/file/test_filestate.py @@ -11,6 +11,7 @@ import salt.utils.files import salt.utils.json import salt.utils.platform +import salt.utils.secret import salt.utils.win_functions import salt.utils.yaml from salt.exceptions import CommandExecutionError @@ -617,3 +618,71 @@ def test_recurse_test_mode_user_group_not_present(): ) assert ret["result"] is not False assert "is not available" not in ret["comment"] + + +def _masking_pillar_get(masked_pillar): + """A fake pillar.get that masks scalar strings unless unmask=True.""" + + def _get(key, default=None, unmask=None, **kwargs): + value = masked_pillar.get(key, default) + if value is default: + return default + if unmask: + return salt.utils.secret.expose(value) + return salt.utils.secret.serial(value) + + return _get + + +def test_decode_contents_pillar_unmasks_pillar_values(tmp_path): + """ + Regression test for issue #69709: file.decode with contents_pillar must + request unmasked pillar values, otherwise the redaction placeholder is + decoded and written to the file instead of the real data. + """ + secret = "c3VwZXItc2VjcmV0LWtleQ==" # base64, a scalar string in pillar + masked_pillar = salt.utils.secret.hide({"encoded_blob": secret}) + captured = {} + + def fake_decodefile(content, name, *args, **kwargs): + captured["content"] = content + return True + + with patch.dict( + filestate.__salt__, + { + "pillar.get": _masking_pillar_get(masked_pillar), + "file.file_exists": MagicMock(return_value=False), + "hashutil.base64_decodefile": fake_decodefile, + "hashutil.digest_file": MagicMock(return_value="deadbeef"), + }, + ): + filestate.decode(str(tmp_path / "out.bin"), contents_pillar="encoded_blob") + + assert captured["content"] == secret + assert captured["content"] != salt.utils.secret.REDACT_PLACEHOLDER + + +def test_decode_contents_pillar_missing_key_still_errors_69709(tmp_path): + """ + Guard against overcorrection of the issue #69709 fix: file.decode passes + False as the positional default to pillar.get (now alongside unmask=True), + and a missing pillar key must still return that default untouched so the + 'Pillar data not found.' error is raised instead of writing anything to + disk. This test passes both with and without the fix applied. + """ + masked_pillar = salt.utils.secret.hide({}) # pillar key does not exist + decodefile = MagicMock() + + with patch.dict( + filestate.__salt__, + { + "pillar.get": _masking_pillar_get(masked_pillar), + "file.file_exists": MagicMock(return_value=False), + "hashutil.base64_decodefile": decodefile, + }, + ): + with pytest.raises(CommandExecutionError, match="Pillar data not found."): + filestate.decode(str(tmp_path / "out.bin"), contents_pillar="missing_blob") + + decodefile.assert_not_called() diff --git a/tests/pytests/unit/states/file/test_serialize.py b/tests/pytests/unit/states/file/test_serialize.py index a1019271a33a..60d1e2072b92 100644 --- a/tests/pytests/unit/states/file/test_serialize.py +++ b/tests/pytests/unit/states/file/test_serialize.py @@ -4,6 +4,7 @@ import salt.serializers.msgpack as msgpackserializer import salt.serializers.yaml as yamlserializer import salt.states.file as filestate +import salt.utils.secret from tests.support.mock import MagicMock, patch @@ -40,3 +41,83 @@ def test_file_serialize_tmp_dir_system_temp(tmp_path): ): filestate.serialize(str(tmp_file), dataset={"wollo": "herld"}, check_cmd="true") mock_mkstemp.assert_called_with(suffix="", dir=None) + + +def _pillar_get(masked_pillar): + """ + A fake pillar.get that mirrors salt.modules.pillar.get masking: it hands + back redacted values unless the caller passes unmask=True. + """ + + def _get(key, default=None, unmask=None, **kwargs): + value = masked_pillar.get(key, default if default is not None else {}) + if unmask: + return salt.utils.secret.expose(value) + return salt.utils.secret.serial(value) + + return _get + + +def test_serialize_dataset_pillar_unmasks_pillar_values(tmp_path): + """ + Regression test for issue #69709: file.serialize with dataset_pillar must + request unmasked pillar values, otherwise scalar string values are written + to the managed file as the redaction placeholder instead of the real data. + """ + dataset = {"db_password": "hunter2", "api_key": "abcdef123456", "port": 5432} + masked_pillar = salt.utils.secret.hide({"app_config": dataset}) + + captured = {} + + def fake_manage_file(name, **kwargs): + # contents is the serialized payload the state would write to disk + captured["contents"] = kwargs.get("contents") + return {"result": True, "changes": {}, "comment": "", "name": name} + + target = tmp_path / "config.yaml" + with patch.dict( + filestate.__salt__, + { + "pillar.get": _pillar_get(masked_pillar), + "file.manage_file": fake_manage_file, + }, + ): + filestate.serialize(str(target), dataset_pillar="app_config", serializer="yaml") + + written = captured["contents"] + assert salt.utils.secret.REDACT_PLACEHOLDER not in written + assert "hunter2" in written + assert "abcdef123456" in written + + +def test_serialize_direct_dataset_bypasses_pillar_get_69709(tmp_path): + """ + Guard against overcorrection of the issue #69709 fix: when a 'dataset' + argument is supplied directly (the non-pillar path), file.serialize must + not start routing the data through pillar.get at all, with or without + unmask=True. The dataset must be serialized exactly as given. This test + passes both with and without the fix applied. + """ + dataset = {"db_password": "hunter2", "port": 5432} + captured = {} + + def fake_manage_file(name, **kwargs): + captured["contents"] = kwargs.get("contents") + return {"result": True, "changes": {}, "comment": "", "name": name} + + # pillar.get is a strict mock so any call to it is detectable + pillar_get = MagicMock() + + target = tmp_path / "config.yaml" + with patch.dict( + filestate.__salt__, + { + "pillar.get": pillar_get, + "file.manage_file": fake_manage_file, + }, + ): + filestate.serialize(str(target), dataset=dataset, serializer="yaml") + + pillar_get.assert_not_called() + assert "hunter2" in captured["contents"] + assert salt.utils.secret.REDACT_PLACEHOLDER not in captured["contents"] From a651bc03bf4273e6e67567ff4a934a113ff10181 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 6 Jul 2026 22:52:08 -0700 Subject: [PATCH 057/469] Fix error handling on prep jid failure (#66457) (#69649) * Fix error handling on prep jid failure When the returner configured as ``master_job_cache`` fails to load, ``ClearFuncs._prep_jid`` returns ``{"error": }`` (a dict) rather than ``None``. ``ClearFuncs.publish`` only checked ``if jid is None``, so the dict was passed through as the jid and later blew up in ``fire_event`` with: TypeError: expected str, bytes, or bytearray not The client-side symptom was ``AttributeError: 'str' object has no attribute 'pop'`` in ``LocalClient.pub`` when the error came back as a bare string that was never wrapped in an enc/load envelope. Treat a dict return from ``_prep_jid`` the same as ``None`` and propagate the error load back to the caller. On the client, coerce a str payload into ``{"error": payload}`` so the error surfaces as a ``PublishError`` instead of an ``AttributeError``. Backport of 0b6f7e2f213 (already on 3007.x, 3008.x, master) to 3006.x. Fixes #66457 * Fix MasterACLTestCase fake prep_jid returner signature MasterACLTestCase and AuthACLTestCase used ``self.clear.mminion.returners = {".prep_jid": lambda x: 1}`` to stub out the jid returner. ``ClearFuncs._prep_jid`` actually invokes it as ``returners[fstr](nocache=nocache, passed_jid=passed_jid)``, so the one-positional lambda always raised ``TypeError`` and ``_prep_jid`` silently returned ``{"error": ...}``. Before the prep-jid error-handling fix, ``publish`` ignored that dict and passed it through as the jid; ``fire_event`` was mocked in the test setUp so the tests happily inspected the resulting call. With the new ``isinstance(jid, dict)`` guard in ``publish``, ``fire_event`` is no longer called when ``_prep_jid`` errors, and eight tests broke on ``TypeError: 'NoneType' object is not subscriptable`` when reading ``fire_event_mock.call_args[0][0]``. Give the stub the real signature (``nocache=False, passed_jid=None``) and a valid string jid so ``publish`` reaches ``fire_event`` as the tests expect. --- changelog/66457.fixed.md | 1 + salt/client/__init__.py | 3 + salt/master.py | 8 +- tests/pytests/unit/test_master.py | 165 ++++++++++++++++++++++++++++++ tests/unit/test_auth.py | 16 ++- 5 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 changelog/66457.fixed.md diff --git a/changelog/66457.fixed.md b/changelog/66457.fixed.md new file mode 100644 index 000000000000..6547b92297dc --- /dev/null +++ b/changelog/66457.fixed.md @@ -0,0 +1 @@ +Fixed error handling when the returner configured as `master_job_cache` fails to load; the error dict returned by `_prep_jid` is now propagated back to `LocalClient` as a proper error instead of being passed through as the jid and blowing up in `fire_event` with `TypeError: expected str, bytes, or bytearray not `. diff --git a/salt/client/__init__.py b/salt/client/__init__.py index 682259f73dd6..cbe561543226 100644 --- a/salt/client/__init__.py +++ b/salt/client/__init__.py @@ -1934,6 +1934,9 @@ def pub( payload_kwargs["key"] = self.key payload = channel.send(payload_kwargs) + if isinstance(payload, str): + payload = {"error": payload} + error = payload.pop("error", None) if error is not None: if isinstance(error, dict): diff --git a/salt/master.py b/salt/master.py index 4ae60564b0ee..5bb537274602 100644 --- a/salt/master.py +++ b/salt/master.py @@ -2368,8 +2368,12 @@ def publish(self, clear_load): }, } jid = self._prep_jid(clear_load, extra) - if jid is None: - return {"enc": "clear", "load": {"error": "Master failed to assign jid"}} + if jid is None or isinstance(jid, dict): + if jid and "error" in jid: + load = jid + else: + load = {"error": "Master failed to assign jid"} + return load payload = self._prep_pub(minions, jid, clear_load, extra, missing) if self.opts.get("order_masters"): diff --git a/tests/pytests/unit/test_master.py b/tests/pytests/unit/test_master.py index f6ecf5242d9b..11ab15181be3 100644 --- a/tests/pytests/unit/test_master.py +++ b/tests/pytests/unit/test_master.py @@ -587,3 +587,168 @@ def test_handle_presence( assert ( set(new_presence_cache["present"]) == connected_ids ), "The presence cache on disk does not reflect the current connected set" + + +@pytest.fixture +def publish_clear_funcs(master_opts): + """ + A ClearFuncs bound to a master_opts that will let ``publish`` reach + ``_prep_jid`` without touching auth, the ACL, or the returner loader. + """ + clear_funcs = salt.master.ClearFuncs(master_opts, {}) + try: + yield clear_funcs + finally: + clear_funcs.destroy() + + +def test_publish_prep_jid_returns_error_dict(publish_clear_funcs): + """ + Regression test for #66457. + + When the returner configured as ``master_job_cache`` fails to load, + ``ClearFuncs._prep_jid`` returns ``{"error": }``. ``publish`` must + treat that dict the same as ``None`` and return the error load back to + the caller instead of passing the dict through as the jid, which would + later blow up in ``fire_event`` with + ``TypeError: expected str, bytes, or bytearray not ``. + """ + load = { + "user": "foo", + "fun": "test.ping", + "tgt": "test_minion", + "arg": [], + } + prep_jid_error = { + "error": ( + "Failed to allocate a jid. The requested returner" + " 'not_a_real_returner' could not be loaded." + ) + } + check_minions_ret = { + "minions": ["test_minion"], + "missing": [], + "ssh_minions": False, + } + with patch( + "salt.acl.PublisherACL.user_is_blacklisted", MagicMock(return_value=False) + ), patch( + "salt.acl.PublisherACL.cmd_is_blacklisted", MagicMock(return_value=False) + ), patch.object( + publish_clear_funcs.ckminions, + "check_minions", + MagicMock(return_value=check_minions_ret), + ), patch.object( + publish_clear_funcs.loadauth, + "check_authentication", + MagicMock(return_value={"auth_list": [], "error": None}), + ), patch.object( + publish_clear_funcs, + "_prep_jid", + MagicMock(return_value=prep_jid_error), + ): + # Before #66457 was fixed, ``publish`` would pass ``prep_jid_error`` + # (a dict) through as the jid and then raise ``TypeError`` inside + # ``fire_event`` while converting it to bytes. + result = publish_clear_funcs.publish(load) + + assert result == prep_jid_error, ( + "publish() must return the error dict from _prep_jid unchanged when" + " the master_job_cache returner fails to load (#66457)." + ) + + +def test_publish_prep_jid_returns_none(publish_clear_funcs): + """ + Companion to :func:`test_publish_prep_jid_returns_error_dict`: verify the + pre-existing ``jid is None`` path still returns the generic error load. + """ + load = { + "user": "foo", + "fun": "test.ping", + "tgt": "test_minion", + "arg": [], + } + check_minions_ret = { + "minions": ["test_minion"], + "missing": [], + "ssh_minions": False, + } + with patch( + "salt.acl.PublisherACL.user_is_blacklisted", MagicMock(return_value=False) + ), patch( + "salt.acl.PublisherACL.cmd_is_blacklisted", MagicMock(return_value=False) + ), patch.object( + publish_clear_funcs.ckminions, + "check_minions", + MagicMock(return_value=check_minions_ret), + ), patch.object( + publish_clear_funcs.loadauth, + "check_authentication", + MagicMock(return_value={"auth_list": [], "error": None}), + ), patch.object( + publish_clear_funcs, + "_prep_jid", + MagicMock(return_value=None), + ): + result = publish_clear_funcs.publish(load) + + assert result == {"error": "Master failed to assign jid"} + + +def test_local_client_pub_handles_str_payload(tmp_path): + """ + Regression test for #66457 (LocalClient side). + + Before the fix, a bare-string payload returned by the master (e.g. an + error string that never got wrapped in an envelope) triggered + ``AttributeError: 'str' object has no attribute 'pop'`` when + ``LocalClient.pub`` tried to extract the error. The client now converts + a str payload into ``{"error": payload}`` so that ``payload.pop`` works + and the error propagates back to the CLI as a ``PublishError``. + """ + import salt.client + from salt.exceptions import PublishError + + sock_dir = tmp_path / "sock" + sock_dir.mkdir() + # LocalClient.pub bails out early with SaltClientError unless the + # publisher IPC socket exists (or ipc_mode is "tcp"). + (sock_dir / "publish_pull.ipc").touch() + + client = salt.client.LocalClient.__new__(salt.client.LocalClient) + client.opts = { + "transport": "zeromq", + "ipc_mode": "ipc", + "sock_dir": str(sock_dir), + "interface": "127.0.0.1", + "ret_port": 4506, + "publish_timeout": 5, + "extension_modules": str(tmp_path / "extmods"), + } + client.key = "fake-key" + client.mopts = None + # Populated so LocalClient.__del__/destroy don't emit an + # unraisable AttributeError when the test-only instance is torn down. + client.event = None + client.auto_reconnect = False + + channel = MagicMock() + channel.send.return_value = "Failed to allocate a jid." + + class _Ctx: + def __enter__(self): + return channel + + def __exit__(self, *exc): + return False + + with patch( + "salt.channel.client.ReqChannel.factory", MagicMock(return_value=_Ctx()) + ), patch.object( + salt.client.LocalClient, + "_prep_pub", + MagicMock(return_value={"cmd": "publish"}), + ): + with pytest.raises(PublishError): + client.pub("test_minion", "test.ping", tgt_type="glob", timeout=5) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index b87961668bc7..8220e1f074f4 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -230,8 +230,12 @@ def setUp(self): # overwrite the _send_pub method so we don't have to serialize MagicMock self.clear._send_pub = lambda payload: True - # make sure to return a JID, instead of a mock - self.clear.mminion.returners = {".prep_jid": lambda x: 1} + # make sure to return a JID, instead of a mock. ``_prep_jid`` invokes + # the returner as ``returners[fstr](nocache=..., passed_jid=...)``, so + # the fake must accept those kwargs and return a string-shaped jid. + self.clear.mminion.returners = { + ".prep_jid": lambda nocache=False, passed_jid=None: "20260704000000000001" + } self.valid_clear_load = { "tgt_type": "glob", @@ -778,8 +782,12 @@ def setUp(self): # overwrite the _send_pub method so we don't have to serialize MagicMock self.clear._send_pub = lambda payload: True - # make sure to return a JID, instead of a mock - self.clear.mminion.returners = {".prep_jid": lambda x: 1} + # make sure to return a JID, instead of a mock. ``_prep_jid`` invokes + # the returner as ``returners[fstr](nocache=..., passed_jid=...)``, so + # the fake must accept those kwargs and return a string-shaped jid. + self.clear.mminion.returners = { + ".prep_jid": lambda nocache=False, passed_jid=None: "20260704000000000001" + } self.valid_clear_load = { "tgt_type": "glob", From 030e255c42293d92606ad1ef4a32f180aa896c01 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Tue, 7 Jul 2026 14:15:23 -0700 Subject: [PATCH 058/469] Fix linux_shadow and solaris_shadow spwd usage on Python 3.13+ (#64264) (#69651) * Remove uses of spwd Module deprecated and no longer shipped with Python 3.13. Co-authored-by: Toyam Cox Co-authored-by: Georg Pfuetzenreuter Signed-off-by: Georg Pfuetzenreuter * Fix solaris_shadow spwd usage on Python 3.13+ The stdlib ``spwd`` module was deprecated in Python 3.11 and removed in Python 3.13. ``salt.modules.solaris_shadow`` still tried to import it and to reference ``spwd.getspnam``/``spwd.struct_spwd`` on the primary Solaris code path, mirroring the earlier ``linux_shadow`` breakage tracked in this issue. Replace the ``spwd`` primary path with a local ``_getspnam`` that reads ``/etc/shadow`` directly and returns a ``struct_spwd``-compatible namedtuple, matching the pattern already used in ``linux_shadow`` (see commit a5cd3113f2d). The SmartOS-style ``pwd`` + ``passwd -s`` fallback is preserved for the case where ``/etc/shadow`` is not readable. Update the ``linux_shadow`` and ``solaris_shadow`` unit tests to patch the new ``_getspnam`` helper instead of ``spwd.getspnam`` so the suites run on Python 3.13+. Fixes #64264 * Address review: broaden exception to OSError in _getspnam callers - Replace `except (KeyError, FileNotFoundError)` with `except (KeyError, OSError)` in both linux_shadow.py and solaris_shadow.py so that PermissionError (and any other OS-level error reading /etc/shadow) falls back gracefully rather than propagating to the caller. --------- Signed-off-by: Georg Pfuetzenreuter Co-authored-by: Toyam Cox Co-authored-by: Georg Pfuetzenreuter --- changelog/64264.fixed.md | 1 + changelog/67119.fixed.md | 1 + salt/modules/linux_shadow.py | 32 +++-- salt/modules/solaris_shadow.py | 107 +++++++++----- .../pytests/unit/modules/test_linux_shadow.py | 21 +-- .../unit/modules/test_solaris_shadow.py | 133 ++++++++++++------ 6 files changed, 193 insertions(+), 102 deletions(-) create mode 100644 changelog/64264.fixed.md create mode 100644 changelog/67119.fixed.md diff --git a/changelog/64264.fixed.md b/changelog/64264.fixed.md new file mode 100644 index 000000000000..24fa8008039d --- /dev/null +++ b/changelog/64264.fixed.md @@ -0,0 +1 @@ +Fixed `salt.modules.linux_shadow` and `salt.modules.solaris_shadow` failing on Python 3.13, where the standard-library `spwd` module has been removed. Both modules now parse `/etc/shadow` directly. diff --git a/changelog/67119.fixed.md b/changelog/67119.fixed.md new file mode 100644 index 000000000000..34eca2d3b2a7 --- /dev/null +++ b/changelog/67119.fixed.md @@ -0,0 +1 @@ +Remove usage of spwd diff --git a/salt/modules/linux_shadow.py b/salt/modules/linux_shadow.py index dda0bbab43f3..b5ef60e0e570 100644 --- a/salt/modules/linux_shadow.py +++ b/salt/modules/linux_shadow.py @@ -8,6 +8,7 @@ `. """ +import collections import datetime import functools import logging @@ -17,12 +18,6 @@ import salt.utils.files from salt.exceptions import CommandExecutionError -try: - import spwd # pylint: disable=deprecated-module -except ImportError: - pass - - try: import salt.utils.pycrypto @@ -34,6 +29,21 @@ log = logging.getLogger(__name__) +struct_spwd = collections.namedtuple( + "struct_spwd", + [ + "sp_namp", + "sp_pwdp", + "sp_lstchg", + "sp_min", + "sp_max", + "sp_warn", + "sp_inact", + "sp_expire", + "sp_flag", + ], +) + def __virtual__(): return __virtualname__ if __grains__.get("kernel", "") == "Linux" else False @@ -71,7 +81,7 @@ def info(name, root=None): if root is not None: getspnam = functools.partial(_getspnam, root=root) else: - getspnam = functools.partial(spwd.getspnam) + getspnam = functools.partial(_getspnam, root="/") try: data = getspnam(name) @@ -85,7 +95,7 @@ def info(name, root=None): "inact": data.sp_inact, "expire": data.sp_expire, } - except (KeyError, FileNotFoundError): + except (KeyError, OSError): return { "name": "", "passwd": "", @@ -509,7 +519,7 @@ def list_users(root=None): if root is not None: getspall = functools.partial(_getspall, root=root) else: - getspall = functools.partial(spwd.getspall) + getspall = functools.partial(_getspall, root="/") return sorted( user.sp_namp if hasattr(user, "sp_namp") else user.sp_nam for user in getspall() @@ -529,7 +539,7 @@ def _getspnam(name, root=None): # Generate a getspnam compatible output for i in range(2, 9): comps[i] = int(comps[i]) if comps[i] else -1 - return spwd.struct_spwd(comps) + return struct_spwd(*comps) raise KeyError @@ -545,4 +555,4 @@ def _getspall(root=None): # Generate a getspall compatible output for i in range(2, 9): comps[i] = int(comps[i]) if comps[i] else -1 - yield spwd.struct_spwd(comps) + yield struct_spwd(*comps) diff --git a/salt/modules/solaris_shadow.py b/salt/modules/solaris_shadow.py index 298b378c9b6e..ce0f700a018a 100644 --- a/salt/modules/solaris_shadow.py +++ b/salt/modules/solaris_shadow.py @@ -8,22 +8,17 @@ `. """ +import collections import os import salt.utils.files +import salt.utils.stringutils from salt.exceptions import CommandExecutionError try: - import spwd # pylint: disable=deprecated-module - - HAS_SPWD = True + import pwd except ImportError: - # SmartOS joyent_20130322T181205Z does not have spwd - HAS_SPWD = False - try: - import pwd - except ImportError: - pass # We're most likely on a Windows machine. + pass # We're most likely on a Windows machine. try: @@ -38,6 +33,26 @@ __virtualname__ = "shadow" +# The stdlib ``spwd`` module was deprecated in Python 3.11 and removed in +# Python 3.13, so we can no longer rely on ``spwd.getspnam``/``spwd.struct_spwd`` +# to read ``/etc/shadow``. Emulate the pieces we need by parsing ``/etc/shadow`` +# directly and returning a namedtuple with the same attribute names. +struct_spwd = collections.namedtuple( + "struct_spwd", + [ + "sp_namp", + "sp_pwdp", + "sp_lstchg", + "sp_min", + "sp_max", + "sp_warn", + "sp_inact", + "sp_expire", + "sp_flag", + ], +) + + def __virtual__(): """ Only work on POSIX-like systems @@ -64,44 +79,62 @@ def default_hash(): return "!" -def info(name): +def _getspnam(name, root=None): + """ + Read ``/etc/shadow`` and return an ``spwd.struct_spwd``-compatible + record for ``name``. Replaces ``spwd.getspnam``, which was removed + in Python 3.13. + """ + root = "/" if not root else root + passwd = os.path.join(root, "etc/shadow") + with salt.utils.files.fopen(passwd) as fp_: + for line in fp_: + line = salt.utils.stringutils.to_unicode(line).rstrip("\n") + comps = line.split(":") + if comps[0] == name: + # Generate a getspnam compatible output + for i in range(2, 9): + if i < len(comps): + comps[i] = int(comps[i]) if comps[i] else -1 + else: + comps.append(-1) + return struct_spwd(*comps[:9]) + raise KeyError + + +def info(name, root=None): """ Return information for the specified user + name + User to get the information for + + root + Directory to chroot into + CLI Example: .. code-block:: bash salt '*' shadow.info root """ - if HAS_SPWD: - try: - data = spwd.getspnam(name) - ret = { - "name": data.sp_nam, - "passwd": data.sp_pwd, - "lstchg": data.sp_lstchg, - "min": data.sp_min, - "max": data.sp_max, - "warn": data.sp_warn, - "inact": data.sp_inact, - "expire": data.sp_expire, - } - except KeyError: - ret = { - "name": "", - "passwd": "", - "lstchg": "", - "min": "", - "max": "", - "warn": "", - "inact": "", - "expire": "", - } - return ret + try: + data = _getspnam(name, root=root) + return { + "name": data.sp_namp, + "passwd": data.sp_pwdp, + "lstchg": data.sp_lstchg, + "min": data.sp_min, + "max": data.sp_max, + "warn": data.sp_warn, + "inact": data.sp_inact, + "expire": data.sp_expire, + } + except (KeyError, OSError): + pass - # SmartOS joyent_20130322T181205Z does not have spwd, but not all is lost - # Return what we can know + # /etc/shadow was not readable or the user was not found there. + # Fall back to what we can learn from pwd + `passwd -s` (SmartOS path). ret = { "name": "", "passwd": "", diff --git a/tests/pytests/unit/modules/test_linux_shadow.py b/tests/pytests/unit/modules/test_linux_shadow.py index 0c742672750b..2a756d32d247 100644 --- a/tests/pytests/unit/modules/test_linux_shadow.py +++ b/tests/pytests/unit/modules/test_linux_shadow.py @@ -15,9 +15,6 @@ shadow = pytest.importorskip( "salt.modules.linux_shadow", reason="shadow module is not available" ) -spwd = pytest.importorskip( - "spwd", reason="Standard library spwd module is not available" -) def _pw_hash_ids(value): @@ -186,10 +183,10 @@ def test_info(password): ("passwd", password.pw_hash), ("warn", 7), ] - getspnam_return = spwd.struct_spwd( - ["foo", password.pw_hash, 31337, 0, 99999, 7, -1, -1, -1] + getspnam_return = shadow.struct_spwd( + "foo", password.pw_hash, 31337, 0, 99999, 7, -1, -1, -1 ) - with patch("spwd.getspnam", return_value=getspnam_return): + with patch("salt.modules.linux_shadow._getspnam", return_value=getspnam_return): result = shadow.info("foo") assert expected_result == sorted(result.items(), key=lambda x: x[0]) @@ -206,12 +203,12 @@ def test_info(password): ] # We get KeyError exception for non-existent users in glibc based systems getspnam_return = KeyError - with patch("spwd.getspnam", side_effect=getspnam_return): + with patch("salt.modules.linux_shadow._getspnam", side_effect=getspnam_return): result = shadow.info("foo") assert expected_result == sorted(result.items(), key=lambda x: x[0]) # And FileNotFoundError in musl based systems getspnam_return = FileNotFoundError - with patch("spwd.getspnam", side_effect=getspnam_return): + with patch("salt.modules.linux_shadow._getspnam", side_effect=getspnam_return): result = shadow.info("foo") assert expected_result == sorted(result.items(), key=lambda x: x[0]) @@ -323,3 +320,11 @@ def test_list_users(): Test if it returns a list of all users """ assert shadow.list_users() + + +def test_module_import_does_not_reference_spwd(): + """ + Regression test for #64264: ``salt.modules.linux_shadow`` must not + import the removed-in-Python-3.13 ``spwd`` module. + """ + assert not hasattr(shadow, "spwd") diff --git a/tests/pytests/unit/modules/test_solaris_shadow.py b/tests/pytests/unit/modules/test_solaris_shadow.py index 4811a8c09590..0fa11c0d9ad3 100644 --- a/tests/pytests/unit/modules/test_solaris_shadow.py +++ b/tests/pytests/unit/modules/test_solaris_shadow.py @@ -14,17 +14,7 @@ pwd = None missing_pwd = True -try: - import spwd # pylint: disable=unused-import,deprecated-module - - missing_spwd = False -except ImportError: - missing_spwd = True - -skip_on_missing_spwd = pytest.mark.skipif( - missing_spwd, reason="Has no spwd module for accessing /etc/shadow passwords" -) skip_on_missing_pwd = pytest.mark.skipif( missing_pwd, reason="Has no pwd module for accessing /etc/password passwords" ) @@ -51,7 +41,7 @@ def fake_fopen_has_etc_shadow(): ) fake_output_shadow_file = io.StringIO() - def fopen(file, mode, *args, **kwargs): + def fopen(file, mode="r", *args, **kwargs): for line in contents.split(): if "b" in mode: return io.BytesIO(contents.encode()) @@ -67,26 +57,26 @@ def fopen(file, mode, *args, **kwargs): @pytest.fixture -def has_spwd(): - with patch.object(solaris_shadow, "HAS_SPWD", True): - yield +def fake_getspnam(): + """ + Patch the module-local ``_getspnam`` helper (formerly ``spwd.getspnam``). + """ + with patch.object(solaris_shadow, "_getspnam", autospec=True) as fake: + yield fake @pytest.fixture -def has_not_spwd(): - with patch.object(solaris_shadow, "HAS_SPWD", False): +def missing_getspnam(): + """ + Simulate ``/etc/shadow`` being unreadable, so the SmartOS-style fallback + (pwd + ``passwd -s``) is exercised. + """ + with patch.object( + solaris_shadow, "_getspnam", autospec=True, side_effect=FileNotFoundError + ): yield -@pytest.fixture -def fake_spnam(): - with patch( - "spwd.getspnam", - autospec=True, - ) as fake_spnam: - yield fake_spnam - - @pytest.fixture def fake_pwnam(): with patch( @@ -108,9 +98,8 @@ def has_not_shadow_file(): yield -@skip_on_missing_spwd -def test_when_spwd_module_exists_results_should_be_returned_from_getspnam( - has_spwd, fake_spnam +def test_when_getspnam_returns_data_results_should_be_returned_from_getspnam( + fake_getspnam, ): expected_results = { "name": "roscivs", @@ -122,23 +111,22 @@ def test_when_spwd_module_exists_results_should_be_returned_from_getspnam( "inact": "whatever", "expire": "never!", } - fake_spnam.return_value.sp_nam = expected_results["name"] - fake_spnam.return_value.sp_pwd = expected_results["passwd"] - fake_spnam.return_value.sp_lstchg = expected_results["lstchg"] - fake_spnam.return_value.sp_min = expected_results["min"] - fake_spnam.return_value.sp_max = expected_results["max"] - fake_spnam.return_value.sp_warn = expected_results["warn"] - fake_spnam.return_value.sp_inact = expected_results["inact"] - fake_spnam.return_value.sp_expire = expected_results["expire"] + fake_getspnam.return_value.sp_namp = expected_results["name"] + fake_getspnam.return_value.sp_pwdp = expected_results["passwd"] + fake_getspnam.return_value.sp_lstchg = expected_results["lstchg"] + fake_getspnam.return_value.sp_min = expected_results["min"] + fake_getspnam.return_value.sp_max = expected_results["max"] + fake_getspnam.return_value.sp_warn = expected_results["warn"] + fake_getspnam.return_value.sp_inact = expected_results["inact"] + fake_getspnam.return_value.sp_expire = expected_results["expire"] actual_results = solaris_shadow.info(name="roscivs") assert actual_results == expected_results -@skip_on_missing_spwd -def test_when_swpd_module_exists_and_no_results_then_results_should_be_empty( - has_spwd, fake_spnam +def test_when_getspnam_finds_no_user_and_pwnam_finds_no_user_results_should_be_empty( + fake_getspnam, fake_pwnam ): expected_results = { "name": "", @@ -150,7 +138,8 @@ def test_when_swpd_module_exists_and_no_results_then_results_should_be_empty( "inact": "", "expire": "", } - fake_spnam.side_effect = KeyError + fake_getspnam.side_effect = KeyError + fake_pwnam.side_effect = KeyError actual_results = solaris_shadow.info(name="roscivs") @@ -159,7 +148,7 @@ def test_when_swpd_module_exists_and_no_results_then_results_should_be_empty( @skip_on_missing_pwd def test_when_pwd_fallback_is_used_and_no_name_exists_results_should_be_empty( - has_not_spwd, fake_pwnam + missing_getspnam, fake_pwnam ): expected_results = { "name": "", @@ -180,7 +169,7 @@ def test_when_pwd_fallback_is_used_and_no_name_exists_results_should_be_empty( @skip_on_missing_pwd def test_when_etc_shadow_does_not_exist_info_should_be_empty_except_for_name( - has_not_spwd, fake_pwnam, has_not_shadow_file + missing_getspnam, fake_pwnam, has_not_shadow_file ): expected_results = { "name": "wayne", @@ -201,7 +190,7 @@ def test_when_etc_shadow_does_not_exist_info_should_be_empty_except_for_name( @skip_on_missing_pwd def test_when_etc_shadow_exists_but_name_not_in_shadow_passwd_field_should_be_empty( - fake_fopen_has_etc_shadow, has_not_spwd, fake_pwnam, has_shadow_file + fake_fopen_has_etc_shadow, missing_getspnam, fake_pwnam, has_shadow_file ): with patch.dict( solaris_shadow.__salt__, @@ -214,7 +203,7 @@ def test_when_etc_shadow_exists_but_name_not_in_shadow_passwd_field_should_be_em @skip_on_missing_pwd def test_when_name_in_etc_shadow_passwd_should_be_in_info( - fake_fopen_has_etc_shadow, has_not_spwd, fake_pwnam, has_shadow_file + fake_fopen_has_etc_shadow, missing_getspnam, fake_pwnam, has_shadow_file ): with patch.dict( solaris_shadow.__salt__, @@ -250,9 +239,8 @@ def test_set_password_should_return_False_if_passwd_in_info_is_different_than_ne assert actual_result == False -@skip_on_missing_spwd def test_when_set_password_and_name_in_shadow_then_password_should_be_changed_for_that_user( - has_shadow_file, fake_fopen_has_etc_shadow, has_spwd, fake_spnam + has_shadow_file, fake_fopen_has_etc_shadow, fake_getspnam ): expected_password = "bottia2" expected_shadow_contents = dedent( @@ -273,3 +261,56 @@ def test_when_set_password_and_name_in_shadow_then_password_should_be_changed_fo assert fake_fopen_has_etc_shadow.getvalue() == expected_shadow_contents assert actual_result == True + + +@skip_on_missing_pwd +def test_module_import_does_not_reference_spwd(): + """ + Regression test for #64264: ``salt.modules.solaris_shadow`` must not + import the removed-in-Python-3.13 ``spwd`` module. + """ + import salt.modules.solaris_shadow as module_under_test + + assert not hasattr(module_under_test, "spwd") + assert not hasattr(module_under_test, "HAS_SPWD") + + +def test_getspnam_parses_etc_shadow_and_returns_struct_spwd(): + """ + Regression test for #64264: the replacement ``_getspnam`` reads + ``/etc/shadow`` directly and returns an ``spwd.struct_spwd``-compatible + namedtuple. + """ + shadow_contents = dedent( + """\ + root:$6$abc$xyz:19000:0:99999:7::: + roscivs:$6$def$uvw:19100:1:42:14:30:19999:0 + """ + ) + + def fopen(file, mode="r", *args, **kwargs): + return io.StringIO(shadow_contents) + + with patch("salt.utils.files.fopen", side_effect=fopen, autospec=True): + record = solaris_shadow._getspnam("roscivs") + + assert record.sp_namp == "roscivs" + assert record.sp_pwdp == "$6$def$uvw" + assert record.sp_lstchg == 19100 + assert record.sp_min == 1 + assert record.sp_max == 42 + assert record.sp_warn == 14 + assert record.sp_inact == 30 + assert record.sp_expire == 19999 + assert record.sp_flag == 0 + + +def test_getspnam_raises_keyerror_when_user_missing(): + shadow_contents = "root:x:19000:0:99999:7:::\n" + + def fopen(file, mode="r", *args, **kwargs): + return io.StringIO(shadow_contents) + + with patch("salt.utils.files.fopen", side_effect=fopen, autospec=True): + with pytest.raises(KeyError): + solaris_shadow._getspnam("nobody") From 803f8fe2d5934e93c28e5a91bc66464b419a7d80 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Wed, 8 Jul 2026 13:51:37 -0700 Subject: [PATCH 059/469] Fix get_sls_opts clobbering pillarenv when pillarenv_from_saltenv is set (#69676) salt.utils.state.get_sls_opts entered its pillarenv-normalization block whenever pillarenv_from_saltenv was true, then set opts["pillarenv"] = kwargs.get("pillarenv") or kwargs.get("saltenv") which resolves to None when neither kwarg is passed. That silently overwrote the pre-existing opts["pillarenv"] (the minion config value) with None on every bare state.highstate / state.apply on a minion whose config sets both pillarenv and pillarenv_from_saltenv, causing subsequent pillar recompiles during the state run to see pillarenv=None and merge across environments instead of honoring the intended env. Restructure the resolution into three clean cases: explicit pillarenv kwarg wins (including None), else if pillarenv_from_saltenv is enabled AND a saltenv kwarg was passed promote it to pillarenv, else leave the configured opts["pillarenv"] untouched. Fixes #68791 --- changelog/68791.fixed.md | 1 + salt/utils/state.py | 16 ++++++- tests/pytests/unit/utils/test_state.py | 61 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 changelog/68791.fixed.md diff --git a/changelog/68791.fixed.md b/changelog/68791.fixed.md new file mode 100644 index 000000000000..22c4f88088fc --- /dev/null +++ b/changelog/68791.fixed.md @@ -0,0 +1 @@ +Fixed `salt.utils.state.get_sls_opts` clobbering the configured `pillarenv` with `None` when `pillarenv_from_saltenv` is enabled but the caller does not pass explicit `saltenv`/`pillarenv` kwargs. A bare `state.highstate`/`state.apply` (or in-template `pillar.get` calls that trigger a pillar refresh) on a minion whose config sets both `pillarenv: ` and `pillarenv_from_saltenv: true` now correctly honors the configured environment. diff --git a/salt/utils/state.py b/salt/utils/state.py index 367a2dd29378..7a9106411f5d 100644 --- a/salt/utils/state.py +++ b/salt/utils/state.py @@ -442,11 +442,23 @@ def get_sls_opts(opts, **kwargs): ) opts["saltenv"] = kwargs["saltenv"] - if "pillarenv" in kwargs or opts.get("pillarenv_from_saltenv", False): - pillarenv = kwargs.get("pillarenv") or kwargs.get("saltenv") + if "pillarenv" in kwargs: + # Explicit pillarenv kwarg wins — including an explicit ``None`` which + # is how callers request "merge all envs". + pillarenv = kwargs["pillarenv"] if pillarenv is not None and not isinstance(pillarenv, str): opts["pillarenv"] = str(pillarenv) else: opts["pillarenv"] = pillarenv + elif opts.get("pillarenv_from_saltenv", False) and "saltenv" in kwargs: + # ``pillarenv_from_saltenv`` only kicks in when the caller actually + # passes a ``saltenv`` kwarg; if they didn't, respect whatever + # ``pillarenv`` was already in opts (typically the minion config). + # Fixes #68791. + saltenv = kwargs["saltenv"] + if saltenv is not None and not isinstance(saltenv, str): + opts["pillarenv"] = str(saltenv) + else: + opts["pillarenv"] = saltenv return opts diff --git a/tests/pytests/unit/utils/test_state.py b/tests/pytests/unit/utils/test_state.py index 2af5173720e9..85cf9bf1c701 100644 --- a/tests/pytests/unit/utils/test_state.py +++ b/tests/pytests/unit/utils/test_state.py @@ -72,3 +72,64 @@ def test_queue_lock_path_makedirs_parent(tmp_path): # acquire_queue_lock side-effect: makedirs(parent). salt.utils.state.acquire_queue_lock(opts) assert os.path.isdir(os.path.dirname(lock_path)) + + +def test_get_sls_opts_preserves_pillarenv_from_saltenv_config_68791(): + """ + Regression test for issue #68791. + + When ``pillarenv_from_saltenv`` is enabled and the caller does not + pass explicit ``saltenv`` / ``pillarenv`` kwargs (e.g. a bare + ``salt-call state.highstate`` on a minion whose config sets both + ``pillarenv: dev`` and ``pillarenv_from_saltenv: true``), the + configured ``opts["pillarenv"]`` must not be clobbered to ``None``. + Previously the branch that honors ``pillarenv_from_saltenv`` fell + through and overwrote the pre-existing value with the ``None`` + result of ``kwargs.get("pillarenv") or kwargs.get("saltenv")``. + """ + opts = { + "saltenv": "dev", + "pillarenv": "dev", + "pillarenv_from_saltenv": True, + "lock_saltenv": False, + } + new_opts = salt.utils.state.get_sls_opts(opts) + assert new_opts["saltenv"] == "dev" + assert new_opts["pillarenv"] == "dev" + + +def test_get_sls_opts_pillarenv_from_saltenv_uses_kwarg_saltenv(): + """ + When ``pillarenv_from_saltenv`` is enabled and the caller passes + ``saltenv`` (but not ``pillarenv``) via kwargs, that saltenv wins + for the resulting pillarenv — this preserves the historical + behavior of pillarenv_from_saltenv. + """ + opts = { + "saltenv": "base", + "pillarenv": "base", + "pillarenv_from_saltenv": True, + "lock_saltenv": False, + } + new_opts = salt.utils.state.get_sls_opts(opts, saltenv="dev") + assert new_opts["saltenv"] == "dev" + assert new_opts["pillarenv"] == "dev" + + +def test_get_sls_opts_explicit_pillarenv_kwarg_wins(): + """ + An explicit ``pillarenv`` kwarg still overrides the configured + ``opts["pillarenv"]`` — including an explicit ``pillarenv=None``, + which is how callers request "merge all envs". + """ + opts = { + "saltenv": "dev", + "pillarenv": "dev", + "pillarenv_from_saltenv": False, + "lock_saltenv": False, + } + new_opts = salt.utils.state.get_sls_opts(opts, pillarenv="qa") + assert new_opts["pillarenv"] == "qa" + + new_opts = salt.utils.state.get_sls_opts(opts, pillarenv=None) + assert new_opts["pillarenv"] is None From 0b76ba45aa56532ad111698100f1736d1ba75a31 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Wed, 8 Jul 2026 14:43:23 -0700 Subject: [PATCH 060/469] Fix get_returner_options ignoring defaults for unset attributes (#69669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #66828 changed the value check in `_options_browser` from `if value:` to `if value != "":`. That fixes the falsy-values bug (#66816/#63980) on the config.option code path, where `_fetch_option` returns `""` for a missing key. But on the plain-dict code path (`__salt__` has no `config.option`, `cfg = __opts__`), `_fetch_option` returns `None` for a missing attribute. `None != ""` is True, so the old check yielded `(option, None)` and never fell through to the supplied `defaults` value — every unset attribute came back as `None` instead of its default. Reporter hit this via `saltext-prometheus`, where the whole `_get_options` result was `None`-valued. Exclude `None` explicitly so unset attributes fall through to defaults, while still yielding legitimately-falsy configured values (0, 0.0, False, [], etc.). Fixes #69654 --- changelog/69654.fixed.md | 1 + salt/returners/__init__.py | 2 +- .../unit/returners/test_returners_init.py | 143 ++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 changelog/69654.fixed.md create mode 100644 tests/pytests/unit/returners/test_returners_init.py diff --git a/changelog/69654.fixed.md b/changelog/69654.fixed.md new file mode 100644 index 000000000000..83c5a633fd7b --- /dev/null +++ b/changelog/69654.fixed.md @@ -0,0 +1 @@ +Fixed ``salt.returners.get_returner_options`` so that attributes not present in the config now fall through to the supplied ``defaults`` value instead of being returned as ``None``. diff --git a/salt/returners/__init__.py b/salt/returners/__init__.py index e87454d7aabd..4c60f99326fc 100644 --- a/salt/returners/__init__.py +++ b/salt/returners/__init__.py @@ -164,7 +164,7 @@ def _options_browser(cfg, ret_config, defaults, virtualname, options): # default place for the option in the config value = _fetch_option(cfg, ret_config, virtualname, options[option]) - if value != "": + if value is not None and value != "": yield option, value continue diff --git a/tests/pytests/unit/returners/test_returners_init.py b/tests/pytests/unit/returners/test_returners_init.py new file mode 100644 index 000000000000..2a22fdf633af --- /dev/null +++ b/tests/pytests/unit/returners/test_returners_init.py @@ -0,0 +1,143 @@ +""" +Unit tests for salt.returners package helpers (``get_returner_options`` / +``_options_browser``). +""" + +import pytest + +import salt.returners +from tests.support.mock import patch + + +@pytest.mark.parametrize( + "configured_value", + [0, 0.0, False, []], + ids=["int-zero", "float-zero", "bool-false", "empty-list"], +) +def test_options_browser_yields_falsy_configured_value(configured_value): + """ + Regression coverage for https://github.com/saltstack/salt/issues/63980: + a falsy-but-set configuration value must be returned as-is instead of + being masked by the returner's default value. + """ + defaults = {"my_option": 42} + options = {"my_option": "my_option"} + + with patch.object(salt.returners, "_fetch_option", return_value=configured_value): + result = dict( + salt.returners._options_browser( + cfg=None, + ret_config=None, + defaults=defaults, + virtualname="custom_returner", + options=options, + ) + ) + + assert result == {"my_option": configured_value} + + +def test_options_browser_falls_back_to_default_when_unset(): + """ + When ``_fetch_option`` returns the empty-string sentinel (i.e. the + option is not configured), the default value should be yielded. + """ + defaults = {"my_option": 42} + options = {"my_option": "my_option"} + + with patch.object(salt.returners, "_fetch_option", return_value=""): + result = dict( + salt.returners._options_browser( + cfg=None, + ret_config=None, + defaults=defaults, + virtualname="custom_returner", + options=options, + ) + ) + + assert result == {"my_option": 42} + + +def test_options_browser_yields_configured_truthy_value(): + """ + A configured, truthy value should be yielded unchanged. + """ + defaults = {"my_option": 42} + options = {"my_option": "my_option"} + + with patch.object(salt.returners, "_fetch_option", return_value="hello"): + result = dict( + salt.returners._options_browser( + cfg=None, + ret_config=None, + defaults=defaults, + virtualname="custom_returner", + options=options, + ) + ) + + assert result == {"my_option": "hello"} + + +def test_options_browser_falls_back_to_default_when_none(): + """ + Regression coverage for https://github.com/saltstack/salt/issues/69654: + when ``_fetch_option`` returns ``None`` (for example because the config + source is a plain ``__opts__`` dict without a value for the attribute), + the default value must be yielded instead of a bare ``None``. + """ + defaults = { + "filename": "/tmp/prometheus.prom", + "uid": -1, + "gid": -1, + "match_exe": False, + "proc_name": "salt-minion", + } + options = {k: k for k in defaults} + + with patch.object(salt.returners, "_fetch_option", return_value=None): + result = dict( + salt.returners._options_browser( + cfg=None, + ret_config=None, + defaults=defaults, + virtualname="prometheus_textfile", + options=options, + ) + ) + + assert result == defaults + + +def test_get_returner_options_defaults_with_plain_opts_dict(): + """ + Regression coverage for https://github.com/saltstack/salt/issues/69654: + when ``get_returner_options`` is called with ``__opts__`` that does not + contain the returner's attributes (and ``__salt__`` has no + ``config.option``), each unset attribute should fall through to its + ``defaults`` value rather than being yielded as ``None``. + """ + opts = {"cachedir": "/tmp"} + defaults = { + "exe": None, + "filename": "/tmp/prometheus.prom", + "uid": -1, + "gid": -1, + "mode": None, + "match_exe": False, + "proc_name": "salt-minion", + "add_state_name": False, + } + attrs = {k: k for k in defaults} + + result = salt.returners.get_returner_options( + "prometheus_textfile", + ret=None, + attrs=attrs, + __salt__={}, + __opts__=opts, + defaults=defaults, + ) + + assert result == defaults From 58bf18137ff2c4ef76b42710275c101052326b61 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Wed, 8 Jul 2026 14:43:38 -0700 Subject: [PATCH 061/469] Fix MasterKeys.gen_signature signing raw PEM bytes (#66259) (#69650) * Fix MasterKeys.gen_signature signing raw PEM bytes ``MasterKeys.gen_signature`` on 3007.x+ signed ``pub.public_bytes(PEM)`` directly, which carries the trailing newline emitted per RFC 7468. The auth-reply path transmits ``get_pub_str()`` = ``clean_key(pub)`` to the minion (newline stripped). A minion verifying ``payload["pub_sig"]`` against ``payload["pub_key"]`` therefore fails every time ``master_use_pubkey_signature: True`` is set: the master signed 451 bytes, the minion verifies 450. This is the same root cause as #68930 (fixed on 3006.x by #68934), but the 3007.x refactor moved ``gen_signature`` into ``MasterKeys`` and the whitespace-normalization patch didn't propagate. Apply ``clean_key()`` to the PEM before signing so the signed content matches what ``get_pub_str()`` sends. Fixes #66259 * Make MasterKeys.gen_signature FIPS-safe The Photon OS FIPS runners refuse ``PKCS1v15-SHA1`` at the ``PrivateKey.sign`` boundary, so the ``master_use_pubkey_signature`` pre-compute path (and the new #66259 regression test that exercises it) crashes with ``UnsupportedAlgorithm`` on those runners. Add an ``algorithm`` parameter to ``MasterKeys.gen_signature`` that defaults to ``PKCS1v15-SHA224`` when ``fips_enabled()`` and ``PKCS1v15-SHA1`` otherwise, and thread it through to ``priv.sign``. Update the regression test to pass the FIPS-safe algorithm on FIPS test runs and to verify with the same algorithm. * Drive gen_signature algorithm from publish_signing_algorithm Salt already exposes the master's outbound signing algorithm as the ``publish_signing_algorithm`` opt, which operators set to ``PKCS1v15-SHA224`` when running under FIPS. The previous commit picked the algorithm at runtime from ``fips_enabled()``, which is inconsistent with how the rest of the auth flow negotiates crypto (all through opts, not runtime FIPS sniffing). Read ``publish_signing_algorithm`` from opts instead. The test now reads the same opt from the ``master_opts`` fixture, which already FIPS-branches this value. --- changelog/66259.fixed.md | 1 + salt/crypt.py | 19 ++++++++- tests/pytests/unit/crypt/test_crypt.py | 53 ++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 changelog/66259.fixed.md diff --git a/changelog/66259.fixed.md b/changelog/66259.fixed.md new file mode 100644 index 000000000000..3c9e58fbd673 --- /dev/null +++ b/changelog/66259.fixed.md @@ -0,0 +1 @@ +Fixed MasterKeys.gen_signature signing raw PEM bytes instead of the clean_key()-normalized form, causing master_use_pubkey_signature verification to always fail against the pub_key transmitted in the auth reply. diff --git a/salt/crypt.py b/salt/crypt.py index db900a164fd8..f3014168a4a7 100644 --- a/salt/crypt.py +++ b/salt/crypt.py @@ -795,7 +795,7 @@ def check_master_shared_pub(self): log.debug("Writing shared key %s", shared_path) self.cache.store("master_keys", f"peers/{self.master_id}.pub", master_pub) - def gen_signature(self, priv=None, pub=None, sign_path=None): + def gen_signature(self, priv=None, pub=None, sign_path=None, algorithm=None): """ creates a signature for the given public-key with the given private key and writes it to sign_path @@ -827,12 +827,27 @@ def gen_signature(self, priv=None, pub=None, sign_path=None): if not pub: pub = priv.public_key() + # Sign with the algorithm the master is already configured to use for + # its outbound signed payloads. ``publish_signing_algorithm`` is the + # opt operators set (to ``PKCS1v15-SHA224``) to make signed traffic + # FIPS-legal, so honoring it keeps this pre-compute path aligned with + # the rest of the auth flow instead of hard-coding a runtime default. + if algorithm is None: + algorithm = self.opts["publish_signing_algorithm"] + pub_pem = pub.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) - mpub_sig = priv.sign(pub_pem) + # ``get_pub_str()`` transmits the pub key through ``clean_key()``, which + # strips the trailing newline that ``public_bytes(PEM)`` emits per + # RFC 7468. Sign the same bytes the minion will verify against, + # otherwise ``verify_signature`` fails when + # ``master_use_pubkey_signature`` is set. See #66259. + pub_pem = salt.utils.stringutils.to_bytes(clean_key(pub_pem.decode())) + + mpub_sig = priv.sign(pub_pem, algorithm=algorithm) mpub_sig_64 = binascii.b2a_base64(mpub_sig) log.trace("Calculating signature for %s with %s", pub, priv) diff --git a/tests/pytests/unit/crypt/test_crypt.py b/tests/pytests/unit/crypt/test_crypt.py index d936ab04d1a7..a3b8067262d5 100644 --- a/tests/pytests/unit/crypt/test_crypt.py +++ b/tests/pytests/unit/crypt/test_crypt.py @@ -5,6 +5,7 @@ Unit tests for salt's crypt module """ +import binascii import os.path import uuid @@ -234,3 +235,55 @@ def test_pwdata_decrypt(): b"\x07\xa5\xa1\x058\xc7\xce\xbeb\x92\xbf\x0bL\xec\xdf\xc3M\x83\xfb$\xec\xd5\xf9" ) assert salt.crypt.pwdata_decrypt(key_string, pwdata) == "1234" + + +def test_master_keys_gen_signature_signs_clean_key(tmp_path, master_opts): + """ + Regression test for https://github.com/saltstack/salt/issues/66259 + + ``MasterKeys.gen_signature`` must sign the ``clean_key()``-normalized + form of the pub key, because that is what ``get_pub_str()`` transmits + to minions in the auth reply. Signing the raw PEM bytes (which include + the trailing newline emitted by ``public_bytes(PEM)``) yields a signature + a minion cannot verify against the transmitted pub_key, causing + ``master_use_pubkey_signature: True`` deployments to fail with "The + Salt Master server's public key did not authenticate!" on every + auth attempt. + """ + master_opts["pki_dir"] = str(tmp_path) + master_opts["master_sign_pubkey"] = True + master_opts["master_use_pubkey_signature"] = False + master_opts["master_sign_key_name"] = "master_sign" + + mk = salt.crypt.MasterKeys(master_opts) + + # ``salt-key --gen-signature`` calls MasterKeys.gen_signature with an + # explicit ``pub`` = master.pub (as a cryptography public-key object) and + # ``priv`` = the sign key. Reproduce that call shape. + master_pub = salt.crypt.PublicKey.from_file( + os.path.join(str(tmp_path), "master.pub") + ).key + + # ``_setup_keys`` may have already written the signature; remove it so the + # ``cache.contains(...)`` guard in ``gen_signature`` does not short-circuit. + sig_path = os.path.join(str(tmp_path), mk.master_pubkey_signature) + if os.path.exists(sig_path): + os.remove(sig_path) + + # Read the signing algorithm from the master opts, the same way the rest + # of the auth flow does. The ``master_opts`` fixture sets this to a + # FIPS-safe algorithm on FIPS test runs. + algorithm = master_opts["publish_signing_algorithm"] + + assert mk.gen_signature(priv=mk.sign_key, pub=master_pub) is True + assert os.path.exists(sig_path) + + # The bytes the master transmits to the minion. + transmitted_pub_key = mk.get_pub_str() + with salt.utils.files.fopen(sig_path) as fp_: + sig_bytes = binascii.a2b_base64(salt.crypt.clean_key(fp_.read())) + + sign_pub_path = os.path.join(str(tmp_path), "master_sign.pub") + assert salt.crypt.verify_signature( + sign_pub_path, transmitted_pub_key, sig_bytes, algorithm=algorithm + ) From 883183b7ac9cc106a8fa5141edc31a90a4f34004 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Wed, 8 Jul 2026 14:43:53 -0700 Subject: [PATCH 062/469] Fix selinux.port_get_policy crash on unparseable semanage output (#69646) On Fedora 38+, the `semanage port -l | grep ...` pipeline invoked from `selinux.port_get_policy` can return a non-empty line that does not match the `(word)(word)(digits)` parsing regex. Previously the code called `parts.group(1).strip()` unconditionally, so `re.match` returning `None` produced `AttributeError: 'NoneType' object has no attribute 'group'` and hid the underlying command failure from the user. Guard the parse: if `re.match` does not match, raise `CommandExecutionError` with the raw output. This mirrors the behavior already present on 3007.x/3008.x/master (introduced by 990f25997ba as part of a broader selinux refactor) as a minimal backport for 3006.x. Fixes #64583 --- changelog/64583.fixed.md | 1 + salt/modules/selinux.py | 4 ++++ tests/pytests/unit/modules/test_selinux.py | 21 ++++++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 changelog/64583.fixed.md diff --git a/changelog/64583.fixed.md b/changelog/64583.fixed.md new file mode 100644 index 000000000000..d40bcc35d203 --- /dev/null +++ b/changelog/64583.fixed.md @@ -0,0 +1 @@ +Fixed `selinux.port_get_policy` raising `AttributeError: 'NoneType' object has no attribute 'group'` when `semanage port -l` output cannot be parsed (e.g. Fedora 38+); it now raises `CommandExecutionError` instead. diff --git a/salt/modules/selinux.py b/salt/modules/selinux.py index f1c98f917027..5e3436650b0a 100644 --- a/salt/modules/selinux.py +++ b/salt/modules/selinux.py @@ -772,6 +772,10 @@ def port_get_policy(name, sel_type=None, protocol=None, port=None): return None parts = re.match(r"^(\w+)[ ]+(\w+)[ ]+([\d\-, ]+)", port_policy) + if parts is None: + raise CommandExecutionError( + f"Port policy {port_policy!r} did not match expected format" + ) return { "sel_type": parts.group(1).strip(), "protocol": parts.group(2).strip(), diff --git a/tests/pytests/unit/modules/test_selinux.py b/tests/pytests/unit/modules/test_selinux.py index 0ceba06a1341..1fe4fc99b6b9 100644 --- a/tests/pytests/unit/modules/test_selinux.py +++ b/tests/pytests/unit/modules/test_selinux.py @@ -3,7 +3,7 @@ import pytest import salt.modules.selinux as selinux -from salt.exceptions import SaltInvocationError +from salt.exceptions import CommandExecutionError, SaltInvocationError from tests.support.mock import MagicMock, mock_open, patch pytestmark = [pytest.mark.skip_unless_on_linux] @@ -219,6 +219,25 @@ def test_port_get_policy_parsing(): assert ret == case["expected"] +def test_port_get_policy_unparseable_raises_command_execution_error_64583(): + """ + Regression test for #64583. + + On Fedora 38+, `semanage port -l` output can change format so that the + grep pipeline in ``port_get_policy`` returns a non-empty line that does + not match the parsing regex. Previously ``re.match`` returned ``None`` + and the code raised ``AttributeError: 'NoneType' object has no + attribute 'group'``. It should raise ``CommandExecutionError`` instead. + """ + unparseable_output = " \n" + with patch.dict( + selinux.__salt__, + {"cmd.shell": MagicMock(return_value=unparseable_output)}, + ): + with pytest.raises(CommandExecutionError): + selinux.port_get_policy("tcp/22") + + def test_fcontext_policy_parsing_new(): """ Test parsing the stdout response of restorecon used in fcontext_policy_applied, new style. From 3abad270e85b0da44c76a64590c124d153b9918f Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Wed, 8 Jul 2026 14:44:15 -0700 Subject: [PATCH 063/469] Fix vm_overrides nested-merge in Cloud.vm_config() (#69640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud.vm_config() layered main, provider and profile with salt.utils.dictupdate.update() (deep merge), then called vm.update(overrides) — a shallow dict.update that clobbers any nested key present at the top level of overrides. Users setting a single nested field via vm_overrides (e.g. devices.network..ip) lost their profile's sibling branches (devices.disk, other adapter fields, etc.). Merge overrides the same way as the other layers so nested keys are preserved. Fixes #63351 --- changelog/63351.fixed.md | 1 + salt/cloud/__init__.py | 2 +- tests/pytests/unit/cloud/test_cloud.py | 45 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 changelog/63351.fixed.md diff --git a/changelog/63351.fixed.md b/changelog/63351.fixed.md new file mode 100644 index 000000000000..85b80ab33ce0 --- /dev/null +++ b/changelog/63351.fixed.md @@ -0,0 +1 @@ +Fixed `Cloud.vm_config()` to deep-merge `vm_overrides` into the profile so nested keys such as `devices.disk` are preserved instead of being replaced by a shallow `dict.update`. diff --git a/salt/cloud/__init__.py b/salt/cloud/__init__.py index f205ee6c920e..da9e23d1bd60 100644 --- a/salt/cloud/__init__.py +++ b/salt/cloud/__init__.py @@ -1306,7 +1306,7 @@ def vm_config(name, main, provider, profile, overrides): vm = main.copy() vm = salt.utils.dictupdate.update(vm, provider) vm = salt.utils.dictupdate.update(vm, profile) - vm.update(overrides) + vm = salt.utils.dictupdate.update(vm, overrides) vm["name"] = name return vm diff --git a/tests/pytests/unit/cloud/test_cloud.py b/tests/pytests/unit/cloud/test_cloud.py index c84f4ac1a0b2..f843016357be 100644 --- a/tests/pytests/unit/cloud/test_cloud.py +++ b/tests/pytests/unit/cloud/test_cloud.py @@ -130,6 +130,51 @@ def test_vm_config_merger(): assert expected == vm +def test_vm_config_merger_with_overrides(): + """ + Nested keys supplied via ``overrides`` (vm_overrides) must be + deep-merged into the profile, not shallow-replaced. + + https://github.com/saltstack/salt/issues/63351 + """ + main = {} + provider = {} + profile = { + "profile": "default", + "provider": "vmware-default:vmware", + "devices": { + "disk": { + "Hard disk 1": {"size": 30}, + }, + "network": { + "Network adapter 1": { + "name": "VM Network", + "switch_type": "standard", + }, + }, + }, + } + overrides = { + "devices": { + "network": { + "Network adapter 1": {"ip": "192.168.0.10"}, + }, + }, + } + vm = Cloud.vm_config("test_vm", main, provider, profile, overrides) + # Nested top-level branch that was not mentioned in the overrides + # must be preserved. + assert "disk" in vm["devices"] + assert vm["devices"]["disk"] == {"Hard disk 1": {"size": 30}} + # Nested sub-key that was mentioned must be merged, not replaced. + assert vm["devices"]["network"]["Network adapter 1"] == { + "name": "VM Network", + "switch_type": "standard", + "ip": "192.168.0.10", + } + assert vm["name"] == "test_vm" + + @pytest.mark.skip_on_fips_enabled_platform def test_cloud_run_profile_create_returns_boolean(master_config): From 5163b1de3937085fc6f9b2879c1bfc0b7cb992f2 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Wed, 8 Jul 2026 14:46:55 -0700 Subject: [PATCH 064/469] Add back Debian support for Sys V init.d service scripts (#69578) * Add Debian Sys V init.d service scripts Restore init.d scripts for salt-minion, salt-master, salt-api, and salt-syndic that were removed from Debian packages. Systems without systemd (e.g. Devuan) need these scripts to manage Salt services. Also add test_salt_sysv_service_files to verify init.d scripts are present in Debian and RedHat packages, and remove the ubuntu/debian exclusion from the Linux PID change assertion in salt_test_upgrade so Debian/Ubuntu upgrades are also validated. Fixes #67765 * Skip test_salt_sysv_service_files on RPM packages The RPM spec ships systemd unit files only; /etc/init.d/salt-* scripts are exclusively part of the Debian package payload restored by this PR. Rocky Linux, Photon OS, and Amazon Linux upgrade jobs were therefore failing on an assertion that could never hold on RPM. Skip the check when dpkg is unavailable so the assertion only runs against .deb packages, and include the missing init.d path in the assertion message for future debuggers. --- changelog/67765.fixed.md | 1 + pkg/debian/salt-api.init | 99 ++++++++++++++++ pkg/debian/salt-master.init | 112 ++++++++++++++++++ pkg/debian/salt-minion.init | 107 +++++++++++++++++ pkg/debian/salt-syndic.init | 107 +++++++++++++++++ .../pytests/pkg/upgrade/test_salt_upgrade.py | 49 +++++++- 6 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 changelog/67765.fixed.md create mode 100644 pkg/debian/salt-api.init create mode 100644 pkg/debian/salt-master.init create mode 100644 pkg/debian/salt-minion.init create mode 100644 pkg/debian/salt-syndic.init diff --git a/changelog/67765.fixed.md b/changelog/67765.fixed.md new file mode 100644 index 000000000000..10e44c28bccf --- /dev/null +++ b/changelog/67765.fixed.md @@ -0,0 +1 @@ +Added back support for init.d service scripts diff --git a/pkg/debian/salt-api.init b/pkg/debian/salt-api.init new file mode 100644 index 000000000000..c9887f852a51 --- /dev/null +++ b/pkg/debian/salt-api.init @@ -0,0 +1,99 @@ +#!/bin/sh +### BEGIN INIT INFO +# Provides: salt-api +# Required-Start: $remote_fs $network +# Required-Stop: $remote_fs $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: REST API for Salt +# Description: salt-api provides a REST interface to the Salt master +### END INIT INFO + +# Author: Michael Prokop + +PATH=/sbin:/usr/sbin:/bin:/usr/bin +DESC="REST API for Salt" +NAME=salt-api +DAEMON=/usr/bin/salt-api +DAEMON_ARGS="-d" +PIDFILE=/var/run/$NAME.pid +SCRIPTNAME=/etc/init.d/$NAME + +# Exit if the package is not installed +[ -x "$DAEMON" ] || exit 0 + +# Read configuration variable file if it is present +[ -r /etc/default/$NAME ] && . /etc/default/$NAME + +. /lib/init/vars.sh +. /lib/lsb/init-functions + +do_start() { + pid=$(pidofproc -p $PIDFILE $DAEMON) + if [ -n "$pid" ] ; then + log_begin_msg "$DESC already running." + log_end_msg 0 + exit 0 + fi + + log_daemon_msg "Starting salt-api daemon: " + start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON -- $DAEMON_ARGS + log_end_msg $? +} + +do_stop() { + log_begin_msg "Stopping $DESC ..." + start-stop-daemon --stop --retry TERM/5 --quiet --oknodo --pidfile $PIDFILE + RC=$? + [ $RC -eq 0 ] && rm -f $PIDFILE + log_end_msg $RC +} + +case "$1" in + start) + [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? + ;; + #reload) + # not implemented + #;; + restart|force-reload) + log_daemon_msg "Restarting $DESC" "$NAME" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; + *) + echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 + exit 3 + ;; +esac + +exit 0 diff --git a/pkg/debian/salt-master.init b/pkg/debian/salt-master.init new file mode 100644 index 000000000000..1edaa3e0e1ce --- /dev/null +++ b/pkg/debian/salt-master.init @@ -0,0 +1,112 @@ +#!/bin/sh +### BEGIN INIT INFO +# Provides: salt-master +# Required-Start: $remote_fs $network +# Required-Stop: $remote_fs $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: The Salt Master daemon +# Description: The Salt Master is the central server (management +# component) to which all Salt Minions connect +### END INIT INFO + +# Author: Michael Prokop + +PATH=/sbin:/usr/sbin:/bin:/usr/bin +DESC="The Salt Master daemon" +NAME=salt-master +DAEMON=/usr/bin/salt-master +DAEMON_ARGS="-d" +PIDFILE=/var/run/$NAME.pid +SCRIPTNAME=/etc/init.d/$NAME + +# Exit if the package is not installed +[ -x "$DAEMON" ] || exit 0 + +# Read configuration variable file if it is present +[ -r /etc/default/$NAME ] && . /etc/default/$NAME + +. /lib/lsb/init-functions + +do_start() { + # Return + # 0 if daemon has been started + # 1 if daemon was already running + # 2 if daemon could not be started + pid=$(pidofproc -p $PIDFILE $DAEMON) + if [ -n "$pid" ] ; then + return 1 + fi + + start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON -- \ + $DAEMON_ARGS \ + || return 2 +} + +do_stop() { + # Return + # 0 if daemon has been stopped + # 1 if daemon was already stopped + # 2 if daemon could not be stopped + # other if a failure occurred + pids=$(pidof -x $DAEMON) + if [ $? -eq 0 ] ; then + echo $pids | xargs kill 2&1> /dev/null + RETVAL=0 + else + RETVAL=1 + fi + + [ "$RETVAL" = 2 ] && return 2 + rm -f $PIDFILE + return "$RETVAL" +} + +case "$1" in + start) + [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? + ;; + #reload) + # not implemented + #;; + restart|force-reload) + log_daemon_msg "Restarting $DESC" "$NAME" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; + *) + echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 + exit 3 + ;; +esac + +exit 0 diff --git a/pkg/debian/salt-minion.init b/pkg/debian/salt-minion.init new file mode 100644 index 000000000000..e7eec559789a --- /dev/null +++ b/pkg/debian/salt-minion.init @@ -0,0 +1,107 @@ +#!/bin/sh +### BEGIN INIT INFO +# Provides: salt-minion +# Required-Start: $remote_fs $network +# Required-Stop: $remote_fs $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: The Salt Minion daemon +# Description: The Salt Minion is the agent component of Salt. It listens +# for instructions from the Master, runs jobs, and returns +# results back to the Salt Master +### END INIT INFO + +# Author: Michael Prokop + +PATH=/sbin:/usr/sbin:/bin:/usr/bin +DESC="The Salt Minion daemon" +NAME=salt-minion +DAEMON=/usr/bin/salt-minion +DAEMON_ARGS="-d" +PIDFILE=/var/run/$NAME.pid +SCRIPTNAME=/etc/init.d/$NAME + +# Exit if the package is not installed +[ -x "$DAEMON" ] || exit 0 + +# Read configuration variable file if it is present +[ -r /etc/default/$NAME ] && . /etc/default/$NAME + +. /lib/lsb/init-functions + +do_start() { + # Return + # 0 if daemon has been started + # 1 if daemon was already running + # 2 if daemon could not be started + pid=$(pidofproc -p $PIDFILE $DAEMON) + if [ -n "$pid" ] ; then + return 1 + fi + + start-stop-daemon --start --quiet --background --pidfile $PIDFILE --exec $DAEMON -- \ + $DAEMON_ARGS \ + || return 2 +} + +do_stop() { + # Return + # 0 if daemon has been stopped + # 1 if daemon was already stopped + # 2 if daemon could not be stopped + # other if a failure occurred + start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE --name $NAME + RETVAL="$?" + [ "$RETVAL" = 2 ] && return 2 + rm -f $PIDFILE + return "$RETVAL" +} + +case "$1" in + start) + [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? + ;; + #reload) + # not implemented + #;; + restart|force-reload) + log_daemon_msg "Restarting $DESC" "$NAME" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; + *) + echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 + exit 3 + ;; +esac + +exit 0 diff --git a/pkg/debian/salt-syndic.init b/pkg/debian/salt-syndic.init new file mode 100644 index 000000000000..b3a8191947c4 --- /dev/null +++ b/pkg/debian/salt-syndic.init @@ -0,0 +1,107 @@ +#!/bin/sh +### BEGIN INIT INFO +# Provides: salt-syndic +# Required-Start: $remote_fs $network +# Required-Stop: $remote_fs $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: The Salt Syndic daemon +# Description: The Salt Syndic is a master daemon which can receive +# instructions from a higher-level Salt Master, allowing +# for tiered organization of your Salt infrastructure +### END INIT INFO + +# Author: Michael Prokop + +PATH=/sbin:/usr/sbin:/bin:/usr/bin +DESC="The Salt Syndic daemon" +NAME=salt-syndic +DAEMON=/usr/bin/salt-syndic +DAEMON_ARGS="-d" +PIDFILE=/var/run/$NAME.pid +SCRIPTNAME=/etc/init.d/$NAME + +# Exit if the package is not installed +[ -x "$DAEMON" ] || exit 0 + +# Read configuration variable file if it is present +[ -r /etc/default/$NAME ] && . /etc/default/$NAME + +. /lib/lsb/init-functions + +do_start() { + # Return + # 0 if daemon has been started + # 1 if daemon was already running + # 2 if daemon could not be started + pid=$(pidofproc -p $PIDFILE $DAEMON) + if [ -n "$pid" ] ; then + return 1 + fi + + start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON -- \ + $DAEMON_ARGS \ + || return 2 +} + +do_stop() { + # Return + # 0 if daemon has been stopped + # 1 if daemon was already stopped + # 2 if daemon could not be stopped + # other if a failure occurred + start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE --name $NAME + RETVAL="$?" + [ "$RETVAL" = 2 ] && return 2 + rm -f $PIDFILE + return "$RETVAL" +} + +case "$1" in + start) + [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? + ;; + #reload) + # not implemented + #;; + restart|force-reload) + log_daemon_msg "Restarting $DESC" "$NAME" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; + *) + echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 + exit 3 + ;; +esac + +exit 0 diff --git a/tests/pytests/pkg/upgrade/test_salt_upgrade.py b/tests/pytests/pkg/upgrade/test_salt_upgrade.py index 3fba8400ef61..05451490d96d 100644 --- a/tests/pytests/pkg/upgrade/test_salt_upgrade.py +++ b/tests/pytests/pkg/upgrade/test_salt_upgrade.py @@ -1,5 +1,7 @@ import logging +import os import pathlib +import subprocess import sys import time @@ -8,6 +10,7 @@ import pytest from pytestskipmarkers.utils import platform +import salt.utils.path from tests.support.pkg import pep440_public_equal log = logging.getLogger(__name__) @@ -184,7 +187,7 @@ def salt_test_upgrade( new_minion_pids = _get_running_named_salt_pid(process_minion_name) new_master_pids = _get_running_named_salt_pid(process_master_name) - if sys.platform == "linux" and install_salt.distro_id not in ("ubuntu", "debian"): + if sys.platform == "linux": assert new_minion_pids assert new_master_pids if start_version < packaging.version.parse(install_salt.artifact_version): @@ -254,6 +257,50 @@ def _get_installed_salt_packages(): return packages +def test_salt_sysv_service_files(install_salt): + """ + Test that init.d service scripts are present in Debian packages. + + RPM packages ship systemd units only; init.d scripts are not part of the + RPM payload, so this check only applies to .deb packages. + """ + if not install_salt.upgrade: + pytest.skip("Not testing an upgrade, do not run") + + if sys.platform != "linux": + pytest.skip("Not testing on a Linux platform, do not run") + + if not salt.utils.path.which("dpkg"): + pytest.skip("Not testing on a Debian family platform, do not run") + + test_pkgs = install_salt.pkgs + for test_pkg_name in test_pkgs: + test_pkg_basename = os.path.basename(test_pkg_name) + # Debian/Ubuntu name typically salt-minion_300xxxxxx + test_pkg_basename_dash_underscore = test_pkg_basename.split("300")[0] + test_pkg_basename_adj = test_pkg_basename_dash_underscore[:-1] + if test_pkg_basename_adj in ( + "salt-minion", + "salt-master", + "salt-syndic", + "salt-api", + ): + test_initd_name = f"/etc/init.d/{test_pkg_basename_adj}" + proc = subprocess.run( + ["dpkg", "-c", f"{test_pkg_name}"], + capture_output=True, + check=True, + ) + found_line = False + for line in proc.stdout.decode().splitlines(): + # If test_initd_name not present we should fail. + if test_initd_name in line: + found_line = True + break + + assert found_line, f"{test_initd_name} not found in {test_pkg_basename}" + + def test_salt_upgrade( salt_call_cli, install_salt, debian_disable_policy_rcd, salt_master, salt_minion ): From ecd383a808d93142d684df3b582c3c0409ae198f Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Wed, 8 Jul 2026 15:53:02 -0700 Subject: [PATCH 065/469] Fix race in TCP publisher close() vs concurrent connect() (#69187) (#69328) * Fix race in _TCPPubServerPublisher close-during-connect _TCPPubServerPublisher.close() sets self._connecting_future = None to mark the client as shut down. If a concurrent _connect() coroutine is awaiting stream.connect() at the time, when the await resumes the next line calls self._connecting_future.set_result(True) or self._connecting_future.set_exception(e) on None, raising AttributeError: 'NoneType' object has no attribute 'set_result' / 'set_exception'. tornado then logs the misleading "Future ... exception was never retrieved" message described by users. Guard both set_result and set_exception with an "if self._connecting_future is not None" check so that close()'s shutdown contract is honored when the in-flight connect resumes. Fixes #69187 * Add functional regression test for #69187 Drives _TCPPubServerPublisher through its real connect()/_connect()/close() entry points on a real tornado io_loop with a real listening TCP socket. Only IOStream.connect is wrapped so the in-flight _connect() task is reliably parked on its await when close() runs. Captures tornado logger records to assert no AttributeError is raised by the close-during-connect race. Verified the test fails on unfixed code with the exact AttributeError ("'NoneType' object has no attribute 'set_exception'") and passes after the fix in salt/transport/tcp.py. * Resolve orphan connect future in _TCPPubServerPublisher.close The previous change guarded set_result / set_exception in _connect() against _connecting_future having been cleared by a concurrent close() -- which prevented the AttributeError crash, but left the original future returned by connect() orphaned. Any caller doing the natural future = publisher.connect(); await future pattern would then hang forever, because _connect() either sees _closing and breaks silently, or resumes past the guards without touching the future. Have close() resolve the in-flight connect future with a ClosingError before nulling it, so awaiters get a definitive answer. Add a regression test that awaits the future without a wait_for and asserts it completes. --------- Co-authored-by: Daniel A. Wozniak --- changelog/69187.fixed.md | 1 + salt/transport/tcp.py | 27 ++- .../transport/tcp/test_pub_server.py | 125 ++++++++++++ tests/pytests/unit/transport/test_tcp.py | 190 ++++++++++++++++++ 4 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 changelog/69187.fixed.md diff --git a/changelog/69187.fixed.md b/changelog/69187.fixed.md new file mode 100644 index 000000000000..96e6ad6c82dc --- /dev/null +++ b/changelog/69187.fixed.md @@ -0,0 +1 @@ +Fix `AttributeError: 'NoneType' object has no attribute 'set_result'` raised from `salt.transport.tcp._TCPPubServerPublisher._connect` when the publisher's `close()` runs concurrently with an in-flight `_connect()` task. `close()` now resolves the in-flight connect future with a `ClosingError` before nulling it, so callers that `await` the future returned by `connect()` get a definitive answer instead of hanging on an orphan. diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index 642804aa6ecc..ccc2406cffb7 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -1941,7 +1941,13 @@ async def _connect(self, timeout=None): self.stream = tornado.iostream.IOStream(sock) try: await self.stream.connect(sock_addr) - self._connecting_future.set_result(True) + # ``close()`` may have run while we were awaiting + # ``stream.connect()``; it nulls ``_connecting_future``. Issue + # #69187: skip the result-setting in that case rather than + # blowing up with ``'NoneType' object has no attribute + # 'set_result'``. + if self._connecting_future is not None: + self._connecting_future.set_result(True) break except Exception as e: # pylint: disable=broad-except if self.stream.closed(): @@ -1951,7 +1957,10 @@ async def _connect(self, timeout=None): if self.stream is not None: self.stream.close() self.stream = None - self._connecting_future.set_exception(e) + # Same race as above (issue #69187): if ``close()`` ran + # while we were awaiting, ``_connecting_future`` is None. + if self._connecting_future is not None: + self._connecting_future.set_exception(e) break def close(self): @@ -1964,7 +1973,21 @@ def close(self): return self._closing = True + # Resolve the in-flight connect future BEFORE nulling it, so any + # caller that ``await``s the future returned by ``connect()`` + # gets a definitive answer instead of hanging on an orphaned + # future. Without this, ``_connect()`` would either see + # ``_closing`` at the top of its next loop and break silently + # (leaving the original future unresolved) or, when + # ``stream.connect()`` unparked, hit the ``is not None`` guards + # added below and skip setting the result/exception -- either + # way the awaiter deadlocks. See issue #69187. + connecting_future = self._connecting_future self._connecting_future = None + if connecting_future is not None and not connecting_future.done(): + connecting_future.set_exception( + ClosingError("Publisher closed before connect completed") + ) log.debug("Closing %s instance", self.__class__.__name__) diff --git a/tests/pytests/functional/transport/tcp/test_pub_server.py b/tests/pytests/functional/transport/tcp/test_pub_server.py index 5abf821d6e19..2d451721aadf 100644 --- a/tests/pytests/functional/transport/tcp/test_pub_server.py +++ b/tests/pytests/functional/transport/tcp/test_pub_server.py @@ -1,12 +1,137 @@ import asyncio +import logging import os +import socket import time import tornado.gen +import tornado.iostream import salt.transport.tcp +async def test_publisher_close_during_connect_no_attribute_error_69187( + io_loop, monkeypatch +): + """ + Regression test for #69187. + + Drives ``_TCPPubServerPublisher`` through its real ``connect()``, + ``_connect()``, and ``close()`` entry points on a real asyncio / + tornado io_loop. The only piece we slow down is ``IOStream.connect`` + — we wrap it so the in-flight ``_connect()`` task is reliably parked + on its ``await`` when ``publisher.close()`` runs, which is the race + described in the issue. + + Without the fix the in-flight ``_connect()`` task raises + ``AttributeError: 'NoneType' object has no attribute 'set_result'`` + (or ``set_exception``). The task is scheduled with + ``io_loop.create_task()``; tornado's ``IOLoop._discard_future_result`` + callback consumes the exception and routes it through + ``IOLoop.handle_callback_exception`` → ``tornado`` logger at ERROR. + This test installs a logging handler on the ``tornado`` logger that + captures records produced during the close-during-connect window and + asserts none reference ``AttributeError``. + """ + # Pause the IOStream connect handshake until the test releases it, so + # _connect() is guaranteed to be awaiting when close() runs. + release = asyncio.Event() + started = asyncio.Event() + real_connect = tornado.iostream.IOStream.connect + + async def slow_connect(self, address, *args, **kwargs): + started.set() + await release.wait() + return await real_connect(self, address, *args, **kwargs) + + monkeypatch.setattr(tornado.iostream.IOStream, "connect", slow_connect) + + # tornado logs exceptions raised inside loop callbacks via the + # ``tornado`` / ``tornado.application`` loggers; capture those records + # for the duration of the test. + captured_records = [] + + class _Capture(logging.Handler): + def emit(self, record): + captured_records.append(record) + + capture_handler = _Capture(level=logging.DEBUG) + tornado_logger = logging.getLogger("tornado") + tornado_logger.addHandler(capture_handler) + prev_level = tornado_logger.level + tornado_logger.setLevel(logging.DEBUG) + + try: + # Bind a real listener so the eventual real connect, when it + # resumes, completes cleanly rather than blocking. + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(5) + host, port = listener.getsockname() + try: + publisher = salt.transport.tcp._TCPPubServerPublisher( + host=host, port=port, path=None, io_loop=io_loop + ) + + # publisher.connect() schedules _connect() on the io_loop via + # io_loop.create_task() and returns the connecting future. + connect_future = publisher.connect(timeout=None) + + # Wait until _connect() has reached the slow IOStream.connect + # await — _connecting_future is the live future at this point + # and close() is about to null it. + await asyncio.wait_for(started.wait(), timeout=5) + + # close() nulls _connecting_future while _connect() is parked; + # without the fix the in-flight task crashes on the next line + # of _connect() (set_result on success, set_exception on + # failure). + publisher.close() + + # Let IOStream.connect resume so _connect() unparks and walks + # into the set_result / set_exception branch. + release.set() + + # Drain the loop so the _connect() task either resolves or + # raises into tornado's discard-future-result callback. + # close() resolves the connect future with ClosingError + # (see #69187 orphan-future follow-up). + try: + await asyncio.wait_for(connect_future, timeout=2) + except ( + asyncio.TimeoutError, + ConnectionRefusedError, + OSError, + salt.transport.tcp.ClosingError, + ): + pass + await asyncio.sleep(0.1) + finally: + listener.close() + finally: + tornado_logger.removeHandler(capture_handler) + tornado_logger.setLevel(prev_level) + + matching = [] + for record in captured_records: + message = record.getMessage() + if record.exc_info: + exc = record.exc_info[1] + chain = [] + while exc is not None: + chain.append(exc) + exc = exc.__context__ or exc.__cause__ + if any(isinstance(e, AttributeError) for e in chain): + matching.append(message) + continue + if "AttributeError" in message: + matching.append(message) + assert ( + not matching + ), f"AttributeError leaked from _connect() after close(): {matching!r}" + + async def test_pub_channel(master_opts, minion_opts, io_loop): def presence_callback(client): pass diff --git a/tests/pytests/unit/transport/test_tcp.py b/tests/pytests/unit/transport/test_tcp.py index 69df83fa1263..965d4591ec0f 100644 --- a/tests/pytests/unit/transport/test_tcp.py +++ b/tests/pytests/unit/transport/test_tcp.py @@ -203,6 +203,196 @@ def fake_socket(family, *args, **kwargs): assert captured_family == [socket.AF_INET6] +async def test_tcppubserverpublisher_close_during_connect_no_attribute_error_69187( + io_loop, +): + """ + Regression test for #69187. + + ``_TCPPubServerPublisher.close()`` nulls ``self._connecting_future`` while + a concurrent ``_connect()`` coroutine is awaiting ``stream.connect()``. + When the await resumes (succeeds or raises), ``_connect()`` calls + ``self._connecting_future.set_result(True)`` or + ``self._connecting_future.set_exception(e)`` on ``None`` and crashes with + ``AttributeError: 'NoneType' object has no attribute 'set_result'`` (or + ``set_exception``). The original future is then orphaned and tornado + logs the misleading ``Future <...> exception was never retrieved`` + message described in the issue. + + This test drives the close-during-connect race both ways: + + 1. ``stream.connect()`` raises (the path that originally caused + ``set_exception`` to be called on ``None``). + 2. ``stream.connect()`` succeeds (the ``set_result`` path). + """ + + # ----- 1. close-during-failed-connect (set_exception path) ----- + publisher = salt.transport.tcp._TCPPubServerPublisher( + host="127.0.0.1", port=4511, path=None, io_loop=io_loop + ) + publisher._connecting_future = tornado.concurrent.Future() + connect_started = asyncio.Event() + let_connect_finish = asyncio.Event() + + class _FakeStream: + def __init__(self, *args, **kwargs): + self._closed = False + + async def connect(self, addr): + connect_started.set() + await let_connect_finish.wait() + raise tornado.iostream.StreamClosedError("Stream is closed") + + def closed(self): + return self._closed + + def close(self): + self._closed = True + + with patch("salt.transport.tcp.socket.socket", lambda *a, **kw: MagicMock()): + with patch("salt.transport.tcp.tornado.iostream.IOStream", _FakeStream): + # timeout=None means the retry-loop's "should I keep retrying?" + # check (``timeout is None or time.monotonic() > timeout_at``) + # always selects the "give up, set_exception" branch — which is + # the exact branch that crashes in the issue's stack trace + # (legacy ipc.py line 343). + connect_task = asyncio.ensure_future(publisher._connect(timeout=None)) + try: + await connect_started.wait() + # close() nulls _connecting_future while _connect is awaiting + publisher.close() + # Now release the awaited stream.connect() so _connect resumes + # and walks into the buggy ``set_exception`` line. + let_connect_finish.set() + # If the bug is present, the connect_task fails with + # AttributeError ("'NoneType' object has no attribute + # 'set_exception'"). If the bug is fixed, the task completes + # cleanly. + await asyncio.wait_for(connect_task, timeout=5) + finally: + if not connect_task.done(): + connect_task.cancel() + try: + await connect_task + except asyncio.CancelledError: + pass + + # ----- 2. close-during-successful-connect (set_result path) ----- + publisher2 = salt.transport.tcp._TCPPubServerPublisher( + host="127.0.0.1", port=4511, path=None, io_loop=io_loop + ) + publisher2._connecting_future = tornado.concurrent.Future() + connect_started2 = asyncio.Event() + let_connect_finish2 = asyncio.Event() + + class _FakeStreamOk: + def __init__(self, *args, **kwargs): + self._closed = False + + async def connect(self, addr): + connect_started2.set() + await let_connect_finish2.wait() + # successful connect — _connect will fall through to set_result + return None + + def closed(self): + return self._closed + + def close(self): + self._closed = True + + with patch("salt.transport.tcp.socket.socket", lambda *a, **kw: MagicMock()): + with patch("salt.transport.tcp.tornado.iostream.IOStream", _FakeStreamOk): + connect_task2 = asyncio.ensure_future(publisher2._connect(timeout=5)) + try: + await connect_started2.wait() + publisher2.close() + let_connect_finish2.set() + await asyncio.wait_for(connect_task2, timeout=5) + finally: + if not connect_task2.done(): + connect_task2.cancel() + try: + await connect_task2 + except asyncio.CancelledError: + pass + + +async def test_tcppubserverpublisher_close_resolves_connecting_future_69187(io_loop): + """ + Regression test for #69187 (orphan-future follow-up). + + Before the fix, ``_TCPPubServerPublisher.close()`` nulled + ``self._connecting_future`` **without** ever calling + ``.set_result()`` or ``.set_exception()`` on it. As a result, any + caller that did:: + + future = publisher.connect() + await future # no wait_for -- production callers do this + + would hang forever, because ``_connect()`` sees ``_closing`` at the + top of its next loop iteration and breaks silently, leaving the + original future unresolved. + + ``close()`` must resolve the future with a + ``salt.transport.tcp.ClosingError`` before nulling it, so awaiters + get a definitive answer. + """ + publisher = salt.transport.tcp._TCPPubServerPublisher( + host="127.0.0.1", port=4511, path=None, io_loop=io_loop + ) + connect_started = asyncio.Event() + let_connect_finish = asyncio.Event() + + class _FakeStream: + def __init__(self, *args, **kwargs): + self._closed = False + + async def connect(self, addr): + connect_started.set() + await let_connect_finish.wait() + return None + + def closed(self): + return self._closed + + def close(self): + self._closed = True + + with patch("salt.transport.tcp.socket.socket", lambda *a, **kw: MagicMock()): + with patch("salt.transport.tcp.tornado.iostream.IOStream", _FakeStream): + future = publisher.connect(timeout=5) + try: + await connect_started.wait() + publisher.close() + # Awaiting the original future MUST NOT hang -- it should + # resolve with ClosingError. A short wait_for is only a + # safety net so a regression manifests as an assertion + # rather than a test timeout. + try: + await asyncio.wait_for(future, timeout=2) + except salt.transport.tcp.ClosingError: + pass + except asyncio.TimeoutError: + raise AssertionError( + "connecting future was orphaned by close() " + "-- caller would hang in production" + ) + else: + raise AssertionError( + "connecting future should have resolved with " + "ClosingError but returned normally" + ) + finally: + # Unpark _connect() so the create_task-backed coroutine + # completes and isn't reported as a warning. It sees + # ``_closing=True`` at the top of its next loop iteration + # and breaks cleanly. + let_connect_finish.set() + # Give the io_loop a chance to drain the _connect task. + await asyncio.sleep(0.05) + + @pytest.mark.usefixtures("_squash_exepected_message_client_warning") async def test_message_client_cleanup_on_close(client_socket, temp_salt_master): """ From 5dc9f72393cd1a5ec7e3b5c23d9fad4845758ce8 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 8 Jul 2026 20:21:01 -0400 Subject: [PATCH 066/469] Fix mysql.db_remove system-database guard checking a misspelled name (#69688) * Fix mysql.db_remove protection for information_schema The system-database guard in db_remove compared the name against the misspelled "information_scheme", so the check never matched and the real information_schema database was not protected from removal. Fixes #54938 * Add direct and inverse regression tests for mysql.db_remove system-database guard The direct test verifies db_remove issues DROP DATABASE for a user database literally named information_scheme (the misspelling formerly stuck in the guard), called with just the database name and empty connection_args exactly as the production caller mysql_database.absent() does; it fails when the source fix is reverted. The inverse test guards against overcorrection by asserting databases whose names merely resemble the protected system databases (mysql_backup, information_schema_old) are still dropped, and passes with and without the fix. Claude-Session: https://claude.ai/code/session_01MF2AuQNhBZg4HDt1x6xxCu --- changelog/54938.fixed.md | 1 + salt/modules/mysql.py | 2 +- tests/pytests/unit/modules/test_mysql.py | 42 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 changelog/54938.fixed.md diff --git a/changelog/54938.fixed.md b/changelog/54938.fixed.md new file mode 100644 index 000000000000..4977bc950fb1 --- /dev/null +++ b/changelog/54938.fixed.md @@ -0,0 +1 @@ +Fixed mysql.db_remove so it correctly refuses to drop the information_schema system database, which was previously misspelled as information_scheme. diff --git a/salt/modules/mysql.py b/salt/modules/mysql.py index 55ba7fde0737..141b48bf73de 100644 --- a/salt/modules/mysql.py +++ b/salt/modules/mysql.py @@ -1364,7 +1364,7 @@ def db_remove(name, **connection_args): log.info("DB '%s' does not exist", name) return False - if name in ("mysql", "information_scheme"): + if name in ("mysql", "information_schema"): log.info("DB '%s' may not be removed", name) return False diff --git a/tests/pytests/unit/modules/test_mysql.py b/tests/pytests/unit/modules/test_mysql.py index 2023f649877f..93af2d042552 100644 --- a/tests/pytests/unit/modules/test_mysql.py +++ b/tests/pytests/unit/modules/test_mysql.py @@ -483,6 +483,48 @@ def test_db_remove(): _test_call(mysql.db_remove, "DROP DATABASE `test``'\" db`;", "test`'\" db") +def test_db_remove_system_db(): + """ + Test that MySQL db_remove refuses to drop the protected system databases + and never issues a DROP for them (regression test for #54938 where + "information_schema" was misspelled as "information_scheme"). + """ + for name in ("mysql", "information_schema"): + connect_mock = MagicMock() + with patch.object( + mysql, "db_exists", MagicMock(return_value=True) + ), patch.object(mysql, "_connect", connect_mock): + assert mysql.db_remove(name) is False + connect_mock.assert_not_called() + + +def test_db_remove_allows_db_named_information_scheme_54938(): + """ + Test that db_remove issues DROP DATABASE for a user database literally + named "information_scheme" (the misspelling that used to sit in the + system-database guard before #54938 was fixed). Called the same way the + production caller mysql_database.absent() calls it: just the database + name, with connection_args empty. + """ + with patch.object(mysql, "db_exists", MagicMock(return_value=True)): + _test_call( + mysql.db_remove, "DROP DATABASE `information_scheme`;", "information_scheme" + ) + + +def test_db_remove_does_not_block_similar_names_54938(): + """ + Guard against overcorrection of the #54938 fix: db_remove must still + issue DROP DATABASE for user databases whose names merely resemble the + protected system databases. This test passes with and without the fix + applied. Like the production caller mysql_database.absent(), db_remove + is called with just the database name (connection_args empty). + """ + for name in ("mysql_backup", "information_schema_old"): + with patch.object(mysql, "db_exists", MagicMock(return_value=True)): + _test_call(mysql.db_remove, f"DROP DATABASE `{name}`;", name) + + def test_db_tables(): """ Test MySQL db_tables function in mysql exec module From 804412e3257ca74d06659ee2fde3ad90408ef5ae Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 8 Jul 2026 20:21:43 -0400 Subject: [PATCH 067/469] Fix ini.set_option deleting indented options (#69689) * Fix ini.set_option deleting indented options In _Section.refresh, an indented line was appended to the previous option's value, but when a section began with indented options (no prior option to append to) the line hit 'continue' and was silently discarded. Move the 'continue' inside the append branch so orphaned indented lines fall through to the normal key/value match and are preserved. Fixes #36354 * Add direct and inverse regression tests for ini indented options fix The direct test calls _Section.refresh() itself the way _Ini.refresh does in production (section body passed positionally as inicontents with separator="=", refresh() invoked with no arguments) on a git-style section whose options are all indented, which the old code silently dropped. The inverse test guards against overcorrection by verifying an indented line following a normal option is still folded into that option's value as a continuation line rather than parsed as a separate entry. --- changelog/36354.fixed.md | 1 + salt/modules/ini_manage.py | 2 +- tests/pytests/unit/modules/test_ini_manage.py | 82 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 changelog/36354.fixed.md diff --git a/changelog/36354.fixed.md b/changelog/36354.fixed.md new file mode 100644 index 000000000000..f7135b159d28 --- /dev/null +++ b/changelog/36354.fixed.md @@ -0,0 +1 @@ +ini.set_option now preserves indented options in other sections instead of deleting them. diff --git a/salt/modules/ini_manage.py b/salt/modules/ini_manage.py index 05072ef318d3..9060ce8e35b1 100644 --- a/salt/modules/ini_manage.py +++ b/salt/modules/ini_manage.py @@ -447,7 +447,7 @@ def refresh(self, inicontents=None): prev_opt = options[-1] value = self.get(prev_opt) self.update({prev_opt: os.linesep.join((value, opt_str))}) - continue + continue # Match normal key+value lines. opt_match = self.opt_regx.match(opt_str) if opt_match: diff --git a/tests/pytests/unit/modules/test_ini_manage.py b/tests/pytests/unit/modules/test_ini_manage.py index e226f34dfaac..cc7c75ee4580 100644 --- a/tests/pytests/unit/modules/test_ini_manage.py +++ b/tests/pytests/unit/modules/test_ini_manage.py @@ -520,3 +520,85 @@ def test_unicode_remove_section(encoding, linesep, ini_file, unicode_content): } assert ini.remove_section(str(ini_file), "Юникод", encoding=encoding) == expected assert ini.get_section(str(ini_file), "Юникод", encoding=encoding) == {} + + +def test_set_option_preserves_indented_options(ini_file): + """ + Test that setting an option does not delete indented options in other + sections (e.g. a git-style config where options are indented). + + Regression test for #36354. + """ + ini_content = os.linesep.join( + [ + "[core]", + "", + '[remote "origin"]', + " url = git@version-control:test.git", + " fetch = +refs/heads/*:refs/remotes/origin/*", + ] + ) + ini_file.write_text(ini_content) + + ini.set_option(str(ini_file), {"core": {"sharedRepository": "group"}}) + + # The indented options in the untouched section must survive + assert ( + ini.get_option(str(ini_file), 'remote "origin"', "url") + == "git@version-control:test.git" + ) + assert ( + ini.get_option(str(ini_file), 'remote "origin"', "fetch") + == "+refs/heads/*:refs/remotes/origin/*" + ) + # The new option was still written + assert ini.get_option(str(ini_file), "core", "sharedRepository") == "group" + + +def test_section_refresh_parses_leading_indented_options_36354(): + """ + Call the fixed _Section.refresh directly with a section body whose + options are all indented (git-style config), the case that used to be + silently dropped. + + Regression test for #36354. + """ + # Mirror the production call site in _Ini.refresh: the section body is + # passed positionally as inicontents with separator="=" and refresh() + # is then called with no arguments, so it parses self.inicontents. + sect_ini = os.linesep.join( + [ + " url = git@version-control:test.git", + " fetch = +refs/heads/*:refs/remotes/origin/*", + ] + ) + sect = ini._Section('remote "origin"', sect_ini, separator="=") + sect.refresh() + + # Before the fix, refresh() consumed indented lines even when there was + # no previous option to append them to, so the section came back empty. + assert sect.get("url") == "git@version-control:test.git" + assert sect.get("fetch") == "+refs/heads/*:refs/remotes/origin/*" + + +def test_section_refresh_keeps_continuation_lines_36354(): + """ + Guard against overcorrection of the #36354 fix: an indented line that + follows a normal option must still be folded into that option's value + as a continuation line, not parsed as a separate option or dropped. + This passes with and without the fix. + """ + sect_ini = os.linesep.join( + [ + "key1 = value1", + " continuation line", + "key2 = value2", + ] + ) + sect = ini._Section("test", sect_ini, separator="=") + sect.refresh() + + assert sect.get("key1") == os.linesep.join(["value1", " continuation line"]) + assert sect.get("key2") == "value2" + # The continuation line must not have become its own entry + assert len(sect) == 2 From 606e33eebf4e5cdc198c600034840139bc27d6b4 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 8 Jul 2026 20:28:37 -0400 Subject: [PATCH 068/469] Fix grain PCRE/glob matching against dict-valued grains (#69690) * Fix grain_pcre/glob matching against dictionary-valued grains subdict_match only applied regex/fnmatch patterns to list members and to literal dict keys, so a pattern like 'roles:(roleA|roleB|roleC)' matched a list-valued grain but not the equivalent dict-valued grain. Scan dict keys with the same _match() helper used for list members so key matching is consistent. Fixes #35567 * Add direct and inverse regression tests for grain dict-key matching The PR's existing test already exercises subdict_match directly with regex_match=True (the exact flag grain_pcre_match passes) and the plain glob shape grain_match passes. This adds the inverse guard: the new key-matching branch runs for every caller, so exact_match=True (the shape pillar_exact_match and the minions cache check pass) must keep glob/regex metacharacters literal, a non-matching glob must not match, and a key-only match must not satisfy a deeper expression whose lower levels do not match. All inverse assertions hold with and without the fix applied. --- changelog/35567.fixed.md | 1 + salt/utils/data.py | 7 +++ tests/pytests/unit/utils/test_data.py | 65 +++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 changelog/35567.fixed.md diff --git a/changelog/35567.fixed.md b/changelog/35567.fixed.md new file mode 100644 index 000000000000..9e95ff814754 --- /dev/null +++ b/changelog/35567.fixed.md @@ -0,0 +1 @@ +Fixed grain_pcre and glob matching against dictionary-valued grains so patterns are applied to dict keys, not only list members. diff --git a/salt/utils/data.py b/salt/utils/data.py index a8760b57699c..7ec5d686a155 100644 --- a/salt/utils/data.py +++ b/salt/utils/data.py @@ -925,6 +925,13 @@ def _dict_match(target, pattern, regex_match=False, exact_match=False): if not ret and pattern in target: # We might want to search for a key ret = True + if not ret and any( + _match(key, pattern, regex_match=regex_match, exact_match=exact_match) + for key in target + ): + # The pattern may be a regex/glob that matches one of the keys, + # just like list members are matched below + ret = True if not ret and subdict_match( target, pattern, regex_match=regex_match, exact_match=exact_match ): diff --git a/tests/pytests/unit/utils/test_data.py b/tests/pytests/unit/utils/test_data.py index ad7c13a07b9f..4feaef9ba751 100644 --- a/tests/pytests/unit/utils/test_data.py +++ b/tests/pytests/unit/utils/test_data.py @@ -142,6 +142,71 @@ def test_subdict_match(): assert salt.utils.data.subdict_match(test_three_level_dict, "a:*:c:v") +def test_subdict_match_regex_on_dict_keys(): + """ + Tests that regex/glob patterns are applied to dict keys, not only to + list members. Regression test for issue #35567. + """ + dict_grain = { + "roles": {"roleA": None, "roleB": ["envA", "envB"], "roleC": ["envA"]} + } + list_grain = {"roles": ["roleA", "roleB", "roleC"]} + + # The list-valued grain has always matched a regex alternation ... + assert salt.utils.data.subdict_match( + list_grain, "roles:(roleA|roleB|roleC)", regex_match=True + ) + # ... and the dict-valued grain should behave the same way against its keys. + assert salt.utils.data.subdict_match( + dict_grain, "roles:(roleA|roleB|roleC)", regex_match=True + ) + # Glob patterns should also match dict keys. + assert salt.utils.data.subdict_match(dict_grain, "roles:role*") + # Negative case: a pattern that matches none of the keys must fail. + assert not salt.utils.data.subdict_match( + dict_grain, "roles:(roleX|roleY)", regex_match=True + ) + + +def test_subdict_match_dict_keys_no_overcorrection_35567(): + """ + Guards against overcorrection of the issue #35567 fix. The new + key-matching branch in subdict_match runs for every caller, so it must + not loosen matching for the paths the fix was not meant to change. + These assertions hold both with and without the fix applied. + """ + dict_grain = { + "roles": {"roleA": None, "roleB": ["envA", "envB"], "roleC": ["envA"]} + } + + # exact_match=True is the production shape passed by + # salt/matchers/pillar_exact_match.py and the master-side cache check in + # salt/utils/minions.py. Glob/regex metacharacters must stay literal: + # the new branch must not start wildcard-matching dict keys here. + assert not salt.utils.data.subdict_match( + dict_grain, "roles:role*", exact_match=True + ) + assert not salt.utils.data.subdict_match( + dict_grain, "roles:role.*", exact_match=True + ) + # A literal key still matches under exact_match=True. + assert salt.utils.data.subdict_match(dict_grain, "roles:roleA", exact_match=True) + + # Glob negative case (grain_match production shape, regex_match=False): + # a glob matching none of the keys must not match. + assert not salt.utils.data.subdict_match(dict_grain, "roles:bogus*") + + # Deeper expressions must still require the deeper levels to match; a + # key-only match on 'roleC' must not satisfy 'roles:roleC:envB' when + # envB is not present under roleC. + assert not salt.utils.data.subdict_match( + dict_grain, "roles:roleC:envB", regex_match=True + ) + assert salt.utils.data.subdict_match( + dict_grain, "roles:roleB:envB", regex_match=True + ) + + @pytest.mark.parametrize( "wildcard", [ From d24df6cff91cdcd4fe7f0e714a0decc9adf93ae4 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 8 Jul 2026 20:29:27 -0400 Subject: [PATCH 069/469] Include the offending path in file module directory errors (#69691) * Include offending path in file.readdir/rmdir invalid-directory error The "A valid directory was not specified." SaltInvocationError raised by file.readdir and file.rmdir omitted the offending path, making template and highstate failures hard to diagnose. Include the path in the message. Fixes #47707 * Add direct and inverse regression tests for file.rmdir/readdir invalid-dir errors The direct test calls file.rmdir with the exact argument shape the file.rmdir state uses (recurse=..., verbose=True, older_than=...), since verbose=True normally routes removal failures into the returned dict instead of raising, and proves the invalid-directory error still raises with the offending path in the message. The inverse tests guard against overcorrection: relative paths must still hit the untouched absolute-path check for both rmdir and readdir, and readdir on an existing directory must still return its listing without raising. --- changelog/47707.fixed.md | 1 + salt/modules/file.py | 4 +- .../unit/modules/file/test_file_rmdir.py | 48 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 changelog/47707.fixed.md diff --git a/changelog/47707.fixed.md b/changelog/47707.fixed.md new file mode 100644 index 000000000000..1160b27c0cd2 --- /dev/null +++ b/changelog/47707.fixed.md @@ -0,0 +1 @@ +Include the offending path in the "A valid directory was not specified" error raised by file.readdir and file.rmdir diff --git a/salt/modules/file.py b/salt/modules/file.py index dfb1c66625fe..834cd8437a3e 100644 --- a/salt/modules/file.py +++ b/salt/modules/file.py @@ -4192,7 +4192,7 @@ def readdir(path): raise SaltInvocationError("Dir path must be absolute.") if not os.path.isdir(path): - raise SaltInvocationError("A valid directory was not specified.") + raise SaltInvocationError(f"A valid directory was not specified: {path}") dirents = [".", ".."] dirents.extend(os.listdir(path)) @@ -4342,7 +4342,7 @@ def rmdir(path, recurse=False, verbose=False, older_than=None): raise SaltInvocationError("File path must be absolute.") if not os.path.isdir(path): - raise SaltInvocationError("A valid directory was not specified.") + raise SaltInvocationError(f"A valid directory was not specified: {path}") if older_than: now = time.time() diff --git a/tests/pytests/unit/modules/file/test_file_rmdir.py b/tests/pytests/unit/modules/file/test_file_rmdir.py index d40a50be50e4..22160f90ecb8 100644 --- a/tests/pytests/unit/modules/file/test_file_rmdir.py +++ b/tests/pytests/unit/modules/file/test_file_rmdir.py @@ -37,6 +37,54 @@ def test_file_rmdir_not_found_exception(): filemod.rmdir("/tmp/not_there") +def test_file_rmdir_not_found_exception_includes_path(): + with pytest.raises(SaltInvocationError, match="/tmp/not_there"): + filemod.rmdir("/tmp/not_there") + + +def test_file_readdir_not_found_exception_includes_path(): + with pytest.raises(SaltInvocationError, match="/tmp/not_there"): + filemod.readdir("/tmp/not_there") + + +def test_file_rmdir_not_found_includes_path_with_state_args_47707(): + # The file.rmdir state (salt/states/file.py) calls this as + # rmdir(name, recurse=recurse, verbose=True, older_than=older_than). + # verbose=True is the decisive flag: with it, removal failures are + # normally collected into the returned dict's "errors" list instead of + # raised, but an invalid directory must still raise, and the message + # must include the offending path. + with pytest.raises(SaltInvocationError, match="/tmp/not_there"): + filemod.rmdir("/tmp/not_there", recurse=True, verbose=True, older_than=None) + + +def test_file_rmdir_relative_path_error_unchanged_47707(): + """ + Guard against overcorrection: a relative path must still fail the + absolute-path check, not the valid-directory check changed for #47707. + """ + with pytest.raises(SaltInvocationError, match="must be absolute"): + filemod.rmdir("not_absolute") + + +def test_file_readdir_relative_path_error_unchanged_47707(): + """ + Guard against overcorrection: a relative path must still fail readdir's + absolute-path check, not the valid-directory check changed for #47707. + """ + with pytest.raises(SaltInvocationError, match="must be absolute"): + filemod.readdir("not_absolute") + + +def test_file_readdir_valid_directory_47707(tmp_path): + """ + Guard against overcorrection: readdir on an existing directory must + still return the directory listing without raising. + """ + (tmp_path / "afile").write_text("data") + assert filemod.readdir(str(tmp_path)) == [".", "..", "afile"] + + def test_file_rmdir_success_return(): with patch("os.rmdir", MagicMock(return_value=True)), patch( "os.path.isdir", MagicMock(return_value=True) From d21b93de6fbc58f433708e89f6d3d9f46c6dd25e Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 8 Jul 2026 20:35:39 -0400 Subject: [PATCH 070/469] Fix postgres.privileges_list crashing on an empty ACL (#69684) * Fix postgres.privileges_list crash on emptied ACL When all privileges are revoked from a role, the relacl column becomes an empty '{}' (or contains entries without a grantor suffix). The ACL parsing loop in privileges_list did an unguarded part.split("/") and perms_part.split("="), which raised ValueError('not enough values to unpack') and crashed postgres_privileges.present when re-granting. Skip empty parts, use str.partition() and require '=' before unpacking, logging and skipping malformed entries. Well-formed ACLs are unchanged; an emptied ACL yields no privileges so the state proceeds to re-grant. Fixes #51450 * Add direct and inverse regression tests for postgres.privileges_list empty ACL The direct test calls privileges_list itself with a mixed relacl (one valid entry plus junk entries lacking an '=' assignment) and object_type 'table', the non-group value that postgres.has_privileges and the postgres_privileges state pass through into the ACL-parsing branch changed for #51450; it proves skipping is per-entry and fails with ValueError if the fix is reverted. The inverse test guards against overcorrection: a PUBLIC grant ('=r/grantor') has an empty rolename but is well-formed, so it must still be reported under 'public' and never be skipped as malformed; it passes with and without the fix. --- changelog/51450.fixed.md | 1 + salt/modules/postgres.py | 11 +- tests/pytests/unit/modules/test_postgres.py | 107 ++++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 changelog/51450.fixed.md diff --git a/changelog/51450.fixed.md b/changelog/51450.fixed.md new file mode 100644 index 000000000000..68803a578919 --- /dev/null +++ b/changelog/51450.fixed.md @@ -0,0 +1 @@ +Fixed postgres.privileges_list raising ValueError on an emptied ACL so postgres_privileges.present can re-grant privileges after they were revoked diff --git a/salt/modules/postgres.py b/salt/modules/postgres.py index 25ff35cba22c..b0503385e63f 100644 --- a/salt/modules/postgres.py +++ b/salt/modules/postgres.py @@ -3149,8 +3149,15 @@ def privileges_list( result = result.strip("{}") parts = result.split(",") for part in parts: - perms_part, _ = part.split("/") - rolename, perms = perms_part.split("=") + if not part: + # Empty ACL (e.g. after all privileges were revoked) + continue + perms_part, _, _grantor = part.partition("/") + if "=" not in perms_part: + # Malformed ACL entry; skip instead of crashing + log.debug("Skipping malformed ACL entry: %s", part) + continue + rolename, _, perms = perms_part.partition("=") if rolename == "": rolename = "public" _tmp = _process_priv_part(perms) diff --git a/tests/pytests/unit/modules/test_postgres.py b/tests/pytests/unit/modules/test_postgres.py index 309e202bb9c4..e7fa5913b802 100644 --- a/tests/pytests/unit/modules/test_postgres.py +++ b/tests/pytests/unit/modules/test_postgres.py @@ -1674,6 +1674,113 @@ def test_privileges_list_table(get_test_privileges_list_table_csv): ) +def test_privileges_list_table_empty_acl(): + """ + Test privilege listing on a table whose ACL has been emptied by REVOKE. + + Regression test for #51450: an empty relacl ('{}') or an entry lacking + the '=' assignment must not raise ValueError but yield no privileges. + """ + empty_acl_csv = 'name\n"{}"\n' + with patch( + "salt.modules.postgres._run_psql", + Mock(return_value={"retcode": 0, "stdout": empty_acl_csv}), + ), patch("salt.utils.path.which", MagicMock(return_value="/usr/bin/pgsql")): + ret = postgres.privileges_list( + "awl", + "table", + maintenance_db="db_name", + runas="user", + host="testhost", + port="testport", + user="testuser", + password="testpassword", + ) + assert ret == {} + + +def test_privileges_list_table_mixed_malformed_acl_51450(): + """ + Test that valid ACL entries are still returned when the relacl also + contains entries the #51450 fix skips (no '=' assignment). + + Skipping must be per-entry: junk entries may not take the valid ones + down with them, and may not raise ValueError as before the fix. + """ + # object_type "table" (anything but "group") is the decisive argument: + # it is what postgres.has_privileges / the postgres_privileges state + # pass through, and it routes into the relacl-parsing branch that + # #51450 changed. prepend="public" matches has_privileges' default. + mixed_acl_csv = 'name\n"{baruwatest=arwdDxtm/baruwatest,garbage,junk/postgres}"\n' + with patch( + "salt.modules.postgres._run_psql", + Mock(return_value={"retcode": 0, "stdout": mixed_acl_csv}), + ), patch("salt.utils.path.which", MagicMock(return_value="/usr/bin/pgsql")): + ret = postgres.privileges_list( + "awl", + "table", + prepend="public", + maintenance_db="db_name", + runas="user", + host="testhost", + port="testport", + user="testuser", + password="testpassword", + ) + assert ret == { + "baruwatest": { + "INSERT": False, + "SELECT": False, + "UPDATE": False, + "DELETE": False, + "TRUNCATE": False, + "REFERENCES": False, + "TRIGGER": False, + "MAINTAIN": False, + } + } + + +def test_privileges_list_table_public_grant_51450(): + """ + Test that a PUBLIC grant (empty rolename, e.g. '=r/postgres') is still + reported under the 'public' key and not skipped as malformed. + + Guards against overcorrection of the #51450 fix: an entry with an empty + rolename contains '=' and is well-formed, so the malformed-entry skip + must not touch it. This passes with and without the fix. + """ + public_acl_csv = 'name\n"{baruwatest=arwdDxtm/baruwatest,=r/baruwatest}"\n' + with patch( + "salt.modules.postgres._run_psql", + Mock(return_value={"retcode": 0, "stdout": public_acl_csv}), + ), patch("salt.utils.path.which", MagicMock(return_value="/usr/bin/pgsql")): + ret = postgres.privileges_list( + "awl", + "table", + prepend="public", + maintenance_db="db_name", + runas="user", + host="testhost", + port="testport", + user="testuser", + password="testpassword", + ) + assert ret == { + "baruwatest": { + "INSERT": False, + "SELECT": False, + "UPDATE": False, + "DELETE": False, + "TRUNCATE": False, + "REFERENCES": False, + "TRIGGER": False, + "MAINTAIN": False, + }, + "public": {"SELECT": False}, + } + + def test_privileges_list_group(get_test_privileges_list_group_csv): """ Test privilege listing on a group From 58542e9549712f265f4e3de0acd06ae219b392e9 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 8 Jul 2026 20:36:10 -0400 Subject: [PATCH 071/469] Fix postgres_database.absent falsely reporting DB removal failure as absent (#69683) When postgres.db_exists() returned True but postgres.db_remove() failed (for example, DROP DATABASE refused because the database is still in use), absent() fell through to the fallback branch and reported result=True with the misleading comment that the database was not present. Add an else branch mirroring postgres_user.absent so a failed removal is reported as a failure. Fixes #37506 --- changelog/37506.fixed.md | 1 + salt/states/postgres_database.py | 4 ++++ .../unit/states/postgresql/test_database.py | 21 +++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 changelog/37506.fixed.md diff --git a/changelog/37506.fixed.md b/changelog/37506.fixed.md new file mode 100644 index 000000000000..e7ad77ce1320 --- /dev/null +++ b/changelog/37506.fixed.md @@ -0,0 +1 @@ +Report a failure when a PostgreSQL database exists but cannot be removed instead of claiming it is not present. diff --git a/salt/states/postgres_database.py b/salt/states/postgres_database.py index 831bbecc0683..61cae674ce1d 100644 --- a/salt/states/postgres_database.py +++ b/salt/states/postgres_database.py @@ -225,6 +225,10 @@ def absent( ret["comment"] = f"Database {name} has been removed" ret["changes"][name] = "Absent" return ret + else: + ret["result"] = False + ret["comment"] = f"Database {name} failed to be removed" + return ret # fallback ret["comment"] = f"Database {name} is not present, so it cannot be removed" diff --git a/tests/pytests/unit/states/postgresql/test_database.py b/tests/pytests/unit/states/postgresql/test_database.py index cb8b4c009c9d..eb345435ce06 100644 --- a/tests/pytests/unit/states/postgresql/test_database.py +++ b/tests/pytests/unit/states/postgresql/test_database.py @@ -80,3 +80,24 @@ def test_absent(): comt = f"Database {name} is not present, so it cannot be removed" ret.update({"comment": comt, "result": True, "changes": {}}) assert postgres_database.absent(name) == ret + + +def test_absent_removal_failure(): + """ + Test that a database which exists but cannot be removed (e.g. it is + still in use) is reported as a failure rather than as "not present". + """ + name = "frank" + + ret = {"name": name, "changes": {}, "result": False, "comment": ""} + + mock_exists = MagicMock(return_value=True) + mock_remove = MagicMock(return_value=False) + with patch.dict( + postgres_database.__salt__, + {"postgres.db_exists": mock_exists, "postgres.db_remove": mock_remove}, + ): + with patch.dict(postgres_database.__opts__, {"test": False}): + comt = f"Database {name} failed to be removed" + ret.update({"comment": comt, "result": False, "changes": {}}) + assert postgres_database.absent(name) == ret From a45079439aab270fd75ae39a45f8148684470bee Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 8 Jul 2026 20:37:21 -0400 Subject: [PATCH 072/469] Fix pyenv.install_pyenv passing user into the python argument (#69682) * Fix pyenv.install_pyenv state to actually install pyenv install_pyenv() called _check_and_install_python(ret, user), passing the user argument into the "python" positional. With the default user=None this fed None into pyenv.install_python(), which raised a TypeError from re.sub() and made the state error out on every run. Add a dedicated _check_and_install_pyenv() helper (mirroring the rbenv state) that installs pyenv itself, and update install_pyenv() to use it, including correct test-mode reporting. Fixes #37648 * Add direct and inverse regression tests for pyenv.install_pyenv user handling The direct test calls install_pyenv with the user kwarg (the argument a production pyenv.install_pyenv state passes, and the one that was previously misrouted into the python version parameter) and asserts pyenv.is_installed/pyenv.install receive it while install_python, default, and versions are never touched. The inverse test guards against overcorrection by confirming the sibling pyenv.installed state still installs a missing python via pyenv.install_python with user passed as runas. --- changelog/37648.fixed.md | 1 + salt/states/pyenv.py | 29 ++++- tests/pytests/unit/states/test_pyenv.py | 145 +++++++++++++++++++----- 3 files changed, 145 insertions(+), 30 deletions(-) create mode 100644 changelog/37648.fixed.md diff --git a/changelog/37648.fixed.md b/changelog/37648.fixed.md new file mode 100644 index 000000000000..2a84694bc064 --- /dev/null +++ b/changelog/37648.fixed.md @@ -0,0 +1 @@ +Fixed the pyenv.install_pyenv state so it installs pyenv itself instead of raising a traceback. diff --git a/salt/states/pyenv.py b/salt/states/pyenv.py index 0d2a30020ab8..a67911e4141c 100644 --- a/salt/states/pyenv.py +++ b/salt/states/pyenv.py @@ -194,6 +194,25 @@ def absent(name, user=None): return _check_and_uninstall_python(ret, name, user=user) +def _check_and_install_pyenv(ret, user=None): + """ + Verify that pyenv is installed, install if unavailable + """ + ret = _check_pyenv(ret, user) + if ret["result"] is False: + if __salt__["pyenv.install"](user): + ret["result"] = True + ret["comment"] = "pyenv installed" + else: + ret["result"] = False + ret["comment"] = "pyenv failed to install" + else: + ret["result"] = True + ret["comment"] = "pyenv is already installed" + + return ret + + def install_pyenv(name, user=None): """ Install pyenv if not installed. Allows you to require pyenv be installed @@ -210,7 +229,13 @@ def install_pyenv(name, user=None): ret = {"name": name, "result": None, "comment": "", "changes": {}} if __opts__["test"]: - ret["comment"] = "pyenv is set to be installed" + ret = _check_pyenv(ret, user=user) + if ret["result"] is False: + ret["result"] = None + ret["comment"] = "pyenv is set to be installed" + else: + ret["result"] = True + ret["comment"] = "pyenv is already installed" return ret - return _check_and_install_python(ret, user) + return _check_and_install_pyenv(ret, user) diff --git a/tests/pytests/unit/states/test_pyenv.py b/tests/pytests/unit/states/test_pyenv.py index 850506814ee2..b3fea3c22bd9 100644 --- a/tests/pytests/unit/states/test_pyenv.py +++ b/tests/pytests/unit/states/test_pyenv.py @@ -96,36 +96,125 @@ def test_absent(): def test_install_pyenv(): """ - Test to install pyenv if not installed. + Test to install pyenv itself if not installed. + + install_pyenv must never try to install a python version (it does not + receive one); it should only call pyenv.install. See issue #37648. """ - name = "python-2.7.6" + name = "install-pyenv" + + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + mock_is = MagicMock(side_effect=[False, True, True, False, False]) + mock_i = MagicMock(side_effect=[False, True]) + # install_python must never be called by install_pyenv. + mock_ip = MagicMock(side_effect=AssertionError("pyenv.install_python called")) + with patch.dict( + pyenv.__salt__, + { + "pyenv.is_installed": mock_is, + "pyenv.install": mock_i, + "pyenv.install_python": mock_ip, + }, + ): + with patch.dict(pyenv.__opts__, {"test": True}): + comt = "pyenv is set to be installed" + ret.update({"comment": comt, "result": None}) + assert pyenv.install_pyenv(name) == ret - ret = {"name": name, "changes": {}, "result": None, "comment": ""} + comt = "pyenv is already installed" + ret.update({"comment": comt, "result": True}) + assert pyenv.install_pyenv(name) == ret - with patch.dict(pyenv.__opts__, {"test": True}): - comt = "pyenv is set to be installed" - ret.update({"comment": comt}) - assert pyenv.install_pyenv(name) == ret + with patch.dict(pyenv.__opts__, {"test": False}): + comt = "pyenv is already installed" + ret.update({"comment": comt, "result": True}) + assert pyenv.install_pyenv(name) == ret - with patch.dict(pyenv.__opts__, {"test": False}): - mock_t = MagicMock(return_value=True) - mock_str = MagicMock(return_value="2.7.6") - mock_lst = MagicMock(return_value=["2.7.6"]) - with patch.dict( - pyenv.__salt__, - { - "pyenv.install_python": mock_t, - "pyenv.default": mock_str, - "pyenv.versions": mock_lst, - }, - ): - comt = "Successfully installed python" - ret.update( - { - "comment": comt, - "result": True, - "default": False, - "changes": {None: "Installed"}, - } - ) + comt = "pyenv failed to install" + ret.update({"comment": comt, "result": False}) + assert pyenv.install_pyenv(name) == ret + + comt = "pyenv installed" + ret.update({"comment": comt, "result": True}) assert pyenv.install_pyenv(name) == ret + + +def test_install_pyenv_with_user_37648(): + """ + Test that install_pyenv passes ``user`` to the pyenv execution module. + + Before the fix for issue #37648, install_pyenv called + _check_and_install_python(ret, user), which put ``user`` into the + ``python`` positional argument and tried to install a python version + named after the user instead of installing pyenv itself. + """ + name = "install-pyenv" + # ``user`` is the decisive kwarg: it is what a production state like + # pyenv.install_pyenv: + # - user: pyenv_user + # passes through, and it is the argument that was previously misrouted + # into the python version parameter. + user = "pyenv_user" + + mock_is = MagicMock(return_value=False) + mock_i = MagicMock(return_value=True) + # None of the python-version machinery may be touched by install_pyenv. + mock_ip = MagicMock(side_effect=AssertionError("pyenv.install_python called")) + mock_d = MagicMock(side_effect=AssertionError("pyenv.default called")) + mock_v = MagicMock(side_effect=AssertionError("pyenv.versions called")) + with patch.dict( + pyenv.__salt__, + { + "pyenv.is_installed": mock_is, + "pyenv.install": mock_i, + "pyenv.install_python": mock_ip, + "pyenv.default": mock_d, + "pyenv.versions": mock_v, + }, + ), patch.dict(pyenv.__opts__, {"test": False}): + ret = pyenv.install_pyenv(name, user=user) + + assert ret == { + "name": name, + "changes": {}, + "result": True, + "comment": "pyenv installed", + } + mock_is.assert_called_once_with(user) + mock_i.assert_called_once_with(user) + + +def test_installed_unaffected_by_install_pyenv_fix_37648(): + """ + Guard against overcorrection of the fix for issue #37648: the sibling + pyenv.installed state must still install a missing python version via + pyenv.install_python, with ``user`` passed as ``runas``. This test is + expected to pass both with and without the install_pyenv fix. + """ + name = "python-2.7.6" + user = "pyenv_user" + + mock_is = MagicMock(return_value=True) + mock_ip = MagicMock(return_value=True) + mock_d = MagicMock(return_value="") + mock_v = MagicMock(return_value=[]) + with patch.dict( + pyenv.__salt__, + { + "pyenv.is_installed": mock_is, + "pyenv.install_python": mock_ip, + "pyenv.default": mock_d, + "pyenv.versions": mock_v, + }, + ), patch.dict(pyenv.__opts__, {"test": False}): + ret = pyenv.installed(name, user=user) + + assert ret == { + "name": name, + "changes": {"2.7.6": "Installed"}, + "result": True, + "comment": "Successfully installed python", + "default": False, + } + mock_ip.assert_called_once_with("2.7.6", runas=user) From 928807b82192c6a893d30dd0e16256a5d0233217 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 8 Jul 2026 20:38:15 -0400 Subject: [PATCH 073/469] Fix logrotate module parsing of multi-line stanza names and includes (#69685) _parse_conf could not handle a stanza whose log-file patterns are listed one per line before the opening brace (as in CentOS 7's stock /etc/logrotate.d/syslog): only the last path attached to the block and the rest became bogus 'path: True' booleans. It also raised KeyError 'include files' from set_ when the config had no include directive. Buffer path/glob-like tokens as pending stanza names until their brace, commit bare-keyword tokens as global directives immediately (so a directive just before a stanza is not mistaken for one of its names), and initialise the include bookkeeping unconditionally. Fixes #48125 --- changelog/48125.fixed.md | 1 + salt/modules/logrotate.py | 53 +++++++-- tests/pytests/unit/modules/test_logrotate.py | 113 +++++++++++++++++++ 3 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 changelog/48125.fixed.md diff --git a/changelog/48125.fixed.md b/changelog/48125.fixed.md new file mode 100644 index 000000000000..9de806f15785 --- /dev/null +++ b/changelog/48125.fixed.md @@ -0,0 +1 @@ +Fixed logrotate.set failing on stanzas that list multiple log paths on separate lines and on conf files without an include directive diff --git a/salt/modules/logrotate.py b/salt/modules/logrotate.py index d34303e05405..a60ab5a34597 100644 --- a/salt/modules/logrotate.py +++ b/salt/modules/logrotate.py @@ -60,6 +60,18 @@ def _convert_if_int(value): return value +def _is_logfile_token(token): + """ + Return True if a lone token that appears before a ``{`` looks like a + logrotate log-file pattern (an absolute/home path or a glob) rather than + a standalone global directive such as ``compress`` or ``missingok``. + logrotate stanza names are filesystem paths or globs; global directives + are bare keywords, so this lets the parser tell the two apart when either + can appear alone on a line. + """ + return token.startswith(("/", "~", '"', "'")) or any(c in token for c in "*?[") + + def _parse_conf(conf_file=_DEFAULT_CONF): """ Parse a logrotate configuration file. @@ -73,7 +85,11 @@ def _parse_conf(conf_file=_DEFAULT_CONF): mode = "single" multi_names = [] multi = {} - prev_comps = None + # Names listed one-per-line before a ``{`` all belong to the same stanza + # (as in CentOS' /etc/logrotate.d/syslog). Buffer consecutive single-token + # lines here until we know whether a ``{`` follows (they are stanza names) + # or another line follows (they were standalone boolean directives). + pending_names = [] # When inside a ``prerotate``/``postrotate``/... block, collect the raw # script body lines here and stash them on the enclosing dict under the # script directive name once ``endscript`` is seen. @@ -107,11 +123,11 @@ def _parse_conf(conf_file=_DEFAULT_CONF): comps = line.split() if "{" in line and "}" not in line: mode = "multi" - if len(comps) == 1 and prev_comps: - multi_names = prev_comps - else: - multi_names = comps - multi_names.pop() + # The stanza name(s) may be listed on preceding lines (buffered + # in ``pending_names``) and/or on this line before the ``{``. + names_on_line = [comp for comp in comps if comp != "{"] + multi_names = pending_names + names_on_line + pending_names = [] continue if "}" in line: mode = "single" @@ -122,11 +138,26 @@ def _parse_conf(conf_file=_DEFAULT_CONF): continue if mode == "single": + # A lone token in single mode is either a log-file pattern + # awaiting its ``{`` on a later line (as in CentOS' syslog + # config, which lists paths one per line) or a standalone + # boolean directive such as ``compress``. Log-file patterns + # look like paths or globs and are buffered until their ``{``; + # everything else is a directive committed immediately, so a + # directive sitting just before a stanza is never mistaken for + # one of that stanza's names. + if len(comps) == 1: + if _is_logfile_token(comps[0]): + pending_names.append(comps[0]) + else: + ret[comps[0]] = True + continue key = ret else: key = multi if comps[0] == "include": + ret["include"] = comps[1] if "include files" not in ret: ret["include files"] = {} for include in os.listdir(comps[1]): @@ -149,13 +180,16 @@ def _parse_conf(conf_file=_DEFAULT_CONF): script_body = [] continue - prev_comps = comps if len(comps) > 2: key[comps[0]] = " ".join(comps[1:]) elif len(comps) > 1: key[comps[0]] = _convert_if_int(comps[1]) else: key[comps[0]] = True + + # Any tokens still buffered at EOF were trailing standalone directives. + for name in pending_names: + ret[name] = True return ret @@ -245,8 +279,9 @@ def set_(key, value, setting=None, conf_file=_DEFAULT_CONF): and make changes in the appropriate file. """ conf = _parse_conf(conf_file) - for include in conf["include files"]: - if key in conf["include files"][include]: + include_files = conf.get("include files", {}) + for include in include_files: + if key in include_files[include]: conf_file = os.path.join(conf["include"], include) new_line = "" diff --git a/tests/pytests/unit/modules/test_logrotate.py b/tests/pytests/unit/modules/test_logrotate.py index c8e2717ce277..b49d38adfa8b 100644 --- a/tests/pytests/unit/modules/test_logrotate.py +++ b/tests/pytests/unit/modules/test_logrotate.py @@ -151,6 +151,119 @@ def test_parse_conf_preserves_script_blocks(tmp_path): assert "invoke-rc.d syslog-ng reload > /dev/null" in rendered +def test_parse_conf_multiple_names_before_brace(tmp_path): + """ + Regression test for #48125. + + When a stanza lists several paths on separate lines before the opening + ``{`` (as in CentOS 7's out-of-the-box /etc/logrotate.d/syslog), every + path must map to the same stanza dict. Previously only the last path was + attached to the block and the preceding paths were stored as + ``path: True`` booleans. + """ + conf = textwrap.dedent( + """\ + /var/log/cron + /var/log/maillog + /var/log/messages + /var/log/secure + /var/log/spooler + { + missingok + sharedscripts + postrotate + /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true + endscript + } + """ + ) + conf_file = tmp_path / "syslog" + conf_file.write_text(conf) + + parsed = logrotate._parse_conf(str(conf_file)) + + paths = [ + "/var/log/cron", + "/var/log/maillog", + "/var/log/messages", + "/var/log/secure", + "/var/log/spooler", + ] + for path in paths: + assert isinstance(parsed[path], dict), parsed[path] + assert parsed[path].get("missingok") is True + assert parsed[path].get("sharedscripts") is True + + # Every path must reference the very same stanza dict. + first = parsed[paths[0]] + for path in paths[1:]: + assert parsed[path] is first + + +def test_parse_conf_global_directive_before_stanza(tmp_path): + """ + Regression test for #48125. + + A bare global boolean directive (e.g. ``compress``) sitting on its own + line immediately before a stanza must be parsed as a global directive, + not swallowed into the following stanza's list of names. This covers both + an inline-brace stanza and a standalone-brace stanza. + """ + conf = textwrap.dedent( + """\ + compress + missingok + /var/log/inline { + rotate 5 + } + dateext + /var/log/standalone + { + rotate 7 + } + """ + ) + conf_file = tmp_path / "logrotate.conf" + conf_file.write_text(conf) + + parsed = logrotate._parse_conf(str(conf_file)) + + # Global directives are top-level booleans, not stanza names. + assert parsed["compress"] is True + assert parsed["missingok"] is True + assert parsed["dateext"] is True + + # Each stanza name maps to its own block with the right rotate count. + assert isinstance(parsed["/var/log/inline"], dict) + assert parsed["/var/log/inline"]["rotate"] == 5 + assert isinstance(parsed["/var/log/standalone"], dict) + assert parsed["/var/log/standalone"]["rotate"] == 7 + + # The directives must not have leaked in as stanza dicts. + assert not isinstance(parsed["compress"], dict) + + +def test_set_without_include(tmp_path): + """ + Regression test for #48125. + + ``set_`` must not raise ``KeyError`` when the target conf file has no + ``include`` directive (e.g. editing /etc/logrotate.d/syslog directly). + """ + conf = textwrap.dedent( + """\ + /var/log/messages { + rotate 1 + } + """ + ) + conf_file = tmp_path / "syslog" + conf_file.write_text(conf) + + with patch.dict(logrotate.__salt__, {"file.replace": MagicMock(return_value=True)}): + assert logrotate.set_("/var/log/messages", "maxsize", "100M", str(conf_file)) + + def test_get(PARSE_CONF): """ Test if get a value for a specific configuration line From fead78eecc0c675ed15018f7924786866cb39422 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 8 Jul 2026 20:39:16 -0400 Subject: [PATCH 074/469] Add test-mode handling to keystone_role_grant present/absent (#69694) * Honour test=True in keystone_role_grant present/absent The keystone_role_grant.present and keystone_role_grant.absent states performed no __opts__['test'] handling, so a state run with test=True still called keystoneng.role_grant / role_revoke and made real changes. absent() in particular would destructively revoke a live role assignment during a test run. Both functions now short-circuit when test mode is active, returning result=None with the predicted changes and a "would be granted/revoked" comment instead of calling the execution module. Fixes #52220 * Add direct and inverse regression tests for keystone_role_grant test mode The existing tests already call present() and absent() directly with __opts__["test"] patched to True, the exact mechanism the state compiler uses in production, so direct-altitude coverage was in place. This adds the inverse cases: test=False must still call role_grant/role_revoke with unchanged result/changes/comment (the legacy path must not be altered by the new test-mode branch), and test=True must keep reporting result=True with empty changes when the system is already in the desired state (no phantom pending changes). All four inverse tests pass with and without the source fix, guarding against overcorrection. --- changelog/52220.fixed.md | 1 + salt/states/keystone_role_grant.py | 14 ++ .../unit/states/test_keystone_role_grant.py | 151 ++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 changelog/52220.fixed.md create mode 100644 tests/pytests/unit/states/test_keystone_role_grant.py diff --git a/changelog/52220.fixed.md b/changelog/52220.fixed.md new file mode 100644 index 000000000000..c5892932d8b7 --- /dev/null +++ b/changelog/52220.fixed.md @@ -0,0 +1 @@ +Fixed keystone_role_grant.present and keystone_role_grant.absent to honour test=True so role assignments are no longer granted or revoked in test mode diff --git a/salt/states/keystone_role_grant.py b/salt/states/keystone_role_grant.py index d908cf93af5a..b2203ab330e1 100644 --- a/salt/states/keystone_role_grant.py +++ b/salt/states/keystone_role_grant.py @@ -109,6 +109,13 @@ def present(name, auth=None, **kwargs): grants = __salt__["keystoneng.role_assignment_list"](filters=filters) if not grants: + if __opts__["test"] is True: + ret["result"] = None + for k, v in filters.items(): + ret["changes"][k] = v + ret["comment"] = "Role assignment would be granted" + return ret + __salt__["keystoneng.role_grant"](**kwargs) for k, v in filters.items(): ret["changes"][k] = v @@ -129,6 +136,13 @@ def absent(name, auth=None, **kwargs): grants = __salt__["keystoneng.role_assignment_list"](filters=filters) if grants: + if __opts__["test"] is True: + ret["result"] = None + for k, v in filters.items(): + ret["changes"][k] = v + ret["comment"] = "Role assignment would be revoked" + return ret + __salt__["keystoneng.role_revoke"](**kwargs) for k, v in filters.items(): ret["changes"][k] = v diff --git a/tests/pytests/unit/states/test_keystone_role_grant.py b/tests/pytests/unit/states/test_keystone_role_grant.py new file mode 100644 index 000000000000..a9d555afc84e --- /dev/null +++ b/tests/pytests/unit/states/test_keystone_role_grant.py @@ -0,0 +1,151 @@ +""" +Test cases for salt.states.keystone_role_grant +""" + +import pytest + +import salt.states.keystone_role_grant as keystone_role_grant +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return {keystone_role_grant: {}} + + +def _base_salt_dunder(**overrides): + role = MagicMock() + role.id = "role-id" + salt_dunder = { + "keystoneng.setup_clouds": MagicMock(), + "keystoneng.role_get": MagicMock(return_value=role), + "keystoneng.role_grant": MagicMock(), + "keystoneng.role_revoke": MagicMock(), + } + salt_dunder.update(overrides) + return salt_dunder + + +def test_present_test_mode_does_not_grant(): + """ + In test=True mode present() must not call role_grant and must + report result=None with predicted changes. + """ + salt_dunder = _base_salt_dunder() + salt_dunder["keystoneng.role_assignment_list"] = MagicMock(return_value=[]) + + with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( + keystone_role_grant.__opts__, {"test": True} + ): + ret = keystone_role_grant.present("myrole") + + assert salt_dunder["keystoneng.role_grant"].call_count == 0 + assert ret["result"] is None + assert ret["changes"] == {"role": "role-id"} + assert ret["comment"] == "Role assignment would be granted" + + +def test_absent_test_mode_does_not_revoke(): + """ + In test=True mode absent() must not call role_revoke and must + report result=None with predicted changes. + """ + salt_dunder = _base_salt_dunder() + salt_dunder["keystoneng.role_assignment_list"] = MagicMock( + return_value=["existing-grant"] + ) + + with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( + keystone_role_grant.__opts__, {"test": True} + ): + ret = keystone_role_grant.absent("myrole") + + assert salt_dunder["keystoneng.role_revoke"].call_count == 0 + assert ret["result"] is None + assert ret["changes"] == {"role": "role-id"} + assert ret["comment"] == "Role assignment would be revoked" + + +def test_present_real_mode_still_grants_52220(): + """ + Guards against overcorrection: with test=False (the state compiler's + default __opts__["test"] value on a real run) present() must still + call role_grant exactly as before the test-mode fix. + """ + salt_dunder = _base_salt_dunder() + salt_dunder["keystoneng.role_assignment_list"] = MagicMock(return_value=[]) + + with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( + keystone_role_grant.__opts__, {"test": False} + ): + ret = keystone_role_grant.present("myrole") + + assert salt_dunder["keystoneng.role_grant"].call_count == 1 + assert ret["result"] is True + assert ret["changes"] == {"role": "role-id"} + assert ret["comment"] == "Granted role assignment" + + +def test_absent_real_mode_still_revokes_52220(): + """ + Guards against overcorrection: with test=False absent() must still + call role_revoke exactly as before the test-mode fix. + """ + salt_dunder = _base_salt_dunder() + salt_dunder["keystoneng.role_assignment_list"] = MagicMock( + return_value=["existing-grant"] + ) + + with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( + keystone_role_grant.__opts__, {"test": False} + ): + ret = keystone_role_grant.absent("myrole") + + assert salt_dunder["keystoneng.role_revoke"].call_count == 1 + assert ret["result"] is True + assert ret["changes"] == {"role": "role-id"} + assert ret["comment"] == "Revoked role assignment" + + +def test_present_test_mode_no_changes_when_grant_exists_52220(): + """ + Guards against overcorrection: in test=True mode, when the role + assignment already exists, present() must keep reporting result=True + with no changes rather than a phantom pending change. + """ + salt_dunder = _base_salt_dunder() + salt_dunder["keystoneng.role_assignment_list"] = MagicMock( + return_value=["existing-grant"] + ) + + # test=True is the decisive flag; the no-grants branch must not run + with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( + keystone_role_grant.__opts__, {"test": True} + ): + ret = keystone_role_grant.present("myrole") + + assert salt_dunder["keystoneng.role_grant"].call_count == 0 + assert ret["result"] is True + assert ret["changes"] == {} + assert ret["comment"] == "" + + +def test_absent_test_mode_no_changes_when_no_grant_52220(): + """ + Guards against overcorrection: in test=True mode, when no role + assignment exists, absent() must keep reporting result=True with no + changes rather than a phantom pending change. + """ + salt_dunder = _base_salt_dunder() + salt_dunder["keystoneng.role_assignment_list"] = MagicMock(return_value=[]) + + # test=True is the decisive flag; the grants-exist branch must not run + with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( + keystone_role_grant.__opts__, {"test": True} + ): + ret = keystone_role_grant.absent("myrole") + + assert salt_dunder["keystoneng.role_revoke"].call_count == 0 + assert ret["result"] is True + assert ret["changes"] == {} + assert ret["comment"] == "" From 8a2241f12be7bcf5011c735ef6cead549f069342 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 18:03:19 -0400 Subject: [PATCH 075/469] Fix misleading docs on virtual module override behaviour (#69699) A custom execution module only fully overrides a stock module when its filename matches the stock module's filename. A custom module with a different filename that returns an already-used virtual name from __virtual__ merely adds new functions under that name and leaves the stock functions in place. Correct the note in the execution module reference docs to describe this behaviour. Fixes #52521 --- changelog/52521.fixed.md | 1 + doc/ref/modules/index.rst | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 changelog/52521.fixed.md diff --git a/changelog/52521.fixed.md b/changelog/52521.fixed.md new file mode 100644 index 000000000000..bfbfdcddd469 --- /dev/null +++ b/changelog/52521.fixed.md @@ -0,0 +1 @@ +Corrected the execution module documentation to clarify that a custom module overrides a stock module only when its filename matches the stock module filename; a custom module with a different filename only adds new functions under the shared virtual name. diff --git a/doc/ref/modules/index.rst b/doc/ref/modules/index.rst index ec3cc8f2361a..a7a3ca44b0bb 100644 --- a/doc/ref/modules/index.rst +++ b/doc/ref/modules/index.rst @@ -310,8 +310,13 @@ be unreliable as not all modules will be available at this point in time. The are available however. .. note:: - Modules which return a string from ``__virtual__`` that is already used by - a module that ships with Salt will _override_ the stock module. + A custom module fully overrides a stock module only when the custom + module's *filename* matches the stock module's filename (for example, a + custom ``_modules/test.py`` overrides the stock ``test`` module). A custom + module with a different filename that returns an already-used virtual name + from ``__virtual__`` does not replace the stock module; instead, it only + adds functions that do not already exist under that virtual name, leaving + the stock functions in place. .. _modules-error-info: From f6ede2170f7d82f0c65628f09b85e27bc55d3d0b Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 18:03:41 -0400 Subject: [PATCH 076/469] CI pipeline reliability: netapi key-leak, event tagger, and docs dedup fixes (#69728, #69730, #69724) (#69733) * Deduplicate HTTP route index entries across netapi doc pages The rest_cherrypy, rest_tornado and rest_wsgi doc pages document overlapping HTTP routes, so each shared route registered multiple httpdomain index entries. sphinxcontrib-httpdomain only detects the duplicates in merge_domaindata, which runs when sphinx -j parallel reader chunks are merged, so the -W builds in tools docs (Prepare Release, Documentation) fail intermittently depending on where the chunk boundary lands: WARNING: duplicate HTTP post method definition / in doc/ref/netapi/all/salt.netapi.rest_tornado.rst, other instance is in doc/ref/netapi/all/salt.netapi.rest_wsgi.rst When it hits, the release patch artifact is never produced and every downstream build job in the run fails with 'Artifact not found'. Mark the tornado and wsgi copies of the shared routes with :noindex: so each route is registered exactly once, by the canonical rest_cherrypy reference. noindex'd directives never enter the domain data, so the merge collision is impossible under any chunking. Page content is unchanged and no :http: cross-references exist that could be affected. This also makes the HTTP routing index deterministic; it previously pointed at whichever page the readers processed last. Fixes #69724 * Keep HTML anchors on noindex'd httpdomain directives sphinxcontrib-httpdomain's add_target_and_index always appends the signature anchor and only gates the global route registration behind :noindex:, but Sphinx's ObjectDescription.run skips the whole method when noindex is set, so the previous commit's dedup also dropped the per-page anchors and permalinks from the rest_tornado and rest_wsgi endpoint signatures. Anchors are per-document HTML ids and cannot collide across pages; only the global registration can produce the parallel-merge duplicate warnings. Add a small extension that hides the noindex option from Sphinx's outer gate and re-presents it to httpdomain's inner gate, restoring the anchors and permalinks (existing deep links into those pages keep working) while the routes stay out of the domain data, so the duplicate-route collision remains impossible under any chunking. * Delete leaked minion keys at teardown in integration test fixtures The startup_states and salt_call ownership test modules start extra minions against the shared session master via 'with factory.started(): yield'. That stops the minion process at teardown but leaves the accepted key on the master, so later tests in the same session that target '*' (the netapi integration tests) key-match minions that no longer exist. The result is the Rocky Linux 9 integration tcp/zeromq pair failing most 3006.x PR runs: AssertionError: assert ['minion-X', 'minion-X-empty-string', 'minion-X-highstate', 'minion-X-sls', 'minion-X-top', 'non-root-minion-Y', 'sub-minion-Z'] == ['minion-X', 'sub-minion-Z'] plus 30 second TimeoutErrors waiting for returns from the dead minions. Nightly runs do not hit it because test sharding separates these modules from the netapi tests. Remove each extra minion's key from the master once the minion is stopped, restoring the isolation the netapi assertions rely on. Fixes #69728 * Skip sub events for non-dict job returns in the event tagger A failing state compilation returns a list of error strings (or a plain string from some renderers) rather than a mapping of per-state results. _fire_ret_load_specific_fun assumed a dict and crashed on ret.items(), logging 'Event iteration failed with exception: list object has no attribute items' at ERROR for every failed compile. The integration tests that assert that message never appears (tests/pytests/integration/states/test_state_test.py) fail whenever they share a run with a failing compile, which is part of the Rocky Linux 9 integration job noise on 3006.x. There are no state tags in a non-dict return, so skip it at debug level instead. Dict-shaped returns keep firing the per-tag failure events unchanged, covered by a new regression pair: the list-return test fails on the previous code via the logged error, and the dict-return test pins the two sub events (old-style dup tag and the namespaced job error tag) with their enriched payload. Fixes #69730 Refs #69728 * Prime the salt-ssh file cache for the renderer tests test_slsutil.py::test_renderer_file runs slsutil.renderer on a file that does {% from "map.jinja" import abc %}. Over salt-ssh, slsutil.renderer fetches the requested file but does not ship its jinja-imported files to the target; only a state run syncs the full state tree to the target's file cache. So the render fails with 'Jinja error: map.jinja / TemplateNotFound' unless an earlier test in the same session already ran a state command that warmed the shared salt-ssh target cache -- flaky by test ordering in CI, deterministic when run in isolation. Prime the cache once in the state_tree fixture with a test-mode state apply, so the renderer tests do not depend on another test having warmed it. Verified against a local 3006.x reproduction: the test failed 3/3 in isolation before and passes 3/3 after, with the rest of the module unaffected. Fixes #69738 --- changelog/69724.fixed.md | 1 + changelog/69728.fixed.md | 1 + changelog/69730.fixed.md | 1 + changelog/69738.fixed.md | 1 + doc/_ext/salthttpanchors.py | 45 ++++++++++++ doc/conf.py | 1 + salt/netapi/rest_tornado/saltnado.py | 10 +++ salt/netapi/rest_wsgi.py | 1 + salt/utils/event.py | 12 ++++ .../cli/test_salt_call_ownership.py | 4 ++ .../integration/minion/test_startup_states.py | 16 +++++ tests/pytests/integration/ssh/conftest.py | 8 ++- tests/pytests/unit/utils/event/test_event.py | 69 +++++++++++++++++++ 13 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 changelog/69724.fixed.md create mode 100644 changelog/69728.fixed.md create mode 100644 changelog/69730.fixed.md create mode 100644 changelog/69738.fixed.md create mode 100644 doc/_ext/salthttpanchors.py diff --git a/changelog/69724.fixed.md b/changelog/69724.fixed.md new file mode 100644 index 000000000000..ea56ced3d3a2 --- /dev/null +++ b/changelog/69724.fixed.md @@ -0,0 +1 @@ +Fixed the intermittent ``duplicate HTTP post method definition`` failure in the -W parallel docs builds (Prepare Release and Documentation jobs) by marking the HTTP routes documented on the rest_tornado and rest_wsgi pages with ``:noindex:``, leaving rest_cherrypy as the single indexed instance of each shared route. diff --git a/changelog/69728.fixed.md b/changelog/69728.fixed.md new file mode 100644 index 000000000000..ca9a2e1b7240 --- /dev/null +++ b/changelog/69728.fixed.md @@ -0,0 +1 @@ +Fixed the Rocky Linux 9 integration tcp/zeromq CI jobs failing most PR runs: the startup_states and salt_call ownership test fixtures left their extra minions' accepted keys on the shared session master after stopping the minions, so later netapi tests targeting ``*`` matched dead minions (wrong minion lists and 30 second timeouts). The fixtures now delete their minion keys at teardown. diff --git a/changelog/69730.fixed.md b/changelog/69730.fixed.md new file mode 100644 index 000000000000..f07f2f6bc20a --- /dev/null +++ b/changelog/69730.fixed.md @@ -0,0 +1 @@ +Fixed the master logging ``Event iteration failed with exception: 'list' object has no attribute 'items'`` for every failing state compilation: the return of a failed compile is a list of error strings, not a mapping of state results, and the event tagger assumed a dict. diff --git a/changelog/69738.fixed.md b/changelog/69738.fixed.md new file mode 100644 index 000000000000..17df8786228d --- /dev/null +++ b/changelog/69738.fixed.md @@ -0,0 +1 @@ +Fixed the flaky ssh test_renderer_file: salt-ssh slsutil.renderer does not ship a rendered file's jinja imports (map.jinja) to the target, so the renderer tests only passed when an earlier state test had warmed the salt-ssh file cache. Prime the cache in the fixture so they are deterministic. diff --git a/doc/_ext/salthttpanchors.py b/doc/_ext/salthttpanchors.py new file mode 100644 index 000000000000..5822384a2a43 --- /dev/null +++ b/doc/_ext/salthttpanchors.py @@ -0,0 +1,45 @@ +""" +Keep HTML anchors on ``:noindex:``'d httpdomain directives. + +sphinxcontrib-httpdomain's ``add_target_and_index`` intentionally splits its +two jobs: it always appends the ``#--`` anchor to the signature +node, and only gates the global route *registration* behind ``:noindex:``. +Sphinx's ``ObjectDescription.run`` however skips the whole method when +``noindex`` is set, so the anchor (and its permalink) is lost along with the +index entry. Anchors are per-page HTML ids and cannot collide across pages, +so restoring them is safe; only the global registration can produce the +parallel-build duplicate-route warnings. + +Hide the option from Sphinx's outer gate and re-present it to httpdomain's +inner gate, so ``:noindex:`` means what httpdomain meant it to mean: no index +entry, anchor kept. +""" + +from sphinxcontrib.httpdomain import HTTPDomain + + +def _make_anchored(cls): + class AnchoredHTTPResource(cls): + def run(self): + self._salt_noindex = "noindex" in self.options + self.options.pop("noindex", None) + return super().run() + + def add_target_and_index(self, name_cls, sig, signode): + if self._salt_noindex: + self.options["noindex"] = None + try: + super().add_target_and_index(name_cls, sig, signode) + finally: + if self._salt_noindex: + self.options.pop("noindex", None) + + AnchoredHTTPResource.__name__ = f"Anchored{cls.__name__}" + return AnchoredHTTPResource + + +def setup(app): + app.setup_extension("sphinxcontrib.httpdomain") + for name, cls in list(HTTPDomain.directives.items()): + app.add_directive_to_domain("http", name, _make_anchored(cls), override=True) + return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/doc/conf.py b/doc/conf.py index 64d38b0e7106..0450729512cc 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -176,6 +176,7 @@ def _safe_urlsplit(url, scheme="", allow_fragments=True): "sphinx.ext.imgconverter", "sphinx.ext.intersphinx", "sphinxcontrib.httpdomain", + "salthttpanchors", "saltrepo", "myst_parser", #'saltautodoc', # Must be AFTER autodoc diff --git a/salt/netapi/rest_tornado/saltnado.py b/salt/netapi/rest_tornado/saltnado.py index 99971b778b9d..079662ead805 100644 --- a/salt/netapi/rest_tornado/saltnado.py +++ b/salt/netapi/rest_tornado/saltnado.py @@ -633,6 +633,7 @@ def get(self): # pylint: disable=arguments-differ All logins are done over post, this is a parked endpoint .. http:get:: /login + :noindex: :status 401: |401| :status 406: |406| @@ -672,6 +673,7 @@ def post(self): # pylint: disable=arguments-differ :ref:`Authenticate ` against Salt's eauth system .. http:post:: /login + :noindex: :reqheader X-Auth-Token: |req_token| :reqheader Accept: |req_accept| @@ -803,6 +805,7 @@ def get(self): # pylint: disable=arguments-differ An endpoint to determine salt-api capabilities .. http:get:: / + :noindex: :reqheader Accept: |req_accept| @@ -841,6 +844,7 @@ def post(self): # pylint: disable=arguments-differ Send one or more Salt commands (lowstates) in the request body .. http:post:: / + :noindex: :reqheader X-Auth-Token: |req_token| :reqheader Accept: |req_accept| @@ -1219,6 +1223,7 @@ def get(self, mid=None): # pylint: disable=W0221 details .. http:get:: /minions/(mid) + :noindex: :reqheader X-Auth-Token: |req_token| :reqheader Accept: |req_accept| @@ -1266,6 +1271,7 @@ def post(self): Start an execution command and immediately return the job id .. http:post:: /minions + :noindex: :reqheader X-Auth-Token: |req_token| :reqheader Accept: |req_accept| @@ -1345,6 +1351,7 @@ def get(self, jid=None): # pylint: disable=W0221 the return from a single job .. http:get:: /jobs/(jid) + :noindex: List jobs or show a single job from the job cache. @@ -1445,6 +1452,7 @@ def post(self): ` .. http:post:: /run + :noindex: This entry point is primarily for "one-off" commands. Each request must pass full Salt authentication credentials. Otherwise this URL @@ -1519,6 +1527,7 @@ def get(self): event is formatted as JSON. .. http:get:: /events + :noindex: :status 200: |200| :status 401: |401| @@ -1678,6 +1687,7 @@ def post(self, tag_suffix=None): # pylint: disable=W0221 Fire an event in Salt with a custom event tag and data .. http:post:: /hook + :noindex: :status 200: |200| :status 401: |401| diff --git a/salt/netapi/rest_wsgi.py b/salt/netapi/rest_wsgi.py index 50dfabe23c1e..1f448be4447d 100644 --- a/salt/netapi/rest_wsgi.py +++ b/salt/netapi/rest_wsgi.py @@ -65,6 +65,7 @@ ============== .. http:post:: / + :noindex: **Example request** for a basic ``test.ping``:: diff --git a/salt/utils/event.py b/salt/utils/event.py index 6cfecd9e4e84..93a3d0518ea0 100644 --- a/salt/utils/event.py +++ b/salt/utils/event.py @@ -937,6 +937,18 @@ def _fire_ret_load_specific_fun(self, load, fun_index=0): ret = load.get("return", {}) retcode = load["retcode"] + if not isinstance(ret, dict): + # A failing state compilation returns a list of error strings (or + # a plain string from some renderers) instead of a mapping of + # per-state results, so there are no state tags to fire sub + # events for. + log.debug( + "Skipping sub event for job %s: return is a %s, not a dict", + load.get("jid"), + type(ret).__name__, + ) + return + try: for tag, data in ret.items(): data["retcode"] = retcode diff --git a/tests/pytests/integration/cli/test_salt_call_ownership.py b/tests/pytests/integration/cli/test_salt_call_ownership.py index 175931da6079..8fe529328b80 100644 --- a/tests/pytests/integration/cli/test_salt_call_ownership.py +++ b/tests/pytests/integration/cli/test_salt_call_ownership.py @@ -64,6 +64,10 @@ def non_root_minion(salt_master, salt_factories): ) with factory.started(): yield factory + # The minion process is stopped at this point, but its accepted key stays + # on the shared session master, where later tests that target '*' (the + # netapi integration tests) would match it as a dead minion. Remove it. + salt_master.salt_key_cli().run("-d", factory.id, "-y") @pytest.mark.skipif(shutil.which("sudo") is None, reason="sudo is not available") diff --git a/tests/pytests/integration/minion/test_startup_states.py b/tests/pytests/integration/minion/test_startup_states.py index d8b891b35c42..f76760b0ba21 100644 --- a/tests/pytests/integration/minion/test_startup_states.py +++ b/tests/pytests/integration/minion/test_startup_states.py @@ -29,6 +29,10 @@ def salt_minion_startup_states_empty_string(salt_master, salt_minion_id): ) with factory.started(): yield factory + # The minion process is stopped at this point, but its accepted key stays + # on the shared session master, where later tests that target '*' (the + # netapi integration tests) would match it as a dead minion. Remove it. + salt_master.salt_key_cli().run("-d", factory.id, "-y") @pytest.fixture @@ -43,6 +47,10 @@ def salt_minion_startup_states_highstate(salt_master, salt_minion_id): ) with factory.started(): yield factory + # The minion process is stopped at this point, but its accepted key stays + # on the shared session master, where later tests that target '*' (the + # netapi integration tests) would match it as a dead minion. Remove it. + salt_master.salt_key_cli().run("-d", factory.id, "-y") @pytest.fixture @@ -58,6 +66,10 @@ def salt_minion_startup_states_sls(salt_master, salt_minion_id): ) with factory.started(): yield factory + # The minion process is stopped at this point, but its accepted key stays + # on the shared session master, where later tests that target '*' (the + # netapi integration tests) would match it as a dead minion. Remove it. + salt_master.salt_key_cli().run("-d", factory.id, "-y") @pytest.fixture @@ -73,6 +85,10 @@ def salt_minion_startup_states_top(salt_master, salt_minion_id): ) with factory.started(): yield factory + # The minion process is stopped at this point, but its accepted key stays + # on the shared session master, where later tests that target '*' (the + # netapi integration tests) would match it as a dead minion. Remove it. + salt_master.salt_key_cli().run("-d", factory.id, "-y") def test_startup_states_empty_string( diff --git a/tests/pytests/integration/ssh/conftest.py b/tests/pytests/integration/ssh/conftest.py index c451a4850e58..b123d9de473c 100644 --- a/tests/pytests/integration/ssh/conftest.py +++ b/tests/pytests/integration/ssh/conftest.py @@ -66,7 +66,7 @@ def _reap_stray_processes(): @pytest.fixture(scope="module") -def state_tree(base_env_state_tree_root_dir): +def state_tree(base_env_state_tree_root_dir, salt_ssh_cli): # Remove unused import from top file to avoid salt-ssh file sync issues # Note: top file references "basic" but we create "test.sls" - this appears # intentional as tests run state.sls directly and don't use the top file @@ -96,6 +96,12 @@ def state_tree(base_env_state_tree_root_dir): "test.sls", state_file, base_env_state_tree_root_dir ) with top_tempfile, map_tempfile, state_tempfile: + # slsutil.renderer over salt-ssh fetches the requested file but does + # not ship its jinja-imported files (map.jinja) to the target; only a + # state run syncs the full state tree to the target's file cache. + # Prime that cache once so the renderer tests are deterministic + # instead of depending on an earlier state test having warmed it. + salt_ssh_cli.run("state.apply", "test", test=True) yield diff --git a/tests/pytests/unit/utils/event/test_event.py b/tests/pytests/unit/utils/event/test_event.py index f4b6c1599966..3b3c2944bde6 100644 --- a/tests/pytests/unit/utils/event/test_event.py +++ b/tests/pytests/unit/utils/event/test_event.py @@ -1,4 +1,5 @@ import hashlib +import logging import os import stat import time @@ -340,3 +341,71 @@ def test_master_pub_permissions(sock_dir): assert bool(os.lstat(p).st_mode & stat.S_IRUSR) assert not bool(os.lstat(p).st_mode & stat.S_IRGRP) assert not bool(os.lstat(p).st_mode & stat.S_IROTH) + + +@pytest.fixture +def ret_load_event(sock_dir): + with salt.utils.event.SaltEvent( + "master", str(sock_dir), opts={"transport": "zeromq"}, listen=False + ) as event: + with patch.object(event, "fire_event") as fire_event: + yield event, fire_event + + +def test_fire_ret_load_list_return_skips_quietly_69730(ret_load_event, caplog): + """ + A failing state compilation returns a list of error strings rather than + a mapping of per-state results. fire_ret_load used to hand that list to + _fire_ret_load_specific_fun, which crashed on ret.items() and logged + "Event iteration failed with exception: 'list' object has no attribute + 'items'" at ERROR for every failed compile. There are no state tags in + such a return, so it must be skipped without logging an error and + without firing sub events. + """ + event, fire_event = ret_load_event + # The exact shape the master receives for a failed state.apply compile: + # fun in SUB_EVENT, a non-zero retcode, and a list-of-errors return. + load = { + "id": "minion", + "jid": "20260706000000000000", + "fun": "state.sls", + "retcode": 1, + "return": ["Rendering SLS 'base:broken' failed: Jinja error"], + } + with caplog.at_level(logging.ERROR, logger="salt.utils.event"): + event.fire_ret_load(load) + assert "Event iteration failed" not in caplog.text + fire_event.assert_not_called() + + +def test_fire_ret_load_dict_return_still_fires_sub_events_69730(ret_load_event, caplog): + """ + Guard against overcorrection: a dict-shaped failing state return (the + normal case) must keep firing the per-tag failure events exactly as + before the non-dict guard was added. This passes with and without the + fix. + """ + event, fire_event = ret_load_event + tag = "file_|-broken_|-/etc/broken_|-managed" + load = { + "id": "minion", + "jid": "20260706000000000000", + "fun": "state.sls", + "retcode": 2, + "return": {tag: {"result": False, "comment": "no such file"}}, + } + with caplog.at_level(logging.ERROR, logger="salt.utils.event"): + event.fire_ret_load(load) + assert "Event iteration failed" not in caplog.text + assert fire_event.call_count == 2 + # old-style duplicate event: . tag + first_data, first_tag = fire_event.call_args_list[0][0] + assert first_tag == "file.managed" + assert first_data["retcode"] == 2 + # namespaced job sub event, enriched with job metadata + second_data, second_tag = fire_event.call_args_list[1][0] + assert second_tag == "salt/job/20260706000000000000/sub/minion/error/state.sls" + assert second_data["jid"] == "20260706000000000000" + assert second_data["id"] == "minion" + assert second_data["success"] is False + assert second_data["fun"] == "state.sls" From a71b9d0f0a873e7cbca44340d59fe241b74093e8 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 18:04:16 -0400 Subject: [PATCH 077/469] Fix debian_ip not emitting IPv6 address stanzas (#69687) * Accept rh_ip-style ipv6addr/ipv6addrs in debian_ip The Debian ip module only understood ipv6ipaddr/ipv6ipaddrs, while the Red Hat module (rh_ip) uses ipv6addr/ipv6addrs. After the ipv6 prefix is stripped, the remaining addr/addrs had no mapping and failed validation, so no inet6 address stanza was emitted. Add addr->address and addrs->addresses aliases to SALT_ATTR_TO_DEBIAN_ATTR_MAP so both spellings (and bare addr/addrs) resolve to the Debian address settings, letting formulas share one set of names across distributions. Fixes #46618 * Add direct and inverse regression tests for debian_ip ipv6addr alias The existing PR test already exercises the direct altitude: it calls build_interface() itself with the rh_ip-style ipv6addr/ipv6addrs kwargs, the exact settings names network.managed forwards verbatim from SLS kwargs. This adds the inverse guard against overcorrection from the new bare addr/addrs map entries: on a dual-family interface the aliased IPv6 address must stay confined to the inet6 stanza (the inet stanza renders byte-identical to a build with no IPv6 address), and a MAC-valued bare addr (the legacy shape network.managed remaps to hwaddr) must still be dropped by validation rather than emitted as a bogus address stanza. Both assertions hold with and without the source fix. --- changelog/46618.fixed.md | 1 + salt/modules/debian_ip.py | 10 ++ tests/pytests/unit/modules/test_debian_ip.py | 113 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 changelog/46618.fixed.md diff --git a/changelog/46618.fixed.md b/changelog/46618.fixed.md new file mode 100644 index 000000000000..f14ce0099c79 --- /dev/null +++ b/changelog/46618.fixed.md @@ -0,0 +1 @@ +Allow the Debian ip module to accept rh_ip-style ipv6addr/ipv6addrs (and bare addr/addrs) as aliases for the address/addresses interface settings. diff --git a/salt/modules/debian_ip.py b/salt/modules/debian_ip.py index f974055ca40f..b0d1442dbb8f 100644 --- a/salt/modules/debian_ip.py +++ b/salt/modules/debian_ip.py @@ -397,6 +397,11 @@ def __space_delimited_list(value): "hwaddr": "hwaddress", # TODO: this limits bootp functionality "ipaddr": "address", "ipaddrs": "addresses", + # Aliases so rh_ip-style names resolve to the Debian attributes. This + # lets ``ipv6addr``/``ipv6addrs`` (stripped to ``addr``/``addrs``) and the + # bare ``addr``/``addrs`` map to the same address stanzas as ``ipaddr``. + "addr": "address", + "addrs": "addresses", } @@ -404,6 +409,7 @@ def __space_delimited_list(value): # TODO DEBIAN_ATTR_TO_SALT_ATTR_MAP["address"] = "address" +DEBIAN_ATTR_TO_SALT_ATTR_MAP["addresses"] = "addresses" DEBIAN_ATTR_TO_SALT_ATTR_MAP["hwaddress"] = "hwaddress" IPV4_VALID_PROTO = ["bootp", "dhcp", "static", "manual", "loopback", "ppp"] @@ -1654,6 +1660,10 @@ def build_interface(iface, iface_type, enabled, **settings): """ Build an interface script for a network interface. + The IPv6 address may be supplied either as ``ipv6ipaddr``/``ipv6ipaddrs`` + or, for consistency with the Red Hat module, as ``ipv6addr``/``ipv6addrs``. + Both spellings map to the same Debian ``address``/``addresses`` stanzas. + CLI Example: .. code-block:: bash diff --git a/tests/pytests/unit/modules/test_debian_ip.py b/tests/pytests/unit/modules/test_debian_ip.py index 2b7b636965ef..3cd5e3589876 100644 --- a/tests/pytests/unit/modules/test_debian_ip.py +++ b/tests/pytests/unit/modules/test_debian_ip.py @@ -1123,6 +1123,119 @@ def test_build_interface(test_interfaces): ) +def test_build_interface_ipv6addr_alias(): + """ + The rh_ip-style ``ipv6addr``/``ipv6addrs`` names should resolve to the + same Debian ``inet6`` address stanzas as ``ipv6ipaddr``/``ipv6ipaddrs``. + + See https://github.com/saltstack/salt/issues/46618 + """ + common = { + "ipv6proto": "static", + "enable_ipv6": True, + "noifupdown": True, + } + with tempfile.NamedTemporaryFile(mode="r", delete=True) as tfile: + with patch("salt.modules.debian_ip._DEB_NETWORK_FILE", str(tfile.name)): + canonical = debian_ip.build_interface( + iface="eth0", + iface_type="eth", + enabled=True, + interface_file=tfile.name, + ipv6ipaddr="2001:db8:dead:beef::5/64", + ipv6ipaddrs=["2001:db8:dead:beef::7/64"], + **common, + ) + aliased = debian_ip.build_interface( + iface="eth0", + iface_type="eth", + enabled=True, + interface_file=tfile.name, + ipv6addr="2001:db8:dead:beef::5/64", + ipv6addrs=["2001:db8:dead:beef::7/64"], + **common, + ) + + assert " address 2001:db8:dead:beef::5/64\n" in aliased + assert " address 2001:db8:dead:beef::7/64\n" in aliased + assert aliased == canonical + + +def test_build_interface_ipv6addr_alias_overcorrection_46618(): + """ + Guard against overcorrection in the issue #46618 fix, which aliased the + rh_ip-style ``addr``/``addrs`` settings names onto the Debian + ``address``/``addresses`` stanzas. + + Two things must NOT start happening because of the alias: + + * on a dual-family interface the aliased ``ipv6addr`` must be confined + to the ``inet6`` stanza; the ``inet`` (IPv4) stanza must render + byte-identical to the same interface built without any IPv6 address + * a MAC-valued bare ``addr`` (the legacy shape that ``network.managed`` + remaps to ``hwaddr`` before calling ``ip.build_interface``) must + still be ignored when passed straight to the module, not rendered as + a bogus ``address`` stanza + + Both assertions hold with and without the source fix applied. + """ + + def inet_stanza(lines): + # Collect only the "iface inet ..." (IPv4) stanza lines. + block = [] + capture = False + for line in lines: + if line.startswith("iface "): + capture = " inet " in line + if capture: + block.append(line) + return block + + common = { + "proto": "static", + "ipaddr": "192.168.4.9", + "netmask": "255.255.255.0", + "ipv6proto": "static", + "enable_ipv6": True, + "noifupdown": True, + } + with tempfile.NamedTemporaryFile(mode="r", delete=True) as tfile: + with patch("salt.modules.debian_ip._DEB_NETWORK_FILE", str(tfile.name)): + baseline = debian_ip.build_interface( + iface="eth9", + iface_type="eth", + enabled=True, + interface_file=tfile.name, + **common, + ) + aliased = debian_ip.build_interface( + iface="eth9", + iface_type="eth", + enabled=True, + interface_file=tfile.name, + ipv6addr="2001:db8:dead:beef::5/64", + **common, + ) + mac_as_addr = debian_ip.build_interface( + iface="eth9", + iface_type="eth", + enabled=True, + interface_file=tfile.name, + proto="manual", + addr="00:11:22:33:44:55", + noifupdown=True, + ) + + # The IPv4 stanza must be untouched by the aliased IPv6 address. + assert inet_stanza(aliased) == inet_stanza(baseline) + assert not any("2001:db8:dead:beef::5/64" in line for line in inet_stanza(aliased)) + + # A MAC in bare ``addr`` fails address validation for both families and + # must be dropped entirely, exactly as before the fix. + assert not any(line.strip().startswith("address ") for line in mac_as_addr) + assert not any("00:11:22:33:44:55" in line for line in mac_as_addr) + + # 'up' function tests: 1 From 67a2ca0cc54d64b58fc265d4df447758fcced24c Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 18:04:57 -0400 Subject: [PATCH 078/469] Fix rest_tornado dropping events to concurrent websocket clients (#69698) * Fix event delivery to multiple websocket clients on /events EventListener._handle_event_socket_recv iterated over the futures list while removing delivered futures from that same list, which skips every other future. With multiple websocket clients registered under the same (tag, matcher) key, each event only reached some of the waiting clients. Iterate over a snapshot of the list so every matching future is resolved. Fixes #35798 * Add direct and inverse regression tests for saltnado websocket event delivery The direct test subscribes multiple clients through get_event(request) with the exact defaults the websocket handlers in saltnado_websockets.py pass (no tag, no matcher, so tag="" with prefix_matcher) and asserts a single event resolves every waiting future; it fails without the snapshot fix in _handle_event_socket_recv. The inverse test guards against overcorrection: an already-done future must keep its original result and stay in the tag_map, and a future waiting on a different exact tag (the SaltAPIHandler.get_minion_returns shape) must stay pending; it passes with and without the fix. --- changelog/35798.fixed.md | 1 + salt/netapi/rest_tornado/saltnado.py | 6 +- .../netapi/saltnado/test_event_listener.py | 121 ++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 changelog/35798.fixed.md create mode 100644 tests/pytests/unit/netapi/saltnado/test_event_listener.py diff --git a/changelog/35798.fixed.md b/changelog/35798.fixed.md new file mode 100644 index 000000000000..2d35485c14cb --- /dev/null +++ b/changelog/35798.fixed.md @@ -0,0 +1 @@ +Fixed a race in the rest_tornado event listener so a single event is delivered to every websocket client waiting on a matching tag instead of only some of them diff --git a/salt/netapi/rest_tornado/saltnado.py b/salt/netapi/rest_tornado/saltnado.py index 079662ead805..db269667201e 100644 --- a/salt/netapi/rest_tornado/saltnado.py +++ b/salt/netapi/rest_tornado/saltnado.py @@ -385,7 +385,11 @@ def _handle_event_socket_recv(self, raw): if not is_matched: continue - for future in futures: + # Iterate over a snapshot of the futures list. We remove delivered + # futures from the underlying list below, and mutating the list + # while iterating it would skip futures, causing some waiting + # clients to miss the event (see #35798). + for future in list(futures): if future.done(): continue future.set_result({"data": data, "tag": mtag}) diff --git a/tests/pytests/unit/netapi/saltnado/test_event_listener.py b/tests/pytests/unit/netapi/saltnado/test_event_listener.py new file mode 100644 index 000000000000..fab8dc10914a --- /dev/null +++ b/tests/pytests/unit/netapi/saltnado/test_event_listener.py @@ -0,0 +1,121 @@ +from collections import defaultdict + +import salt.netapi.rest_tornado.saltnado as saltnado_app +from salt.ext.tornado.concurrent import Future +from tests.support.mock import MagicMock + + +def _make_event_listener(): + """ + Build an EventListener without touching the real master event bus. + """ + event_listener = saltnado_app.EventListener.__new__(saltnado_app.EventListener) + event_listener.tag_map = defaultdict(list) + event_listener.request_map = defaultdict(list) + event_listener.timeout_map = {} + event_listener.event = MagicMock() + return event_listener + + +def test_handle_event_socket_recv_delivers_to_all_waiters(): + """ + A single matching event must resolve every future waiting on that tag. + + Regression test for #35798: the delivery loop used to remove futures from + the very list it was iterating, skipping every other waiter so that only + some websocket clients received the event. + """ + event_listener = _make_event_listener() + matcher = saltnado_app.EventListener.exact_matcher + key = ("evt1", matcher) + + futures = [Future() for _ in range(4)] + for future in futures: + event_listener.tag_map[key].append(future) + + # event.unpack(raw) -> (mtag, data) + event_listener.event.unpack.return_value = ("evt1", {"data": "foo"}) + + event_listener._handle_event_socket_recv("raw") + + for future in futures: + assert future.done() + assert future.result() == {"data": {"data": "foo"}, "tag": "evt1"} + + # every delivered future should be removed from the tag_map list + assert event_listener.tag_map[key] == [] + + +def test_handle_event_socket_recv_websocket_default_subscription_35798(): + """ + One event must reach every concurrent websocket client subscribed through + the production entry point. + + This is the exact #35798 scenario: AllEventsHandler.on_message in + saltnado_websockets.py subscribes each client with + ``event_listener.get_event(self)`` and nothing else, so the decisive + arguments are the defaults, ``tag=""`` with ``prefix_matcher``, which + every event matches. All clients therefore share a single tag_map entry, + and a single incoming event must resolve all of their futures. + """ + event_listener = _make_event_listener() + + # one future per connected websocket client, registered exactly the way + # the websocket handlers do it: get_event(request) with no tag/matcher + requests = [MagicMock() for _ in range(3)] + futures = [event_listener.get_event(request) for request in requests] + + event_listener.event.unpack.return_value = ( + "salt/job/20260705000000000000/ret/minion1", + {"data": "foo"}, + ) + + event_listener._handle_event_socket_recv("raw") + + for future in futures: + assert future.done() + assert future.result() == { + "data": {"data": "foo"}, + "tag": "salt/job/20260705000000000000/ret/minion1", + } + + key = ("", saltnado_app.EventListener.prefix_matcher) + assert event_listener.tag_map[key] == [] + + +def test_handle_event_socket_recv_ignores_done_and_unmatched_35798(): + """ + Guard against overcorrection in the #35798 fix: iterating a snapshot of + the futures list must not widen delivery. A future that is already done + (for example one that timed out) must keep its original result and must + not be re-resolved, and a future waiting on a different exact tag must + stay pending and stay registered. This test passes with and without the + fix. + """ + event_listener = _make_event_listener() + # exact_matcher is what SaltAPIHandler.get_minion_returns passes in + # production (saltnado.py) for salt/job and syndic/job return tags + matcher = saltnado_app.EventListener.exact_matcher + matched_key = ("evt1", matcher) + other_key = ("evt2", matcher) + + done_future = Future() + done_future.set_result("already-done") + pending_future = Future() + other_future = Future() + event_listener.tag_map[matched_key].extend([done_future, pending_future]) + event_listener.tag_map[other_key].append(other_future) + + event_listener.event.unpack.return_value = ("evt1", {"data": "foo"}) + + event_listener._handle_event_socket_recv("raw") + + # an already-done future must not be re-resolved with the event payload + assert done_future.result() == "already-done" + # the pending waiter on the matching tag still receives the event + assert pending_future.result() == {"data": {"data": "foo"}, "tag": "evt1"} + # a waiter on a non-matching exact tag must not receive the event + assert not other_future.done() + assert event_listener.tag_map[other_key] == [other_future] + # done futures are skipped by the delivery loop, not removed + assert event_listener.tag_map[matched_key] == [done_future] From db683cb96ea7e8c6e1faccecdcd7e780d906b37a Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 18:05:38 -0400 Subject: [PATCH 079/469] Deduplicate HTTP route index entries across netapi doc pages (#69724) (#69725) * Deduplicate HTTP route index entries across netapi doc pages The rest_cherrypy, rest_tornado and rest_wsgi doc pages document overlapping HTTP routes, so each shared route registered multiple httpdomain index entries. sphinxcontrib-httpdomain only detects the duplicates in merge_domaindata, which runs when sphinx -j parallel reader chunks are merged, so the -W builds in tools docs (Prepare Release, Documentation) fail intermittently depending on where the chunk boundary lands: WARNING: duplicate HTTP post method definition / in doc/ref/netapi/all/salt.netapi.rest_tornado.rst, other instance is in doc/ref/netapi/all/salt.netapi.rest_wsgi.rst When it hits, the release patch artifact is never produced and every downstream build job in the run fails with 'Artifact not found'. Mark the tornado and wsgi copies of the shared routes with :noindex: so each route is registered exactly once, by the canonical rest_cherrypy reference. noindex'd directives never enter the domain data, so the merge collision is impossible under any chunking. Page content is unchanged and no :http: cross-references exist that could be affected. This also makes the HTTP routing index deterministic; it previously pointed at whichever page the readers processed last. Fixes #69724 * Keep HTML anchors on noindex'd httpdomain directives sphinxcontrib-httpdomain's add_target_and_index always appends the signature anchor and only gates the global route registration behind :noindex:, but Sphinx's ObjectDescription.run skips the whole method when noindex is set, so the previous commit's dedup also dropped the per-page anchors and permalinks from the rest_tornado and rest_wsgi endpoint signatures. Anchors are per-document HTML ids and cannot collide across pages; only the global registration can produce the parallel-merge duplicate warnings. Add a small extension that hides the noindex option from Sphinx's outer gate and re-presents it to httpdomain's inner gate, restoring the anchors and permalinks (existing deep links into those pages keep working) while the routes stay out of the domain data, so the duplicate-route collision remains impossible under any chunking. From cc3fbc49933ec57ccfac864d967a594f2ba45773 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 18:10:25 -0400 Subject: [PATCH 080/469] Fix pkg.installed false 'available: none' for arch-qualified RPM package names (#69604) (#69606) * Fix pkg.installed false 'available: none' for arch-qualified RPM names Since #68932 the pkg.installed preflight runs _find_install_targets with split_arch=False so APT multiarch names (foo:amd64) survive un-normalized. On yum/dnf, pkg.list_pkgs is keyed by the arch-stripped name, so an arch-qualified, native-arch name from the SLS (foo.x86_64) no longer matched the installed package. The preflight then treated it as missing and attempted an install that failed with: No version matching '' found for package 'foo.x86_64' (available: none) The same commit added a normalize-aware fallback to _verify_install but not to the preflight lookup. Mirror it: when an arch-qualified name is not found in cur_pkgs, retry with the provider-normalized name. Foreign-arch (.i686, :i386) and multiarch (:amd64) names normalize to themselves and are unaffected. Fixes #69604 * Add unit tests for pkg state mod_watch, mod_init, and downloaded Expand test coverage of salt/states/pkg.py beyond the #69604 change, per the contributor policy of improving coverage in modules a PR touches: - mod_watch: dispatches to the matching state for a supported sfun, and returns a failure for an unsupported one - mod_init: writes the refresh tag and returns True for install-type states, returns False otherwise - downloaded: fails cleanly without pkg.list_downloaded, and short-circuits on an empty pkgs list * Add tests for _find_install_targets arch-qualified RPM name fix Regression test for #69604. Exercises _find_install_targets directly with split_arch=False (the flag pkg.installed passes since #68932) to confirm that an arch-qualified native-arch package (e.g. saltdemo.x86_64) is recognized as already installed via the normalize fallback -- and that a foreign-arch package (saltdemo.i686) still correctly triggers an install. --------- Co-authored-by: Daniel A. Wozniak --- changelog/69604.fixed.md | 1 + salt/states/pkg.py | 11 ++ tests/pytests/unit/states/test_pkg.py | 229 ++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 changelog/69604.fixed.md diff --git a/changelog/69604.fixed.md b/changelog/69604.fixed.md new file mode 100644 index 000000000000..c5a6e2a1aa40 --- /dev/null +++ b/changelog/69604.fixed.md @@ -0,0 +1 @@ +Fixed ``pkg.installed`` on RPM (yum/dnf) wrongly reporting ``No version matching '' found for package '.' (available: none)`` for an already-installed, architecture-qualified package (e.g. ``foo.x86_64``) passed via ``pkgs``. Since #68932 the preflight runs with ``split_arch=False`` and no longer normalizes the name, but ``pkg.list_pkgs`` is keyed by the arch-stripped name, so the package was mistaken for missing. The preflight now falls back to the normalized name, matching the existing ``_verify_install`` behavior; APT multiarch names (``foo:amd64``) are unaffected. diff --git a/salt/states/pkg.py b/salt/states/pkg.py index cd586623ff1f..3ed2305553e5 100644 --- a/salt/states/pkg.py +++ b/salt/states/pkg.py @@ -746,6 +746,17 @@ def _find_install_targets( failed_verify = False for package_name, version_string in desired.items(): cver = cur_pkgs.get(package_name, []) + if not cver and "pkg.normalize_name" in __salt__: + # Providers such as yum/dnf strip a redundant architecture from + # package names (e.g. ``foo.x86_64`` -> ``foo``), so pkg.list_pkgs + # is keyed by the normalized name while an arch-qualified name from + # the SLS is not. Fall back to the normalized name so an already + # installed, arch-qualified package is not mistaken for a missing + # one. Multiarch names (e.g. ``foo:amd64`` on apt) normalize to + # themselves and are unaffected. See #69604. + normalized_name = __salt__["pkg.normalize_name"](package_name) + if normalized_name != package_name: + cver = cur_pkgs.get(normalized_name, []) if resolve_capabilities and not cver and package_name in cur_prov: cver = cur_pkgs.get(cur_prov.get(package_name)[0], []) diff --git a/tests/pytests/unit/states/test_pkg.py b/tests/pytests/unit/states/test_pkg.py index e9e6f3060d16..782a48412401 100644 --- a/tests/pytests/unit/states/test_pkg.py +++ b/tests/pytests/unit/states/test_pkg.py @@ -1157,6 +1157,166 @@ def test_installed_with_single_normalize_32bit(): assert ret["changes"] == expected +def test_installed_arch_qualified_native_name_already_installed_69604(): + """ + Regression test for https://github.com/saltstack/salt/issues/69604. + + Since #68932 the pkg.installed preflight runs with ``split_arch=False`` so + that APT multiarch names (``foo:amd64``) survive un-normalized. On yum/dnf, + however, ``pkg.list_pkgs`` is keyed by the arch-stripped (normalized) name, + so an arch-qualified, native-arch name from the SLS (``foo.x86_64``) no + longer matched the installed package and the state wrongly treated it as + missing -- attempting a doomed install that failed with + "No version matching '...' found for package 'foo.x86_64' (available: none)". + + The preflight must fall back to the normalized name (mirroring the + ``_verify_install`` lookup) so the already-installed package is recognized + and ``pkg.install`` is never invoked. + """ + installed_version = "10.4.0.1-1717258879" + version_wildcard = installed_version.split("-", maxsplit=1)[0] + "-*" + list_pkgs_mock = MagicMock(return_value={"saltdemo": [installed_version]}) + # If the regression is present, the state mis-detects the package as + # missing and calls pkg.install; assert it is never called. + install_mock = MagicMock() + + salt_dict = { + "pkg.install": install_mock, + "pkg.list_pkgs": list_pkgs_mock, + "pkg.normalize_name": yumpkg.normalize_name, + "pkg_resource.check_extra_requirements": MagicMock(return_value=True), + "pkg_resource.version_clean": pkg_resource.version_clean, + } + + with patch.dict(pkg.__salt__, salt_dict), patch.dict( + pkg_resource.__salt__, salt_dict + ), patch.dict( + pkg.__grains__, {"os": "CentOS", "os_family": "RedHat", "osarch": "x86_64"} + ), patch.dict( + yumpkg.__grains__, {"os": "CentOS", "osarch": "x86_64", "osmajorrelease": 8} + ): + ret = pkg.installed( + "test_install", + pkgs=[{"saltdemo.x86_64": version_wildcard}], + skip_suggestions=True, + ) + + install_mock.assert_not_called() + assert ret["result"] is True, ret + assert ret["changes"] == {} + assert "already installed" in ret["comment"] + + +def test_find_install_targets_arch_qualified_native_already_installed_69604(): + """ + Regression test for https://github.com/saltstack/salt/issues/69604. + + Directly tests ``_find_install_targets`` -- the preflight that decides + which packages actually need to be installed. Before the fix, calling + ``pkg.installed`` with an arch-qualified native name such as + ``saltdemo.x86_64`` on a yum/dnf host would return the package as a + *target* (to be installed) even though ``pkg.list_pkgs`` already reported + it as installed under the normalized name ``saltdemo``. The bug caused a + doomed ``pkg.install`` call that failed with + ``No version matching '...' found for package 'saltdemo.x86_64' (available: none)``. + + The fix mirrors the ``_verify_install`` normalize fallback: when the arch- + qualified name is not found in ``cur_pkgs``, retry with the normalized name. + After the fix ``_find_install_targets`` must return an empty ``targets`` + dict (nothing to install) for an already-installed, native-arch package. + """ + installed_version = "10.4.0.1-1717258879" + version_wildcard = installed_version.split("-", maxsplit=1)[0] + "-*" + + # pkg.list_pkgs returns the normalized name (no arch suffix) + cur_pkgs = {"saltdemo": [installed_version]} + + salt_dict = { + "pkg.list_pkgs": MagicMock(return_value=cur_pkgs), + "pkg.normalize_name": yumpkg.normalize_name, + "pkg_resource.check_extra_requirements": MagicMock(return_value=True), + "pkg_resource.version_clean": pkg_resource.version_clean, + } + + with patch.dict(pkg.__salt__, salt_dict), patch.dict( + pkg_resource.__salt__, salt_dict + ), patch.dict( + pkg.__grains__, {"os": "CentOS", "os_family": "RedHat", "osarch": "x86_64"} + ), patch.dict( + yumpkg.__grains__, {"os": "CentOS", "osarch": "x86_64", "osmajorrelease": 8} + ): + # split_arch=False is the key trigger: pkg.installed passes this to + # preserve APT multiarch names (e.g. foo:amd64). With split_arch=False, + # _repack_pkgs does NOT normalize the package name, so ``desired`` + # contains ``{"saltdemo.x86_64": "..."}`` -- the arch-qualified name + # that is absent from pkg.list_pkgs. Without the fix the package would + # be added to ``targets`` and trigger a doomed pkg.install call. + result = pkg._find_install_targets( + pkgs=[{"saltdemo.x86_64": version_wildcard}], + skip_suggestions=True, + split_arch=False, + ) + + # _find_install_targets short-circuits to a dict when all packages are + # already installed: {"name": ..., "changes": {}, "result": True, + # "comment": "All specified packages are already installed..."}. + # Before the fix it returned a tuple with targets={"saltdemo.x86_64": ...} + # because the arch-qualified name was not found in cur_pkgs. + assert isinstance(result, dict), ( + "Expected _find_install_targets to return the 'already installed' dict, " + f"but got a tuple with targets={result[1]!r} -- " + "the arch-qualified package was not recognized as already installed" + ) + assert result["result"] is True, result + assert result["changes"] == {}, result + assert "already installed" in result["comment"] + + +def test_installed_arch_qualified_foreign_arch_not_confused_with_native_69604(): + """ + Regression test for https://github.com/saltstack/salt/issues/69604. + + Companion to test_installed_arch_qualified_native_name_already_installed_69604: + a *foreign*-arch yum/dnf package (e.g. ``saltdemo.i686``) must NOT be + mistaken for an already-installed native-arch package (``saltdemo``). + The normalization fallback introduced by #69604 applies only when the + arch-qualified name normalizes to a *different* string; foreign-arch names + (e.g. ``.i686`` on an ``x86_64`` host) are left unchanged by + ``yumpkg.normalize_name``, so the fallback is skipped and the package is + correctly treated as missing -- triggering ``pkg.install`` as expected. + """ + installed_version = "1.2.3-1" + # Only the native-arch (``saltdemo``) package is installed; the + # foreign-arch (``saltdemo.i686``) version is NOT installed. + list_pkgs_mock = MagicMock(return_value={"saltdemo": [installed_version]}) + install_mock = MagicMock(return_value={}) + + salt_dict = { + "pkg.install": install_mock, + "pkg.list_pkgs": list_pkgs_mock, + "pkg.normalize_name": yumpkg.normalize_name, + "pkg_resource.check_extra_requirements": MagicMock(return_value=True), + "pkg_resource.version_clean": pkg_resource.version_clean, + } + + with patch.dict(pkg.__salt__, salt_dict), patch.dict( + pkg_resource.__salt__, salt_dict + ), patch.dict( + pkg.__grains__, {"os": "CentOS", "os_family": "RedHat", "osarch": "x86_64"} + ), patch.dict( + yumpkg.__grains__, {"os": "CentOS", "osarch": "x86_64", "osmajorrelease": 8} + ): + ret = pkg.installed( + "test_install", + pkgs=["saltdemo.i686"], + skip_suggestions=True, + ) + + # The foreign-arch package must be flagged as a new install target -- + # ``pkg.install`` must be called, not short-circuited as "already installed" + install_mock.assert_called_once() + + @pytest.mark.parametrize( "kwargs, expected_cli_options", ( @@ -1452,3 +1612,72 @@ def test_verify_install_freebsd_with_origin( _ok, failed = pkg._verify_install(desired, new_pkgs) assert _ok == expected_ok, f"_ok mismatch: got {_ok}" assert failed == expected_failed, f"failed mismatch: got {failed}" + + +def test_mod_watch_dispatches_to_installed(): + """ + pkg.mod_watch routes a watch trigger to the matching state function based + on the ``sfun`` it was invoked for, forwarding the remaining kwargs. + """ + installed_mock = MagicMock(return_value={"result": True, "changes": {"foo": {}}}) + with patch.object(pkg, "installed", installed_mock): + ret = pkg.mod_watch("foo", sfun="installed", version="1.0") + assert ret == {"result": True, "changes": {"foo": {}}} + installed_mock.assert_called_once_with("foo", version="1.0") + + +def test_mod_watch_unsupported_sfun(): + """ + pkg.mod_watch returns a failure result for state functions that do not + support the watch requisite (e.g. uptodate). + """ + ret = pkg.mod_watch("foo", sfun="uptodate") + assert ret["result"] is False + assert ret["name"] == "foo" + assert ret["changes"] == {} + assert "does not work with the watch requisite" in ret["comment"] + + +def test_mod_init_installed_sets_refresh_flag(): + """ + pkg.mod_init writes the refresh tag and returns True for install-type + states so the package database is refreshed only once per state run. + """ + write_rtag = MagicMock() + with patch("salt.utils.pkg.write_rtag", write_rtag): + ret = pkg.mod_init({"fun": "installed"}) + assert ret is True + write_rtag.assert_called_once() + + +def test_mod_init_non_install_returns_false(): + """ + pkg.mod_init returns False, and does not write the refresh tag, for + non-install states such as removed. + """ + write_rtag = MagicMock() + with patch("salt.utils.pkg.write_rtag", write_rtag): + ret = pkg.mod_init({"fun": "removed"}) + assert ret is False + write_rtag.assert_not_called() + + +def test_downloaded_not_supported_platform(): + """ + pkg.downloaded fails cleanly when the provider does not implement + pkg.list_downloaded. + """ + with patch.dict(pkg.__salt__, {}, clear=True): + ret = pkg.downloaded("foo") + assert ret["result"] is False + assert "not available on this platform" in ret["comment"] + + +def test_downloaded_empty_pkgs_list(): + """ + pkg.downloaded short-circuits to success when handed an empty pkgs list. + """ + with patch.dict(pkg.__salt__, {"pkg.list_downloaded": MagicMock()}): + ret = pkg.downloaded("foo", pkgs=[]) + assert ret["result"] is True + assert ret["comment"] == "No packages to download provided" From b365ea158a032c5e884f7c19600d18fef2648470 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 18:35:29 -0400 Subject: [PATCH 081/469] Skip sub events for non-dict job returns in the event tagger (#69731) A failing state compilation returns a list of error strings (or a plain string from some renderers) rather than a mapping of per-state results. _fire_ret_load_specific_fun assumed a dict and crashed on ret.items(), logging 'Event iteration failed with exception: list object has no attribute items' at ERROR for every failed compile. The integration tests that assert that message never appears (tests/pytests/integration/states/test_state_test.py) fail whenever they share a run with a failing compile, which is part of the Rocky Linux 9 integration job noise on 3006.x. There are no state tags in a non-dict return, so skip it at debug level instead. Dict-shaped returns keep firing the per-tag failure events unchanged, covered by a new regression pair: the list-return test fails on the previous code via the logged error, and the dict-return test pins the two sub events (old-style dup tag and the namespaced job error tag) with their enriched payload. Fixes #69730 Refs #69728 From 9b4a465750c2e9784204a40bee470a510615e475 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 18:36:05 -0400 Subject: [PATCH 082/469] Migrate rest_tornado saltnado tests to pytest (#69704) * Migrate rest_tornado saltnado tests to pytest Port the AsyncTestCase suite in tests/unit/netapi/rest_tornado/test_saltnado.py to pytest, driving the handler coroutines with the io_loop fixture, and remove the legacy file. * Address review findings on the saltnado test migration - Bound every io_loop.run_sync call with a 30 second timeout. The legacy gen_test decorator enforced a per-test deadline; run_sync has none by default and the harness fallback timeout is not applied on Windows, so a hang regression (the exact case the infinite-loop guard test exists for) would have consumed the whole job. - Re-raise exceptions from IOLoop callbacks at test teardown via an io_loop fixture wrapper. AsyncTestCase failed a test when a scheduled callback raised; the plain loop only logs, which would let a broken completer pass a test that no longer exercises anything. - Share one handler fixture (conftest.py) instead of three copies of the same mock scaffold, resolving the 2020 TODO carried over from the unittest file, and move test_base_handler.py in from the sibling saltnado directory so the module's unit tests live in one place and consume the same fixture. - Make the two timer-based _disbatch_local tests deterministic by routing the gather timeout through an already-resolved fake sleep, removing a real 2 second wait and a 50 millisecond ordering race. - Drop the dead boop() generators (never iterated; spawn_callback of a bare generator is a no-op), differentiate the two byte-identical disbatch-after-finish tests by asserting the response buffer is not written to, and remove the now-empty legacy tests/unit/netapi packages. --- .../{saltnado => rest_tornado}/__init__.py | 0 .../unit/netapi/rest_tornado/conftest.py | 50 + .../test_base_handler.py | 27 +- .../unit/netapi/rest_tornado/test_saltnado.py | 1134 +++++++++++++++++ tests/unit/netapi/__init__.py | 0 tests/unit/netapi/rest_tornado/__init__.py | 0 .../unit/netapi/rest_tornado/test_saltnado.py | 1029 --------------- 7 files changed, 1189 insertions(+), 1051 deletions(-) rename tests/pytests/unit/netapi/{saltnado => rest_tornado}/__init__.py (100%) create mode 100644 tests/pytests/unit/netapi/rest_tornado/conftest.py rename tests/pytests/unit/netapi/{saltnado => rest_tornado}/test_base_handler.py (50%) create mode 100644 tests/pytests/unit/netapi/rest_tornado/test_saltnado.py delete mode 100644 tests/unit/netapi/__init__.py delete mode 100644 tests/unit/netapi/rest_tornado/__init__.py delete mode 100644 tests/unit/netapi/rest_tornado/test_saltnado.py diff --git a/tests/pytests/unit/netapi/saltnado/__init__.py b/tests/pytests/unit/netapi/rest_tornado/__init__.py similarity index 100% rename from tests/pytests/unit/netapi/saltnado/__init__.py rename to tests/pytests/unit/netapi/rest_tornado/__init__.py diff --git a/tests/pytests/unit/netapi/rest_tornado/conftest.py b/tests/pytests/unit/netapi/rest_tornado/conftest.py new file mode 100644 index 000000000000..31a7dc8bcc70 --- /dev/null +++ b/tests/pytests/unit/netapi/rest_tornado/conftest.py @@ -0,0 +1,50 @@ +import sys + +import pytest + +import salt.netapi.rest_tornado.saltnado as saltnado +from tests.support.mock import MagicMock + + +@pytest.fixture +def io_loop(io_loop): + """ + Fail tests on exceptions raised inside IOLoop callbacks. + + The legacy AsyncTestCase harness rethrew exceptions raised in scheduled + callbacks; the plain IOLoop only logs them, which would let a broken + completer callback pass a test that no longer exercises anything. + Capture such exceptions and re-raise them at teardown. + """ + captured = [] + + def capture_callback_exception(callback): + captured.append(sys.exc_info()[1]) + + io_loop.handle_callback_exception = capture_callback_exception + yield io_loop + if captured: + raise captured[0] + + +@pytest.fixture +def app_mock(): + mock = MagicMock() + mock.opts = { + "syndic_wait": 0.1, + "cachedir": "/tmp/testing/cachedir", + "sock_dir": "/tmp/testing/sock_drawer", + "transport": "zeromq", + "extension_modules": "/tmp/testing/moduuuuules", + "order_masters": False, + "gather_job_timeout": 10.001, + } + return mock + + +@pytest.fixture +def salt_api_handler(io_loop, app_mock): + # io_loop is requested first so the fresh loop is current before the + # handler is constructed, matching the setUp ordering the legacy + # AsyncTestCase suite relied on. + return saltnado.SaltAPIHandler(app_mock, app_mock) diff --git a/tests/pytests/unit/netapi/saltnado/test_base_handler.py b/tests/pytests/unit/netapi/rest_tornado/test_base_handler.py similarity index 50% rename from tests/pytests/unit/netapi/saltnado/test_base_handler.py rename to tests/pytests/unit/netapi/rest_tornado/test_base_handler.py index 5f64b22fe04c..5d0a22cde5e3 100644 --- a/tests/pytests/unit/netapi/saltnado/test_base_handler.py +++ b/tests/pytests/unit/netapi/rest_tornado/test_base_handler.py @@ -1,28 +1,11 @@ import time -import pytest - import salt.netapi.rest_tornado.saltnado as saltnado_app -from tests.support.mock import MagicMock, patch - - -@pytest.fixture -def arg_mock(): - mock = MagicMock() - mock.opts = { - "syndic_wait": 0.1, - "cachedir": "/tmp/testing/cachedir", - "sock_dir": "/tmp/testing/sock_drawer", - "transport": "zeromq", - "extension_modules": "/tmp/testing/moduuuuules", - "order_masters": False, - "gather_job_timeout": 10.001, - } - return mock +from tests.support.mock import patch -def test__verify_auth(arg_mock): - base_handler = saltnado_app.BaseSaltAPIHandler(arg_mock, arg_mock) +def test__verify_auth(app_mock): + base_handler = saltnado_app.BaseSaltAPIHandler(app_mock, app_mock) with patch.object(base_handler, "get_cookie", return_value="ABCDEF"): with patch.object( base_handler.application.auth, @@ -32,8 +15,8 @@ def test__verify_auth(arg_mock): assert base_handler._verify_auth() -def test__verify_auth_expired(arg_mock): - base_handler = saltnado_app.BaseSaltAPIHandler(arg_mock, arg_mock) +def test__verify_auth_expired(app_mock): + base_handler = saltnado_app.BaseSaltAPIHandler(app_mock, app_mock) with patch.object(base_handler, "get_cookie", return_value="ABCDEF"): with patch.object( base_handler.application.auth, diff --git a/tests/pytests/unit/netapi/rest_tornado/test_saltnado.py b/tests/pytests/unit/netapi/rest_tornado/test_saltnado.py new file mode 100644 index 000000000000..8f4f51328760 --- /dev/null +++ b/tests/pytests/unit/netapi/rest_tornado/test_saltnado.py @@ -0,0 +1,1134 @@ +import pytest + +import salt.ext.tornado +import salt.ext.tornado.gen +import salt.netapi.rest_tornado.saltnado as saltnado +from tests.support.mock import patch + +# The legacy suite ran under tornado's gen_test decorator, which enforced a +# per-test deadline; io_loop.run_sync has none by default, and the harness +# fallback timeout is not applied on Windows. Keep every coroutine bounded. +RUN_SYNC_TIMEOUT = 30 + + +# ----- TestJobNotRunning -------------------------------------------------------------------------------------------> +@pytest.fixture +def job_not_running_handler(salt_api_handler): + handler = salt_api_handler + handler._write_buffer = [] + handler._transforms = [] + handler.lowstate = [] + handler.content_type = "text/plain" + handler.dumper = lambda x: x + f = salt.ext.tornado.gen.Future() + f.set_result({"jid": f, "minions": []}) + handler.saltclients.update({"local": lambda *args, **kwargs: f}) + return handler + + +def test_when_disbatch_has_already_finished_then_writing_return_should_not_fail( + io_loop, job_not_running_handler +): + handler = job_not_running_handler + handler.finish() + buffered = list(handler._write_buffer) + io_loop.run_sync(handler.disbatch, timeout=RUN_SYNC_TIMEOUT) + # disbatch on a finished handler must not raise, and must not write + # anything more into the response buffer. + assert handler._write_buffer == buffered + + +def test_when_disbatch_has_already_finished_then_finishing_should_not_fail( + io_loop, job_not_running_handler +): + handler = job_not_running_handler + handler.finish() + io_loop.run_sync(handler.disbatch, timeout=RUN_SYNC_TIMEOUT) + # No assertion necessary, because we just want no failure here. + # Asserting that it doesn't raise anything is... the default behavior + # for a test. + + +def test_when_event_times_out_and_minion_is_not_running_result_should_be_True( + io_loop, job_not_running_handler +): + handler = job_not_running_handler + fut = salt.ext.tornado.gen.Future() + fut.set_exception(saltnado.TimeoutException()) + handler.application.event_listener.get_event.return_value = fut + wrong_future = salt.ext.tornado.gen.Future() + + result = io_loop.run_sync( + lambda: handler.job_not_running( + jid=42, tgt="*", tgt_type="glob", minions=[], is_finished=wrong_future + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert result + + +def test_when_event_times_out_and_minion_is_not_running_minion_data_should_not_be_set( + io_loop, job_not_running_handler +): + handler = job_not_running_handler + fut = salt.ext.tornado.gen.Future() + fut.set_exception(saltnado.TimeoutException()) + handler.application.event_listener.get_event.return_value = fut + wrong_future = salt.ext.tornado.gen.Future() + minions = {} + + io_loop.run_sync( + lambda: handler.job_not_running( + jid=42, tgt="*", tgt_type="glob", minions=minions, is_finished=wrong_future + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert not minions + + +def test_when_event_finally_finishes_and_returned_minion_not_in_minions_it_should_be_set_to_False( + io_loop, job_not_running_handler +): + handler = job_not_running_handler + expected_id = 42 + no_data_event = salt.ext.tornado.gen.Future() + no_data_event.set_result({"data": {}}) + empty_return_event = salt.ext.tornado.gen.Future() + empty_return_event.set_result({"data": {"return": {}}}) + actual_return_event = salt.ext.tornado.gen.Future() + actual_return_event.set_result( + {"data": {"return": {"something happened here": "OK?"}, "id": expected_id}} + ) + timed_out_event = salt.ext.tornado.gen.Future() + timed_out_event.set_exception(saltnado.TimeoutException()) + handler.application.event_listener.get_event.side_effect = [ + no_data_event, + empty_return_event, + actual_return_event, + timed_out_event, + timed_out_event, + ] + minions = {} + + io_loop.run_sync( + lambda: handler.job_not_running( + jid=99, + tgt="*", + tgt_type="fnord", + minions=minions, + is_finished=salt.ext.tornado.gen.Future(), + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert not minions[expected_id] + + +def test_when_event_finally_finishes_and_returned_minion_already_in_minions_it_should_not_be_changed( + io_loop, job_not_running_handler +): + handler = job_not_running_handler + expected_id = 42 + expected_value = object() + minions = {expected_id: expected_value} + no_data_event = salt.ext.tornado.gen.Future() + no_data_event.set_result({"data": {}}) + empty_return_event = salt.ext.tornado.gen.Future() + empty_return_event.set_result({"data": {"return": {}}}) + actual_return_event = salt.ext.tornado.gen.Future() + actual_return_event.set_result( + {"data": {"return": {"something happened here": "OK?"}, "id": expected_id}} + ) + timed_out_event = salt.ext.tornado.gen.Future() + timed_out_event.set_exception(saltnado.TimeoutException()) + handler.application.event_listener.get_event.side_effect = [ + no_data_event, + empty_return_event, + actual_return_event, + timed_out_event, + timed_out_event, + ] + + io_loop.run_sync( + lambda: handler.job_not_running( + jid=99, + tgt="*", + tgt_type="fnord", + minions=minions, + is_finished=salt.ext.tornado.gen.Future(), + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert minions[expected_id] is expected_value + + +def test_when_event_returns_early_and_finally_times_out_result_should_be_True( + io_loop, job_not_running_handler +): + handler = job_not_running_handler + no_data_event = salt.ext.tornado.gen.Future() + no_data_event.set_result({"data": {}}) + empty_return_event = salt.ext.tornado.gen.Future() + empty_return_event.set_result({"data": {"return": {}}}) + actual_return_event = salt.ext.tornado.gen.Future() + actual_return_event.set_result( + {"data": {"return": {"something happened here": "OK?"}, "id": "fnord"}} + ) + timed_out_event = salt.ext.tornado.gen.Future() + timed_out_event.set_exception(saltnado.TimeoutException()) + handler.application.event_listener.get_event.side_effect = [ + no_data_event, + empty_return_event, + actual_return_event, + timed_out_event, + timed_out_event, + ] + + result = io_loop.run_sync( + lambda: handler.job_not_running( + jid=99, + tgt="*", + tgt_type="fnord", + minions={}, + is_finished=salt.ext.tornado.gen.Future(), + ), + timeout=RUN_SYNC_TIMEOUT, + ) + assert result + + +def test_when_event_finishes_but_is_finished_is_done_then_result_should_be_True( + io_loop, job_not_running_handler +): + handler = job_not_running_handler + expected_minion_id = "fnord" + expected_minion_value = object() + no_data_event = salt.ext.tornado.gen.Future() + no_data_event.set_result({"data": {}}) + empty_return_event = salt.ext.tornado.gen.Future() + empty_return_event.set_result({"data": {"return": {}}}) + actual_return_event = salt.ext.tornado.gen.Future() + actual_return_event.set_result( + { + "data": { + "return": {"something happened here": "OK?"}, + "id": expected_minion_id, + } + } + ) + is_finished = salt.ext.tornado.gen.Future() + + def abort(*args, **kwargs): + yield actual_return_event + f = salt.ext.tornado.gen.Future() + f.set_exception(saltnado.TimeoutException()) + is_finished.set_result("This is done") + yield f + assert False, "Never should make it here" + + minions = {expected_minion_id: expected_minion_value} + + handler.application.event_listener.get_event.side_effect = (x for x in abort()) + + result = io_loop.run_sync( + lambda: handler.job_not_running( + jid=99, + tgt="*", + tgt_type="fnord", + minions=minions, + is_finished=is_finished, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + assert result + + # These are failsafes to ensure nothing super sideways happened + assert len(minions) == 1, str(minions) + assert minions[expected_minion_id] is expected_minion_value + + +def test_when_is_finished_times_out_before_event_finishes_result_should_be_True( + io_loop, job_not_running_handler +): + handler = job_not_running_handler + # Other test times out with event - this one should time out for is_finished + finished = salt.ext.tornado.gen.Future() + finished.set_exception(saltnado.TimeoutException()) + wrong_future = salt.ext.tornado.gen.Future() + handler.application.event_listener.get_event.return_value = wrong_future + + result = io_loop.run_sync( + lambda: handler.job_not_running( + jid=42, tgt="*", tgt_type="glob", minions=[], is_finished=finished + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert result + + +def test_when_is_finished_times_out_before_event_finishes_event_should_have_result_set_to_None( + io_loop, job_not_running_handler +): + handler = job_not_running_handler + finished = salt.ext.tornado.gen.Future() + finished.set_exception(saltnado.TimeoutException()) + wrong_future = salt.ext.tornado.gen.Future() + handler.application.event_listener.get_event.return_value = wrong_future + + io_loop.run_sync( + lambda: handler.job_not_running( + jid=42, tgt="*", tgt_type="glob", minions=[], is_finished=finished + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert wrong_future.result() is None + + +# <----- TestJobNotRunning ------------------------------------------------------------------------------------------- + + +# ----- TestGetMinionReturns ----------------------------------------------------------------------------------------> +def test_if_finished_before_any_events_return_then_result_should_be_empty_dictionary( + io_loop, salt_api_handler +): + handler = salt_api_handler + expected_result = {} + xxx = salt.ext.tornado.gen.Future() + xxx.set_result(None) + is_finished = salt.ext.tornado.gen.Future() + is_finished.set_result(None) + actual_result = io_loop.run_sync( + lambda: handler.get_minion_returns( + events=[], + is_finished=is_finished, + is_timed_out=salt.ext.tornado.gen.Future(), + min_wait_time=xxx, + minions={}, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + assert actual_result == expected_result + + +# TODO: Copy above - test with timed out -W. Werner, 2020-11-05 + + +def test_if_is_finished_after_events_return_then_result_should_contain_event_result_data( + io_loop, salt_api_handler +): + handler = salt_api_handler + expected_result = { + "minion1": {"fnord": "this is some fnordish data"}, + "minion2": {"fnord": "this is some other fnordish data"}, + } + xxx = salt.ext.tornado.gen.Future() + xxx.set_result(None) + is_finished = salt.ext.tornado.gen.Future() + # XXX what do I do here? + events = [ + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + ] + events[0].set_result( + { + "tag": "fnord", + "data": {"id": "minion1", "return": expected_result["minion1"]}, + } + ) + events[1].set_result( + { + "tag": "fnord", + "data": {"id": "minion2", "return": expected_result["minion2"]}, + } + ) + io_loop.call_later(0.2, lambda: is_finished.set_result(None)) + + actual_result = io_loop.run_sync( + lambda: handler.get_minion_returns( + events=events, + is_finished=is_finished, + is_timed_out=salt.ext.tornado.gen.Future(), + min_wait_time=xxx, + minions={ + "minion1": False, + "minion2": False, + "never returning minion": False, + }, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert actual_result == expected_result + + +def test_if_timed_out_after_events_return_then_result_should_contain_event_result_data( + io_loop, salt_api_handler +): + handler = salt_api_handler + expected_result = { + "minion1": {"fnord": "this is some fnordish data"}, + "minion2": {"fnord": "this is some other fnordish data"}, + } + xxx = salt.ext.tornado.gen.Future() + xxx.set_result(None) + is_timed_out = salt.ext.tornado.gen.Future() + # XXX what do I do here? + events = [ + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + ] + events[0].set_result( + { + "tag": "fnord", + "data": {"id": "minion1", "return": expected_result["minion1"]}, + } + ) + events[1].set_result( + { + "tag": "fnord", + "data": {"id": "minion2", "return": expected_result["minion2"]}, + } + ) + io_loop.call_later(0.2, lambda: is_timed_out.set_result(None)) + + actual_result = io_loop.run_sync( + lambda: handler.get_minion_returns( + events=events, + is_finished=salt.ext.tornado.gen.Future(), + is_timed_out=is_timed_out, + min_wait_time=xxx, + minions={ + "minion1": False, + "minion2": False, + "never returning minion": False, + }, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert actual_result == expected_result + + +def test_if_wait_timer_is_not_done_even_though_results_are_then_data_should_not_yet_be_returned( + io_loop, salt_api_handler +): + handler = salt_api_handler + expected_result = { + "one": {"fnordy one": "one has some data"}, + "two": {"fnordy two": "two has some data"}, + } + events = [salt.ext.tornado.gen.Future(), salt.ext.tornado.gen.Future()] + events[0].set_result( + {"tag": "fnord", "data": {"id": "one", "return": expected_result["one"]}} + ) + events[1].set_result( + {"tag": "fnord", "data": {"id": "two", "return": expected_result["two"]}} + ) + wait_timer = salt.ext.tornado.gen.Future() + + @salt.ext.tornado.gen.coroutine + def run(): + fut = handler.get_minion_returns( + events=events, + is_finished=salt.ext.tornado.gen.Future(), + is_timed_out=salt.ext.tornado.gen.Future(), + min_wait_time=wait_timer, + minions={"one": False, "two": False}, + ) + + yield salt.ext.tornado.gen.sleep(0.1) + + assert not fut.done() + + wait_timer.set_result(None) + actual_result = yield fut + raise salt.ext.tornado.gen.Return(actual_result) + + actual_result = io_loop.run_sync(run, timeout=RUN_SYNC_TIMEOUT) + + assert actual_result == expected_result + + +def test_when_is_finished_any_other_futures_should_be_canceled( + io_loop, salt_api_handler +): + handler = salt_api_handler + events = [ + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + ] + + is_finished = salt.ext.tornado.gen.Future() + is_finished.set_result(None) + io_loop.run_sync( + lambda: handler.get_minion_returns( + events=events, + is_finished=is_finished, + is_timed_out=salt.ext.tornado.gen.Future(), + min_wait_time=salt.ext.tornado.gen.Future(), + minions={"one": False, "two": False}, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + are_done = [event.done() for event in events] + assert all(are_done) + + +def test_when_an_event_times_out_then_we_should_not_enter_an_infinite_loop( + io_loop, salt_api_handler +): + handler = salt_api_handler + # NOTE: this test will enter an infinite loop if the code is broken. I + # was not able to figure out a way to ensure that the test exits with + # failure rather than stalling forever. That is because the + # TimeoutException happens first and then tornado will never yield + # control to another coroutine. Like a coroutine to remove the future + # with the TimeoutException. It is also not possible to clear the + # TimeoutException. + + events = [ + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + ] + + # Arguably any event would work, but 3 isn't the first, so it + # gives us a little more confidence that this test is testing + # correctly + events[3].set_exception(saltnado.TimeoutException()) + times_out_later = salt.ext.tornado.gen.Future() + # 0.5s should be long enough that the test gets through doing other + # things before hitting this timeout, which will cancel all the + # in-flight futures. + io_loop.call_later(0.5, lambda: times_out_later.set_result(None)) + io_loop.run_sync( + lambda: handler.get_minion_returns( + events=events, + is_finished=salt.ext.tornado.gen.Future(), + is_timed_out=times_out_later, + min_wait_time=salt.ext.tornado.gen.Future(), + minions={"one": False, "two": False}, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + # Technically we don't /need/ to check that all events are done, + # but it's incorrect to exit the function without ensuring all + # futures are canceled. + are_done = [event.done() for event in events] + assert all(are_done) + assert times_out_later.done() + + +def test_when_is_timed_out_any_other_futures_should_be_canceled( + io_loop, salt_api_handler +): + handler = salt_api_handler + # There is some question about whether this test is or should be + # necessary. Or if it's meaningful. The code that this is testing + # should never actually be able to make it to this point -- because + # when all events have completed it should exit at a different branch. + # That being said, the worst case is that this is just a duplicate + # or irrelevant test, and can be removed. + events = [ + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + ] + + is_timed_out = salt.ext.tornado.gen.Future() + is_timed_out.set_result(None) + io_loop.run_sync( + lambda: handler.get_minion_returns( + events=events, + is_finished=salt.ext.tornado.gen.Future(), + is_timed_out=is_timed_out, + min_wait_time=salt.ext.tornado.gen.Future(), + minions={"one": False, "two": False}, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + are_done = [event.done() for event in events] + assert all(are_done) + + +def test_when_min_wait_time_and_nothing_todo_any_other_futures_should_be_canceled( + io_loop, salt_api_handler +): + handler = salt_api_handler + events = [ + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + salt.ext.tornado.gen.Future(), + ] + + is_finished = salt.ext.tornado.gen.Future() + min_wait_time = salt.ext.tornado.gen.Future() + io_loop.call_later(0.2, lambda: min_wait_time.set_result(None)) + + io_loop.run_sync( + lambda: handler.get_minion_returns( + events=events, + is_finished=is_finished, + is_timed_out=salt.ext.tornado.gen.Future(), + min_wait_time=min_wait_time, + minions={"one": True, "two": True}, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + are_done = [event.done() for event in events] + [is_finished.done()] + assert all(are_done) + + +def test_when_is_finished_but_not_is_timed_out_then_timed_out_should_not_be_set_to_done( + io_loop, salt_api_handler +): + handler = salt_api_handler + events = [salt.ext.tornado.gen.Future()] + is_timed_out = salt.ext.tornado.gen.Future() + is_finished = salt.ext.tornado.gen.Future() + is_finished.set_result(None) + + io_loop.run_sync( + lambda: handler.get_minion_returns( + events=events, + is_finished=is_finished, + is_timed_out=is_timed_out, + min_wait_time=salt.ext.tornado.gen.Future(), + minions={"one": False, "two": False}, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert not is_timed_out.done() + + +def test_when_min_wait_time_and_all_completed_but_not_is_timed_out_then_timed_out_should_not_be_set_to_done( + io_loop, salt_api_handler +): + handler = salt_api_handler + events = [salt.ext.tornado.gen.Future()] + is_timed_out = salt.ext.tornado.gen.Future() + min_wait_time = salt.ext.tornado.gen.Future() + io_loop.call_later(0.2, lambda: min_wait_time.set_result(None)) + + io_loop.run_sync( + lambda: handler.get_minion_returns( + events=events, + is_finished=salt.ext.tornado.gen.Future(), + is_timed_out=is_timed_out, + min_wait_time=min_wait_time, + minions={"one": True}, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert not is_timed_out.done() + + +def test_when_things_are_completed_but_not_timed_out_then_timed_out_event_should_not_be_done( + io_loop, salt_api_handler +): + handler = salt_api_handler + events = [ + salt.ext.tornado.gen.Future(), + ] + events[0].set_result({"tag": "fnord", "data": {"id": "one", "return": {}}}) + min_wait_time = salt.ext.tornado.gen.Future() + min_wait_time.set_result(None) + is_timed_out = salt.ext.tornado.gen.Future() + + io_loop.run_sync( + lambda: handler.get_minion_returns( + events=events, + is_finished=salt.ext.tornado.gen.Future(), + is_timed_out=is_timed_out, + min_wait_time=min_wait_time, + minions={"one": True}, + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert not is_timed_out.done() + + +# <----- TestGetMinionReturns ---------------------------------------------------------------------------------------- + + +# ----- TestDisbatchLocal -------------------------------------------------------------------------------------------> +def test_when_is_timed_out_is_set_before_other_events_are_completed_then_result_should_be_empty_dictionary( + io_loop, salt_api_handler +): + handler = salt_api_handler + completed_event = salt.ext.tornado.gen.Future() + never_completed = salt.ext.tornado.gen.Future() + # Route the gather timeout through a fake sleep that is already timed + # out, so the ordering this test asserts (timeout strictly before any + # event completes) is deterministic instead of racing two real timers. + fakeo_timer = object() + timed_out = salt.ext.tornado.gen.Future() + timed_out.set_result(None) + orig_sleep = salt.ext.tornado.gen.sleep + + def fake_sleep(timer): + if timer is fakeo_timer: + return timed_out + return orig_sleep(timer) + + def fancy_get_event(*args, **kwargs): + if kwargs.get("tag").endswith("/ret"): + return never_completed + return completed_event + + f = salt.ext.tornado.gen.Future() + f.set_result({"jid": "42", "minions": []}) + with patch.object( + handler.application.event_listener, + "get_event", + side_effect=fancy_get_event, + ), patch( + "salt.ext.tornado.gen.sleep", + autospec=True, + side_effect=fake_sleep, + ), patch.dict( + handler.application.opts, + {"gather_job_timeout": fakeo_timer, "timeout": 42}, + ), patch.dict( + handler.saltclients, {"local": lambda *args, **kwargs: f} + ): + result = io_loop.run_sync( + lambda: handler._disbatch_local( + chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert result == {} + + +def test_when_is_finished_is_set_before_events_return_then_no_data_should_be_returned( + io_loop, salt_api_handler +): + handler = salt_api_handler + completed_event = salt.ext.tornado.gen.Future() + never_completed = salt.ext.tornado.gen.Future() + gather_timeout = 2 + event_timeout = gather_timeout - 1 + + def fancy_get_event(*args, **kwargs): + if kwargs.get("tag").endswith("/ret"): + return never_completed + return completed_event + + def completer(): + completed_event.set_result( + { + "tag": "fnord", + "data": { + "return": "This should never be in chunk_ret", + "id": "fnord", + }, + } + ) + + io_loop.call_later(event_timeout, completer) + + def toggle_is_finished(*args, **kwargs): + finished = kwargs.get("is_finished", args[4] if len(args) > 4 else None) + assert finished is not None + finished.set_result(42) + + f = salt.ext.tornado.gen.Future() + f.set_result({"jid": "42", "minions": []}) + with patch.object( + handler.application.event_listener, + "get_event", + side_effect=fancy_get_event, + ), patch.object( + handler, + "job_not_running", + autospec=True, + side_effect=toggle_is_finished, + ), patch.dict( + handler.application.opts, + {"gather_job_timeout": gather_timeout, "timeout": 42}, + ), patch.dict( + handler.saltclients, {"local": lambda *args, **kwargs: f} + ): + result = io_loop.run_sync( + lambda: handler._disbatch_local( + chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert result == {} + + +def test_when_is_finished_then_all_collected_data_should_be_returned( + io_loop, salt_api_handler +): + handler = salt_api_handler + completed_event = salt.ext.tornado.gen.Future() + never_completed = salt.ext.tornado.gen.Future() + # This timeout should never be reached + gather_timeout = 42 + completed_events = [salt.ext.tornado.gen.Future() for _ in range(5)] + for i, event in enumerate(completed_events): + event.set_result( + { + "tag": "fnord", + "data": { + "return": f"return from fnord {i}", + "id": f"fnord {i}", + }, + } + ) + uncompleted_events = [salt.ext.tornado.gen.Future() for _ in range(5)] + events = iter(completed_events + uncompleted_events) + expected_result = { + "fnord 0": "return from fnord 0", + "fnord 1": "return from fnord 1", + "fnord 2": "return from fnord 2", + "fnord 3": "return from fnord 3", + "fnord 4": "return from fnord 4", + } + + def fancy_get_event(*args, **kwargs): + if kwargs.get("tag").endswith("/ret"): + return never_completed + else: + return next(events) + + def toggle_is_finished(*args, **kwargs): + finished = kwargs.get("is_finished", args[4] if len(args) > 4 else None) + assert finished is not None + finished.set_result(42) + + f = salt.ext.tornado.gen.Future() + f.set_result({"jid": "42", "minions": ["non-existent minion"]}) + with patch.object( + handler.application.event_listener, + "get_event", + side_effect=fancy_get_event, + ), patch.object( + handler, + "job_not_running", + autospec=True, + side_effect=toggle_is_finished, + ), patch.dict( + handler.application.opts, + {"gather_job_timeout": gather_timeout, "timeout": 42}, + ), patch.dict( + handler.saltclients, {"local": lambda *args, **kwargs: f} + ): + result = io_loop.run_sync( + lambda: handler._disbatch_local( + chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert result == expected_result + + +def test_when_is_timed_out_then_all_collected_data_should_be_returned( + io_loop, salt_api_handler +): + handler = salt_api_handler + completed_event = salt.ext.tornado.gen.Future() + never_completed = salt.ext.tornado.gen.Future() + # Route the gather timeout through a fake sleep that is already timed + # out. The completed events still win each wait round (their callbacks + # are scheduled first), so all collected data is returned and the test + # no longer needs a real 2 second timer. + fakeo_timer = object() + timed_out = salt.ext.tornado.gen.Future() + timed_out.set_result(None) + orig_sleep = salt.ext.tornado.gen.sleep + + def fake_sleep(timer): + if timer is fakeo_timer: + return timed_out + return orig_sleep(timer) + + completed_events = [salt.ext.tornado.gen.Future() for _ in range(5)] + for i, event in enumerate(completed_events): + event.set_result( + { + "tag": "fnord", + "data": { + "return": f"return from fnord {i}", + "id": f"fnord {i}", + }, + } + ) + uncompleted_events = [salt.ext.tornado.gen.Future() for _ in range(5)] + events = iter(completed_events + uncompleted_events) + expected_result = { + "fnord 0": "return from fnord 0", + "fnord 1": "return from fnord 1", + "fnord 2": "return from fnord 2", + "fnord 3": "return from fnord 3", + "fnord 4": "return from fnord 4", + } + + def fancy_get_event(*args, **kwargs): + if kwargs.get("tag").endswith("/ret"): + return never_completed + else: + return next(events) + + f = salt.ext.tornado.gen.Future() + f.set_result({"jid": "42", "minions": ["non-existent minion"]}) + with patch.object( + handler.application.event_listener, + "get_event", + side_effect=fancy_get_event, + ), patch( + "salt.ext.tornado.gen.sleep", + autospec=True, + side_effect=fake_sleep, + ), patch.dict( + handler.application.opts, + {"gather_job_timeout": fakeo_timer, "timeout": 42}, + ), patch.dict( + handler.saltclients, {"local": lambda *args, **kwargs: f} + ): + result = io_loop.run_sync( + lambda: handler._disbatch_local( + chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert result == expected_result + + +def test_when_minions_all_return_then_all_collected_data_should_be_returned( + io_loop, salt_api_handler +): + handler = salt_api_handler + completed_event = salt.ext.tornado.gen.Future() + never_completed = salt.ext.tornado.gen.Future() + # Timeout is something ridiculously high - it should never be reached + gather_timeout = 20 + completed_events = [salt.ext.tornado.gen.Future() for _ in range(10)] + events_by_id = {} + for i, event in enumerate(completed_events): + id_ = f"fnord {i}" + events_by_id[id_] = event + event.set_result( + { + "tag": "fnord", + "data": {"return": f"return from {id_}", "id": id_}, + } + ) + expected_result = { + "fnord 0": "return from fnord 0", + "fnord 1": "return from fnord 1", + "fnord 2": "return from fnord 2", + "fnord 3": "return from fnord 3", + "fnord 4": "return from fnord 4", + "fnord 5": "return from fnord 5", + "fnord 6": "return from fnord 6", + "fnord 7": "return from fnord 7", + "fnord 8": "return from fnord 8", + "fnord 9": "return from fnord 9", + } + + def fancy_get_event(*args, **kwargs): + tag = kwargs.get("tag", "").rpartition("/")[-1] + return events_by_id.get(tag, never_completed) + + f = salt.ext.tornado.gen.Future() + f.set_result( + { + "jid": "42", + "minions": [e.result()["data"]["id"] for e in completed_events], + } + ) + with patch.object( + handler.application.event_listener, + "get_event", + side_effect=fancy_get_event, + ), patch.dict( + handler.application.opts, + {"gather_job_timeout": gather_timeout, "timeout": 42}, + ), patch.dict( + handler.saltclients, {"local": lambda *args, **kwargs: f} + ): + result = io_loop.run_sync( + lambda: handler._disbatch_local( + chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} + ), + timeout=RUN_SYNC_TIMEOUT, + ) + + assert result == expected_result + + +def test_when_min_wait_time_has_not_passed_then_disbatch_should_not_return_expected_data_until_time_has_passed( + io_loop, salt_api_handler +): + handler = salt_api_handler + completed_event = salt.ext.tornado.gen.Future() + never_completed = salt.ext.tornado.gen.Future() + wait_timer = salt.ext.tornado.gen.Future() + gather_timeout = 20 + completed_events = [salt.ext.tornado.gen.Future() for _ in range(10)] + events_by_id = {} + # Setup some real-enough looking return data + for i, event in enumerate(completed_events): + id_ = f"fnord {i}" + events_by_id[id_] = event + event.set_result( + { + "tag": "fnord", + "data": {"return": f"return from {id_}", "id": id_}, + } + ) + # Hard coded instead of dynamic to avoid potentially writing a test + # that does nothing + expected_result = { + "fnord 0": "return from fnord 0", + "fnord 1": "return from fnord 1", + "fnord 2": "return from fnord 2", + "fnord 3": "return from fnord 3", + "fnord 4": "return from fnord 4", + "fnord 5": "return from fnord 5", + "fnord 6": "return from fnord 6", + "fnord 7": "return from fnord 7", + "fnord 8": "return from fnord 8", + "fnord 9": "return from fnord 9", + } + + # If this is one of our fnord events, return that future, otherwise + # they're bogus events that are irrelevant to our current testing. + # They get to wait for-ev-errrrr + def fancy_get_event(*args, **kwargs): + tag = kwargs.get("tag", "").rpartition("/")[-1] + return events_by_id.get(tag, never_completed) + + minions = {} + + def capture_minions(*args, **kwargs): + """ + Take minions that would be passed to a function, and + store them for later checking. + """ + nonlocal minions + minions = args[3] + + # Needed to have both a fake sleep, as well as a *real* sleep. + # The fake sleep is necessary so that we can return our own + # min_wait_time future. The fakeo_timer object is how we signal + # which one we need to be returning. + orig_sleep = salt.ext.tornado.gen.sleep + + fakeo_timer = object() + + @salt.ext.tornado.gen.coroutine + def fake_sleep(timer): + # only return our fake min_wait_time future when the sentinel + # value is provided. Otherwise it's just a number. + if timer is fakeo_timer: + yield wait_timer + else: + yield orig_sleep(timer) + + f = salt.ext.tornado.gen.Future() + f.set_result( + { + "jid": "42", + "minions": [e.result()["data"]["id"] for e in completed_events], + } + ) + with patch.object( + handler.application.event_listener, + "get_event", + side_effect=fancy_get_event, + ), patch.object( + handler, + "job_not_running", + autospec=True, + side_effect=capture_minions, + ), patch.dict( + handler.application.opts, + { + "gather_job_timeout": gather_timeout, + "timeout": 42, + "syndic_wait": fakeo_timer, + "order_masters": True, + }, + ), patch( + "salt.ext.tornado.gen.sleep", + autospec=True, + side_effect=fake_sleep, + ), patch.dict( + handler.saltclients, {"local": lambda *args, **kwargs: f} + ): + + # Example timeline that we're testing: + # + # If there's a min wait time of 10s, and all the results come + # back in 5s, we still need to wait the full 10s. + # + # Here: + # t=0, all events are completed + # t=0.1, we check that all minions have been set to True, i.e. all + # events are completed. We also ensure that the future has + # not completed. + # t=0.1+, we complete our injected timer, and then ensure that all + # the correct data has been returned. + + @salt.ext.tornado.gen.coroutine + def run(): + fut = handler._disbatch_local( + chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} + ) + + yield salt.ext.tornado.gen.sleep(0.1) + # here, all the minions should be complete (i.e. "True") + assert all(minions[m_id] for m_id in minions) + # But _disbatch_local is not returned yet because min_wait_time has not passed + assert not fut.done() + wait_timer.set_result(None) + result = yield fut + raise salt.ext.tornado.gen.Return(result) + + result = io_loop.run_sync(run, timeout=RUN_SYNC_TIMEOUT) + + assert result == expected_result + + +# Question: Currently, job_not_running can add to the minions dict, which +# affects the more_todo result. However, the events are never added to +# once we have entered the loop. I'm not sure if this is an oversight, or +# simply an implicit expectation. I am making the assumption that this +# behavior is correct and does not need extra testing. Otherwise, we should +# be testing that when minions are added within job_not_running, that it +# should affect the regular loop +# -W. Werner, 2020-11-19 +# <----- TestDisbatchLocal ------------------------------------------------------------------------------------------- diff --git a/tests/unit/netapi/__init__.py b/tests/unit/netapi/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/tests/unit/netapi/rest_tornado/__init__.py b/tests/unit/netapi/rest_tornado/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/tests/unit/netapi/rest_tornado/test_saltnado.py b/tests/unit/netapi/rest_tornado/test_saltnado.py deleted file mode 100644 index c4758e700aba..000000000000 --- a/tests/unit/netapi/rest_tornado/test_saltnado.py +++ /dev/null @@ -1,1029 +0,0 @@ -import salt.ext.tornado -import salt.ext.tornado.testing -import salt.netapi.rest_tornado.saltnado as saltnado -from tests.support.mock import MagicMock, patch - - -class TestJobNotRunning(salt.ext.tornado.testing.AsyncTestCase): - def setUp(self): - super().setUp() - self.mock = MagicMock() - self.mock.opts = { - "syndic_wait": 0.1, - "cachedir": "/tmp/testing/cachedir", - "sock_dir": "/tmp/testing/sock_drawer", - "transport": "zeromq", - "extension_modules": "/tmp/testing/moduuuuules", - "order_masters": False, - "gather_job_timeout": 10.001, - } - self.handler = saltnado.SaltAPIHandler(self.mock, self.mock) - self.handler._write_buffer = [] - self.handler._transforms = [] - self.handler.lowstate = [] - self.handler.content_type = "text/plain" - self.handler.dumper = lambda x: x - f = salt.ext.tornado.gen.Future() - f.set_result({"jid": f, "minions": []}) - self.handler.saltclients.update({"local": lambda *args, **kwargs: f}) - - @salt.ext.tornado.testing.gen_test - def test_when_disbatch_has_already_finished_then_writing_return_should_not_fail( - self, - ): - self.handler.finish() - result = yield self.handler.disbatch() - # No assertion necessary, because we just want no failure here. - # Asserting that it doesn't raise anything is... the default behavior - # for a test. - - @salt.ext.tornado.testing.gen_test - def test_when_disbatch_has_already_finished_then_finishing_should_not_fail(self): - self.handler.finish() - result = yield self.handler.disbatch() - # No assertion necessary, because we just want no failure here. - # Asserting that it doesn't raise anything is... the default behavior - # for a test. - - @salt.ext.tornado.testing.gen_test - def test_when_event_times_out_and_minion_is_not_running_result_should_be_True(self): - fut = salt.ext.tornado.gen.Future() - fut.set_exception(saltnado.TimeoutException()) - self.mock.event_listener.get_event.return_value = fut - wrong_future = salt.ext.tornado.gen.Future() - - result = yield self.handler.job_not_running( - jid=42, tgt="*", tgt_type="glob", minions=[], is_finished=wrong_future - ) - - self.assertTrue(result) - - @salt.ext.tornado.testing.gen_test - def test_when_event_times_out_and_minion_is_not_running_minion_data_should_not_be_set( - self, - ): - fut = salt.ext.tornado.gen.Future() - fut.set_exception(saltnado.TimeoutException()) - self.mock.event_listener.get_event.return_value = fut - wrong_future = salt.ext.tornado.gen.Future() - minions = {} - - result = yield self.handler.job_not_running( - jid=42, tgt="*", tgt_type="glob", minions=minions, is_finished=wrong_future - ) - - assert not minions - - @salt.ext.tornado.testing.gen_test - def test_when_event_finally_finishes_and_returned_minion_not_in_minions_it_should_be_set_to_False( - self, - ): - expected_id = 42 - no_data_event = salt.ext.tornado.gen.Future() - no_data_event.set_result({"data": {}}) - empty_return_event = salt.ext.tornado.gen.Future() - empty_return_event.set_result({"data": {"return": {}}}) - actual_return_event = salt.ext.tornado.gen.Future() - actual_return_event.set_result( - {"data": {"return": {"something happened here": "OK?"}, "id": expected_id}} - ) - timed_out_event = salt.ext.tornado.gen.Future() - timed_out_event.set_exception(saltnado.TimeoutException()) - self.mock.event_listener.get_event.side_effect = [ - no_data_event, - empty_return_event, - actual_return_event, - timed_out_event, - timed_out_event, - ] - minions = {} - - yield self.handler.job_not_running( - jid=99, - tgt="*", - tgt_type="fnord", - minions=minions, - is_finished=salt.ext.tornado.gen.Future(), - ) - - self.assertFalse(minions[expected_id]) - - @salt.ext.tornado.testing.gen_test - def test_when_event_finally_finishes_and_returned_minion_already_in_minions_it_should_not_be_changed( - self, - ): - expected_id = 42 - expected_value = object() - minions = {expected_id: expected_value} - no_data_event = salt.ext.tornado.gen.Future() - no_data_event.set_result({"data": {}}) - empty_return_event = salt.ext.tornado.gen.Future() - empty_return_event.set_result({"data": {"return": {}}}) - actual_return_event = salt.ext.tornado.gen.Future() - actual_return_event.set_result( - {"data": {"return": {"something happened here": "OK?"}, "id": expected_id}} - ) - timed_out_event = salt.ext.tornado.gen.Future() - timed_out_event.set_exception(saltnado.TimeoutException()) - self.mock.event_listener.get_event.side_effect = [ - no_data_event, - empty_return_event, - actual_return_event, - timed_out_event, - timed_out_event, - ] - - yield self.handler.job_not_running( - jid=99, - tgt="*", - tgt_type="fnord", - minions=minions, - is_finished=salt.ext.tornado.gen.Future(), - ) - - self.assertIs(minions[expected_id], expected_value) - - @salt.ext.tornado.testing.gen_test - def test_when_event_returns_early_and_finally_times_out_result_should_be_True(self): - no_data_event = salt.ext.tornado.gen.Future() - no_data_event.set_result({"data": {}}) - empty_return_event = salt.ext.tornado.gen.Future() - empty_return_event.set_result({"data": {"return": {}}}) - actual_return_event = salt.ext.tornado.gen.Future() - actual_return_event.set_result( - {"data": {"return": {"something happened here": "OK?"}, "id": "fnord"}} - ) - timed_out_event = salt.ext.tornado.gen.Future() - timed_out_event.set_exception(saltnado.TimeoutException()) - self.mock.event_listener.get_event.side_effect = [ - no_data_event, - empty_return_event, - actual_return_event, - timed_out_event, - timed_out_event, - ] - - result = yield self.handler.job_not_running( - jid=99, - tgt="*", - tgt_type="fnord", - minions={}, - is_finished=salt.ext.tornado.gen.Future(), - ) - self.assertTrue(result) - - @salt.ext.tornado.testing.gen_test - def test_when_event_finishes_but_is_finished_is_done_then_result_should_be_True( - self, - ): - expected_minion_id = "fnord" - expected_minion_value = object() - no_data_event = salt.ext.tornado.gen.Future() - no_data_event.set_result({"data": {}}) - empty_return_event = salt.ext.tornado.gen.Future() - empty_return_event.set_result({"data": {"return": {}}}) - actual_return_event = salt.ext.tornado.gen.Future() - actual_return_event.set_result( - { - "data": { - "return": {"something happened here": "OK?"}, - "id": expected_minion_id, - } - } - ) - is_finished = salt.ext.tornado.gen.Future() - - def abort(*args, **kwargs): - yield actual_return_event - f = salt.ext.tornado.gen.Future() - f.set_exception(saltnado.TimeoutException()) - is_finished.set_result("This is done") - yield f - assert False, "Never should make it here" - - minions = {expected_minion_id: expected_minion_value} - - self.mock.event_listener.get_event.side_effect = (x for x in abort()) - - result = yield self.handler.job_not_running( - jid=99, - tgt="*", - tgt_type="fnord", - minions=minions, - is_finished=is_finished, - ) - self.assertTrue(result) - - # These are failsafes to ensure nothing super sideways happened - self.assertTrue(len(minions) == 1, str(minions)) - self.assertIs(minions[expected_minion_id], expected_minion_value) - - @salt.ext.tornado.testing.gen_test - def test_when_is_finished_times_out_before_event_finishes_result_should_be_True( - self, - ): - # Other test times out with event - this one should time out for is_finished - finished = salt.ext.tornado.gen.Future() - finished.set_exception(saltnado.TimeoutException()) - wrong_future = salt.ext.tornado.gen.Future() - self.mock.event_listener.get_event.return_value = wrong_future - - result = yield self.handler.job_not_running( - jid=42, tgt="*", tgt_type="glob", minions=[], is_finished=finished - ) - - self.assertTrue(result) - - @salt.ext.tornado.testing.gen_test - def test_when_is_finished_times_out_before_event_finishes_event_should_have_result_set_to_None( - self, - ): - finished = salt.ext.tornado.gen.Future() - finished.set_exception(saltnado.TimeoutException()) - wrong_future = salt.ext.tornado.gen.Future() - self.mock.event_listener.get_event.return_value = wrong_future - - result = yield self.handler.job_not_running( - jid=42, tgt="*", tgt_type="glob", minions=[], is_finished=finished - ) - - self.assertIsNone(wrong_future.result()) - - -# TODO: I think we can extract seUp into a superclass -W. Werner, 2020-11-03 -class TestGetMinionReturns(salt.ext.tornado.testing.AsyncTestCase): - def setUp(self): - super().setUp() - self.mock = MagicMock() - self.mock.opts = { - "syndic_wait": 0.1, - "cachedir": "/tmp/testing/cachedir", - "sock_dir": "/tmp/testing/sock_drawer", - "transport": "zeromq", - "extension_modules": "/tmp/testing/moduuuuules", - "order_masters": False, - "gather_job_timeout": 10.001, - } - self.handler = saltnado.SaltAPIHandler(self.mock, self.mock) - f = salt.ext.tornado.gen.Future() - f.set_result({"jid": f, "minions": []}) - - @salt.ext.tornado.testing.gen_test - def test_if_finished_before_any_events_return_then_result_should_be_empty_dictionary( - self, - ): - expected_result = {} - xxx = salt.ext.tornado.gen.Future() - xxx.set_result(None) - is_finished = salt.ext.tornado.gen.Future() - is_finished.set_result(None) - actual_result = yield self.handler.get_minion_returns( - events=[], - is_finished=is_finished, - is_timed_out=salt.ext.tornado.gen.Future(), - min_wait_time=xxx, - minions={}, - ) - self.assertDictEqual(actual_result, expected_result) - - # TODO: Copy above - test with timed out -W. Werner, 2020-11-05 - - @salt.ext.tornado.testing.gen_test - def test_if_is_finished_after_events_return_then_result_should_contain_event_result_data( - self, - ): - expected_result = { - "minion1": {"fnord": "this is some fnordish data"}, - "minion2": {"fnord": "this is some other fnordish data"}, - } - xxx = salt.ext.tornado.gen.Future() - xxx.set_result(None) - is_finished = salt.ext.tornado.gen.Future() - # XXX what do I do here? - events = [ - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - ] - events[0].set_result( - { - "tag": "fnord", - "data": {"id": "minion1", "return": expected_result["minion1"]}, - } - ) - events[1].set_result( - { - "tag": "fnord", - "data": {"id": "minion2", "return": expected_result["minion2"]}, - } - ) - self.io_loop.call_later(0.2, lambda: is_finished.set_result(None)) - - actual_result = yield self.handler.get_minion_returns( - events=events, - is_finished=is_finished, - is_timed_out=salt.ext.tornado.gen.Future(), - min_wait_time=xxx, - minions={ - "minion1": False, - "minion2": False, - "never returning minion": False, - }, - ) - - assert actual_result == expected_result - - @salt.ext.tornado.testing.gen_test - def test_if_timed_out_after_events_return_then_result_should_contain_event_result_data( - self, - ): - expected_result = { - "minion1": {"fnord": "this is some fnordish data"}, - "minion2": {"fnord": "this is some other fnordish data"}, - } - xxx = salt.ext.tornado.gen.Future() - xxx.set_result(None) - is_timed_out = salt.ext.tornado.gen.Future() - # XXX what do I do here? - events = [ - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - ] - events[0].set_result( - { - "tag": "fnord", - "data": {"id": "minion1", "return": expected_result["minion1"]}, - } - ) - events[1].set_result( - { - "tag": "fnord", - "data": {"id": "minion2", "return": expected_result["minion2"]}, - } - ) - self.io_loop.call_later(0.2, lambda: is_timed_out.set_result(None)) - - actual_result = yield self.handler.get_minion_returns( - events=events, - is_finished=salt.ext.tornado.gen.Future(), - is_timed_out=is_timed_out, - min_wait_time=xxx, - minions={ - "minion1": False, - "minion2": False, - "never returning minion": False, - }, - ) - - assert actual_result == expected_result - - @salt.ext.tornado.testing.gen_test - def test_if_wait_timer_is_not_done_even_though_results_are_then_data_should_not_yet_be_returned( - self, - ): - expected_result = { - "one": {"fnordy one": "one has some data"}, - "two": {"fnordy two": "two has some data"}, - } - events = [salt.ext.tornado.gen.Future(), salt.ext.tornado.gen.Future()] - events[0].set_result( - {"tag": "fnord", "data": {"id": "one", "return": expected_result["one"]}} - ) - events[1].set_result( - {"tag": "fnord", "data": {"id": "two", "return": expected_result["two"]}} - ) - wait_timer = salt.ext.tornado.gen.Future() - fut = self.handler.get_minion_returns( - events=events, - is_finished=salt.ext.tornado.gen.Future(), - is_timed_out=salt.ext.tornado.gen.Future(), - min_wait_time=wait_timer, - minions={"one": False, "two": False}, - ) - - def boop(): - yield fut - - self.io_loop.spawn_callback(boop) - yield salt.ext.tornado.gen.sleep(0.1) - - assert not fut.done() - - wait_timer.set_result(None) - actual_result = yield fut - - assert actual_result == expected_result - - @salt.ext.tornado.testing.gen_test - def test_when_is_finished_any_other_futures_should_be_canceled(self): - events = [ - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - ] - - is_finished = salt.ext.tornado.gen.Future() - is_finished.set_result(None) - yield self.handler.get_minion_returns( - events=events, - is_finished=is_finished, - is_timed_out=salt.ext.tornado.gen.Future(), - min_wait_time=salt.ext.tornado.gen.Future(), - minions={"one": False, "two": False}, - ) - - are_done = [event.done() for event in events] - assert all(are_done) - - @salt.ext.tornado.testing.gen_test - def test_when_an_event_times_out_then_we_should_not_enter_an_infinite_loop(self): - # NOTE: this test will enter an infinite loop if the code is broken. I - # was not able to figure out a way to ensure that the test exits with - # failure rather than stalling forever. That is because the - # TimeoutException happens first and then tornado will never yield - # control to another coroutine. Like a coroutine to remove the future - # with the TimeoutException. It is also not possible to clear the - # TimeoutException. - - events = [ - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - ] - - # Arguably any event would work, but 3 isn't the first, so it - # gives us a little more confidence that this test is testing - # correctly - events[3].set_exception(saltnado.TimeoutException()) - times_out_later = salt.ext.tornado.gen.Future() - # 0.5s should be long enough that the test gets through doing other - # things before hitting this timeout, which will cancel all the - # in-flight futures. - self.io_loop.call_later(0.5, lambda: times_out_later.set_result(None)) - yield self.handler.get_minion_returns( - events=events, - is_finished=salt.ext.tornado.gen.Future(), - is_timed_out=times_out_later, - min_wait_time=salt.ext.tornado.gen.Future(), - minions={"one": False, "two": False}, - ) - - # Technically we don't /need/ to check that all events are done, - # but it's incorrect to exit the function without ensuring all - # futures are canceled. - are_done = [event.done() for event in events] - assert all(are_done) - assert times_out_later.done() - - @salt.ext.tornado.testing.gen_test - def test_when_is_timed_out_any_other_futures_should_be_canceled(self): - # There is some question about whether this test is or should be - # necessary. Or if it's meaningful. The code that this is testing - # should never actually be able to make it to this point -- because - # when all events have completed it should exit at a different branch. - # That being said, the worst case is that this is just a duplicate - # or irrelevant test, and can be removed. - events = [ - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - ] - - is_timed_out = salt.ext.tornado.gen.Future() - is_timed_out.set_result(None) - yield self.handler.get_minion_returns( - events=events, - is_finished=salt.ext.tornado.gen.Future(), - is_timed_out=is_timed_out, - min_wait_time=salt.ext.tornado.gen.Future(), - minions={"one": False, "two": False}, - ) - - are_done = [event.done() for event in events] - assert all(are_done) - - @salt.ext.tornado.testing.gen_test - def test_when_min_wait_time_and_nothing_todo_any_other_futures_should_be_canceled( - self, - ): - events = [ - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - salt.ext.tornado.gen.Future(), - ] - - is_finished = salt.ext.tornado.gen.Future() - min_wait_time = salt.ext.tornado.gen.Future() - self.io_loop.call_later(0.2, lambda: min_wait_time.set_result(None)) - - yield self.handler.get_minion_returns( - events=events, - is_finished=is_finished, - is_timed_out=salt.ext.tornado.gen.Future(), - min_wait_time=min_wait_time, - minions={"one": True, "two": True}, - ) - - are_done = [event.done() for event in events] + [is_finished.done()] - assert all(are_done) - - @salt.ext.tornado.testing.gen_test - def test_when_is_finished_but_not_is_timed_out_then_timed_out_should_not_be_set_to_done( - self, - ): - events = [salt.ext.tornado.gen.Future()] - is_timed_out = salt.ext.tornado.gen.Future() - is_finished = salt.ext.tornado.gen.Future() - is_finished.set_result(None) - - yield self.handler.get_minion_returns( - events=events, - is_finished=is_finished, - is_timed_out=is_timed_out, - min_wait_time=salt.ext.tornado.gen.Future(), - minions={"one": False, "two": False}, - ) - - assert not is_timed_out.done() - - @salt.ext.tornado.testing.gen_test - def test_when_min_wait_time_and_all_completed_but_not_is_timed_out_then_timed_out_should_not_be_set_to_done( - self, - ): - events = [salt.ext.tornado.gen.Future()] - is_timed_out = salt.ext.tornado.gen.Future() - min_wait_time = salt.ext.tornado.gen.Future() - self.io_loop.call_later(0.2, lambda: min_wait_time.set_result(None)) - - yield self.handler.get_minion_returns( - events=events, - is_finished=salt.ext.tornado.gen.Future(), - is_timed_out=is_timed_out, - min_wait_time=min_wait_time, - minions={"one": True}, - ) - - assert not is_timed_out.done() - - @salt.ext.tornado.testing.gen_test - def test_when_things_are_completed_but_not_timed_out_then_timed_out_event_should_not_be_done( - self, - ): - events = [ - salt.ext.tornado.gen.Future(), - ] - events[0].set_result({"tag": "fnord", "data": {"id": "one", "return": {}}}) - min_wait_time = salt.ext.tornado.gen.Future() - min_wait_time.set_result(None) - is_timed_out = salt.ext.tornado.gen.Future() - - yield self.handler.get_minion_returns( - events=events, - is_finished=salt.ext.tornado.gen.Future(), - is_timed_out=is_timed_out, - min_wait_time=min_wait_time, - minions={"one": True}, - ) - - assert not is_timed_out.done() - - -class TestDisbatchLocal(salt.ext.tornado.testing.AsyncTestCase): - def setUp(self): - super().setUp() - self.mock = MagicMock() - self.mock.opts = { - "syndic_wait": 0.1, - "cachedir": "/tmp/testing/cachedir", - "sock_dir": "/tmp/testing/sock_drawer", - "transport": "zeromq", - "extension_modules": "/tmp/testing/moduuuuules", - "order_masters": False, - "gather_job_timeout": 10.001, - } - self.handler = saltnado.SaltAPIHandler(self.mock, self.mock) - - @salt.ext.tornado.testing.gen_test - def test_when_is_timed_out_is_set_before_other_events_are_completed_then_result_should_be_empty_dictionary( - self, - ): - completed_event = salt.ext.tornado.gen.Future() - never_completed = salt.ext.tornado.gen.Future() - # TODO: We may need to tweak these values to get them close enough but not so far away -W. Werner, 2020-11-17 - gather_timeout = 0.1 - event_timeout = gather_timeout + 0.05 - - def fancy_get_event(*args, **kwargs): - if kwargs.get("tag").endswith("/ret"): - return never_completed - return completed_event - - def completer(): - completed_event.set_result( - { - "tag": "fnord", - "data": { - "return": "This should never be in chunk_ret", - "id": "fnord", - }, - } - ) - - self.io_loop.call_later(event_timeout, completer) - - f = salt.ext.tornado.gen.Future() - f.set_result({"jid": "42", "minions": []}) - with patch.object( - self.handler.application.event_listener, - "get_event", - side_effect=fancy_get_event, - ), patch.dict( - self.handler.application.opts, - {"gather_job_timeout": gather_timeout, "timeout": 42}, - ), patch.dict( - self.handler.saltclients, {"local": lambda *args, **kwargs: f} - ): - result = yield self.handler._disbatch_local( - chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} - ) - - assert result == {} - - @salt.ext.tornado.testing.gen_test - def test_when_is_finished_is_set_before_events_return_then_no_data_should_be_returned( - self, - ): - completed_event = salt.ext.tornado.gen.Future() - never_completed = salt.ext.tornado.gen.Future() - gather_timeout = 2 - event_timeout = gather_timeout - 1 - - def fancy_get_event(*args, **kwargs): - if kwargs.get("tag").endswith("/ret"): - return never_completed - return completed_event - - def completer(): - completed_event.set_result( - { - "tag": "fnord", - "data": { - "return": "This should never be in chunk_ret", - "id": "fnord", - }, - } - ) - - self.io_loop.call_later(event_timeout, completer) - - def toggle_is_finished(*args, **kwargs): - finished = kwargs.get("is_finished", args[4] if len(args) > 4 else None) - assert finished is not None - finished.set_result(42) - - f = salt.ext.tornado.gen.Future() - f.set_result({"jid": "42", "minions": []}) - with patch.object( - self.handler.application.event_listener, - "get_event", - side_effect=fancy_get_event, - ), patch.object( - self.handler, - "job_not_running", - autospec=True, - side_effect=toggle_is_finished, - ), patch.dict( - self.handler.application.opts, - {"gather_job_timeout": gather_timeout, "timeout": 42}, - ), patch.dict( - self.handler.saltclients, {"local": lambda *args, **kwargs: f} - ): - result = yield self.handler._disbatch_local( - chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} - ) - - assert result == {} - - @salt.ext.tornado.testing.gen_test - def test_when_is_finished_then_all_collected_data_should_be_returned(self): - completed_event = salt.ext.tornado.gen.Future() - never_completed = salt.ext.tornado.gen.Future() - # This timeout should never be reached - gather_timeout = 42 - completed_events = [salt.ext.tornado.gen.Future() for _ in range(5)] - for i, event in enumerate(completed_events): - event.set_result( - { - "tag": "fnord", - "data": { - "return": f"return from fnord {i}", - "id": f"fnord {i}", - }, - } - ) - uncompleted_events = [salt.ext.tornado.gen.Future() for _ in range(5)] - events = iter(completed_events + uncompleted_events) - expected_result = { - "fnord 0": "return from fnord 0", - "fnord 1": "return from fnord 1", - "fnord 2": "return from fnord 2", - "fnord 3": "return from fnord 3", - "fnord 4": "return from fnord 4", - } - - def fancy_get_event(*args, **kwargs): - if kwargs.get("tag").endswith("/ret"): - return never_completed - else: - return next(events) - - def toggle_is_finished(*args, **kwargs): - finished = kwargs.get("is_finished", args[4] if len(args) > 4 else None) - assert finished is not None - finished.set_result(42) - - f = salt.ext.tornado.gen.Future() - f.set_result({"jid": "42", "minions": ["non-existent minion"]}) - with patch.object( - self.handler.application.event_listener, - "get_event", - side_effect=fancy_get_event, - ), patch.object( - self.handler, - "job_not_running", - autospec=True, - side_effect=toggle_is_finished, - ), patch.dict( - self.handler.application.opts, - {"gather_job_timeout": gather_timeout, "timeout": 42}, - ), patch.dict( - self.handler.saltclients, {"local": lambda *args, **kwargs: f} - ): - result = yield self.handler._disbatch_local( - chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} - ) - - assert result == expected_result - - @salt.ext.tornado.testing.gen_test - def test_when_is_timed_out_then_all_collected_data_should_be_returned(self): - completed_event = salt.ext.tornado.gen.Future() - never_completed = salt.ext.tornado.gen.Future() - # 2s is probably enough for any kind of computer to manage to - # do all the other processing. We could maybe reduce this - just - # depends on how slow of a system we're running on. - # TODO: Maybe we should have a test helper/fixture that benchmarks the system and gets a reasonable timeout? -W. Werner, 2020-11-19 - gather_timeout = 2 - completed_events = [salt.ext.tornado.gen.Future() for _ in range(5)] - for i, event in enumerate(completed_events): - event.set_result( - { - "tag": "fnord", - "data": { - "return": f"return from fnord {i}", - "id": f"fnord {i}", - }, - } - ) - uncompleted_events = [salt.ext.tornado.gen.Future() for _ in range(5)] - events = iter(completed_events + uncompleted_events) - expected_result = { - "fnord 0": "return from fnord 0", - "fnord 1": "return from fnord 1", - "fnord 2": "return from fnord 2", - "fnord 3": "return from fnord 3", - "fnord 4": "return from fnord 4", - } - - def fancy_get_event(*args, **kwargs): - if kwargs.get("tag").endswith("/ret"): - return never_completed - else: - return next(events) - - f = salt.ext.tornado.gen.Future() - f.set_result({"jid": "42", "minions": ["non-existent minion"]}) - with patch.object( - self.handler.application.event_listener, - "get_event", - side_effect=fancy_get_event, - ), patch.dict( - self.handler.application.opts, - {"gather_job_timeout": gather_timeout, "timeout": 42}, - ), patch.dict( - self.handler.saltclients, {"local": lambda *args, **kwargs: f} - ): - result = yield self.handler._disbatch_local( - chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} - ) - - assert result == expected_result - - @salt.ext.tornado.testing.gen_test - def test_when_minions_all_return_then_all_collected_data_should_be_returned(self): - completed_event = salt.ext.tornado.gen.Future() - never_completed = salt.ext.tornado.gen.Future() - # Timeout is something ridiculously high - it should never be reached - gather_timeout = 20 - completed_events = [salt.ext.tornado.gen.Future() for _ in range(10)] - events_by_id = {} - for i, event in enumerate(completed_events): - id_ = f"fnord {i}" - events_by_id[id_] = event - event.set_result( - { - "tag": "fnord", - "data": {"return": f"return from {id_}", "id": id_}, - } - ) - expected_result = { - "fnord 0": "return from fnord 0", - "fnord 1": "return from fnord 1", - "fnord 2": "return from fnord 2", - "fnord 3": "return from fnord 3", - "fnord 4": "return from fnord 4", - "fnord 5": "return from fnord 5", - "fnord 6": "return from fnord 6", - "fnord 7": "return from fnord 7", - "fnord 8": "return from fnord 8", - "fnord 9": "return from fnord 9", - } - - def fancy_get_event(*args, **kwargs): - tag = kwargs.get("tag", "").rpartition("/")[-1] - return events_by_id.get(tag, never_completed) - - f = salt.ext.tornado.gen.Future() - f.set_result( - { - "jid": "42", - "minions": [e.result()["data"]["id"] for e in completed_events], - } - ) - with patch.object( - self.handler.application.event_listener, - "get_event", - side_effect=fancy_get_event, - ), patch.dict( - self.handler.application.opts, - {"gather_job_timeout": gather_timeout, "timeout": 42}, - ), patch.dict( - self.handler.saltclients, {"local": lambda *args, **kwargs: f} - ): - result = yield self.handler._disbatch_local( - chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} - ) - - assert result == expected_result - - @salt.ext.tornado.testing.gen_test - def test_when_min_wait_time_has_not_passed_then_disbatch_should_not_return_expected_data_until_time_has_passed( - self, - ): - completed_event = salt.ext.tornado.gen.Future() - never_completed = salt.ext.tornado.gen.Future() - wait_timer = salt.ext.tornado.gen.Future() - gather_timeout = 20 - completed_events = [salt.ext.tornado.gen.Future() for _ in range(10)] - events_by_id = {} - # Setup some real-enough looking return data - for i, event in enumerate(completed_events): - id_ = f"fnord {i}" - events_by_id[id_] = event - event.set_result( - { - "tag": "fnord", - "data": {"return": f"return from {id_}", "id": id_}, - } - ) - # Hard coded instead of dynamic to avoid potentially writing a test - # that does nothing - expected_result = { - "fnord 0": "return from fnord 0", - "fnord 1": "return from fnord 1", - "fnord 2": "return from fnord 2", - "fnord 3": "return from fnord 3", - "fnord 4": "return from fnord 4", - "fnord 5": "return from fnord 5", - "fnord 6": "return from fnord 6", - "fnord 7": "return from fnord 7", - "fnord 8": "return from fnord 8", - "fnord 9": "return from fnord 9", - } - - # If this is one of our fnord events, return that future, otherwise - # they're bogus events that are irrelevant to our current testing. - # They get to wait for-ev-errrrr - def fancy_get_event(*args, **kwargs): - tag = kwargs.get("tag", "").rpartition("/")[-1] - return events_by_id.get(tag, never_completed) - - minions = {} - - def capture_minions(*args, **kwargs): - """ - Take minions that would be passed to a function, and - store them for later checking. - """ - nonlocal minions - minions = args[3] - - # Needed to have both a fake sleep, as well as a *real* sleep. - # The fake sleep is necessary so that we can return our own - # min_wait_time future. The fakeo_timer object is how we signal - # which one we need to be returning. - orig_sleep = salt.ext.tornado.gen.sleep - - fakeo_timer = object() - - @salt.ext.tornado.gen.coroutine - def fake_sleep(timer): - # only return our fake min_wait_time future when the sentinel - # value is provided. Otherwise it's just a number. - if timer is fakeo_timer: - yield wait_timer - else: - yield orig_sleep(timer) - - f = salt.ext.tornado.gen.Future() - f.set_result( - { - "jid": "42", - "minions": [e.result()["data"]["id"] for e in completed_events], - } - ) - with patch.object( - self.handler.application.event_listener, - "get_event", - side_effect=fancy_get_event, - ), patch.object( - self.handler, - "job_not_running", - autospec=True, - side_effect=capture_minions, - ), patch.dict( - self.handler.application.opts, - { - "gather_job_timeout": gather_timeout, - "timeout": 42, - "syndic_wait": fakeo_timer, - "order_masters": True, - }, - ), patch( - "salt.ext.tornado.gen.sleep", - autospec=True, - side_effect=fake_sleep, - ), patch.dict( - self.handler.saltclients, {"local": lambda *args, **kwargs: f} - ): - - # Example timeline that we're testing: - # - # If there's a min wait time of 10s, and all the results come - # back in 5s, we still need to wait the full 10s. - # - # Here: - # t=0, all events are completed - # t=0.1, we check that all minions have been set to True, i.e. all - # events are completed. We also ensure that the future has - # not completed. - # t=0.1+, we complete our injected timer, and then ensure that all - # the correct data has been returned. - - fut = self.handler._disbatch_local( - chunk={"tgt": "*", "tgt_type": "glob", "fun": "test.ping"} - ) - - def boop(): - yield fut - - self.io_loop.spawn_callback(boop) - yield salt.ext.tornado.gen.sleep(0.1) - # here, all the minions should be complete (i.e. "True") - assert all(minions[m_id] for m_id in minions) - # But _disbatch_local is not returned yet because min_wait_time has not passed - assert not fut.done() - wait_timer.set_result(None) - result = yield fut - - assert result == expected_result - - # Question: Currently, job_not_running can add to the minions dict, which - # affects the more_todo result. However, the events are never added to - # once we have entered the loop. I'm not sure if this is an oversight, or - # simply an implicit expectation. I am making the assumption that this - # behavior is correct and does not need extra testing. Otherwise, we should - # be testing that when minions are added within job_not_running, that it - # should affect the regular loop - # -W. Werner, 2020-11-19 From 81d6e1b7abbb1f881a2b5a61be13c7e1df1f73ca Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 18:40:13 -0400 Subject: [PATCH 083/469] Fix minionfs raising when the minions cache dir is absent (#69695) * Fix minionfs raising when minions cache dir is missing Under the salt-ssh shim the master cachedir is a fresh temp dir with no 'minions' subdirectory, so file_list and dir_list called os.listdir(minions_cache_dir) on a non-existent path and raised FileNotFoundError, killing state.highstate. Guard the listdir calls by returning an empty list when the minions cache directory does not exist. Fixes #50351 * Add direct and inverse regression tests for minionfs missing minions cache dir The direct tests call minionfs.file_list and minionfs.dir_list with the exact load shape production sends (fileclient always includes a prefix and cmd key alongside saltenv), using a non-empty prefix since the prefix handling sits below the os.listdir() call that used to raise when the cache dir was absent. The inverse tests guard against overcorrection: with an existing, populated minions cache dir, both functions must still return the pushed files and directories rather than short-circuiting to an empty list. --- changelog/50351.fixed.md | 1 + salt/fileserver/minionfs.py | 14 +++ .../pytests/unit/fileserver/test_minionfs.py | 96 +++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 changelog/50351.fixed.md create mode 100644 tests/pytests/unit/fileserver/test_minionfs.py diff --git a/changelog/50351.fixed.md b/changelog/50351.fixed.md new file mode 100644 index 000000000000..06c562562172 --- /dev/null +++ b/changelog/50351.fixed.md @@ -0,0 +1 @@ +salt-ssh: fix minionfs raising when minions cache dir is missing diff --git a/salt/fileserver/minionfs.py b/salt/fileserver/minionfs.py index 0cc77994fc2c..a8f30a3b4715 100644 --- a/salt/fileserver/minionfs.py +++ b/salt/fileserver/minionfs.py @@ -238,6 +238,13 @@ def file_list(load): prefix = prefix[len(mountpoint + os.path.sep) :] minions_cache_dir = os.path.join(__opts__["cachedir"], "minions") + if not os.path.isdir(minions_cache_dir): + # The minions cache dir may not exist yet (e.g. under the salt-ssh + # shim, where the cachedir is a fresh temp dir with no pushed files). + log.debug( + "minionfs: minions cache directory %s does not exist", minions_cache_dir + ) + return [] minion_dirs = os.listdir(minions_cache_dir) # If the prefix is not an empty string, then get the minion id from it. The @@ -314,6 +321,13 @@ def dir_list(load): prefix = prefix[len(mountpoint + os.path.sep) :] minions_cache_dir = os.path.join(__opts__["cachedir"], "minions") + if not os.path.isdir(minions_cache_dir): + # The minions cache dir may not exist yet (e.g. under the salt-ssh + # shim, where the cachedir is a fresh temp dir with no pushed files). + log.debug( + "minionfs: minions cache directory %s does not exist", minions_cache_dir + ) + return [] minion_dirs = os.listdir(minions_cache_dir) # If the prefix is not an empty string, then get the minion id from it. The diff --git a/tests/pytests/unit/fileserver/test_minionfs.py b/tests/pytests/unit/fileserver/test_minionfs.py new file mode 100644 index 000000000000..3a5638aa0040 --- /dev/null +++ b/tests/pytests/unit/fileserver/test_minionfs.py @@ -0,0 +1,96 @@ +import os + +import pytest + +import salt.fileserver.minionfs as minionfs + + +@pytest.fixture +def configure_loader_modules(tmp_path): + opts = { + "cachedir": str(tmp_path), + "minionfs_env": "base", + "minionfs_mountpoint": "", + "minionfs_whitelist": [], + "minionfs_blacklist": [], + "file_ignore_regex": [], + "file_ignore_glob": [], + } + return {minionfs: {"__opts__": opts}} + + +def test_file_list_missing_minions_cache_dir(): + """ + file_list should return an empty list rather than raising when the + minions cache directory does not exist (e.g. under the salt-ssh shim). + """ + minions_cache_dir = os.path.join(minionfs.__opts__["cachedir"], "minions") + assert not os.path.isdir(minions_cache_dir) + assert minionfs.file_list({"saltenv": "base"}) == [] + + +def test_dir_list_missing_minions_cache_dir(): + """ + dir_list should return an empty list rather than raising when the + minions cache directory does not exist (e.g. under the salt-ssh shim). + """ + minions_cache_dir = os.path.join(minionfs.__opts__["cachedir"], "minions") + assert not os.path.isdir(minions_cache_dir) + assert minionfs.dir_list({"saltenv": "base"}) == [] + + +def test_file_list_missing_minions_cache_dir_production_load_50351(): + """ + file_list must return an empty list rather than raising when the minions + cache directory is absent and the load carries the exact shape production + sends. + """ + # Production callers (fileclient.RemoteClient.file_list -> + # Fileserver.file_list -> backend) always include a "prefix" key (and + # "cmd") in the load. A non-empty prefix is the decisive case: the + # prefix-to-minion-ID handling sits below the os.listdir() call that + # used to raise FileNotFoundError, so it was never reached. + load = {"saltenv": "base", "prefix": "webserver/etc", "cmd": "_file_list"} + minions_cache_dir = os.path.join(minionfs.__opts__["cachedir"], "minions") + assert not os.path.isdir(minions_cache_dir) + assert minionfs.file_list(load) == [] + + +def test_dir_list_missing_minions_cache_dir_production_load_50351(): + """ + dir_list must return an empty list rather than raising when the minions + cache directory is absent and the load carries the exact shape production + sends. + """ + # Same production load shape as file_list: fileclient always sends + # "prefix" (and "cmd") alongside "saltenv". + load = {"saltenv": "base", "prefix": "webserver/etc", "cmd": "_dir_list"} + minions_cache_dir = os.path.join(minionfs.__opts__["cachedir"], "minions") + assert not os.path.isdir(minions_cache_dir) + assert minionfs.dir_list(load) == [] + + +def test_file_list_existing_minions_cache_dir_50351(tmp_path): + """ + Guard against overcorrection: when the minions cache directory exists + and holds pushed files, the missing-directory guard must not kick in. + file_list must still return the pushed files. + """ + files_dir = tmp_path / "minions" / "webserver" / "files" / "etc" + files_dir.mkdir(parents=True) + (files_dir / "some.conf").write_text("pushed") + load = {"saltenv": "base", "prefix": "", "cmd": "_file_list"} + assert minionfs.file_list(load) == [os.path.join("webserver", "etc", "some.conf")] + + +def test_dir_list_existing_minions_cache_dir_50351(tmp_path): + """ + Guard against overcorrection: when the minions cache directory exists + and holds pushed files, the missing-directory guard must not kick in. + dir_list must still return the pushed directories. + """ + files_dir = tmp_path / "minions" / "webserver" / "files" / "etc" + files_dir.mkdir(parents=True) + (files_dir / "some.conf").write_text("pushed") + load = {"saltenv": "base", "prefix": "", "cmd": "_dir_list"} + assert minionfs.dir_list(load) == [os.path.join("webserver", "etc")] From 12e325881a7362ade7cb200033299562253b0666 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Thu, 9 Jul 2026 16:32:19 -0700 Subject: [PATCH 084/469] Fix race in s3fs _write_buckets_cache_file cache removal (#69670) Under overlapping S3 fileserver cache refreshes, _write_buckets_cache_file could race between the os.path.isfile guard and the os.remove call: a concurrent invocation may have already deleted the stale cache file, so the second call raised an unhandled FileNotFoundError. The exception propagated through the async handler, polluted the event bus with a stringified traceback, and left the master unresponsive. Replace the TOCTOU pattern with an unconditional os.remove wrapped in try/except FileNotFoundError, so concurrent refreshes now no-op on the already-removed file instead of aborting the write. Fixes #69529 --- changelog/69529.fixed.md | 1 + salt/fileserver/s3fs.py | 7 +++- tests/pytests/unit/fileserver/test_s3fs.py | 40 ++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 changelog/69529.fixed.md diff --git a/changelog/69529.fixed.md b/changelog/69529.fixed.md new file mode 100644 index 000000000000..7260539f56e8 --- /dev/null +++ b/changelog/69529.fixed.md @@ -0,0 +1 @@ +Fixed a race condition in the s3fs fileserver where two concurrent cache refreshes could raise an unhandled `FileNotFoundError` from `_write_buckets_cache_file` when the second call reached `os.remove` after the first had already removed the stale cache file. The removal is now tolerant of the file being missing, so overlapping refreshes no longer propagate the error onto the event bus or hang the master. diff --git a/salt/fileserver/s3fs.py b/salt/fileserver/s3fs.py index 431cf8d6f42b..4275629d4aab 100644 --- a/salt/fileserver/s3fs.py +++ b/salt/fileserver/s3fs.py @@ -671,8 +671,13 @@ def _write_buckets_cache_file(metadata, cache_file): if not os.path.exists(cache_dir): os.makedirs(cache_dir) - if os.path.isfile(cache_file): + # Remove any prior cache file. Guard against the race where a concurrent + # invocation deletes the file between the existence check and the + # ``os.remove`` call (see #69529). + try: os.remove(cache_file) + except FileNotFoundError: + pass log.debug("Writing buckets cache file") diff --git a/tests/pytests/unit/fileserver/test_s3fs.py b/tests/pytests/unit/fileserver/test_s3fs.py index 165eef4711f7..2cd24220fea7 100644 --- a/tests/pytests/unit/fileserver/test_s3fs.py +++ b/tests/pytests/unit/fileserver/test_s3fs.py @@ -3,6 +3,8 @@ import pytest import yaml +from tests.support.mock import patch + # moto must be imported before boto3 try: import boto3 @@ -179,3 +181,41 @@ def test_ignore_pickle_load_exceptions(): # TODO: parameterized test with patched pickle.load that raises the # various allowable exception from _read_buckets_cache_file pass + + +@pytest.mark.skip_on_fips_enabled_platform +def test_write_buckets_cache_file_race_condition(bucket): + """ + Regression test for #69529. + + When two concurrent invocations of _write_buckets_cache_file overlap, the + second call's ``os.path.isfile(cache_file)`` check can return True while + the file is subsequently deleted by the first call before the second call + reaches ``os.remove``. The unhandled ``FileNotFoundError`` used to + propagate up through the async handler, polluting the event bus and + causing master hangs. + """ + metadata = {"foo": "bar"} + cache_file = s3fs._get_buckets_cache_filename() + # Prime the on-disk cache so the ``isfile`` guard reports True. + s3fs._write_buckets_cache_file(metadata, cache_file) + assert os.path.isfile(cache_file) + + # Simulate a concurrent invocation that removed the file between the + # ``isfile`` check and the ``os.remove`` call: raise FileNotFoundError + # exactly once from os.remove and then delegate to the real implementation. + real_remove = os.remove + call_count = {"n": 0} + + def flaky_remove(path, *args, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + raise FileNotFoundError(2, "No such file or directory", path) + return real_remove(path, *args, **kwargs) + + with patch("salt.fileserver.s3fs.os.remove", side_effect=flaky_remove): + # The write must not raise; the stale cache_file should still be + # replaced by the new metadata. + s3fs._write_buckets_cache_file({"baz": "qux"}, cache_file) + + assert s3fs._read_buckets_cache_file(cache_file) == {"baz": "qux"} From 530867a8112c75458c10d5c4b9c2f0761d26b438 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Thu, 9 Jul 2026 16:32:32 -0700 Subject: [PATCH 085/469] Fix pkg.installed with missing salt:// source (#69665) When pkg.installed is called with a sources: entry pointing at a salt:// URL that does not exist on the fileserver, cp.cache_file returns False. pkg_resource.parse_targets silently appended that False into srcinfo, so the boolean propagated into aptpkg.install and then dpkg_lowpkg.bin_pkg_info, where posixpath.isabs raised a cryptic TypeError: expected str, bytes or os.PathLike object, not bool. Validate the cp.cache_file return value at the point of caching and raise a CommandExecutionError that names the offending source, so the caller gets a clear state failure instead of a stack trace. Fixes #68002 --- changelog/68002.fixed.md | 1 + salt/modules/pkg_resource.py | 11 +++++-- .../pytests/unit/modules/test_pkg_resource.py | 32 ++++++++++++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 changelog/68002.fixed.md diff --git a/changelog/68002.fixed.md b/changelog/68002.fixed.md new file mode 100644 index 000000000000..0f3ca3943480 --- /dev/null +++ b/changelog/68002.fixed.md @@ -0,0 +1 @@ +Fixed `pkg.installed` with a `sources:` entry pointing at a missing `salt://` URL to raise a clear `CommandExecutionError` naming the source, rather than propagating a `False` from `cp.cache_file` that later crashed with a cryptic `TypeError` in `dpkg_lowpkg.bin_pkg_info`. diff --git a/salt/modules/pkg_resource.py b/salt/modules/pkg_resource.py index 88e38b91a41d..bf245678762e 100644 --- a/salt/modules/pkg_resource.py +++ b/salt/modules/pkg_resource.py @@ -11,7 +11,7 @@ import salt.utils.data import salt.utils.versions import salt.utils.yaml -from salt.exceptions import SaltInvocationError +from salt.exceptions import CommandExecutionError, SaltInvocationError log = logging.getLogger(__name__) __SUFFIX_NOT_NEEDED = ("x86_64", "noarch") @@ -155,7 +155,14 @@ def parse_targets( if __salt__["config.valid_fileproto"](pkg_src): # Cache package from remote source (salt master, HTTP, FTP) and # append the cached path. - srcinfo.append(__salt__["cp.cache_file"](pkg_src, saltenv)) + cached_path = __salt__["cp.cache_file"](pkg_src, saltenv) + if not cached_path: + raise CommandExecutionError( + "Unable to cache source {} for package {}".format( + pkg_src, pkg_name + ) + ) + srcinfo.append(cached_path) else: # Package file local to the minion, just append the path to the # package file. diff --git a/tests/pytests/unit/modules/test_pkg_resource.py b/tests/pytests/unit/modules/test_pkg_resource.py index ddd9de80ab09..89ed335ed1a1 100644 --- a/tests/pytests/unit/modules/test_pkg_resource.py +++ b/tests/pytests/unit/modules/test_pkg_resource.py @@ -8,7 +8,7 @@ import salt.modules.pkg_resource as pkg_resource import salt.utils.data import salt.utils.yaml -from salt.exceptions import SaltInvocationError +from salt.exceptions import CommandExecutionError, SaltInvocationError from tests.support.mock import MagicMock, patch @@ -75,6 +75,36 @@ def test_parse_targets(): assert pkg_resource.parse_targets() == (None, None) +def test_parse_targets_missing_salt_source(): + """ + Regression test for #68002. + + When a ``salt://`` package source cannot be cached (e.g. the file does + not exist on the fileserver), ``cp.cache_file`` returns ``False``. + ``parse_targets`` must raise a ``CommandExecutionError`` that names the + offending source rather than silently propagating ``False`` into the + caller, which previously bubbled up as a cryptic ``TypeError`` from + ``dpkg_lowpkg.bin_pkg_info``. + """ + with patch.dict(pkg_resource.__grains__, {"os": "Ubuntu"}): + with patch.object( + pkg_resource, + "pack_sources", + return_value={"my-package": "salt://this/does/not/exist.deb"}, + ): + with patch.dict( + pkg_resource.__salt__, + { + "config.valid_fileproto": MagicMock(return_value=True), + "cp.cache_file": MagicMock(return_value=False), + }, + ): + with pytest.raises(CommandExecutionError) as excinfo: + pkg_resource.parse_targets(sources="s") + assert "salt://this/does/not/exist.deb" in str(excinfo.value) + assert "my-package" in str(excinfo.value) + + def test_version(): """ Test to Common interface for obtaining the version From 28ce2e6ec46572f9179c0bb0723426dda1c4f3fe Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Thu, 9 Jul 2026 16:32:43 -0700 Subject: [PATCH 086/469] Fix AsyncAuth AttributeError on _creds race with creds_map (#69666) AsyncAuth.__singleton_init__ only assigned self._creds when the key was already in the class-wide creds_map. When it was not, __init__ fell through to self.authenticate() and left _creds unset. If a sibling AsyncAuth for the same key (same pki_dir + id + master_uri + key-mtime tuple) completed its sign_in between our construction and our _authenticate reaching the `if key not in AsyncAuth.creds_map:` check, the coroutine took the else branch and dereferenced self._creds["aes"], raising AttributeError: 'AsyncAuth' object has no attribute '_creds' That crash aborted the authenticate coroutine mid-flight, so _authenticate_future never resolved, the minion's connection to the master silently died, and running jobs continued to publish results into the void until manual restart. It reproduced most often on multi-master failover minions where several AsyncAuth instances race for the same key. Initialize self._creds = None in __singleton_init__ (matching the sibling SAuth.__init__) and treat `self._creds is None` as the first-authentication case in _authenticate, so the else branch is only entered when we have prior creds to compare against. Fixes #67947 --- changelog/67947.fixed.md | 1 + salt/crypt.py | 13 +++++- tests/pytests/unit/test_crypt.py | 80 ++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 changelog/67947.fixed.md diff --git a/changelog/67947.fixed.md b/changelog/67947.fixed.md new file mode 100644 index 000000000000..86e96410467e --- /dev/null +++ b/changelog/67947.fixed.md @@ -0,0 +1 @@ +Fixed a race in the minion's `AsyncAuth._authenticate` that raised `AttributeError: 'AsyncAuth' object has no attribute '_creds'` and silently severed master communication when a sibling `AsyncAuth` populated `creds_map` between construction and the coroutine's `key not in creds_map` check. diff --git a/salt/crypt.py b/salt/crypt.py index 6e0e2c40a9c1..bbdd8bf8bb09 100644 --- a/salt/crypt.py +++ b/salt/crypt.py @@ -689,6 +689,14 @@ def __singleton_init__(self, opts, io_loop=None): self.pub_path = os.path.join(self.opts["pki_dir"], "minion.pub") self.rsa_path = os.path.join(self.opts["pki_dir"], "minion.pem") self._private_key = None + # Initialize ``_creds`` so ``_authenticate`` can safely check it even + # when a sibling ``AsyncAuth`` populates ``creds_map`` between our + # construction and the ``key not in AsyncAuth.creds_map`` check in + # the coroutine. Without this pre-assignment the else-branch below + # falls through to ``self.authenticate()`` and ``_authenticate`` + # later raises ``AttributeError`` on ``self._creds["aes"]`` (see + # issue #67947). + self._creds = None if self.opts["__role"] == "syndic": self.mpub = "syndic_master.pub" else: @@ -878,7 +886,10 @@ def _authenticate(self): else: key = self.__key(self.opts) new_aes, changed_aes, changed_session = False, False, False - if key not in AsyncAuth.creds_map: + # ``self._creds is None`` covers the first-authentication case + # even when a sibling ``AsyncAuth`` for the same key raced us + # into ``creds_map``. See issue #67947. + if key not in AsyncAuth.creds_map or self._creds is None: new_aes = True log.debug("%s Got new master aes key.", self) else: diff --git a/tests/pytests/unit/test_crypt.py b/tests/pytests/unit/test_crypt.py index 9e208640890d..670e0b70d8b5 100644 --- a/tests/pytests/unit/test_crypt.py +++ b/tests/pytests/unit/test_crypt.py @@ -523,3 +523,83 @@ def mock_sign_in(*args, **kwargs): exc_info.value ) assert "Attempt to authenticate with the salt master failed" in str(exc_info.value) + + +async def test_authenticate_missing_creds_attribute_67947(minion_root, io_loop, caplog): + """ + Regression test for https://github.com/saltstack/salt/issues/67947 + + ``AsyncAuth.__singleton_init__`` only assigned ``self._creds`` when the + minion's ``creds_map`` already contained the key for this auth instance. + In the not-in-cache branch it fell through to ``self.authenticate()`` and + left ``_creds`` unset. + + ``_authenticate`` then runs on the io_loop and checks ``if key not in + AsyncAuth.creds_map:`` after the round-trip to the master. If a *sibling* + ``AsyncAuth`` instance for the same key (same pki_dir + id + master_uri + + key-mtime tuple) completed its own sign_in between our construction and + our ``_authenticate`` running, ``creds_map`` now contains the key and the + check goes into the ``else`` branch that dereferences ``self._creds``. + That raises ``AttributeError: 'AsyncAuth' object has no attribute + '_creds'`` on the reporter's Windows minion, aborts the authenticate + coroutine, and silently disconnects the minion until manual restart. + + The fix initializes ``self._creds = None`` in the constructor (matching + the sibling ``SAuth`` class) and updates the else-branch to treat + ``self._creds is None`` as the first-time case rather than the + key-changed case. + """ + pki_dir = minion_root / "etc" / "salt" / "pki" + opts = { + "id": "minion", + "__role": "minion", + "pki_dir": str(pki_dir), + "master_uri": "tcp://127.0.0.1:4505", + "keysize": 4096, + "acceptance_wait_time": 0, + "acceptance_wait_time_max": 0, + } + crypt.gen_keys(pki_dir, "minion", opts["keysize"]) + credskey = ( + opts["pki_dir"], + opts["id"], + opts["master_uri"], + str(os.path.getmtime(os.path.join(opts["pki_dir"], "minion.pem"))), + ) + + # Make sure any leftover mapping from prior tests in this session does not + # mask the bug: the constructor's short-circuit branch would otherwise set + # ``_creds`` for us. + crypt.AsyncAuth.creds_map.pop(credskey, None) + + auth = crypt.AsyncAuth(opts, io_loop) + + aes = crypt.Crypticle.generate_key_string() + session = crypt.Crypticle.generate_key_string() + + async def mock_sign_in(*args, **kwargs): + # Simulate a sibling ``AsyncAuth`` for the same key winning the race + # and populating ``creds_map`` after our constructor ran but before + # our ``_authenticate`` reaches the ``key not in creds_map`` check. + crypt.AsyncAuth.creds_map[credskey] = { + "aes": aes, + "session": session, + } + return {"enc": "pub", "aes": aes, "session": session} + + auth.sign_in = mock_sign_in + + try: + with caplog.at_level(logging.DEBUG): + await auth.authenticate() + finally: + crypt.AsyncAuth.creds_map.pop(credskey, None) + + # Before the fix, ``_authenticate`` raised ``AttributeError: 'AsyncAuth' + # object has no attribute '_creds'`` from the else branch that compared + # ``self._creds["aes"]`` against the freshly signed-in creds. After the + # fix, the constructor initializes ``_creds`` to ``None`` and the else + # branch treats that as the first-authentication case. + assert isinstance(auth._creds, dict) + assert auth._creds["aes"] == aes + assert auth._creds["session"] == session From 6b0e94a0592064a4df4a8f9d21f57f12bec99e29 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Thu, 9 Jul 2026 16:32:53 -0700 Subject: [PATCH 087/469] Fix grains.append leaking defaultdict into persisted grain state (#69648) grains.append walked up the delimited-key hierarchy calling `get(key, _infinitedict(), delimiter)`. When the parent path did not yet exist, `get()` returned that fresh `collections.defaultdict`, which was then mutated in-place and persisted through `setval`. The next `grains.get` on any sibling path under the same parent traversed through the defaultdict; `salt.utils.data.traverse_dict_and_list` relies on `KeyError` from `ptr[each]` to signal a missing key, but a `defaultdict(_infinitedict)` silently auto-inserts an empty child instead. Callers such as `grains.list_present` therefore received an empty defaultdict where they expected `[]`, rejected it as "not a valid list", and failed with "Failed append value X to grain Y". Use a plain `{}` as the default. Auto-nesting is never needed there: the loop writes one level at a time via `.update({rest: grains})`, and every parent along the path is written in its own iteration. Fixes #64017 --- changelog/64017.fixed.md | 1 + salt/modules/grains.py | 9 +++++- tests/pytests/unit/states/test_grains.py | 41 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 changelog/64017.fixed.md diff --git a/changelog/64017.fixed.md b/changelog/64017.fixed.md new file mode 100644 index 000000000000..168175b7157c --- /dev/null +++ b/changelog/64017.fixed.md @@ -0,0 +1 @@ +Fixed `grains.append` (and by extension `grains.list_present`) leaking a `collections.defaultdict` into persisted grain state, which caused sibling `list_present` calls under a shared nested path to fail with "not a valid list". diff --git a/salt/modules/grains.py b/salt/modules/grains.py index c9f9d1481d53..7ce9ea05b906 100644 --- a/salt/modules/grains.py +++ b/salt/modules/grains.py @@ -371,7 +371,14 @@ def append(key, val, convert=False, delimiter=DEFAULT_TARGET_DELIM): while delimiter in key: key, rest = key.rsplit(delimiter, 1) - _grain = get(key, _infinitedict(), delimiter) + # NOTE: default must be a plain dict, not `_infinitedict()`. A + # `collections.defaultdict` returned here (when `key` does not yet + # exist) is later persisted via `setval` and, on subsequent lookups + # through `salt.utils.data.traverse_dict_and_list`, auto-materializes + # empty children instead of raising `KeyError`. That silent-insert + # made sibling nested `grains.append`/`grains.list_present` calls + # fail with "not a valid list". See #64017. + _grain = get(key, {}, delimiter) if isinstance(_grain, dict): _grain.update({rest: grains}) grains = _grain diff --git a/tests/pytests/unit/states/test_grains.py b/tests/pytests/unit/states/test_grains.py index a2df4028244d..f6d4cabc9c59 100644 --- a/tests/pytests/unit/states/test_grains.py +++ b/tests/pytests/unit/states/test_grains.py @@ -734,6 +734,47 @@ def test_list_present_unknown_failure(): assert_grain_file_content("a: aval\nfoo:\n- bar\n") +def test_list_present_multiple_nested_siblings_64017(): + """ + Regression test for #64017. + + Successive ``grains.list_present`` calls that create nested keys sharing + a common parent path should all succeed. Previously the first call left a + ``collections.defaultdict`` (from ``_infinitedict``) in ``__grains__``, + which auto-materialized empty children when the second call traversed + the shared parent -- so ``grains.append`` was handed an empty + ``defaultdict`` instead of ``[]`` and rejected it as "not a valid list". + """ + with set_grains({}): + ret = grains.list_present(name="core-services:monitored", value="basic") + assert ret["result"] is True, ret["comment"] + + ret = grains.list_present(name="core-services:mon-config:rules", value="rules1") + assert ret["result"] is True, ret["comment"] + + ret = grains.list_present( + name="core-services:mon-config:store-servers", value="1.1.1.1" + ) + assert ret["result"] is True, ret["comment"] + + ret = grains.list_present(name="core-services:mon-config:rules", value="rules2") + assert ret["result"] is True, ret["comment"] + + assert grains.__grains__ == { + "core-services": { + "monitored": ["basic"], + "mon-config": { + "rules": ["rules1", "rules2"], + "store-servers": ["1.1.1.1"], + }, + }, + } + # The persisted grain state must contain only plain dicts, not + # defaultdicts that would leak the same bug forward. + assert type(grains.__grains__["core-services"]) is dict + assert type(grains.__grains__["core-services"]["mon-config"]) is dict + + # 'list_absent' function tests: 6 From 537cd09a444860598904edd86306adcf4a9d9551 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Thu, 9 Jul 2026 16:33:08 -0700 Subject: [PATCH 088/469] Fix pkgrepo.managed disabled=True on plain Debian (#69647) On Debian (non-Ubuntu/Mint) systems pkgrepo.managed silently returned "already configured" when called with disabled=True against an existing enabled apt one-line source, because the kwargs["disabled"] normalization was gated on __grains__["os"] in ("Ubuntu", "Mint"). On plain Debian the block was skipped, so _expand_repo_def was called without a disabled kwarg; sanitized["disabled"] then defaulted to False, matched the pre-existing repo's disabled=False, and the compare-loop's for/else short-circuited before pkg.mod_repo could run. Widen the predicate to __grains__["os_family"] == "Debian" so all apt-based distros normalize the flag consistently. This matches the predicate already used a few lines later when calling _expand_repo_def (salt/states/pkgrepo.py:479) and the pkg.mod_repo dispatch (salt/states/pkgrepo.py:612). Same fix as 3007.x+ commit 2e0faafe239 (that commit also adds a deb822 uri/uris adoption block, out of scope for this backport). Fixes #60184 --- changelog/60184.fixed.md | 1 + salt/states/pkgrepo.py | 2 +- tests/pytests/unit/states/test_pkgrepo.py | 74 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 changelog/60184.fixed.md diff --git a/changelog/60184.fixed.md b/changelog/60184.fixed.md new file mode 100644 index 000000000000..d3bf9e65a05f --- /dev/null +++ b/changelog/60184.fixed.md @@ -0,0 +1 @@ +Fixed `pkgrepo.managed` with `disabled: True` on plain Debian (non-Ubuntu/Mint). The `kwargs["disabled"]` normalization was gated on `__grains__["os"] in ("Ubuntu", "Mint")`, so on Debian the state compared the requested `disabled` value against the parsed apt source's default (`False`), found them equal, and silently short-circuited to "already configured" without commenting the repo line out. Widened the predicate to `__grains__["os_family"] == "Debian"` so all apt-based distros normalize the flag consistently. diff --git a/salt/states/pkgrepo.py b/salt/states/pkgrepo.py index 25942ac9420d..009374686f3d 100644 --- a/salt/states/pkgrepo.py +++ b/salt/states/pkgrepo.py @@ -424,7 +424,7 @@ def managed(name, ppa=None, copr=None, aptkey=True, **kwargs): kwargs["name"] = repo = name - if __grains__["os"] in ("Ubuntu", "Mint"): + if __grains__["os_family"] == "Debian": if ppa is not None: # overload the name/repo value for PPAs cleanly # this allows us to have one code-path for PPAs diff --git a/tests/pytests/unit/states/test_pkgrepo.py b/tests/pytests/unit/states/test_pkgrepo.py index e63bb201d461..419c18251d44 100644 --- a/tests/pytests/unit/states/test_pkgrepo.py +++ b/tests/pytests/unit/states/test_pkgrepo.py @@ -161,6 +161,80 @@ def _track_fopen(*args, **kw): assert mod_repo.called +def test_managed_disabled_on_debian_60184(): + """ + Regression test for #60184. + + On plain Debian (not Ubuntu/Mint) ``pkgrepo.managed`` with + ``disabled=True`` for an existing enabled apt one-line source must + normalize ``kwargs["disabled"]`` and drive ``pkg.mod_repo`` to + comment the line out. Prior to the fix, the ``kwargs["disabled"]`` + assignment was gated on ``__grains__["os"] in ("Ubuntu", "Mint")``, + so on Debian the state silently returned ``already configured`` + without ever calling ``pkg.mod_repo``. + """ + repo_line = "deb http://deb.debian.org/debian bookworm main" + pre = { + "file": "/etc/apt/sources.list.d/debian.list", + "comps": ["main"], + "disabled": False, + "dist": "bookworm", + "type": "deb", + "uri": "http://deb.debian.org/debian", + "line": repo_line, + "architectures": [], + } + post = dict(pre, disabled=True, line="# " + repo_line) + + def _sanitize(os_name, os_codename, repo, **kw): + # Mirror the real _expand_repo_def contract: return only the + # apt-schema keys, using kw["disabled"] when provided (which is + # what the pkgrepo.managed disabled-kwarg normalization must set). + return { + "file": pre["file"], + "comps": pre["comps"], + "disabled": kw.get("disabled", False), + "dist": pre["dist"], + "type": pre["type"], + "uri": pre["uri"], + "line": repo_line, + "architectures": pre["architectures"], + } + + get_repo = MagicMock(side_effect=[pre, post]) + mod_repo = MagicMock(return_value=None) + + # ``pkgrepo.managed`` clears the ``pkg._avail`` cache via + # ``sys.modules[__salt__["test.ping"].__module__].__context__``; bind + # ``test.ping`` to ``pkgrepo.managed`` itself (a real function whose + # module is ``salt.states.pkgrepo``) so the lookup finds a real + # ``__context__`` dict instead of exploding. + with patch.dict( + pkgrepo.__salt__, + { + "pkg.get_repo": get_repo, + "pkg.mod_repo": mod_repo, + "test.ping": pkgrepo.managed, + }, + ), patch.dict(pkgrepo.__opts__, {"test": False}), patch.dict( + pkgrepo.__grains__, + {"os": "Debian", "os_family": "Debian", "oscodename": "bookworm"}, + ), patch( + "salt.modules.aptpkg._expand_repo_def", + MagicMock(side_effect=_sanitize), + ), patch( + "salt.utils.path.which", MagicMock(return_value=None) + ): + ret = pkgrepo.managed(name=repo_line, disabled=True) + + assert mod_repo.called, ( + "pkg.mod_repo must be called on Debian when disabled=True flips the " + "state; the short-circuit indicates the disabled kwarg was not " + "normalized for the Debian family." + ) + assert ret["changes"].get("disabled") == {"old": False, "new": True} + + def test_managed_clean_file_with_only_desired_line_no_changes_68208(tmp_path): """ Companion to #68208 regression. When ``clean_file: True`` is set and From 646eba2dbb272d7f2791731500ab2ec2b6e968a8 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Thu, 9 Jul 2026 16:33:26 -0700 Subject: [PATCH 089/469] fix(cp): fall back to __opts__ when __file_client__ is not packed (#69739) cp._client() used `if __file_client__:`, which raises LoaderError instead of being falsey when the executing loader has not packed a __file_client__ context (loaders other than minion_mods, e.g. the resource module loader). The raise short-circuited the __opts__ fallback, so cp.cache_file and every salt:// fetch failed with KeyError: '__file_client__'. Guard the lookup and fall back to building a file client from __opts__. Fixes #69734 --- changelog/69734.fixed.md | 1 + salt/modules/cp.py | 18 ++++++++++--- tests/pytests/unit/modules/test_cp.py | 37 ++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 changelog/69734.fixed.md diff --git a/changelog/69734.fixed.md b/changelog/69734.fixed.md new file mode 100644 index 000000000000..9ef0eb2fc9cb --- /dev/null +++ b/changelog/69734.fixed.md @@ -0,0 +1 @@ +Fixed ``cp._client`` raising ``LoaderError`` (surfaced as ``KeyError: '__file_client__'``) when the executing loader has not packed a ``__file_client__`` context. It now falls back to building a file client from ``__opts__``, so ``cp.cache_file`` and other ``salt://`` fetches work under loaders that do not pack a file client. diff --git a/salt/modules/cp.py b/salt/modules/cp.py index a7d08a384748..892317e49f8c 100644 --- a/salt/modules/cp.py +++ b/salt/modules/cp.py @@ -19,7 +19,7 @@ import salt.utils.path import salt.utils.templates import salt.utils.url -from salt.exceptions import CommandExecutionError +from salt.exceptions import CommandExecutionError, LoaderError from salt.loader.dunder import ( __context__, __file_client__, @@ -171,8 +171,20 @@ def _client(): If the __file_client__ context is set return it, otherwize create a new file client using __opts__. """ - if __file_client__: - return __file_client__.value() + # ``__file_client__`` is a NamedLoaderContext. When the loader executing + # this module has not packed a file client (e.g. loaders other than + # minion_mods, such as the resource module loader), evaluating the context + # raises ``LoaderError`` instead of yielding a falsey value -- so the + # ``__opts__`` fallback below was never reached and callers such as + # ``cp.cache_file`` (and therefore every ``salt://`` fetch) failed with + # ``KeyError: '__file_client__'``. Guard the lookup so a missing/None + # context falls back to building a client from ``__opts__``. + try: + file_client = __file_client__.value() + except LoaderError: + file_client = None + if file_client: + return file_client return salt.fileclient.get_file_client(__opts__.value()) diff --git a/tests/pytests/unit/modules/test_cp.py b/tests/pytests/unit/modules/test_cp.py index 50c17120920d..3cdb4f11ae90 100644 --- a/tests/pytests/unit/modules/test_cp.py +++ b/tests/pytests/unit/modules/test_cp.py @@ -9,7 +9,7 @@ import salt.utils.files import salt.utils.platform import salt.utils.templates as templates -from salt.exceptions import CommandExecutionError +from salt.exceptions import CommandExecutionError, LoaderError from tests.support.mock import MagicMock, Mock, mock_open, patch @@ -18,6 +18,41 @@ def configure_loader_modules(): return {cp: {"__opts__": {"saltenv": None}}} +def test__client_returns_packed_file_client(): + """ + _client() returns the file client from the __file_client__ context when + one is packed. + """ + packed_client = Mock() + ctx = MagicMock() + ctx.value.return_value = packed_client + with patch.object(cp, "__file_client__", ctx, create=True): + with patch("salt.fileclient.get_file_client") as get_file_client: + assert cp._client() is packed_client + get_file_client.assert_not_called() + + +def test__client_falls_back_when_file_client_not_packed(): + """ + When the executing loader has not packed __file_client__, evaluating the + context raises LoaderError. _client() must fall back to building a client + from __opts__ instead of propagating the error. + """ + opts = {"saltenv": None} + opts_ctx = MagicMock() + opts_ctx.value.return_value = opts + file_client_ctx = MagicMock() + file_client_ctx.value.side_effect = LoaderError("__file_client__ not packed") + built_client = Mock() + with patch.object(cp, "__file_client__", file_client_ctx, create=True): + with patch.object(cp, "__opts__", opts_ctx, create=True): + with patch( + "salt.fileclient.get_file_client", return_value=built_client + ) as get_file_client: + assert cp._client() is built_client + get_file_client.assert_called_once_with(opts) + + def test__render_filenames_undefined_template(): """ Test if _render_filenames fails upon getting a template not in From f5c25cde107fa2a0217ad82121f770d3415c1b3d Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 19:38:24 -0400 Subject: [PATCH 090/469] Document the rest_cherrypy /token and /app endpoints (#69727) The REST URI Reference page has an autoclass section for every handler class except Token (POST /token) and App (GET /app), so those two docstrings were never rendered anywhere and both endpoints are missing from the API reference and the HTTP routing table, despite being wired up in the running API. Add the two sections following the existing page style. Both routes are unique, so this does not interact with the duplicate route index cleanup in #69725; verified with the pinned docs toolchain that html and man build clean under -W -j auto, both sections render with transformed field lists and anchors, and each route registers exactly once. Fixes #69726 --- changelog/69726.fixed.md | 1 + doc/ref/netapi/all/salt.netapi.rest_cherrypy.rst | 12 ++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 changelog/69726.fixed.md diff --git a/changelog/69726.fixed.md b/changelog/69726.fixed.md new file mode 100644 index 000000000000..382f3119f3df --- /dev/null +++ b/changelog/69726.fixed.md @@ -0,0 +1 @@ +Added the missing ``POST /token`` and ``GET /app`` sections to the rest_cherrypy REST API reference; their docstrings were never rendered because the page lacked autoclass entries for the Token and App handlers. diff --git a/doc/ref/netapi/all/salt.netapi.rest_cherrypy.rst b/doc/ref/netapi/all/salt.netapi.rest_cherrypy.rst index 0891dd296b0c..061df0c5ffa4 100644 --- a/doc/ref/netapi/all/salt.netapi.rest_cherrypy.rst +++ b/doc/ref/netapi/all/salt.netapi.rest_cherrypy.rst @@ -33,6 +33,12 @@ REST URI Reference .. autoclass:: Logout :members: POST +``/token`` +---------- + +.. autoclass:: Token + :members: POST + ``/minions`` ------------ @@ -80,3 +86,9 @@ REST URI Reference .. autoclass:: Stats :members: GET + +``/app`` +-------- + +.. autoclass:: App + :members: GET From 3f1c33bbdecc0cd68e289cefe9b80258d60ca0c0 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 19:39:06 -0400 Subject: [PATCH 091/469] Delete leaked minion keys at teardown in integration test fixtures (#69729) The startup_states and salt_call ownership test modules start extra minions against the shared session master via 'with factory.started(): yield'. That stops the minion process at teardown but leaves the accepted key on the master, so later tests in the same session that target '*' (the netapi integration tests) key-match minions that no longer exist. The result is the Rocky Linux 9 integration tcp/zeromq pair failing most 3006.x PR runs: AssertionError: assert ['minion-X', 'minion-X-empty-string', 'minion-X-highstate', 'minion-X-sls', 'minion-X-top', 'non-root-minion-Y', 'sub-minion-Z'] == ['minion-X', 'sub-minion-Z'] plus 30 second TimeoutErrors waiting for returns from the dead minions. Nightly runs do not hit it because test sharding separates these modules from the netapi tests. Remove each extra minion's key from the master once the minion is stopped, restoring the isolation the netapi assertions rely on. Fixes #69728 From 62291080b141d72a948ec8c776aa74d33c8f7aba Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 19:41:15 -0400 Subject: [PATCH 092/469] Render iptables SYNPROXY/CT/SET/SNAT options after the jump target (#69693) * Render iptables SYNPROXY/CT/SET/SNAT options after the jump target build_rule only emitted a fixed whitelist of options after --jump; the SYNPROXY (mss, wscale, sack-perm, timestamp), CT (zone-orig, zone-reply), SET (map-set) and SNAT/MASQUERADE (random-fully) options rendered before -j, producing rules iptables rejects. Add them to the after-jump whitelist. Note: mss is also a tcpmss match option (-m tcpmss --mss), which is now emitted after the jump target. Fixes #46616 * Add direct and inverse regression tests for iptables after-jump options The direct test builds a SYNPROXY rule through the exact argument shape the iptables.append state passes to build_rule (full="True" as a string, plus command="A", family, and the name/table/chain kwargs build_rule must strip), asserting the new --mss/--wscale/--sack-perm/--timestamp options land after the --jump target in the full command line. The inverse test guards against overcorrection: the mark match option (a near-miss sibling of the newly whitelisted mask/mss names) must keep rendering before the jump, and pre-existing whitelist entries (SNAT --to-source/--random) must render unchanged. Claude-Session: https://claude.ai/code/session_01MF2AuQNhBZg4HDt1x6xxCu --- changelog/46616.fixed.md | 1 + salt/modules/iptables.py | 8 ++ tests/pytests/unit/modules/test_iptables.py | 102 ++++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 changelog/46616.fixed.md diff --git a/changelog/46616.fixed.md b/changelog/46616.fixed.md new file mode 100644 index 000000000000..4976b85c520c --- /dev/null +++ b/changelog/46616.fixed.md @@ -0,0 +1 @@ +Fixed the iptables module rendering the SYNPROXY (mss, wscale, sack-perm, timestamp), CT (zone-orig, zone-reply), SET (map-set) and SNAT/MASQUERADE (random-fully) jump-target options before -j instead of after it, so the generated rules are now valid. diff --git a/salt/modules/iptables.py b/salt/modules/iptables.py index 986005b1f712..9a0011e0c85a 100644 --- a/salt/modules/iptables.py +++ b/salt/modules/iptables.py @@ -431,7 +431,9 @@ def maybe_add_negation(arg): "log-tcp-options", "log-tcp-sequence", "log-uid", + "map-set", "mask", + "mss", "new", "nfmask", "nflog-group", @@ -449,12 +451,14 @@ def maybe_add_negation(arg): "queue-bypass", "queue-num", "random", + "random-fully", "rateest-ewmalog", "rateest-interval", "rateest-name", "reject-with", "restore", "restore-mark", + "sack-perm", #'save', # no arg, problematic name: How do we avoid collision with this? "save-mark", "selctx", @@ -467,6 +471,7 @@ def maybe_add_negation(arg): "set-xmark", "strip-options", "timeout", + "timestamp", "to", "to-destination", "to-ports", @@ -481,9 +486,12 @@ def maybe_add_negation(arg): "ulog-nlgroup", "ulog-prefix", "ulog-qthreshold", + "wscale", "xor-mark", "xor-tos", "zone", + "zone-orig", + "zone-reply", # IPTABLES-EXTENSIONS "dst-pfx", "hl-dec", diff --git a/tests/pytests/unit/modules/test_iptables.py b/tests/pytests/unit/modules/test_iptables.py index 27fc171c86ca..edab7ebe8ca4 100644 --- a/tests/pytests/unit/modules/test_iptables.py +++ b/tests/pytests/unit/modules/test_iptables.py @@ -242,6 +242,108 @@ def test_build_rule(): ) +def test_build_rule_after_jump_arguments(): + """ + Test that jump-target arguments for SYNPROXY, CT, SET and SNAT + (regression for issue #46616) are rendered after the --jump target + rather than before it. + """ + with patch.object(iptables, "_has_option", MagicMock(return_value=True)): + # SYNPROXY: --mss, --wscale, --sack-perm, --timestamp + assert ( + iptables.build_rule( + jump="SYNPROXY", + **{"sack-perm": "", "timestamp": "", "wscale": 7, "mss": 1460}, + ) + == "--jump SYNPROXY --mss 1460 --sack-perm --timestamp --wscale 7" + ) + + # CT: --zone-orig / --zone-reply + assert ( + iptables.build_rule(jump="CT", **{"zone-orig": 1}) + == "--jump CT --zone-orig 1" + ) + assert ( + iptables.build_rule(jump="CT", **{"zone-reply": 2}) + == "--jump CT --zone-reply 2" + ) + + # SET: --map-set + assert ( + iptables.build_rule(jump="SET", **{"map-set": "myset src"}) + == '--jump SET --map-set "myset src"' + ) + + # SNAT: --random-fully + assert ( + iptables.build_rule(jump="SNAT", **{"random-fully": None}) + == "--jump SNAT --random-fully" + ) + + +def test_build_rule_synproxy_state_append_46616(): + """ + Test issue #46616 through the exact argument shape used by the + iptables.append state (salt/states/iptables.py), which is the + production caller of build_rule. + """ + # The state passes full="True" (a string, not a bool) together with + # command="A" and family, plus name/table/chain kwargs that build_rule + # must strip; full="True" is the decisive flag because it exercises the + # complete command line the state hands to iptables.append/check. + kwargs = { + "name": "synproxy web traffic", + "table": "filter", + "chain": "INPUT", + "protocol": "tcp", + "dport": 443, + "match": "state", + "connstate": "INVALID,UNTRACKED", + "jump": "SYNPROXY", + "mss": 1460, + "wscale": 7, + "sack-perm": "", + "timestamp": "", + } + with patch.object(iptables, "_has_option", MagicMock(return_value=True)): + with patch.object( + iptables, "_iptables_cmd", MagicMock(return_value="/sbin/iptables") + ): + assert iptables.build_rule( + full="True", family="ipv4", command="A", **kwargs + ) == ( + "/sbin/iptables --wait -t filter -A INPUT " + "-p tcp -m state --state INVALID,UNTRACKED --dport 443 " + "--jump SYNPROXY --mss 1460 --sack-perm --timestamp --wscale 7" + ) + + +def test_build_rule_non_jump_options_unaffected_46616(): + """ + Guard against overcorrection of the #46616 fix: options that are not + on the after-jump whitelist must keep rendering before the --jump + target, and whitelist entries that predate the fix must render + exactly as before. This test passes with and without the fix. + """ + with patch.object(iptables, "_has_option", MagicMock(return_value=True)): + # "mark" (the mark match option) is a near-miss sibling of the + # newly whitelisted "mask"/"mss" names and must stay before the + # jump target. + assert ( + iptables.build_rule(match="mark", mark="0x64", jump="RETURN") + == "-m mark --mark 0x64 --jump RETURN" + ) + + # Pre-existing whitelist entries (SNAT --to-source/--random) must + # be rendered unchanged by the additions. + assert ( + iptables.build_rule( + jump="SNAT", **{"to-source": "192.168.0.1", "random": ""} + ) + == "--jump SNAT --random --to-source 192.168.0.1" + ) + + # 'get_saved_rules' function tests: 2 From 47d425e92e5dab4306cdf5a91e29329fcf7a78b0 Mon Sep 17 00:00:00 2001 From: Stepan <51859698+co-cy@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:14:40 -0300 Subject: [PATCH 093/469] Pick latest pgjsonb get_fun return by alter_time, not by MAX(jid) (#69065) * Fix pgjsonb get_fun raising SQL syntax error on PostgreSQL `get_fun` issued: SELECT s.id, s.jid, s.full_ret FROM salt_returns s JOIN (SELECT MAX(`jid`) AS jid FROM salt_returns GROUP BY fun, id) max ON s.jid = max.jid WHERE s.fun = %s The backticks around `jid` are MySQL-style identifier quoting. On PostgreSQL -- which is the only server pgjsonb talks to -- the parser rejects this with `syntax error at or near "`"`. The query was copy-pasted from `salt/returners/mysql.py` where the syntax is valid; the SQL identifier quoting was not adjusted. The bug is dormant in typical deployments: stock `master_job_cache` operation goes through `get_load`, `get_jid`, `get_jids`, but not `get_fun`. It surfaces only when an operator (or an extension) explicitly calls `pgjsonb.get_fun` -- e.g. through a custom runner or via `master_job_cache.get_fun` from external tooling. Drop the backticks. `jid` is not a reserved word in PostgreSQL, so no quoting is needed. While here, normalise the indentation of the SQL string for readability; behaviour is otherwise unchanged. Add a behavioural test that calls `get_fun` against a mocked cursor, asserts the per-minion mapping is built correctly, and guards against backticks creeping back into the issued SQL through future copy-paste from the mysql returner. Refs: #69062 * Pick latest pgjsonb get_fun return by alter_time, not by MAX(jid) `get_fun` chose the latest return per minion with: SELECT s.id, s.jid, s.full_ret FROM salt_returns s JOIN (SELECT MAX(jid) AS jid FROM salt_returns GROUP BY fun, id) max ON s.jid = max.jid WHERE s.fun = %s `MAX(jid)` is the lexicographic max of the `jid` column, not the time-latest return. It happens to coincide for Salt's default `YYYYMMDDHHMMSSffffff` format and the `nano` variant because those are timestamp-formatted strings of equal length. It silently fails for: * Deployments that override `master_job_cache.gen_jid` with a custom scheme -- random UUIDs, snowflake ids, hash-based identifiers -- none of which sort lexicographically as timestamps. * `prep_jid(passed_jid="...")` from external publishers (salt-api, salt-ssh, runners, orchestrate) that hand the master a custom jid. * Salt-runs that span a `jid_format` config change, leaving rows of different formats in the same `salt_returns` table. In all of those cases the lexicographic max is not the time-latest and `get_fun` returns the wrong row. The function looks like it worked, the operator gets the wrong answer. Replace with `alter_time` ordering, which Postgres populates from `DEFAULT NOW()` and which therefore reflects the actual insertion order regardless of jid format: SELECT DISTINCT ON (id) id, jid, full_ret FROM salt_returns WHERE fun = %s ORDER BY id, alter_time DESC `DISTINCT ON (id)` is Postgres-specific (the file already targets Postgres). It returns one row per `id`, picking the first per `ORDER BY` -- here, the most recent `alter_time` per minion. Add a behavioural test that pins the algorithm: the issued SQL must order by `alter_time DESC`, and must not contain `MAX(jid)`. Note on performance: there is no index on `salt_returns.alter_time` in the documented schema, so this remains a sequential scan, same as the previous `MAX(jid) GROUP BY` form. The lack of an `alter_time` index is a separate, larger schema discussion that will be tracked as a follow-up RFC. Depends on #69063 (which removed the MySQL-style backtick quoting from this function); this PR builds on that branch and should be merged after it. Refs: #69064 --------- Co-authored-by: co-cy --- changelog/69062.fixed.md | 4 ++ changelog/69064.fixed.md | 12 ++++ salt/returners/pgjsonb.py | 23 ++++--- tests/pytests/unit/returners/test_pgjsonb.py | 64 ++++++++++++++++++++ 4 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 changelog/69062.fixed.md create mode 100644 changelog/69064.fixed.md diff --git a/changelog/69062.fixed.md b/changelog/69062.fixed.md new file mode 100644 index 000000000000..fd5553208cfe --- /dev/null +++ b/changelog/69062.fixed.md @@ -0,0 +1,4 @@ +Fixed `salt.returners.pgjsonb.get_fun` raising a SQL syntax error on +PostgreSQL because of MySQL-style backtick quoting (`` MAX(`jid`) ``) +left over from a copy-paste of the `mysql` returner. The query now +uses unquoted identifiers, which is valid on PostgreSQL. diff --git a/changelog/69064.fixed.md b/changelog/69064.fixed.md new file mode 100644 index 000000000000..2ed929025ef6 --- /dev/null +++ b/changelog/69064.fixed.md @@ -0,0 +1,12 @@ +Fixed `salt.returners.pgjsonb.get_fun` returning the wrong row per +minion when jids are not lexicographically sortable as timestamps. +The previous SQL used `MAX(jid)` to pick the "latest" return, which +was correct only for Salt's default jid format +(`YYYYMMDDHHMMSSffffff` and the `nano` variant). Deployments that +override `master_job_cache.gen_jid` (custom prep_jid emitting UUIDs, +snowflake ids, or any non-sortable scheme) -- or that hold rows +written under different jid formats from a past config change -- +got a silently wrong answer. The query now orders by +`alter_time DESC` and picks one row per minion via `DISTINCT ON`, +so "latest" is determined from the timestamp Postgres populates via +`DEFAULT NOW()`. diff --git a/salt/returners/pgjsonb.py b/salt/returners/pgjsonb.py index aaf5869768dd..a9345547e5b5 100644 --- a/salt/returners/pgjsonb.py +++ b/salt/returners/pgjsonb.py @@ -430,13 +430,22 @@ def get_fun(fun): """ with _get_serv(ret=None, commit=True) as cur: - sql = """SELECT s.id,s.jid, s.full_ret - FROM salt_returns s - JOIN ( SELECT MAX(`jid`) as jid - from salt_returns GROUP BY fun, id) max - ON s.jid = max.jid - WHERE s.fun = %s - """ + # The previous query picked the latest return per minion with + # ``MAX(jid)``. That assumed jids are lexicographically sortable + # as timestamps (the default ``YYYYMMDDHHMMSSffffff`` format and + # the ``nano`` variant), which silently returns the wrong row + # for any deployment that overrides ``master_job_cache.gen_jid`` + # or that has a mix of jid formats in ``salt_returns`` from a + # past config change. Use ``alter_time`` -- which Postgres + # populates from ``DEFAULT NOW()`` -- as the source of truth + # for "latest" instead, and pick one row per minion with + # ``DISTINCT ON``. + sql = """SELECT DISTINCT ON (id) + id, jid, full_ret + FROM salt_returns + WHERE fun = %s + ORDER BY id, alter_time DESC + """ cur.execute(sql, (fun,)) data = cur.fetchall() diff --git a/tests/pytests/unit/returners/test_pgjsonb.py b/tests/pytests/unit/returners/test_pgjsonb.py index 6a1bdb05eb37..da5bf7720f49 100644 --- a/tests/pytests/unit/returners/test_pgjsonb.py +++ b/tests/pytests/unit/returners/test_pgjsonb.py @@ -470,3 +470,67 @@ def test__archive_jobs_keeps_jids_with_any_recent_salt_returns_row(): assert "not exists" in sql.lower() assert "alter_time >= %s" in sql assert "alter_time < %s" not in sql + + +@pytest.mark.skipif(not pgjsonb.HAS_PG, reason="psycopg2 not installed") +def test_get_fun_returns_one_full_ret_per_minion_with_postgres_compatible_sql(): + """``get_fun`` builds a per-minion last-execution dict. + + The previous SQL used MySQL-style backtick quoting (``MAX(`jid`)``), + which raises a syntax error on PostgreSQL where the function lives. + Verify both the produced mapping and that the issued SQL is free of + backticks so the fix does not regress through future copy-paste from + the mysql returner. + """ + rows = [ + ("minion-1", "20260505000000000001", {"return": "ok-1", "fun": "test.ping"}), + ("minion-2", "20260505000000000002", {"return": "ok-2", "fun": "test.ping"}), + ] + cur = MagicMock() + cur.fetchall.return_value = rows + serv = MagicMock() + serv.return_value.__enter__.return_value = cur + + with patch.object(pgjsonb, "_get_serv", serv): + result = pgjsonb.get_fun("test.ping") + + assert result == { + "minion-1": {"return": "ok-1", "fun": "test.ping"}, + "minion-2": {"return": "ok-2", "fun": "test.ping"}, + } + issued_sql = cur.execute.call_args.args[0] + assert ( + "`" not in issued_sql + ), "MySQL-style backtick quoting in pgjsonb SQL — invalid on PostgreSQL" + + +def test_get_fun_orders_by_alter_time_desc_not_max_jid(): + """``get_fun`` must determine "latest execution per minion" from + ``alter_time`` rather than from a lexicographic ordering of jids. + + The previous SQL used ``MAX(jid)``, which works only when jids are + timestamp-formatted strings of equal length (Salt's default + ``YYYYMMDDHHMMSSffffff`` and the ``nano`` variant). Deployments that + override ``master_job_cache.gen_jid`` (custom prep_jid emitting UUIDs, + snowflake ids, or any non-sortable scheme), or that hold rows written + under different jid formats from a past config change, get a + silently wrong answer with ``MAX(jid)`` -- the lexicographic max is + not the time-latest. + + Pin the algorithm: order by ``alter_time DESC`` (which Postgres + populates via ``DEFAULT NOW()``), and guard against regression to + the ``MAX(jid)`` form. + """ + cur = MagicMock() + cur.fetchall.return_value = [] + serv = MagicMock() + serv.return_value.__enter__.return_value = cur + + with patch.object(pgjsonb, "_get_serv", serv): + pgjsonb.get_fun("test.ping") + + sql = cur.execute.call_args.args[0].lower() + assert "alter_time" in sql + assert "order by" in sql + assert "desc" in sql + assert "max(jid)" not in sql From 9b839e0b24ba62394e1cfc278b5c0df02c5c5f56 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 12:57:26 -0400 Subject: [PATCH 094/469] Stop git.latest logging a spurious rev-parse error on first clone (#69697) * Suppress noisy ERROR logs from git.is_worktree non-repo probe is_worktree() probes cwd with 'git rev-parse --show-toplevel' and expects that command to fail (retcode 128) when cwd is not a git repository, which it already handles by catching CommandExecutionError and returning False. However _get_toplevel() ran the probe without ignore_retcode, so cmd.run_all logged the expected failure at ERROR level, producing noise such as: [ERROR ] Command 'git rev-parse --show-toplevel' failed with return code: 128 Add an ignore_retcode parameter to _get_toplevel (default False, preserving behaviour for other callers) and pass ignore_retcode=True from is_worktree's probe so the expected failure no longer logs at ERROR level. Fixes #51157 * Add direct and inverse regression tests for git.is_worktree rev-parse probe The direct test calls _get_toplevel itself with ignore_retcode=True, the exact flag its production caller is_worktree passes when probing a path that may not be a repo, and asserts the flag is forwarded to cmd.run_all so the expected failure is not logged at ERROR level. The inverse test guards against overcorrection: callers like list_worktrees invoke _get_toplevel without the flag, and a genuine rev-parse failure there must still reach cmd.run_all with ignore_retcode=False and raise CommandExecutionError. * Clarify that is_worktree probe's ignore_retcode is logging-only Add a comment at the is_worktree call site explaining that ignore_retcode=True on the rev-parse probe only suppresses the noisy ERROR log for the expected non-repo failure. Success/failure detection is unchanged: failhard stays True, so _git_run still raises CommandExecutionError on a nonzero retcode, which is_worktree catches and returns False. --- changelog/51157.fixed.md | 1 + salt/modules/git.py | 17 ++++- tests/pytests/unit/modules/test_git.py | 88 ++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 changelog/51157.fixed.md diff --git a/changelog/51157.fixed.md b/changelog/51157.fixed.md new file mode 100644 index 000000000000..1b40b814d34d --- /dev/null +++ b/changelog/51157.fixed.md @@ -0,0 +1 @@ +Suppress noisy ERROR log messages when git.is_worktree probes a directory that is not a git repository. diff --git a/salt/modules/git.py b/salt/modules/git.py index 7bde2c65bde3..354339854ad5 100644 --- a/salt/modules/git.py +++ b/salt/modules/git.py @@ -408,7 +408,9 @@ def _git_run( return result -def _get_toplevel(path, user=None, password=None, output_encoding=None): +def _get_toplevel( + path, user=None, password=None, ignore_retcode=False, output_encoding=None +): """ Use git rev-parse to return the top level of a repo """ @@ -417,6 +419,7 @@ def _get_toplevel(path, user=None, password=None, output_encoding=None): cwd=path, user=user, password=password, + ignore_retcode=ignore_retcode, output_encoding=output_encoding, )["stdout"] @@ -2418,8 +2421,18 @@ def is_worktree(cwd, user=None, password=None, output_encoding=None): """ cwd = _expand_path(cwd, user) try: + # This probe is expected to fail (rev-parse returns 128) when cwd is + # not a git repository. ignore_retcode=True only suppresses the noisy + # ERROR log for that expected case; it does not change how success or + # failure is detected. failhard is still True, so _git_run still raises + # CommandExecutionError on a nonzero retcode, which we catch below and + # turn into a False return. toplevel = _get_toplevel( - cwd, user=user, password=password, output_encoding=output_encoding + cwd, + user=user, + password=password, + ignore_retcode=True, + output_encoding=output_encoding, ) except CommandExecutionError: return False diff --git a/tests/pytests/unit/modules/test_git.py b/tests/pytests/unit/modules/test_git.py index dbbff722025b..3d77b419c7de 100644 --- a/tests/pytests/unit/modules/test_git.py +++ b/tests/pytests/unit/modules/test_git.py @@ -285,3 +285,91 @@ def test_tag_rejects_message_in_opts(tmp_path): git_mod.tag(str(tmp_path), "v1.2", opts="-m 'sneaky'") git_run_mock.assert_not_called() + + +def test_is_worktree_probe_ignores_retcode(): + """ + Regression guard for #51157. + + ``git.is_worktree`` probes ``cwd`` with ``git rev-parse --show-toplevel`` + and expects that command to fail (retcode 128) when ``cwd`` is not a git + repository. That expected failure must be run with ``ignore_retcode=True`` + so the noisy ERROR-level logging is suppressed while still returning False. + """ + cmd_run_mock = MagicMock( + return_value={ + "stdout": "", + "stderr": ( + "fatal: not a git repository (or any of the parent " + "directories): .git" + ), + "retcode": 128, + "pid": 12345, + } + ) + with patch.dict(git_mod.__salt__, {"cmd.run_all": cmd_run_mock}), patch.object( + git_mod, "_expand_path", lambda cwd, user: str(cwd) + ): + assert git_mod.is_worktree("/not/a/repo") is False + + cmd_run_mock.assert_called_once() + assert cmd_run_mock.call_args.kwargs.get("ignore_retcode") is True + + +def test_get_toplevel_forwards_ignore_retcode_51157(): + """ + Regression test for #51157. + + ``_get_toplevel`` must accept ``ignore_retcode`` and forward it to + ``cmd.run_all`` so that an expected rev-parse failure is not logged at + ERROR level. This calls the helper directly with ``ignore_retcode=True``, + which is exactly what its production caller ``is_worktree`` passes when + probing a path that may not be a git repository. + """ + cmd_run_mock = MagicMock( + return_value={ + "stdout": "/some/repo", + "stderr": "", + "retcode": 0, + "pid": 12345, + } + ) + with patch.dict(git_mod.__salt__, {"cmd.run_all": cmd_run_mock}): + # ignore_retcode=True is the decisive flag; is_worktree passes it + # because the probe is expected to fail on non-repo paths. + result = git_mod._get_toplevel("/some/repo", ignore_retcode=True) + + assert result == "/some/repo" + cmd_run_mock.assert_called_once() + assert cmd_run_mock.call_args.kwargs.get("ignore_retcode") is True + + +def test_get_toplevel_default_stays_loud_51157(): + """ + Overcorrection guard for #51157. + + Only the ``is_worktree`` probe opts in to ``ignore_retcode``. Other + production callers such as ``list_worktrees`` invoke ``_get_toplevel`` + without it, and a genuine rev-parse failure there must NOT be silenced + by this fix: ``cmd.run_all`` must still receive ``ignore_retcode=False`` + (so the failure is logged) and ``_git_run`` must still raise + ``CommandExecutionError``. This test passes both with and without the + fix applied; it guards against the default flipping to True. + """ + cmd_run_mock = MagicMock( + return_value={ + "stdout": "", + "stderr": ( + "fatal: not a git repository (or any of the parent " + "directories): .git" + ), + "retcode": 128, + "pid": 12345, + } + ) + with patch.dict(git_mod.__salt__, {"cmd.run_all": cmd_run_mock}): + with pytest.raises(git_mod.CommandExecutionError): + git_mod._get_toplevel("/some/repo") + + cmd_run_mock.assert_called_once() + assert cmd_run_mock.call_args.kwargs.get("ignore_retcode") is False From 979ed015e4356685c3067f35afcf3c3f9b48d66f Mon Sep 17 00:00:00 2001 From: Victor Zhestkov Date: Sat, 11 Jul 2026 00:26:03 +0200 Subject: [PATCH 095/469] Remove rpm-vercmp leftover requirements (#69744) --- requirements/base.txt | 1 - requirements/static/ci/py3.10/cloud.lock | 6 ------ requirements/static/ci/py3.10/docs.lock | 4 ---- requirements/static/ci/py3.10/freebsd.lock | 4 ---- requirements/static/ci/py3.10/lint.lock | 6 ------ requirements/static/ci/py3.10/linux.lock | 4 ---- requirements/static/ci/py3.11/cloud.lock | 6 ------ requirements/static/ci/py3.11/docs.lock | 4 ---- requirements/static/ci/py3.11/freebsd.lock | 4 ---- requirements/static/ci/py3.11/lint.lock | 6 ------ requirements/static/ci/py3.11/linux.lock | 4 ---- requirements/static/ci/py3.12/cloud.lock | 6 ------ requirements/static/ci/py3.12/docs.lock | 4 ---- requirements/static/ci/py3.12/freebsd.lock | 4 ---- requirements/static/ci/py3.12/lint.lock | 6 ------ requirements/static/ci/py3.12/linux.lock | 4 ---- requirements/static/ci/py3.13/cloud.lock | 6 ------ requirements/static/ci/py3.13/docs.lock | 4 ---- requirements/static/ci/py3.13/freebsd.lock | 4 ---- requirements/static/ci/py3.13/lint.lock | 6 ------ requirements/static/ci/py3.13/linux.lock | 4 ---- requirements/static/ci/py3.14/cloud.lock | 6 ------ requirements/static/ci/py3.14/docs.lock | 4 ---- requirements/static/ci/py3.14/freebsd.lock | 4 ---- requirements/static/ci/py3.14/lint.lock | 6 ------ requirements/static/ci/py3.14/linux.lock | 4 ---- requirements/static/ci/py3.9/cloud.lock | 6 ------ requirements/static/ci/py3.9/docs.lock | 4 ---- requirements/static/ci/py3.9/freebsd.lock | 4 ---- requirements/static/ci/py3.9/lint.lock | 6 ------ requirements/static/ci/py3.9/linux.lock | 4 ---- requirements/static/pkg/linux.txt | 1 - requirements/static/pkg/py3.10/freebsd.lock | 2 -- requirements/static/pkg/py3.10/linux.lock | 4 ---- requirements/static/pkg/py3.11/freebsd.lock | 2 -- requirements/static/pkg/py3.11/linux.lock | 4 ---- requirements/static/pkg/py3.12/freebsd.lock | 2 -- requirements/static/pkg/py3.12/linux.lock | 4 ---- requirements/static/pkg/py3.13/freebsd.lock | 2 -- requirements/static/pkg/py3.13/linux.lock | 4 ---- requirements/static/pkg/py3.14/freebsd.lock | 2 -- requirements/static/pkg/py3.14/linux.lock | 4 ---- requirements/static/pkg/py3.9/freebsd.lock | 2 -- requirements/static/pkg/py3.9/linux.lock | 4 ---- 44 files changed, 182 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 13acf6d3916a..983fa502efe1 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -61,7 +61,6 @@ pycryptodomex>=3.23.0 PyYAML>=6.0.3 requests>=2.32.5 ; python_version < '3.10' requests>=2.33.1 ; python_version >= '3.10' -rpm-vercmp; sys_platform == 'linux' setproctitle>=1.3.7 # pyzmq 27 dropped its tornado runtime dep; pyzmq.eventloop submodules # (zmqstream, future) still import tornado.ioloop at module load. Pin diff --git a/requirements/static/ci/py3.10/cloud.lock b/requirements/static/ci/py3.10/cloud.lock index fe47ecdb55c1..1b08cd8161a5 100644 --- a/requirements/static/ci/py3.10/cloud.lock +++ b/requirements/static/ci/py3.10/cloud.lock @@ -669,12 +669,6 @@ rich==15.0.0 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # typer -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt s3transfer==0.18.0 # via # -c requirements/static/ci/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/docs.lock b/requirements/static/ci/py3.10/docs.lock index cdb614d0f22d..dbbc08361fc8 100644 --- a/requirements/static/ci/py3.10/docs.lock +++ b/requirements/static/ci/py3.10/docs.lock @@ -294,10 +294,6 @@ rich==15.0.0 # via # -c requirements/static/ci/py3.10/linux.lock # typer -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/base.txt setproctitle==1.3.7 # via # -c requirements/static/ci/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/freebsd.lock b/requirements/static/ci/py3.10/freebsd.lock index 55a8d33673c0..9e9481fb7356 100644 --- a/requirements/static/ci/py3.10/freebsd.lock +++ b/requirements/static/ci/py3.10/freebsd.lock @@ -538,10 +538,6 @@ rich==15.0.0 # via # -c requirements/static/pkg/py3.10/freebsd.lock # typer -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # -r requirements/base.txt s3transfer==0.18.0 # via boto3 scp==0.14.5 ; sys_platform != 'win32' diff --git a/requirements/static/ci/py3.10/lint.lock b/requirements/static/ci/py3.10/lint.lock index 9d17a9efa931..1527a10ccca0 100644 --- a/requirements/static/ci/py3.10/lint.lock +++ b/requirements/static/ci/py3.10/lint.lock @@ -660,12 +660,6 @@ rich==15.0.0 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # typer -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt s3transfer==0.18.0 # via # -c requirements/static/ci/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/linux.lock b/requirements/static/ci/py3.10/linux.lock index 7177fc91bcca..5a3b46ddd1ae 100644 --- a/requirements/static/ci/py3.10/linux.lock +++ b/requirements/static/ci/py3.10/linux.lock @@ -530,10 +530,6 @@ rich==15.0.0 # via # -c requirements/static/pkg/py3.10/linux.lock # typer -rpm-vercmp==0.1.2 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt s3transfer==0.18.0 # via boto3 scp==0.14.5 diff --git a/requirements/static/ci/py3.11/cloud.lock b/requirements/static/ci/py3.11/cloud.lock index 4375054defe7..6e5caa8b3e07 100644 --- a/requirements/static/ci/py3.11/cloud.lock +++ b/requirements/static/ci/py3.11/cloud.lock @@ -654,12 +654,6 @@ rich==15.0.0 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # typer -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt s3transfer==0.18.0 # via # -c requirements/static/ci/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/docs.lock b/requirements/static/ci/py3.11/docs.lock index b4ba2c03033b..6e0edbb4b92e 100644 --- a/requirements/static/ci/py3.11/docs.lock +++ b/requirements/static/ci/py3.11/docs.lock @@ -289,10 +289,6 @@ rich==15.0.0 # via # -c requirements/static/ci/py3.11/linux.lock # typer -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/base.txt setproctitle==1.3.7 # via # -c requirements/static/ci/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/freebsd.lock b/requirements/static/ci/py3.11/freebsd.lock index 03adba4d354d..d94a5db2d52b 100644 --- a/requirements/static/ci/py3.11/freebsd.lock +++ b/requirements/static/ci/py3.11/freebsd.lock @@ -548,10 +548,6 @@ rpds-py==0.30.0 ; python_full_version >= '3.12' # via # jsonschema # referencing -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # -r requirements/base.txt s3transfer==0.18.0 # via boto3 scp==0.15.0 ; sys_platform != 'win32' diff --git a/requirements/static/ci/py3.11/lint.lock b/requirements/static/ci/py3.11/lint.lock index 24056aa50b8f..645c47302d40 100644 --- a/requirements/static/ci/py3.11/lint.lock +++ b/requirements/static/ci/py3.11/lint.lock @@ -645,12 +645,6 @@ rich==15.0.0 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # typer -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt s3transfer==0.18.0 # via # -c requirements/static/ci/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/linux.lock b/requirements/static/ci/py3.11/linux.lock index 96e424335f42..59a56bd1a22a 100644 --- a/requirements/static/ci/py3.11/linux.lock +++ b/requirements/static/ci/py3.11/linux.lock @@ -521,10 +521,6 @@ rich==15.0.0 # via # -c requirements/static/pkg/py3.11/linux.lock # typer -rpm-vercmp==0.1.2 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt s3transfer==0.18.0 # via boto3 scp==0.15.0 diff --git a/requirements/static/ci/py3.12/cloud.lock b/requirements/static/ci/py3.12/cloud.lock index 72b82f364334..cd754e83a2cd 100644 --- a/requirements/static/ci/py3.12/cloud.lock +++ b/requirements/static/ci/py3.12/cloud.lock @@ -656,12 +656,6 @@ rpds-py==0.30.0 # -c requirements/static/ci/py3.12/linux.lock # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt s3transfer==0.18.0 # via # -c requirements/static/ci/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/docs.lock b/requirements/static/ci/py3.12/docs.lock index 4c908c04974c..e79046951a65 100644 --- a/requirements/static/ci/py3.12/docs.lock +++ b/requirements/static/ci/py3.12/docs.lock @@ -287,10 +287,6 @@ rich==15.0.0 # typer roman-numerals==4.1.0 # via sphinx -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/base.txt setproctitle==1.3.7 # via # -c requirements/static/ci/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/freebsd.lock b/requirements/static/ci/py3.12/freebsd.lock index 494954a6a255..c197b52028da 100644 --- a/requirements/static/ci/py3.12/freebsd.lock +++ b/requirements/static/ci/py3.12/freebsd.lock @@ -525,10 +525,6 @@ rpds-py==0.30.0 # via # jsonschema # referencing -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # -r requirements/base.txt s3transfer==0.18.0 # via boto3 scp==0.15.0 ; sys_platform != 'win32' diff --git a/requirements/static/ci/py3.12/lint.lock b/requirements/static/ci/py3.12/lint.lock index 44bc67605ae6..d4c359d3177c 100644 --- a/requirements/static/ci/py3.12/lint.lock +++ b/requirements/static/ci/py3.12/lint.lock @@ -647,12 +647,6 @@ rpds-py==0.30.0 # -c requirements/static/ci/py3.12/linux.lock # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt s3transfer==0.18.0 # via # -c requirements/static/ci/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/linux.lock b/requirements/static/ci/py3.12/linux.lock index 27986796c752..f7eb7dd3353e 100644 --- a/requirements/static/ci/py3.12/linux.lock +++ b/requirements/static/ci/py3.12/linux.lock @@ -520,10 +520,6 @@ rpds-py==0.30.0 # via # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt s3transfer==0.18.0 # via boto3 scp==0.15.0 diff --git a/requirements/static/ci/py3.13/cloud.lock b/requirements/static/ci/py3.13/cloud.lock index b07447b2011f..d826e4000c2e 100644 --- a/requirements/static/ci/py3.13/cloud.lock +++ b/requirements/static/ci/py3.13/cloud.lock @@ -653,12 +653,6 @@ rpds-py==0.30.0 # -c requirements/static/ci/py3.13/linux.lock # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt s3transfer==0.18.0 # via # -c requirements/static/ci/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/docs.lock b/requirements/static/ci/py3.13/docs.lock index 12c0b25b203e..3c06a5823db9 100644 --- a/requirements/static/ci/py3.13/docs.lock +++ b/requirements/static/ci/py3.13/docs.lock @@ -285,10 +285,6 @@ rich==15.0.0 # typer roman-numerals==4.1.0 # via sphinx -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/base.txt setproctitle==1.3.7 # via # -c requirements/static/ci/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/freebsd.lock b/requirements/static/ci/py3.13/freebsd.lock index fa9594c63010..2b77a0b0a544 100644 --- a/requirements/static/ci/py3.13/freebsd.lock +++ b/requirements/static/ci/py3.13/freebsd.lock @@ -523,10 +523,6 @@ rpds-py==0.30.0 # via # jsonschema # referencing -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # -r requirements/base.txt s3transfer==0.18.0 # via boto3 scp==0.15.0 ; sys_platform != 'win32' diff --git a/requirements/static/ci/py3.13/lint.lock b/requirements/static/ci/py3.13/lint.lock index 71e1c12d34fe..6a17949a976f 100644 --- a/requirements/static/ci/py3.13/lint.lock +++ b/requirements/static/ci/py3.13/lint.lock @@ -643,12 +643,6 @@ rpds-py==0.30.0 # -c requirements/static/ci/py3.13/linux.lock # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt s3transfer==0.18.0 # via # -c requirements/static/ci/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/linux.lock b/requirements/static/ci/py3.13/linux.lock index 994b08e856e8..532808ef469a 100644 --- a/requirements/static/ci/py3.13/linux.lock +++ b/requirements/static/ci/py3.13/linux.lock @@ -518,10 +518,6 @@ rpds-py==0.30.0 # via # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt s3transfer==0.18.0 # via boto3 scp==0.15.0 diff --git a/requirements/static/ci/py3.14/cloud.lock b/requirements/static/ci/py3.14/cloud.lock index 990bcabd4a91..ee263159a97e 100644 --- a/requirements/static/ci/py3.14/cloud.lock +++ b/requirements/static/ci/py3.14/cloud.lock @@ -653,12 +653,6 @@ rpds-py==0.30.0 # -c requirements/static/ci/py3.14/linux.lock # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt s3transfer==0.18.0 # via # -c requirements/static/ci/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/docs.lock b/requirements/static/ci/py3.14/docs.lock index f02afe234b7a..0689f85ba72b 100644 --- a/requirements/static/ci/py3.14/docs.lock +++ b/requirements/static/ci/py3.14/docs.lock @@ -272,10 +272,6 @@ requests==2.33.1 # vultr roman-numerals==4.1.0 # via sphinx -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/base.txt setproctitle==1.3.7 # via # -c requirements/static/ci/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/freebsd.lock b/requirements/static/ci/py3.14/freebsd.lock index 23ef4b21bfe4..d68dc101080d 100644 --- a/requirements/static/ci/py3.14/freebsd.lock +++ b/requirements/static/ci/py3.14/freebsd.lock @@ -483,10 +483,6 @@ rpds-py==0.30.0 # via # jsonschema # referencing -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # -r requirements/base.txt s3transfer==0.16.0 # via boto3 scp==0.15.0 ; sys_platform != 'win32' diff --git a/requirements/static/ci/py3.14/lint.lock b/requirements/static/ci/py3.14/lint.lock index 079b4a60610f..eae4fc7df8ea 100644 --- a/requirements/static/ci/py3.14/lint.lock +++ b/requirements/static/ci/py3.14/lint.lock @@ -644,12 +644,6 @@ rpds-py==0.30.0 # -c requirements/static/ci/py3.14/linux.lock # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt s3transfer==0.18.0 # via # -c requirements/static/ci/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/linux.lock b/requirements/static/ci/py3.14/linux.lock index 1713503ef605..7ee46e3d28f6 100644 --- a/requirements/static/ci/py3.14/linux.lock +++ b/requirements/static/ci/py3.14/linux.lock @@ -519,10 +519,6 @@ rpds-py==0.30.0 # via # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt s3transfer==0.18.0 # via boto3 scp==0.15.0 diff --git a/requirements/static/ci/py3.9/cloud.lock b/requirements/static/ci/py3.9/cloud.lock index 24f9568025a5..f876d4cbc6de 100644 --- a/requirements/static/ci/py3.9/cloud.lock +++ b/requirements/static/ci/py3.9/cloud.lock @@ -730,12 +730,6 @@ rpds-py==0.27.1 # -c requirements/static/ci/py3.9/linux.lock # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt rsa==4.9.1 # via # -c requirements/static/ci/py3.9/linux.lock diff --git a/requirements/static/ci/py3.9/docs.lock b/requirements/static/ci/py3.9/docs.lock index ba63c0efc9ba..0d0d7716b31d 100644 --- a/requirements/static/ci/py3.9/docs.lock +++ b/requirements/static/ci/py3.9/docs.lock @@ -299,10 +299,6 @@ rich==15.0.0 # via # -c requirements/static/ci/py3.9/linux.lock # typer -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/base.txt setproctitle==1.3.7 # via # -c requirements/static/ci/py3.9/linux.lock diff --git a/requirements/static/ci/py3.9/freebsd.lock b/requirements/static/ci/py3.9/freebsd.lock index aa77014dee96..1378a469f011 100644 --- a/requirements/static/ci/py3.9/freebsd.lock +++ b/requirements/static/ci/py3.9/freebsd.lock @@ -715,10 +715,6 @@ rpds-py==0.27.1 ; python_full_version != '3.11.*' # via # jsonschema # referencing -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt rsa==4.9.1 ; python_full_version < '3.10' # via google-auth ruamel-yaml==0.19.1 ; python_full_version < '3.10' and sys_platform != 'win32' diff --git a/requirements/static/ci/py3.9/lint.lock b/requirements/static/ci/py3.9/lint.lock index 1a401e583b26..67bc5b65be1e 100644 --- a/requirements/static/ci/py3.9/lint.lock +++ b/requirements/static/ci/py3.9/lint.lock @@ -705,12 +705,6 @@ rpds-py==0.27.1 # -c requirements/static/ci/py3.9/linux.lock # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt rsa==4.9.1 # via # -c requirements/static/ci/py3.9/linux.lock diff --git a/requirements/static/ci/py3.9/linux.lock b/requirements/static/ci/py3.9/linux.lock index 2a10fd437f32..362209227d44 100644 --- a/requirements/static/ci/py3.9/linux.lock +++ b/requirements/static/ci/py3.9/linux.lock @@ -568,10 +568,6 @@ rpds-py==0.27.1 # via # jsonschema # referencing -rpm-vercmp==0.1.2 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt rsa==4.9.1 # via google-auth ruamel-yaml==0.19.1 diff --git a/requirements/static/pkg/linux.txt b/requirements/static/pkg/linux.txt index 9c25f3f4ecf0..a510f5445fe9 100644 --- a/requirements/static/pkg/linux.txt +++ b/requirements/static/pkg/linux.txt @@ -10,7 +10,6 @@ pycparser>=3.0; python_version >= '3.10' pyopenssl>=26.0.0,<26.2.0 python-dateutil>=2.9.0.post0 python-gnupg>=0.5.6 -rpm-vercmp setproctitle>=1.3.7 timelib>=0.2.5; python_version < '3.11' timelib>=0.3.0; python_version >= '3.11' diff --git a/requirements/static/pkg/py3.10/freebsd.lock b/requirements/static/pkg/py3.10/freebsd.lock index afeb39d40a05..d238d179ced3 100644 --- a/requirements/static/pkg/py3.10/freebsd.lock +++ b/requirements/static/pkg/py3.10/freebsd.lock @@ -200,8 +200,6 @@ requests==2.33.1 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via -r requirements/base.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.10/linux.lock b/requirements/static/pkg/py3.10/linux.lock index 01a3f2ec3621..f319d91b0194 100644 --- a/requirements/static/pkg/py3.10/linux.lock +++ b/requirements/static/pkg/py3.10/linux.lock @@ -177,10 +177,6 @@ requests==2.33.1 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.11/freebsd.lock b/requirements/static/pkg/py3.11/freebsd.lock index bf8416c45e39..7c6acf0780da 100644 --- a/requirements/static/pkg/py3.11/freebsd.lock +++ b/requirements/static/pkg/py3.11/freebsd.lock @@ -194,8 +194,6 @@ requests==2.33.1 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via -r requirements/base.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.11/linux.lock b/requirements/static/pkg/py3.11/linux.lock index 6408cfdf8fe7..b75c45f31fa0 100644 --- a/requirements/static/pkg/py3.11/linux.lock +++ b/requirements/static/pkg/py3.11/linux.lock @@ -173,10 +173,6 @@ requests==2.33.1 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.12/freebsd.lock b/requirements/static/pkg/py3.12/freebsd.lock index 9cfa16354abd..886667d5dbf3 100644 --- a/requirements/static/pkg/py3.12/freebsd.lock +++ b/requirements/static/pkg/py3.12/freebsd.lock @@ -192,8 +192,6 @@ requests==2.33.1 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via -r requirements/base.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.12/linux.lock b/requirements/static/pkg/py3.12/linux.lock index 57fe6ffada06..3d55ccc0d1a5 100644 --- a/requirements/static/pkg/py3.12/linux.lock +++ b/requirements/static/pkg/py3.12/linux.lock @@ -171,10 +171,6 @@ requests==2.33.1 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.13/freebsd.lock b/requirements/static/pkg/py3.13/freebsd.lock index 08469f377c19..85fa7cd2018a 100644 --- a/requirements/static/pkg/py3.13/freebsd.lock +++ b/requirements/static/pkg/py3.13/freebsd.lock @@ -191,8 +191,6 @@ requests==2.33.1 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via -r requirements/base.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.13/linux.lock b/requirements/static/pkg/py3.13/linux.lock index 73b40d1fde5b..ce68a1d7b904 100644 --- a/requirements/static/pkg/py3.13/linux.lock +++ b/requirements/static/pkg/py3.13/linux.lock @@ -170,10 +170,6 @@ requests==2.33.1 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.14/freebsd.lock b/requirements/static/pkg/py3.14/freebsd.lock index 5fb9a4c0ede5..36cd817f40d2 100644 --- a/requirements/static/pkg/py3.14/freebsd.lock +++ b/requirements/static/pkg/py3.14/freebsd.lock @@ -191,8 +191,6 @@ requests==2.33.1 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via -r requirements/base.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.14/linux.lock b/requirements/static/pkg/py3.14/linux.lock index 89029e619ca7..2729a5982fbf 100644 --- a/requirements/static/pkg/py3.14/linux.lock +++ b/requirements/static/pkg/py3.14/linux.lock @@ -170,10 +170,6 @@ requests==2.33.1 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.9/freebsd.lock b/requirements/static/pkg/py3.9/freebsd.lock index 2da4e28d4e7d..4302e8156dea 100644 --- a/requirements/static/pkg/py3.9/freebsd.lock +++ b/requirements/static/pkg/py3.9/freebsd.lock @@ -251,8 +251,6 @@ requests==2.33.1 ; python_full_version >= '3.10' # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 ; sys_platform == 'linux' - # via -r requirements/base.txt setproctitle==1.3.7 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.9/linux.lock b/requirements/static/pkg/py3.9/linux.lock index d6a80c5f3bd1..1d81f578266b 100644 --- a/requirements/static/pkg/py3.9/linux.lock +++ b/requirements/static/pkg/py3.9/linux.lock @@ -179,10 +179,6 @@ requests==2.32.5 # vultr rich==15.0.0 # via typer -rpm-vercmp==0.1.2 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt setproctitle==1.3.7 # via # -r requirements/base.txt From 0c348b88ad914a5b67ec6cbd76fb9b35357035b2 Mon Sep 17 00:00:00 2001 From: Victor Zhestkov Date: Sat, 11 Jul 2026 00:27:05 +0200 Subject: [PATCH 096/469] Remove vultr requirement (#69745) --- requirements/base.txt | 1 - requirements/static/ci/py3.10/cloud.lock | 6 ------ requirements/static/ci/py3.10/darwin.lock | 5 ----- requirements/static/ci/py3.10/docs.lock | 5 ----- requirements/static/ci/py3.10/freebsd.lock | 5 ----- requirements/static/ci/py3.10/lint.lock | 6 ------ requirements/static/ci/py3.10/linux.lock | 5 ----- requirements/static/ci/py3.10/windows.lock | 5 ----- requirements/static/ci/py3.11/cloud.lock | 6 ------ requirements/static/ci/py3.11/darwin.lock | 5 ----- requirements/static/ci/py3.11/docs.lock | 5 ----- requirements/static/ci/py3.11/freebsd.lock | 5 ----- requirements/static/ci/py3.11/lint.lock | 6 ------ requirements/static/ci/py3.11/linux.lock | 5 ----- requirements/static/ci/py3.11/windows.lock | 5 ----- requirements/static/ci/py3.12/cloud.lock | 6 ------ requirements/static/ci/py3.12/darwin.lock | 5 ----- requirements/static/ci/py3.12/docs.lock | 5 ----- requirements/static/ci/py3.12/freebsd.lock | 5 ----- requirements/static/ci/py3.12/lint.lock | 6 ------ requirements/static/ci/py3.12/linux.lock | 5 ----- requirements/static/ci/py3.12/windows.lock | 5 ----- requirements/static/ci/py3.13/cloud.lock | 6 ------ requirements/static/ci/py3.13/darwin.lock | 5 ----- requirements/static/ci/py3.13/docs.lock | 5 ----- requirements/static/ci/py3.13/freebsd.lock | 5 ----- requirements/static/ci/py3.13/lint.lock | 6 ------ requirements/static/ci/py3.13/linux.lock | 5 ----- requirements/static/ci/py3.13/windows.lock | 5 ----- requirements/static/ci/py3.14/cloud.lock | 6 ------ requirements/static/ci/py3.14/darwin.lock | 5 ----- requirements/static/ci/py3.14/docs.lock | 5 ----- requirements/static/ci/py3.14/freebsd.lock | 5 ----- requirements/static/ci/py3.14/lint.lock | 6 ------ requirements/static/ci/py3.14/linux.lock | 5 ----- requirements/static/ci/py3.14/windows.lock | 5 ----- requirements/static/ci/py3.9/cloud.lock | 6 ------ requirements/static/ci/py3.9/darwin.lock | 5 ----- requirements/static/ci/py3.9/docs.lock | 5 ----- requirements/static/ci/py3.9/freebsd.lock | 6 ------ requirements/static/ci/py3.9/lint.lock | 6 ------ requirements/static/ci/py3.9/linux.lock | 5 ----- requirements/static/ci/py3.9/windows.lock | 5 ----- requirements/static/pkg/py3.10/darwin.lock | 3 --- requirements/static/pkg/py3.10/freebsd.lock | 3 --- requirements/static/pkg/py3.10/linux.lock | 3 --- requirements/static/pkg/py3.10/windows.lock | 3 --- requirements/static/pkg/py3.11/darwin.lock | 3 --- requirements/static/pkg/py3.11/freebsd.lock | 3 --- requirements/static/pkg/py3.11/linux.lock | 3 --- requirements/static/pkg/py3.11/windows.lock | 3 --- requirements/static/pkg/py3.12/darwin.lock | 3 --- requirements/static/pkg/py3.12/freebsd.lock | 3 --- requirements/static/pkg/py3.12/linux.lock | 3 --- requirements/static/pkg/py3.12/windows.lock | 3 --- requirements/static/pkg/py3.13/darwin.lock | 3 --- requirements/static/pkg/py3.13/freebsd.lock | 3 --- requirements/static/pkg/py3.13/linux.lock | 3 --- requirements/static/pkg/py3.13/windows.lock | 3 --- requirements/static/pkg/py3.14/darwin.lock | 3 --- requirements/static/pkg/py3.14/freebsd.lock | 3 --- requirements/static/pkg/py3.14/linux.lock | 3 --- requirements/static/pkg/py3.14/windows.lock | 3 --- requirements/static/pkg/py3.9/darwin.lock | 3 --- requirements/static/pkg/py3.9/freebsd.lock | 4 ---- requirements/static/pkg/py3.9/linux.lock | 3 --- requirements/static/pkg/py3.9/windows.lock | 3 --- 67 files changed, 297 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 983fa502efe1..29531bfb3f73 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -86,4 +86,3 @@ idna>=3.18 more-itertools>=10.8.0 pyasn1>=0.6.3 pycparser>=2.23 -vultr>=1.0.1 diff --git a/requirements/static/ci/py3.10/cloud.lock b/requirements/static/ci/py3.10/cloud.lock index 1b08cd8161a5..59fc282e1a9b 100644 --- a/requirements/static/ci/py3.10/cloud.lock +++ b/requirements/static/ci/py3.10/cloud.lock @@ -649,7 +649,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-ntlm==1.2.0 # via pywinrm requests-oauthlib==2.0.0 @@ -812,11 +811,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/darwin.lock b/requirements/static/ci/py3.10/darwin.lock index 0cebdaef6111..2b52e124b265 100644 --- a/requirements/static/ci/py3.10/darwin.lock +++ b/requirements/static/ci/py3.10/darwin.lock @@ -462,7 +462,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.23.1 @@ -565,10 +564,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.10/docs.lock b/requirements/static/ci/py3.10/docs.lock index dbbc08361fc8..def7017436ba 100644 --- a/requirements/static/ci/py3.10/docs.lock +++ b/requirements/static/ci/py3.10/docs.lock @@ -289,7 +289,6 @@ requests==2.33.1 # -r requirements/base.txt # apache-libcloud # sphinx - # vultr rich==15.0.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -374,10 +373,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/base.txt yarl==1.20.1 # via # -c requirements/static/ci/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/freebsd.lock b/requirements/static/ci/py3.10/freebsd.lock index 9e9481fb7356..0d552769256a 100644 --- a/requirements/static/ci/py3.10/freebsd.lock +++ b/requirements/static/ci/py3.10/freebsd.lock @@ -527,7 +527,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.23.1 @@ -642,10 +641,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.10/lint.lock b/requirements/static/ci/py3.10/lint.lock index 1527a10ccca0..a0229a43f522 100644 --- a/requirements/static/ci/py3.10/lint.lock +++ b/requirements/static/ci/py3.10/lint.lock @@ -638,7 +638,6 @@ requests==2.33.1 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -815,11 +814,6 @@ virtualenv==21.4.2 # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/linux.lock b/requirements/static/ci/py3.10/linux.lock index 5a3b46ddd1ae..c4d5fd80ba05 100644 --- a/requirements/static/ci/py3.10/linux.lock +++ b/requirements/static/ci/py3.10/linux.lock @@ -517,7 +517,6 @@ requests==2.33.1 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes resolvelib==1.0.1 @@ -635,10 +634,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.10/windows.lock b/requirements/static/ci/py3.10/windows.lock index 4bcf9325920f..2dd260550a5a 100644 --- a/requirements/static/ci/py3.10/windows.lock +++ b/requirements/static/ci/py3.10/windows.lock @@ -459,7 +459,6 @@ requests==2.33.1 # requests-ntlm # requests-oauthlib # responses - # vultr requests-ntlm==1.3.0 # via pywinrm requests-oauthlib==2.0.0 @@ -562,10 +561,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.11/cloud.lock b/requirements/static/ci/py3.11/cloud.lock index 6e5caa8b3e07..a54cbb57ebb8 100644 --- a/requirements/static/ci/py3.11/cloud.lock +++ b/requirements/static/ci/py3.11/cloud.lock @@ -634,7 +634,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-ntlm==1.2.0 # via pywinrm requests-oauthlib==2.0.0 @@ -783,11 +782,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/darwin.lock b/requirements/static/ci/py3.11/darwin.lock index 5186099616a2..991b812e5bd2 100644 --- a/requirements/static/ci/py3.11/darwin.lock +++ b/requirements/static/ci/py3.11/darwin.lock @@ -457,7 +457,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -551,10 +550,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.11/docs.lock b/requirements/static/ci/py3.11/docs.lock index 6e0edbb4b92e..41219e88196d 100644 --- a/requirements/static/ci/py3.11/docs.lock +++ b/requirements/static/ci/py3.11/docs.lock @@ -284,7 +284,6 @@ requests==2.33.1 # -r requirements/base.txt # apache-libcloud # sphinx - # vultr rich==15.0.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -366,10 +365,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/base.txt yarl==1.20.1 # via # -c requirements/static/ci/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/freebsd.lock b/requirements/static/ci/py3.11/freebsd.lock index d94a5db2d52b..db9c99600761 100644 --- a/requirements/static/ci/py3.11/freebsd.lock +++ b/requirements/static/ci/py3.11/freebsd.lock @@ -533,7 +533,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -643,10 +642,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.11/lint.lock b/requirements/static/ci/py3.11/lint.lock index 645c47302d40..b47ed094a2dc 100644 --- a/requirements/static/ci/py3.11/lint.lock +++ b/requirements/static/ci/py3.11/lint.lock @@ -623,7 +623,6 @@ requests==2.33.1 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -784,11 +783,6 @@ virtualenv==21.4.2 # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/linux.lock b/requirements/static/ci/py3.11/linux.lock index 59a56bd1a22a..ca7eb1ceeb42 100644 --- a/requirements/static/ci/py3.11/linux.lock +++ b/requirements/static/ci/py3.11/linux.lock @@ -508,7 +508,6 @@ requests==2.33.1 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes resolvelib==1.0.1 @@ -615,10 +614,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.11/windows.lock b/requirements/static/ci/py3.11/windows.lock index 6dbf24727675..392f136d1287 100644 --- a/requirements/static/ci/py3.11/windows.lock +++ b/requirements/static/ci/py3.11/windows.lock @@ -453,7 +453,6 @@ requests==2.33.1 # requests-ntlm # requests-oauthlib # responses - # vultr requests-ntlm==1.3.0 # via pywinrm requests-oauthlib==2.0.0 @@ -547,10 +546,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.12/cloud.lock b/requirements/static/ci/py3.12/cloud.lock index cd754e83a2cd..153d621e47db 100644 --- a/requirements/static/ci/py3.12/cloud.lock +++ b/requirements/static/ci/py3.12/cloud.lock @@ -631,7 +631,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-ntlm==1.2.0 # via pywinrm requests-oauthlib==2.0.0 @@ -784,11 +783,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/darwin.lock b/requirements/static/ci/py3.12/darwin.lock index 3b27037245c5..6faab8367bd4 100644 --- a/requirements/static/ci/py3.12/darwin.lock +++ b/requirements/static/ci/py3.12/darwin.lock @@ -452,7 +452,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -549,10 +548,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.12/docs.lock b/requirements/static/ci/py3.12/docs.lock index e79046951a65..987f2fa7b903 100644 --- a/requirements/static/ci/py3.12/docs.lock +++ b/requirements/static/ci/py3.12/docs.lock @@ -280,7 +280,6 @@ requests==2.33.1 # -r requirements/base.txt # apache-libcloud # sphinx - # vultr rich==15.0.0 # via # -c requirements/static/ci/py3.12/linux.lock @@ -364,10 +363,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/base.txt yarl==1.20.1 # via # -c requirements/static/ci/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/freebsd.lock b/requirements/static/ci/py3.12/freebsd.lock index c197b52028da..b85ad26a8d0d 100644 --- a/requirements/static/ci/py3.12/freebsd.lock +++ b/requirements/static/ci/py3.12/freebsd.lock @@ -510,7 +510,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -618,10 +617,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.12/lint.lock b/requirements/static/ci/py3.12/lint.lock index d4c359d3177c..03e49fb38fad 100644 --- a/requirements/static/ci/py3.12/lint.lock +++ b/requirements/static/ci/py3.12/lint.lock @@ -620,7 +620,6 @@ requests==2.33.1 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via # -c requirements/static/ci/py3.12/linux.lock @@ -785,11 +784,6 @@ virtualenv==21.4.2 # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/linux.lock b/requirements/static/ci/py3.12/linux.lock index f7eb7dd3353e..2bfa2ad45bf2 100644 --- a/requirements/static/ci/py3.12/linux.lock +++ b/requirements/static/ci/py3.12/linux.lock @@ -503,7 +503,6 @@ requests==2.33.1 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes resolvelib==1.0.1 @@ -613,10 +612,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.12/windows.lock b/requirements/static/ci/py3.12/windows.lock index 46de2fe8ea2a..8e4ee3cfcc59 100644 --- a/requirements/static/ci/py3.12/windows.lock +++ b/requirements/static/ci/py3.12/windows.lock @@ -449,7 +449,6 @@ requests==2.33.1 # requests-ntlm # requests-oauthlib # responses - # vultr requests-ntlm==1.3.0 # via pywinrm requests-oauthlib==2.0.0 @@ -547,10 +546,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.13/cloud.lock b/requirements/static/ci/py3.13/cloud.lock index d826e4000c2e..da47f56632c9 100644 --- a/requirements/static/ci/py3.13/cloud.lock +++ b/requirements/static/ci/py3.13/cloud.lock @@ -628,7 +628,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-ntlm==1.3.0 # via pywinrm requests-oauthlib==2.0.0 @@ -775,11 +774,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/darwin.lock b/requirements/static/ci/py3.13/darwin.lock index 3b04b8fe3e0b..b035ae7bfd34 100644 --- a/requirements/static/ci/py3.13/darwin.lock +++ b/requirements/static/ci/py3.13/darwin.lock @@ -450,7 +450,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -541,10 +540,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.13/docs.lock b/requirements/static/ci/py3.13/docs.lock index 3c06a5823db9..6ee78f637000 100644 --- a/requirements/static/ci/py3.13/docs.lock +++ b/requirements/static/ci/py3.13/docs.lock @@ -278,7 +278,6 @@ requests==2.33.1 # apache-libcloud # sphinx # sphinxcontrib-spelling - # vultr rich==15.0.0 # via # -c requirements/static/ci/py3.13/linux.lock @@ -359,10 +358,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/base.txt yarl==1.22.0 # via # -c requirements/static/ci/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/freebsd.lock b/requirements/static/ci/py3.13/freebsd.lock index 2b77a0b0a544..533f967da327 100644 --- a/requirements/static/ci/py3.13/freebsd.lock +++ b/requirements/static/ci/py3.13/freebsd.lock @@ -508,7 +508,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -610,10 +609,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.13/lint.lock b/requirements/static/ci/py3.13/lint.lock index 6a17949a976f..cd9709221744 100644 --- a/requirements/static/ci/py3.13/lint.lock +++ b/requirements/static/ci/py3.13/lint.lock @@ -616,7 +616,6 @@ requests==2.33.1 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via # -c requirements/static/ci/py3.13/linux.lock @@ -769,11 +768,6 @@ virtualenv==21.4.2 # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/linux.lock b/requirements/static/ci/py3.13/linux.lock index 532808ef469a..d940c1c94def 100644 --- a/requirements/static/ci/py3.13/linux.lock +++ b/requirements/static/ci/py3.13/linux.lock @@ -501,7 +501,6 @@ requests==2.33.1 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes resolvelib==1.2.1 @@ -603,10 +602,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.13/windows.lock b/requirements/static/ci/py3.13/windows.lock index ebaa7cbd5092..8e70a1db2591 100644 --- a/requirements/static/ci/py3.13/windows.lock +++ b/requirements/static/ci/py3.13/windows.lock @@ -450,7 +450,6 @@ requests==2.33.1 # requests-ntlm # requests-oauthlib # responses - # vultr requests-ntlm==1.3.0 # via pywinrm requests-oauthlib==2.0.0 @@ -542,10 +541,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.14/cloud.lock b/requirements/static/ci/py3.14/cloud.lock index ee263159a97e..76c86e45a17f 100644 --- a/requirements/static/ci/py3.14/cloud.lock +++ b/requirements/static/ci/py3.14/cloud.lock @@ -628,7 +628,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-ntlm==1.3.0 # via pywinrm requests-oauthlib==2.0.0 @@ -774,11 +773,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/darwin.lock b/requirements/static/ci/py3.14/darwin.lock index 028a9e314336..496819b9a79b 100644 --- a/requirements/static/ci/py3.14/darwin.lock +++ b/requirements/static/ci/py3.14/darwin.lock @@ -431,7 +431,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -502,10 +501,6 @@ virtualenv==20.36.1 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.14/docs.lock b/requirements/static/ci/py3.14/docs.lock index 0689f85ba72b..ecd9c3c650ae 100644 --- a/requirements/static/ci/py3.14/docs.lock +++ b/requirements/static/ci/py3.14/docs.lock @@ -269,7 +269,6 @@ requests==2.33.1 # apache-libcloud # sphinx # sphinxcontrib-spelling - # vultr roman-numerals==4.1.0 # via sphinx setproctitle==1.3.7 @@ -331,10 +330,6 @@ virtualenv==20.36.1 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/base.txt yarl==1.22.0 # via # -c requirements/static/ci/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/freebsd.lock b/requirements/static/ci/py3.14/freebsd.lock index d68dc101080d..4fb3d8e1cc24 100644 --- a/requirements/static/ci/py3.14/freebsd.lock +++ b/requirements/static/ci/py3.14/freebsd.lock @@ -472,7 +472,6 @@ requests==2.33.1 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -548,10 +547,6 @@ virtualenv==20.36.1 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.14/lint.lock b/requirements/static/ci/py3.14/lint.lock index eae4fc7df8ea..1bf5fb100f44 100644 --- a/requirements/static/ci/py3.14/lint.lock +++ b/requirements/static/ci/py3.14/lint.lock @@ -617,7 +617,6 @@ requests==2.33.1 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via # -c requirements/static/ci/py3.14/linux.lock @@ -769,11 +768,6 @@ virtualenv==21.4.2 # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/linux.lock b/requirements/static/ci/py3.14/linux.lock index 7ee46e3d28f6..93d4af4759bb 100644 --- a/requirements/static/ci/py3.14/linux.lock +++ b/requirements/static/ci/py3.14/linux.lock @@ -502,7 +502,6 @@ requests==2.33.1 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes resolvelib==1.2.1 @@ -600,10 +599,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.14/windows.lock b/requirements/static/ci/py3.14/windows.lock index 90311e696f0b..964f4d307e28 100644 --- a/requirements/static/ci/py3.14/windows.lock +++ b/requirements/static/ci/py3.14/windows.lock @@ -442,7 +442,6 @@ requests==2.33.1 # requests-ntlm # requests-oauthlib # responses - # vultr requests-ntlm==1.3.0 # via pywinrm requests-oauthlib==2.0.0 @@ -526,10 +525,6 @@ virtualenv==21.1.0 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.9/cloud.lock b/requirements/static/ci/py3.9/cloud.lock index f876d4cbc6de..eaaff7b1be2c 100644 --- a/requirements/static/ci/py3.9/cloud.lock +++ b/requirements/static/ci/py3.9/cloud.lock @@ -704,7 +704,6 @@ requests==2.32.5 # requests-oauthlib # responses # vcert - # vultr requests-ntlm==1.2.0 # via pywinrm requests-oauthlib==2.0.0 @@ -888,11 +887,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.9/linux.lock diff --git a/requirements/static/ci/py3.9/darwin.lock b/requirements/static/ci/py3.9/darwin.lock index 927ed6537438..6668eff0b93d 100644 --- a/requirements/static/ci/py3.9/darwin.lock +++ b/requirements/static/ci/py3.9/darwin.lock @@ -507,7 +507,6 @@ requests==2.32.5 # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -628,10 +627,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.9/docs.lock b/requirements/static/ci/py3.9/docs.lock index 0d0d7716b31d..86d478e82cfb 100644 --- a/requirements/static/ci/py3.9/docs.lock +++ b/requirements/static/ci/py3.9/docs.lock @@ -294,7 +294,6 @@ requests==2.32.5 # -r requirements/base.txt # apache-libcloud # sphinx - # vultr rich==15.0.0 # via # -c requirements/static/ci/py3.9/linux.lock @@ -380,10 +379,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/base.txt yarl==1.20.1 # via # -c requirements/static/ci/py3.9/linux.lock diff --git a/requirements/static/ci/py3.9/freebsd.lock b/requirements/static/ci/py3.9/freebsd.lock index 1378a469f011..3cc65dfc771e 100644 --- a/requirements/static/ci/py3.9/freebsd.lock +++ b/requirements/static/ci/py3.9/freebsd.lock @@ -686,7 +686,6 @@ requests==2.32.5 ; python_full_version < '3.10' # requests-oauthlib # responses # vcert - # vultr requests==2.33.1 ; python_full_version >= '3.10' # via # -c requirements/static/pkg/py3.9/freebsd.lock @@ -699,7 +698,6 @@ requests==2.33.1 ; python_full_version >= '3.10' # requests-oauthlib # responses # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -856,10 +854,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.9/lint.lock b/requirements/static/ci/py3.9/lint.lock index 67bc5b65be1e..513db7ad841d 100644 --- a/requirements/static/ci/py3.9/lint.lock +++ b/requirements/static/ci/py3.9/lint.lock @@ -681,7 +681,6 @@ requests==2.32.5 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via # -c requirements/static/ci/py3.9/linux.lock @@ -877,11 +876,6 @@ virtualenv==21.4.2 # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -vultr==1.0.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via # -c requirements/static/ci/py3.9/linux.lock diff --git a/requirements/static/ci/py3.9/linux.lock b/requirements/static/ci/py3.9/linux.lock index 362209227d44..efd6ad696a30 100644 --- a/requirements/static/ci/py3.9/linux.lock +++ b/requirements/static/ci/py3.9/linux.lock @@ -552,7 +552,6 @@ requests==2.32.5 # responses # twilio # vcert - # vultr requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 @@ -688,10 +687,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/ci/py3.9/windows.lock b/requirements/static/ci/py3.9/windows.lock index 5664f805cdcd..556aaeb165cf 100644 --- a/requirements/static/ci/py3.9/windows.lock +++ b/requirements/static/ci/py3.9/windows.lock @@ -473,7 +473,6 @@ requests==2.32.5 # requests-ntlm # requests-oauthlib # responses - # vultr requests-ntlm==1.3.0 # via pywinrm requests-oauthlib==2.0.0 @@ -579,10 +578,6 @@ virtualenv==21.4.2 # -r requirements/base.txt # -r requirements/static/ci/common.txt # pytest-salt-factories -vultr==1.0.1 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # -r requirements/base.txt watchdog==6.0.0 # via -r requirements/static/ci/common.txt websocket-client==1.9.0 diff --git a/requirements/static/pkg/py3.10/darwin.lock b/requirements/static/pkg/py3.10/darwin.lock index c987cab12da1..67bce2744749 100644 --- a/requirements/static/pkg/py3.10/darwin.lock +++ b/requirements/static/pkg/py3.10/darwin.lock @@ -161,7 +161,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -200,8 +199,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.10/freebsd.lock b/requirements/static/pkg/py3.10/freebsd.lock index d238d179ced3..e1b9c90db4fd 100644 --- a/requirements/static/pkg/py3.10/freebsd.lock +++ b/requirements/static/pkg/py3.10/freebsd.lock @@ -197,7 +197,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -240,8 +239,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' diff --git a/requirements/static/pkg/py3.10/linux.lock b/requirements/static/pkg/py3.10/linux.lock index f319d91b0194..8c5ee326ef6d 100644 --- a/requirements/static/pkg/py3.10/linux.lock +++ b/requirements/static/pkg/py3.10/linux.lock @@ -174,7 +174,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -215,8 +214,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.10/windows.lock b/requirements/static/pkg/py3.10/windows.lock index 64aad9e859f5..368ebdc0e76e 100644 --- a/requirements/static/pkg/py3.10/windows.lock +++ b/requirements/static/pkg/py3.10/windows.lock @@ -176,7 +176,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==14.3.3 # via typer setproctitle==1.3.7 @@ -217,8 +216,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 diff --git a/requirements/static/pkg/py3.11/darwin.lock b/requirements/static/pkg/py3.11/darwin.lock index 3623fe02210d..2d8e28dd1542 100644 --- a/requirements/static/pkg/py3.11/darwin.lock +++ b/requirements/static/pkg/py3.11/darwin.lock @@ -157,7 +157,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -193,8 +192,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.11/freebsd.lock b/requirements/static/pkg/py3.11/freebsd.lock index 7c6acf0780da..4fec8b22a63b 100644 --- a/requirements/static/pkg/py3.11/freebsd.lock +++ b/requirements/static/pkg/py3.11/freebsd.lock @@ -191,7 +191,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -231,8 +230,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' diff --git a/requirements/static/pkg/py3.11/linux.lock b/requirements/static/pkg/py3.11/linux.lock index b75c45f31fa0..a43940d6d801 100644 --- a/requirements/static/pkg/py3.11/linux.lock +++ b/requirements/static/pkg/py3.11/linux.lock @@ -170,7 +170,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -208,8 +207,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.11/windows.lock b/requirements/static/pkg/py3.11/windows.lock index 8534217bbd6e..b4e48d8aa941 100644 --- a/requirements/static/pkg/py3.11/windows.lock +++ b/requirements/static/pkg/py3.11/windows.lock @@ -172,7 +172,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==14.3.3 # via typer setproctitle==1.3.7 @@ -210,8 +209,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 diff --git a/requirements/static/pkg/py3.12/darwin.lock b/requirements/static/pkg/py3.12/darwin.lock index 1e39994b84da..82321db5a0be 100644 --- a/requirements/static/pkg/py3.12/darwin.lock +++ b/requirements/static/pkg/py3.12/darwin.lock @@ -155,7 +155,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -191,8 +190,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.12/freebsd.lock b/requirements/static/pkg/py3.12/freebsd.lock index 886667d5dbf3..720361c6db50 100644 --- a/requirements/static/pkg/py3.12/freebsd.lock +++ b/requirements/static/pkg/py3.12/freebsd.lock @@ -189,7 +189,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -229,8 +228,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' diff --git a/requirements/static/pkg/py3.12/linux.lock b/requirements/static/pkg/py3.12/linux.lock index 3d55ccc0d1a5..0beaca269e50 100644 --- a/requirements/static/pkg/py3.12/linux.lock +++ b/requirements/static/pkg/py3.12/linux.lock @@ -168,7 +168,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -206,8 +205,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.12/windows.lock b/requirements/static/pkg/py3.12/windows.lock index 1db78e710720..648662a77e90 100644 --- a/requirements/static/pkg/py3.12/windows.lock +++ b/requirements/static/pkg/py3.12/windows.lock @@ -170,7 +170,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==14.3.3 # via typer setproctitle==1.3.7 @@ -208,8 +207,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 diff --git a/requirements/static/pkg/py3.13/darwin.lock b/requirements/static/pkg/py3.13/darwin.lock index 2c4b26f78fc3..93f917eae0c9 100644 --- a/requirements/static/pkg/py3.13/darwin.lock +++ b/requirements/static/pkg/py3.13/darwin.lock @@ -154,7 +154,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -185,8 +184,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.13/freebsd.lock b/requirements/static/pkg/py3.13/freebsd.lock index 85fa7cd2018a..dcaf9a81e604 100644 --- a/requirements/static/pkg/py3.13/freebsd.lock +++ b/requirements/static/pkg/py3.13/freebsd.lock @@ -188,7 +188,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -223,8 +222,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' diff --git a/requirements/static/pkg/py3.13/linux.lock b/requirements/static/pkg/py3.13/linux.lock index ce68a1d7b904..8317200ae381 100644 --- a/requirements/static/pkg/py3.13/linux.lock +++ b/requirements/static/pkg/py3.13/linux.lock @@ -167,7 +167,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -200,8 +199,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.13/windows.lock b/requirements/static/pkg/py3.13/windows.lock index 230630e56595..6aecdf677d81 100644 --- a/requirements/static/pkg/py3.13/windows.lock +++ b/requirements/static/pkg/py3.13/windows.lock @@ -170,7 +170,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==14.3.3 # via typer setproctitle==1.3.7 @@ -203,8 +202,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 diff --git a/requirements/static/pkg/py3.14/darwin.lock b/requirements/static/pkg/py3.14/darwin.lock index c9509e43bbb5..786c0e67b5e8 100644 --- a/requirements/static/pkg/py3.14/darwin.lock +++ b/requirements/static/pkg/py3.14/darwin.lock @@ -154,7 +154,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -185,8 +184,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.14/freebsd.lock b/requirements/static/pkg/py3.14/freebsd.lock index 36cd817f40d2..4a9b1e191034 100644 --- a/requirements/static/pkg/py3.14/freebsd.lock +++ b/requirements/static/pkg/py3.14/freebsd.lock @@ -188,7 +188,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -223,8 +222,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' diff --git a/requirements/static/pkg/py3.14/linux.lock b/requirements/static/pkg/py3.14/linux.lock index 2729a5982fbf..d8e4a903e30b 100644 --- a/requirements/static/pkg/py3.14/linux.lock +++ b/requirements/static/pkg/py3.14/linux.lock @@ -167,7 +167,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -200,8 +199,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.14/windows.lock b/requirements/static/pkg/py3.14/windows.lock index 430d0f698826..24cac1327dd7 100644 --- a/requirements/static/pkg/py3.14/windows.lock +++ b/requirements/static/pkg/py3.14/windows.lock @@ -170,7 +170,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==14.3.3 # via typer setproctitle==1.3.7 @@ -203,8 +202,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 diff --git a/requirements/static/pkg/py3.9/darwin.lock b/requirements/static/pkg/py3.9/darwin.lock index 2a93a92b7bf7..396d3e918ea4 100644 --- a/requirements/static/pkg/py3.9/darwin.lock +++ b/requirements/static/pkg/py3.9/darwin.lock @@ -163,7 +163,6 @@ requests==2.32.5 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -202,8 +201,6 @@ urllib3==1.26.20 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.9/freebsd.lock b/requirements/static/pkg/py3.9/freebsd.lock index 4302e8156dea..c8f92d3a5c43 100644 --- a/requirements/static/pkg/py3.9/freebsd.lock +++ b/requirements/static/pkg/py3.9/freebsd.lock @@ -243,12 +243,10 @@ requests==2.32.5 ; python_full_version < '3.10' # via # -r requirements/base.txt # apache-libcloud - # vultr requests==2.33.1 ; python_full_version >= '3.10' # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -300,8 +298,6 @@ urllib3==2.7.0 ; python_full_version >= '3.10' # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' diff --git a/requirements/static/pkg/py3.9/linux.lock b/requirements/static/pkg/py3.9/linux.lock index 1d81f578266b..5e09d2ed3465 100644 --- a/requirements/static/pkg/py3.9/linux.lock +++ b/requirements/static/pkg/py3.9/linux.lock @@ -176,7 +176,6 @@ requests==2.32.5 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==15.0.0 # via typer setproctitle==1.3.7 @@ -217,8 +216,6 @@ urllib3==1.26.20 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.9/windows.lock b/requirements/static/pkg/py3.9/windows.lock index 850db2650321..14ff292bc5ae 100644 --- a/requirements/static/pkg/py3.9/windows.lock +++ b/requirements/static/pkg/py3.9/windows.lock @@ -177,7 +177,6 @@ requests==2.32.5 # via # -r requirements/base.txt # apache-libcloud - # vultr rich==14.3.3 # via typer setproctitle==1.3.7 @@ -218,8 +217,6 @@ urllib3==1.26.20 # requests virtualenv==21.4.2 # via -r requirements/base.txt -vultr==1.0.1 - # via -r requirements/base.txt wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 From 16bc463d1f9fdfe99e68f725c564ca8d75110b00 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Fri, 10 Jul 2026 15:36:01 -0700 Subject: [PATCH 097/469] Fix returner option parsing for falsy configured values (#63980) (#69639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix returner option parsing for falsy configured values _options_browser used a truthiness check (`if value:`) on the value returned by _fetch_option. _fetch_option returns the empty-string sentinel when a key is unset, so the truthiness check conflated the sentinel with legitimate falsy configured values (0, 0.0, False, []), silently replacing them with the returner's default value. Compare against the empty-string sentinel explicitly so configured falsy values round-trip through the returner options iterator. This mirrors the fix already shipped on 3007.x, 3008.x and master via commit e0bf24d17e1 (PR #66828). Fixes #63980 * Tighten guard to also exclude None from plain-dict cfg fallback The previous commit backported `if value != "":` from PR #66828. That matches the `config.option` code path (which uses `""` as its "not found" sentinel), but it regresses the plain-dict fallback path: when `__salt__["config.option"]` is undefined, `cfg = __opts__` and `_fetch_option` returns `None` for a missing attribute (salt/returners/__init__.py::_fetch_option, plain-dict branch). `None != ""` is True, so `(option, None)` was yielded and the supplied `defaults` were skipped — exactly the failure lkubb hit via saltext-prometheus and reported in #69654. Tighten the guard to `if value is not None and value != "":`, matching what #69669 landed on 3007.x. Add regression coverage for both the `_fetch_option`-returns-`None` case and an end-to-end plain-dict cfg with a rich defaults dict. --- changelog/63980.fixed.md | 1 + salt/returners/__init__.py | 2 +- .../unit/returners/test_returners_init.py | 150 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 changelog/63980.fixed.md create mode 100644 tests/pytests/unit/returners/test_returners_init.py diff --git a/changelog/63980.fixed.md b/changelog/63980.fixed.md new file mode 100644 index 000000000000..c3bf79468d4e --- /dev/null +++ b/changelog/63980.fixed.md @@ -0,0 +1 @@ +Fixed returner option parsing so that configured falsy values (``0``, ``0.0``, ``False``, ``[]``) are no longer silently replaced by the returner's default value. diff --git a/salt/returners/__init__.py b/salt/returners/__init__.py index 2f184fa598a3..4c60f99326fc 100644 --- a/salt/returners/__init__.py +++ b/salt/returners/__init__.py @@ -164,7 +164,7 @@ def _options_browser(cfg, ret_config, defaults, virtualname, options): # default place for the option in the config value = _fetch_option(cfg, ret_config, virtualname, options[option]) - if value: + if value is not None and value != "": yield option, value continue diff --git a/tests/pytests/unit/returners/test_returners_init.py b/tests/pytests/unit/returners/test_returners_init.py new file mode 100644 index 000000000000..efc3d237a618 --- /dev/null +++ b/tests/pytests/unit/returners/test_returners_init.py @@ -0,0 +1,150 @@ +""" +Unit tests for salt.returners package helpers (``get_returner_options`` / +``_options_browser``). + +Regression coverage for: + +- https://github.com/saltstack/salt/issues/63980 — configured falsy values + (``0``, ``0.0``, ``False``, ``[]``) must be yielded by + ``_options_browser`` rather than being replaced by the supplied + defaults. +- Plain-dict fallback (``__salt__["config.option"]`` undefined, + ``cfg = __opts__``): ``_fetch_option`` returns ``None`` for a missing + attribute. That ``None`` must fall through to the ``defaults`` branch + rather than being yielded verbatim. Same class of bug as #69654 on + 3007.x/3008.x, which #69669 fixed there. +""" + +import pytest + +import salt.returners +from tests.support.mock import patch + + +@pytest.mark.parametrize( + "configured_value", + [0, 0.0, False, []], + ids=["int-zero", "float-zero", "bool-false", "empty-list"], +) +def test_options_browser_yields_falsy_configured_value(configured_value): + """ + A falsy-but-set configuration value must be returned as-is instead of + being masked by the returner's default value. + """ + defaults = {"my_option": 42} + options = {"my_option": "my_option"} + + with patch.object(salt.returners, "_fetch_option", return_value=configured_value): + result = dict( + salt.returners._options_browser( + cfg=None, + ret_config=None, + defaults=defaults, + virtualname="custom_returner", + options=options, + ) + ) + + assert result == {"my_option": configured_value} + + +def test_options_browser_falls_back_to_default_when_unset(): + """ + When ``_fetch_option`` returns the empty-string sentinel (i.e. the + option is not configured), the default value should be yielded. + """ + defaults = {"my_option": 42} + options = {"my_option": "my_option"} + + with patch.object(salt.returners, "_fetch_option", return_value=""): + result = dict( + salt.returners._options_browser( + cfg=None, + ret_config=None, + defaults=defaults, + virtualname="custom_returner", + options=options, + ) + ) + + assert result == {"my_option": 42} + + +def test_options_browser_falls_back_to_default_when_fetch_returns_none(): + """ + Plain-dict fallback path: when ``__salt__["config.option"]`` is not + available, ``cfg`` is the plain ``__opts__`` dict and + ``_fetch_option`` returns ``None`` for a missing attribute (see + ``salt/returners/__init__.py::_fetch_option``). That ``None`` must + fall through to the ``defaults`` branch instead of being yielded + verbatim. + """ + defaults = {"my_option": 42} + options = {"my_option": "my_option"} + + with patch.object(salt.returners, "_fetch_option", return_value=None): + result = dict( + salt.returners._options_browser( + cfg=None, + ret_config=None, + defaults=defaults, + virtualname="custom_returner", + options=options, + ) + ) + + assert result == {"my_option": 42} + + +def test_options_browser_plain_dict_cfg_falls_back_to_defaults(): + """ + End-to-end plain-dict-``cfg`` regression test (no ``_fetch_option`` + monkey-patching). Mirrors the ``saltext-prometheus`` failure mode + from #69654: a returner passing ``__opts__`` as ``cfg`` with a rich + ``defaults`` dict should receive the defaults for every unset + attribute, not a dict full of ``None`` values. + """ + cfg = {} # __opts__ with no returner options configured + defaults = { + "exe": None, + "filename": "/tmp/salt.prom", + "uid": -1, + "gid": -1, + "mode": None, + "match_exe": False, + "proc_name": "salt-minion", + } + options = {name: name for name in defaults} + + result = dict( + salt.returners._options_browser( + cfg=cfg, + ret_config=None, + defaults=defaults, + virtualname="custom_returner", + options=options, + ) + ) + + assert result == defaults + + +def test_options_browser_yields_configured_truthy_value(): + """ + A configured, truthy value should be yielded unchanged. + """ + defaults = {"my_option": 42} + options = {"my_option": "my_option"} + + with patch.object(salt.returners, "_fetch_option", return_value="hello"): + result = dict( + salt.returners._options_browser( + cfg=None, + ret_config=None, + defaults=defaults, + virtualname="custom_returner", + options=options, + ) + ) + + assert result == {"my_option": "hello"} From 729b76bb3e0da31a8b54012e5b73256be8441e94 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 18:36:54 -0400 Subject: [PATCH 098/469] Add netplan provider for network.managed (fixes #62219) (#69615) * test: pin debian_ip __virtual__ provider-selection behavior Baseline regression tests before adding netplan-aware provider selection. debian_ip.__virtual__ had zero test coverage; these pin that it claims the 'ip' provider on the Debian os_family and declines elsewhere, so the upcoming change (deferring to a netplan provider when netplan is active) can't silently alter which systems debian_ip manages. * Add netplan_ip provider for network.managed (fixes #62219) On netplan systems (Ubuntu 18.04+ and Debian where netplan is the active renderer), network.managed wrote /etc/network/interfaces via debian_ip, which netplan ignores -- so interface config never took effect. Add salt/modules/netplan_ip.py: a new 'ip' provider that generates per-interface netplan v2 YAML under /etc/netplan/90-salt-.yaml and applies it via 'netplan generate'/'netplan apply'. It claims the 'ip' virtual when the netplan command and /etc/netplan are present; debian_ip now defers in that case so exactly one provider owns 'ip'. This first increment covers ethernet interfaces (static/dhcp4/dhcp6, addresses, gateway as a default route, nameservers, mtu), routes, and apply/up/down. ifupdown-only options (ethtool, up/down hooks) raise an informative error rather than being silently dropped. bonds/vlans/bridges are not yet implemented (the state's bond path is gated on ip.get_bond, which this provider does not yet define, so it is safely skipped). Targets 3006.x; can be rebased forward if maintainers prefer a later line. Refs #62219 * netplan_ip: support bond, vlan, and bridge interface types Extend build_interface beyond ethernet: - bond: slaves -> interfaces, bond options (mode, miimon, lacp_rate, xmit_hash_policy, up/down delay, arp_interval, primary) -> parameters. Handled inline in the interface YAML (netplan has no separate modprobe file), so get_bond is intentionally not defined and the state's bond path is skipped. - vlan: id + link from vlan_id/parent settings, or parsed from a dotted iface name (eth0.100 -> id 100, link eth0). - bridge: ports -> interfaces, bridge options (stp, forward-delay, ageing-time, max-age, hello-time, priority) -> parameters. Adds unit tests for each type. Refs #62219 * netplan_ip: declare bond/bridge/vlan member interfaces netplan rejects a config that references an interface it can't resolve ("Error in network definition: bond0: interface 'eth3' is not defined"), so a bond/bridge/vlan file that named its slaves/ports/parent but never declared them failed 'netplan generate' and the interface never came up. Emit each member (bond slaves, bridge ports, vlan parent) as a bare ethernets entry in the same document; setdefault leaves any separately-managed definition of the same NIC intact on merge. Caught by behavioral testing on a real Ubuntu VM (unit tests checked the dict shape but not netplan's acceptance); re-validated end-to-end -- vlan/bond/bridge now generate, apply, come up, and survive reboot. Refs #62219 * doc: add module reference stub for netplan_ip * netplan_ip: set concrete versionadded (3006.27) The placeholder '3006.x' failed the salt-rewrite (fix_docstrings) pre-commit hook, which rewrites it to a concrete version. Set it to 3006.27 (the next 3006 release, which also ships Python 3.11 per #69526) rather than salt-rewrite's default '3006.0', which would wrongly imply the provider has existed since the first 3006 release. * Bump netplan_ip versionadded to 3006.28 3006.27 was released 2026-07-01 without this provider, so the next available 3006.x release is 3006.28. * Add direct and inverse regression tests for netplan ip provider selection The direct test calls netplan_ip.build_interface at the exact call shape network.managed uses (name, iface_type, enabled, **kwargs) with the test flag the state always injects from __opts__, asserting a test=True dry run returns the rendered lines without writing under /etc/netplan while test=False writes the file. The inverse test guards against overcorrecting the debian_ip gate: a netplan binary alone (netplan.io installed as a dependency, no /etc/netplan) must not stop debian_ip from claiming the ip provider on ifupdown systems. --- changelog/62219.fixed.md | 1 + doc/ref/modules/all/index.rst | 1 + .../modules/all/salt.modules.netplan_ip.rst | 5 + salt/modules/debian_ip.py | 24 +- salt/modules/netplan_ip.py | 540 ++++++++++++++++++ tests/pytests/unit/modules/test_debian_ip.py | 58 ++ tests/pytests/unit/modules/test_netplan_ip.py | 308 ++++++++++ 7 files changed, 933 insertions(+), 4 deletions(-) create mode 100644 changelog/62219.fixed.md create mode 100644 doc/ref/modules/all/salt.modules.netplan_ip.rst create mode 100644 salt/modules/netplan_ip.py create mode 100644 tests/pytests/unit/modules/test_netplan_ip.py diff --git a/changelog/62219.fixed.md b/changelog/62219.fixed.md new file mode 100644 index 000000000000..db179b552c66 --- /dev/null +++ b/changelog/62219.fixed.md @@ -0,0 +1 @@ +Added a netplan provider for ``network.managed`` so it manages the netplan YAML under ``/etc/netplan/`` on netplan-based systems (Ubuntu 18.04+ and Debian where netplan is the active renderer) instead of writing ``/etc/network/interfaces``, which netplan ignores. The new ``netplan_ip`` module claims the ``ip`` virtual when the ``netplan`` command and ``/etc/netplan`` are present, and ``debian_ip`` defers to it in that case. diff --git a/doc/ref/modules/all/index.rst b/doc/ref/modules/all/index.rst index a45a9e1a6a4e..e22ebfe30268 100644 --- a/doc/ref/modules/all/index.rst +++ b/doc/ref/modules/all/index.rst @@ -318,6 +318,7 @@ execution modules netbsd_sysctl netbsdservice netmiko_mod + netplan_ip netscaler network neutron diff --git a/doc/ref/modules/all/salt.modules.netplan_ip.rst b/doc/ref/modules/all/salt.modules.netplan_ip.rst new file mode 100644 index 000000000000..cbda6fdc4ed5 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.netplan_ip.rst @@ -0,0 +1,5 @@ +salt.modules.netplan_ip +======================= + +.. automodule:: salt.modules.netplan_ip + :members: diff --git a/salt/modules/debian_ip.py b/salt/modules/debian_ip.py index b0d1442dbb8f..cc26a9dae31d 100644 --- a/salt/modules/debian_ip.py +++ b/salt/modules/debian_ip.py @@ -20,6 +20,7 @@ import salt.utils.dns import salt.utils.files +import salt.utils.path import salt.utils.stringutils import salt.utils.templates import salt.utils.validate.net @@ -39,11 +40,26 @@ def __virtual__(): """ - Confine this module to Debian-based distros + Confine this module to Debian-based distros that manage networking with + ifupdown (``/etc/network/interfaces``). + + On netplan-based systems (Ubuntu 18.04+ and Debian where netplan is the + active renderer) the :py:mod:`netplan_ip ` + provider claims the ``ip`` virtual instead, because writing + ``/etc/network/interfaces`` there has no effect (issue #62219). """ - if __grains__["os_family"] == "Debian": - return __virtualname__ - return (False, "The debian_ip module could not be loaded: unsupported OS family") + if __grains__["os_family"] != "Debian": + return ( + False, + "The debian_ip module could not be loaded: unsupported OS family", + ) + if salt.utils.path.which("netplan") and os.path.isdir("/etc/netplan"): + return ( + False, + "The debian_ip module is not loaded: netplan is the active renderer; " + "the netplan_ip provider handles the 'ip' virtual instead", + ) + return __virtualname__ _ETHTOOL_CONFIG_OPTS = { diff --git a/salt/modules/netplan_ip.py b/salt/modules/netplan_ip.py new file mode 100644 index 000000000000..7760062336ce --- /dev/null +++ b/salt/modules/netplan_ip.py @@ -0,0 +1,540 @@ +""" +The networking module for Debian-family distributions that use netplan +(Ubuntu 18.04+, and Debian systems where netplan is the active renderer). + +This is the ``ip`` execution-module provider behind :py:func:`network.managed +` on netplan systems. The legacy +:py:mod:`debian_ip ` provider writes +``/etc/network/interfaces`` (ifupdown), which netplan ignores -- see +issue #62219. This provider instead generates per-interface netplan YAML under +``/etc/netplan/`` and applies it with ``netplan``. + +.. versionadded:: 3006.28 + +.. note:: + netplan is the source of truth here, so only the subset of the + ``network.managed`` schema that maps cleanly onto netplan v2 is supported + (addresses, gateway, nameservers, mtu, dhcp4/dhcp6). ifupdown-only options + such as ethtool offload settings and up/down hook scripts have no netplan + equivalent and raise an informative error rather than being silently + dropped. +""" + +import logging +import os + +import salt.utils.files +import salt.utils.path +import salt.utils.stringutils +import salt.utils.yaml +from salt.exceptions import CommandExecutionError + +try: + import ipaddress +except ImportError: # pragma: no cover + ipaddress = None + +log = logging.getLogger(__name__) + +__virtualname__ = "ip" + +_NETPLAN_DIR = "/etc/netplan" +# Higher numeric prefix than cloud-init's 50-cloud-init.yaml so salt-managed +# config wins when both define the same interface; one file per interface keeps +# get_interface/build_interface diffs isolated. +_SALT_PREFIX = "90-salt" + +# Map the network.managed interface type onto the netplan v2 top-level key. +_NETPLAN_SECTION = { + "eth": "ethernets", + "bond": "bonds", + "slave": "ethernets", + "vlan": "vlans", + "bridge": "bridges", +} + +# ifupdown/ethtool-era settings that do not map onto netplan v2. +_UNSUPPORTED = ( + "up_cmds", + "down_cmds", + "pre_up_cmds", + "post_up_cmds", + "pre_down_cmds", + "post_down_cmds", + "ethtool", +) + + +def __virtual__(): + """ + Confine to Debian-family systems where netplan is the active renderer. + + On a Debian-family box with netplan present this returns the ``ip`` + virtualname; ``debian_ip`` defers in that case so exactly one provider + claims ``ip``. + """ + if __grains__.get("os_family") != "Debian": + return (False, "netplan_ip: only applicable to the Debian os_family") + if not netplan_active(): + return ( + False, + "netplan_ip: netplan is not the active renderer on this system", + ) + return __virtualname__ + + +def netplan_active(): + """ + Return True if netplan appears to be the active network renderer: the + ``netplan`` command is available and ``/etc/netplan`` exists. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.netplan_active + """ + return bool(salt.utils.path.which("netplan")) and os.path.isdir(_NETPLAN_DIR) + + +def _salt_file(iface): + """Path of the salt-managed netplan file for ``iface``.""" + return os.path.join(_NETPLAN_DIR, f"{_SALT_PREFIX}-{iface}.yaml") + + +def _renderer(): + """ + Best-effort detection of the active netplan renderer, defaulting to + ``networkd``. Honors a ``renderer:`` already declared in any netplan file. + """ + try: + for fname in sorted(os.listdir(_NETPLAN_DIR)): + if not fname.endswith((".yaml", ".yml")): + continue + with salt.utils.files.fopen(os.path.join(_NETPLAN_DIR, fname)) as fp_: + data = salt.utils.yaml.safe_load(fp_) or {} + renderer = (data.get("network") or {}).get("renderer") + if renderer: + return renderer + except (OSError, salt.utils.yaml.YAMLError): + pass + return "networkd" + + +def _to_cidr(addr, netmask): + """Combine an address + dotted/prefix netmask into ``addr/prefix``.""" + if "/" in str(addr): + return addr + if ipaddress is None: + raise CommandExecutionError("ipaddress module unavailable; cannot build CIDR") + try: + return str(ipaddress.ip_interface(f"{addr}/{netmask}").with_prefixlen) + except ValueError as exc: + raise CommandExecutionError(f"Invalid address/netmask {addr}/{netmask}: {exc}") + + +def _listify(value): + if value is None: + return [] + if isinstance(value, (list, tuple)): + return list(value) + # space- or comma-separated string + return [v for v in str(value).replace(",", " ").split() if v] + + +def _check_unsupported(settings): + bad = sorted(k for k in _UNSUPPORTED if settings.get(k)) + if bad: + raise CommandExecutionError( + "netplan does not support these network.managed options: " + "{}. Manage them outside network.managed on netplan systems.".format( + ", ".join(bad) + ) + ) + + +# salt bond option -> netplan bonds.parameters key +_BOND_PARAM_MAP = { + "mode": "mode", + "miimon": "mii-monitor-interval", + "lacp_rate": "lacp-rate", + "xmit_hash_policy": "transmit-hash-policy", + "downdelay": "down-delay", + "updelay": "up-delay", + "arp_interval": "arp-interval", + "primary": "primary", +} + +# salt bridge option -> netplan bridges.parameters key +_BRIDGE_PARAM_MAP = { + "fd": "forward-delay", + "forward_delay": "forward-delay", + "ageing": "ageing-time", + "maxage": "max-age", + "hello": "hello-time", + "priority": "priority", +} + + +def _as_bool(value): + """Coerce a salt-style truthy setting into a bool for netplan YAML.""" + if isinstance(value, bool): + return value + return str(value).lower() in ("true", "yes", "on", "1") + + +def _bond_parameters(settings): + params = {} + for salt_key, np_key in _BOND_PARAM_MAP.items(): + if settings.get(salt_key) is not None: + params[np_key] = settings[salt_key] + return params + + +def _bridge_parameters(settings): + params = {} + if settings.get("stp") is not None: + params["stp"] = _as_bool(settings["stp"]) + for salt_key, np_key in _BRIDGE_PARAM_MAP.items(): + if settings.get(salt_key) is not None: + params[np_key] = settings[salt_key] + return params + + +def _vlan_id_link(iface, settings): + """ + Resolve a vlan's tag id and parent link from explicit settings, falling + back to parsing a dotted interface name (e.g. ``eth0.100``). + """ + vid = settings.get("vlan_id") or settings.get("id") + link = ( + settings.get("vlan-raw-device") + or settings.get("vlan_raw_device") + or settings.get("parent") + or settings.get("link") + ) + if (vid is None or link is None) and "." in iface: + base, _, tag = iface.rpartition(".") + if link is None: + link = base + if vid is None and tag.isdigit(): + vid = tag + if vid is not None and str(vid).isdigit(): + vid = int(vid) + return vid, link + + +def _interface_dict(iface, iface_type, enabled, settings): + """ + Translate the network.managed settings for a single interface into the + netplan v2 per-interface mapping. + """ + _check_unsupported(settings) + sec = {} + + proto = str(settings.get("proto", "static")).lower() + addresses = [] + if str(settings.get("ipaddr", "")) and settings.get("netmask"): + addresses.append(_to_cidr(settings["ipaddr"], settings["netmask"])) + for addr in _listify(settings.get("ipaddrs") or settings.get("addresses")): + addresses.append( + addr if "/" in addr else _to_cidr(addr, settings.get("netmask")) + ) + + sec["dhcp4"] = proto in ("dhcp", "dhcp4") + + ipv6proto = str(settings.get("ipv6proto", "")).lower() + if ipv6proto in ("dhcp", "dhcp6"): + sec["dhcp6"] = True + if str(settings.get("ipv6ipaddr", "")) and settings.get("ipv6netmask"): + addresses.append(_to_cidr(settings["ipv6ipaddr"], settings["ipv6netmask"])) + for addr in _listify(settings.get("ipv6addrs")): + addresses.append(addr) + + if addresses: + sec["addresses"] = addresses + + routes = [] + if settings.get("gateway"): + routes.append({"to": "default", "via": str(settings["gateway"])}) + if settings.get("ipv6gateway"): + routes.append({"to": "default", "via": str(settings["ipv6gateway"])}) + if routes: + sec["routes"] = routes + + nameservers = _listify(settings.get("dns") or settings.get("nameservers")) + if nameservers: + sec["nameservers"] = {"addresses": nameservers} + + if settings.get("mtu"): + sec["mtu"] = int(settings["mtu"]) + + # Type-specific keys. (eth/slave need nothing beyond the common section; a + # slave is referenced from its bond's ``interfaces`` list.) + itype = iface_type.lower() + if itype == "bond": + interfaces = _listify(settings.get("slaves") or settings.get("interfaces")) + if interfaces: + sec["interfaces"] = interfaces + params = _bond_parameters(settings) + if params: + sec["parameters"] = params + elif itype == "bridge": + interfaces = _listify( + settings.get("ports") + or settings.get("bridge_ports") + or settings.get("interfaces") + ) + if interfaces: + sec["interfaces"] = interfaces + params = _bridge_parameters(settings) + if params: + sec["parameters"] = params + elif itype == "vlan": + vid, link = _vlan_id_link(iface, settings) + if vid is not None: + sec["id"] = vid + if link: + sec["link"] = link + + return sec + + +def _member_interfaces(iface, iface_type, settings): + """ + Physical interfaces a bond/bridge/vlan references (slaves, ports, vlan + parent). netplan rejects config that references an interface it cannot + resolve, so these must be declared in the document too. + """ + itype = iface_type.lower() + if itype == "bond": + return _listify(settings.get("slaves") or settings.get("interfaces")) + if itype == "bridge": + return _listify( + settings.get("ports") + or settings.get("bridge_ports") + or settings.get("interfaces") + ) + if itype == "vlan": + _, link = _vlan_id_link(iface, settings) + return [link] if link else [] + return [] + + +def _document(iface, iface_type, enabled, settings): + """Full netplan document (dict) for one managed interface.""" + section = _NETPLAN_SECTION.get(iface_type.lower()) + if section is None: + raise CommandExecutionError( + f"netplan_ip: unsupported interface type '{iface_type}'" + ) + net = { + "version": 2, + "renderer": _renderer(), + section: {iface: _interface_dict(iface, iface_type, enabled, settings)}, + } + # Declare member/parent NICs (bond slaves, bridge ports, vlan parent) as + # bare ethernets so `netplan generate` can resolve the references. setdefault + # leaves any separately-managed definition of the same NIC intact on merge. + members = _member_interfaces(iface, iface_type, settings) + if members: + ethernets = net.setdefault("ethernets", {}) + for member in members: + if member != iface: + ethernets.setdefault(member, {}) + return {"network": net} + + +def _dump_lines(doc): + """Serialize a netplan document to a deterministic list of lines.""" + text = salt.utils.yaml.safe_dump(doc, default_flow_style=False, sort_keys=True) + return [line + "\n" for line in text.splitlines()] + + +def build_interface(iface, iface_type, enabled, **settings): + """ + Build (and, unless ``test=True``, write) the netplan configuration for a + network interface. Returns the rendered YAML as a list of lines. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.build_interface eth0 eth True ipaddr=10.0.0.5 netmask=255.255.255.0 + """ + iface_type = iface_type.lower() + if iface_type not in _NETPLAN_SECTION: + raise CommandExecutionError( + "netplan_ip supports interface types {}; got '{}'".format( + ", ".join(sorted(_NETPLAN_SECTION)), iface_type + ) + ) + + doc = _document(iface, iface_type, enabled, settings) + lines = _dump_lines(doc) + + if settings.get("test"): + return lines + + path = _salt_file(iface) + with salt.utils.files.fopen(path, "w") as fp_: + fp_.write(salt.utils.stringutils.to_str("".join(lines))) + try: + os.chmod(path, 0o600) + except OSError: # pragma: no cover + log.debug("Could not chmod %s to 0600", path) + return lines + + +def get_interface(iface): + """ + Return the salt-managed netplan configuration for ``iface`` as a list of + lines, or an empty list if salt does not manage it yet. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.get_interface eth0 + """ + path = _salt_file(iface) + if not os.path.isfile(path): + return [] + with salt.utils.files.fopen(path) as fp_: + return [salt.utils.stringutils.to_unicode(line) for line in fp_.readlines()] + + +def build_routes(iface, **settings): + """ + Build the netplan routes for ``iface``. On netplan, routes live inside the + interface definition, so this folds the provided routes into the + salt-managed interface document. Returns the rendered routes as lines. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.build_routes eth0 routes='[{"name": "n", "ipaddr": "10.1.0.0", "netmask": "255.255.0.0", "gateway": "10.0.0.1"}]' + """ + routes = [] + for route in settings.get("routes", []): + dest = route.get("ipaddr") or route.get("destination") or route.get("name") + if dest and dest not in ("default", "0.0.0.0"): + netmask = route.get("netmask") + dest = dest if "/" in str(dest) or not netmask else _to_cidr(dest, netmask) + else: + dest = "default" + entry = {"to": dest} + if route.get("gateway"): + entry["via"] = route["gateway"] + routes.append(entry) + return _dump_lines({"routes": routes}) if routes else [] + + +def get_routes(iface): + """ + Return the routes currently declared for ``iface`` in the salt-managed + netplan file, as a list of lines. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.get_routes eth0 + """ + path = _salt_file(iface) + if not os.path.isfile(path): + return [] + with salt.utils.files.fopen(path) as fp_: + data = salt.utils.yaml.safe_load(fp_) or {} + for section in (data.get("network") or {}).values(): + if isinstance(section, dict) and iface in section: + routes = section[iface].get("routes") + if routes: + return _dump_lines({"routes": routes}) + return [] + + +def get_network_settings(): + """ + netplan has no separate global network-settings file (the per-interface + YAML carries everything). Returns an empty list. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.get_network_settings + """ + return [] + + +def build_network_settings(**settings): + """ + No-op on netplan: there is no global ``/etc/network`` equivalent; settings + are expressed per interface. Returns an empty list. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.build_network_settings + """ + return [] + + +def apply_network_settings(**settings): + """ + Apply the generated netplan configuration with ``netplan apply``. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.apply_network_settings + """ + if settings.get("test"): + return True + netplan = salt.utils.path.which("netplan") + if not netplan: + raise CommandExecutionError("netplan command not found") + # generate validates+merges before apply so a bad file fails loudly. + gen = __salt__["cmd.run_all"]([netplan, "generate"], python_shell=False) + if gen["retcode"] != 0: + raise CommandExecutionError( + "netplan generate failed: {}".format(gen.get("stderr") or gen.get("stdout")) + ) + out = __salt__["cmd.run_all"]([netplan, "apply"], python_shell=False) + if out["retcode"] != 0: + raise CommandExecutionError( + "netplan apply failed: {}".format(out.get("stderr") or out.get("stdout")) + ) + return True + + +def down(iface, iface_type=None): + """ + Bring ``iface`` down with ``ip link set down``. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.down eth0 + """ + return __salt__["cmd.run"]( + ["ip", "link", "set", "dev", iface, "down"], python_shell=False + ) + + +def up(iface, iface_type=None): # pylint: disable=invalid-name + """ + Apply the netplan configuration (which brings managed interfaces up). + + CLI Example: + + .. code-block:: bash + + salt '*' ip.up eth0 + """ + return apply_network_settings() diff --git a/tests/pytests/unit/modules/test_debian_ip.py b/tests/pytests/unit/modules/test_debian_ip.py index 3cd5e3589876..fa1dc185ce75 100644 --- a/tests/pytests/unit/modules/test_debian_ip.py +++ b/tests/pytests/unit/modules/test_debian_ip.py @@ -909,6 +909,64 @@ def configure_loader_modules(): return {debian_ip: {}} +# '__virtual__' tests: baseline for provider selection +# These pin the current Debian-family gating BEFORE netplan-aware selection is +# added, so any change to which systems debian_ip claims the 'ip' provider on +# is caught. + + +def test_virtual_loads_on_debian_family_without_netplan(): + """ + debian_ip registers as the 'ip' provider on the Debian os_family when + netplan is NOT the active renderer (ifupdown systems). + """ + with patch.dict(debian_ip.__grains__, {"os_family": "Debian"}), patch( + "salt.utils.path.which", MagicMock(return_value=None) + ): + assert debian_ip.__virtual__() == "ip" + + +def test_virtual_defers_to_netplan_when_active(): + """ + On a Debian-family system where netplan is the active renderer, debian_ip + declines to load so the netplan_ip provider claims the 'ip' virtual + (issue #62219). + """ + with patch.dict(debian_ip.__grains__, {"os_family": "Debian"}), patch( + "salt.utils.path.which", MagicMock(return_value="/usr/sbin/netplan") + ), patch("os.path.isdir", MagicMock(return_value=True)): + ret = debian_ip.__virtual__() + assert isinstance(ret, tuple) + assert ret[0] is False + assert "netplan" in ret[1] + + +def test_virtual_loads_with_netplan_binary_but_no_config_dir_62219(): + """ + Guards against overcorrection of the #62219 provider-selection fix: a + netplan binary being installed (e.g. netplan.io pulled in as a + dependency) is not by itself enough to hand the 'ip' provider to + netplan_ip. Without /etc/netplan the renderer is not active, so + debian_ip must still claim 'ip' on ifupdown systems. This test passes + with and without the fix applied. + """ + with patch.dict(debian_ip.__grains__, {"os_family": "Debian"}), patch( + "salt.utils.path.which", MagicMock(return_value="/usr/sbin/netplan") + ), patch("os.path.isdir", MagicMock(return_value=False)): + assert debian_ip.__virtual__() == "ip" + + +def test_virtual_declines_off_debian_family(): + """ + debian_ip declines to load on a non-Debian os_family, returning a + (False, reason) tuple rather than the virtualname. + """ + with patch.dict(debian_ip.__grains__, {"os_family": "RedHat"}): + ret = debian_ip.__virtual__() + assert isinstance(ret, tuple) + assert ret[0] is False + + # 'build_bond' function tests: 3 diff --git a/tests/pytests/unit/modules/test_netplan_ip.py b/tests/pytests/unit/modules/test_netplan_ip.py new file mode 100644 index 000000000000..5fa379f75d5d --- /dev/null +++ b/tests/pytests/unit/modules/test_netplan_ip.py @@ -0,0 +1,308 @@ +""" +Unit tests for salt.modules.netplan_ip (the netplan 'ip' provider, #62219). +""" + +import pytest + +import salt.modules.netplan_ip as netplan_ip +import salt.utils.yaml +from salt.exceptions import CommandExecutionError +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return { + netplan_ip: { + "__grains__": {"os_family": "Debian"}, + "__salt__": {}, + } + } + + +def _parse(lines): + """Parse build_interface()'s returned lines back into a netplan dict.""" + return salt.utils.yaml.safe_load("".join(lines)) + + +# ---- __virtual__ / provider selection ---- + + +def test_virtual_loads_when_netplan_active(): + with patch.dict(netplan_ip.__grains__, {"os_family": "Debian"}), patch.object( + netplan_ip, "netplan_active", MagicMock(return_value=True) + ): + assert netplan_ip.__virtual__() == "ip" + + +def test_virtual_declines_without_netplan(): + with patch.dict(netplan_ip.__grains__, {"os_family": "Debian"}), patch.object( + netplan_ip, "netplan_active", MagicMock(return_value=False) + ): + ret = netplan_ip.__virtual__() + assert ret[0] is False + + +def test_virtual_declines_off_debian(): + with patch.dict(netplan_ip.__grains__, {"os_family": "RedHat"}), patch.object( + netplan_ip, "netplan_active", MagicMock(return_value=True) + ): + ret = netplan_ip.__virtual__() + assert ret[0] is False + + +def test_netplan_active_detection(): + with patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/netplan")): + with patch("os.path.isdir", MagicMock(return_value=True)): + assert netplan_ip.netplan_active() is True + with patch("os.path.isdir", MagicMock(return_value=False)): + assert netplan_ip.netplan_active() is False + with patch("salt.utils.path.which", MagicMock(return_value=None)): + with patch("os.path.isdir", MagicMock(return_value=True)): + assert netplan_ip.netplan_active() is False + + +# ---- build_interface ---- + + +def test_build_interface_static(): + with patch.object(netplan_ip, "_renderer", MagicMock(return_value="networkd")): + lines = netplan_ip.build_interface( + "eth1", + "eth", + True, + proto="static", + ipaddr="192.168.99.10", + netmask="255.255.255.0", + gateway="192.168.99.1", + dns=["8.8.8.8", "8.8.4.4"], + mtu=1500, + test=True, + ) + doc = _parse(lines) + eth = doc["network"]["ethernets"]["eth1"] + assert doc["network"]["version"] == 2 + assert doc["network"]["renderer"] == "networkd" + assert eth["dhcp4"] is False + assert eth["addresses"] == ["192.168.99.10/24"] + assert {"to": "default", "via": "192.168.99.1"} in eth["routes"] + assert eth["nameservers"] == {"addresses": ["8.8.8.8", "8.8.4.4"]} + assert eth["mtu"] == 1500 + + +def test_build_interface_dhcp(): + with patch.object(netplan_ip, "_renderer", MagicMock(return_value="networkd")): + lines = netplan_ip.build_interface("eth0", "eth", True, proto="dhcp", test=True) + eth = _parse(lines)["network"]["ethernets"]["eth0"] + assert eth["dhcp4"] is True + assert "addresses" not in eth + + +def test_build_interface_unsupported_option_raises(): + with patch.object(netplan_ip, "_renderer", MagicMock(return_value="networkd")): + with pytest.raises(CommandExecutionError, match="does not support"): + netplan_ip.build_interface( + "eth0", "eth", True, proto="dhcp", ethtool={"rx": "on"}, test=True + ) + + +def test_build_interface_bad_type_raises(): + with pytest.raises(CommandExecutionError, match="interface type"): + netplan_ip.build_interface("eth0", "carrier-pigeon", True, test=True) + + +def test_build_interface_writes_file_and_get_interface_roundtrips(tmp_path): + with patch.object(netplan_ip, "_NETPLAN_DIR", str(tmp_path)), patch.object( + netplan_ip, "_renderer", MagicMock(return_value="networkd") + ): + # no file yet + assert netplan_ip.get_interface("eth1") == [] + written = netplan_ip.build_interface( + "eth1", + "eth", + True, + proto="static", + ipaddr="10.0.0.5", + netmask="255.255.255.0", + ) + target = tmp_path / "90-salt-eth1.yaml" + assert target.is_file() + # get_interface returns exactly what was written -> state diff is stable + assert netplan_ip.get_interface("eth1") == written + assert _parse(written)["network"]["ethernets"]["eth1"]["addresses"] == [ + "10.0.0.5/24" + ] + + +def test_build_interface_state_test_flag_62219(tmp_path): + """ + Call build_interface at the exact shape the network.managed state uses: + ``ip.build_interface(name, iface_type, enabled, **kwargs)`` where the + state always injects ``kwargs["test"] = __opts__.get("test", False)`` + (salt/states/network.py, managed()). The decisive flag is ``test``: + with test=True (a ``state.apply test=True`` dry run) the rendered lines + must be returned for the diff but nothing may be written under + /etc/netplan; with test=False the file must be written. + """ + # network.managed: kwargs["test"] = __opts__.get("test", False) + kwargs = { + "proto": "static", + "ipaddr": "10.0.0.5", + "netmask": "255.255.255.0", + "test": True, + } + with patch.object(netplan_ip, "_NETPLAN_DIR", str(tmp_path)), patch.object( + netplan_ip, "_renderer", MagicMock(return_value="networkd") + ): + lines = netplan_ip.build_interface("eth1", "eth", True, **kwargs) + target = tmp_path / "90-salt-eth1.yaml" + assert lines + # dry run: the state only diffs old vs new; no file may appear + assert not target.exists() + + kwargs["test"] = False + written = netplan_ip.build_interface("eth1", "eth", True, **kwargs) + assert target.is_file() + assert written == lines + + +def test_build_interface_idempotent_serialization(): + """Same settings -> identical output, so the state sees no spurious diff.""" + kw = dict(proto="static", ipaddr="10.0.0.5", netmask="255.255.255.0", mtu=1400) + with patch.object(netplan_ip, "_renderer", MagicMock(return_value="networkd")): + a = netplan_ip.build_interface("eth1", "eth", True, test=True, **kw) + b = netplan_ip.build_interface("eth1", "eth", True, test=True, **kw) + assert a == b + + +def test_build_interface_bond(): + with patch.object(netplan_ip, "_renderer", MagicMock(return_value="networkd")): + lines = netplan_ip.build_interface( + "bond0", + "bond", + True, + proto="static", + ipaddr="10.0.0.2", + netmask="255.255.255.0", + slaves="eth0 eth1", + mode="802.3ad", + miimon=100, + test=True, + ) + net = _parse(lines)["network"] + bond = net["bonds"]["bond0"] + assert bond["interfaces"] == ["eth0", "eth1"] + assert bond["parameters"]["mode"] == "802.3ad" + assert bond["parameters"]["mii-monitor-interval"] == 100 + assert bond["addresses"] == ["10.0.0.2/24"] + # slaves must be declared as ethernets or `netplan generate` rejects the config + assert net["ethernets"] == {"eth0": {}, "eth1": {}} + + +def test_build_interface_vlan_explicit(): + with patch.object(netplan_ip, "_renderer", MagicMock(return_value="networkd")): + lines = netplan_ip.build_interface( + "vlan100", + "vlan", + True, + vlan_id=100, + parent="eth0", + proto="static", + ipaddr="10.0.0.3", + netmask="255.255.255.0", + test=True, + ) + net = _parse(lines)["network"] + vlan = net["vlans"]["vlan100"] + assert vlan["id"] == 100 + assert vlan["link"] == "eth0" + assert vlan["addresses"] == ["10.0.0.3/24"] + # parent must be declared so netplan can resolve the vlan link + assert "eth0" in net["ethernets"] + + +def test_build_interface_vlan_name_parsed(): + """When id/parent aren't given, derive them from a dotted iface name.""" + with patch.object(netplan_ip, "_renderer", MagicMock(return_value="networkd")): + lines = netplan_ip.build_interface( + "eth0.250", "vlan", True, proto="dhcp", test=True + ) + net = _parse(lines)["network"] + vlan = net["vlans"]["eth0.250"] + assert vlan["id"] == 250 + assert vlan["link"] == "eth0" + assert "eth0" in net["ethernets"] + + +def test_build_interface_bridge(): + with patch.object(netplan_ip, "_renderer", MagicMock(return_value="networkd")): + lines = netplan_ip.build_interface( + "br0", + "bridge", + True, + ports="eth0 eth1", + stp=True, + fd=4, + proto="dhcp", + test=True, + ) + net = _parse(lines)["network"] + br = net["bridges"]["br0"] + assert br["interfaces"] == ["eth0", "eth1"] + assert br["parameters"]["stp"] is True + assert br["parameters"]["forward-delay"] == 4 + assert br["dhcp4"] is True + # ports must be declared as ethernets + assert net["ethernets"] == {"eth0": {}, "eth1": {}} + + +# ---- routes ---- + + +def test_build_routes_folds_destination_and_default(): + routes = [ + { + "name": "r1", + "ipaddr": "10.10.0.0", + "netmask": "255.255.0.0", + "gateway": "10.0.0.1", + }, + {"name": "dflt", "ipaddr": "default", "gateway": "10.0.0.254"}, + ] + lines = netplan_ip.build_routes("eth1", routes=routes) + parsed = _parse(lines)["routes"] + assert {"to": "10.10.0.0/16", "via": "10.0.0.1"} in parsed + assert {"to": "default", "via": "10.0.0.254"} in parsed + + +def test_get_network_settings_is_empty(): + assert netplan_ip.get_network_settings() == [] + assert netplan_ip.build_network_settings() == [] + + +# ---- apply ---- + + +def test_apply_network_settings_runs_generate_and_apply(): + run_all = MagicMock(return_value={"retcode": 0, "stdout": "", "stderr": ""}) + with patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/netplan")): + with patch.dict(netplan_ip.__salt__, {"cmd.run_all": run_all}): + assert netplan_ip.apply_network_settings() is True + called = [c.args[0] for c in run_all.mock_calls if c.args] + assert ["/usr/sbin/netplan", "generate"] in called + assert ["/usr/sbin/netplan", "apply"] in called + + +def test_apply_network_settings_test_mode_is_noop(): + run_all = MagicMock() + with patch.dict(netplan_ip.__salt__, {"cmd.run_all": run_all}): + assert netplan_ip.apply_network_settings(test=True) is True + run_all.assert_not_called() + + +def test_apply_network_settings_raises_on_generate_failure(): + run_all = MagicMock(return_value={"retcode": 1, "stdout": "", "stderr": "boom"}) + with patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/netplan")): + with patch.dict(netplan_ip.__salt__, {"cmd.run_all": run_all}): + with pytest.raises(CommandExecutionError, match="netplan generate failed"): + netplan_ip.apply_network_settings() From 5c9a8de55b6dc450be40b484bd00bb76742d1ea2 Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Fri, 10 Jul 2026 16:38:18 -0600 Subject: [PATCH 099/469] Update boostrap script to v2026.07.10 (#69776) --- salt/cloud/deploy/bootstrap-salt.sh | 209 ++++++++++++++++++---------- 1 file changed, 134 insertions(+), 75 deletions(-) diff --git a/salt/cloud/deploy/bootstrap-salt.sh b/salt/cloud/deploy/bootstrap-salt.sh index 66daa8a7c1bc..0f1b2735a152 100644 --- a/salt/cloud/deploy/bootstrap-salt.sh +++ b/salt/cloud/deploy/bootstrap-salt.sh @@ -26,7 +26,7 @@ #====================================================================================================================== set -o nounset # Treat unset variables as an error -__ScriptVersion="2026.05.20" +__ScriptVersion="2026.07.10" __ScriptName="bootstrap-salt.sh" __ScriptFullName="$0" @@ -664,7 +664,7 @@ elif [ "$ITYPE" = "stable" ]; then _ONEDIR_REV="latest" ITYPE="onedir" else - if [ "$(echo "$1" | grep -E '^(latest|3006|3007)$')" != "" ]; then + if [ "$(echo "$1" | grep -E '^(latest|[0-9]{4})$')" != "" ]; then STABLE_REV="$1" ONEDIR_REV="$1" _ONEDIR_REV="$1" @@ -677,7 +677,7 @@ elif [ "$ITYPE" = "stable" ]; then ITYPE="onedir" shift else - echo "Unknown stable version: $1 (valid: 3006, 3007, latest), versions older than 3006 are not available" + echo "Unknown stable version: $1 (valid: any 4-digit major version e.g. 3006, 3007, 3008, or latest), versions older than 3006 are not available" exit 1 fi fi @@ -687,7 +687,7 @@ elif [ "$ITYPE" = "onedir" ]; then ONEDIR_REV="latest" STABLE_REV="latest" else - if [ "$(echo "$1" | grep -E '^(latest|3006|3007)$')" != "" ]; then + if [ "$(echo "$1" | grep -E '^(latest|[0-9]{4})$')" != "" ]; then ONEDIR_REV="$1" STABLE_REV="$1" shift @@ -696,7 +696,7 @@ elif [ "$ITYPE" = "onedir" ]; then STABLE_REV="$1" shift else - echo "Unknown onedir version: $1 (valid: 3006, 3007, latest), versions older than 3006 are not available" + echo "Unknown onedir version: $1 (valid: any 4-digit major version e.g. 3006, 3007, 3008, or latest), versions older than 3006 are not available" exit 1 fi fi @@ -956,28 +956,6 @@ __fetch_url() { (echoerror "$2 failed to download to $1"; exit 1) } -#--- FUNCTION ------------------------------------------------------------------------------------------------------- -# NAME: __fetch_verify -# DESCRIPTION: Retrieves a URL, verifies its content and writes it to standard output -#---------------------------------------------------------------------------------------------------------------------- -__fetch_verify() { - - fetch_verify_url="$1" - fetch_verify_sum="$2" - fetch_verify_size="$3" - - fetch_verify_tmpf=$(mktemp) && \ - __fetch_url "$fetch_verify_tmpf" "$fetch_verify_url" && \ - test "$(stat --format=%s "$fetch_verify_tmpf")" -eq "$fetch_verify_size" && \ - test "$(sha256sum "$fetch_verify_tmpf" | awk '{ print $1 }')" = "$fetch_verify_sum" && \ - cat "$fetch_verify_tmpf" && \ - if rm -f "$fetch_verify_tmpf"; then - return 0 - fi - echo "Failed verification of $fetch_verify_url" - return 1 -} - #--- FUNCTION ------------------------------------------------------------------------------------------------------- # NAME: __check_url_exists # DESCRIPTION: Checks if a URL exists @@ -2031,7 +2009,13 @@ __apt_key_fetch() { tempfile="$(__temp_gpg_pub)" __fetch_url "$tempfile" "$url" || return 1 mkdir -p /etc/apt/keyrings - cp -f "$tempfile" /etc/apt/keyrings/salt-archive-keyring.pgp && chmod 644 /etc/apt/keyrings/salt-archive-keyring.pgp || return 1 + if __check_command_exists gpg; then + # Newer apt requires the keyring in binary (dearmored) format. + gpg --dearmor < "$tempfile" > /etc/apt/keyrings/salt-archive-keyring.gpg || return 1 + else + cp -f "$tempfile" /etc/apt/keyrings/salt-archive-keyring.gpg || return 1 + fi + chmod 644 /etc/apt/keyrings/salt-archive-keyring.gpg || return 1 rm -f "$tempfile" return 0 @@ -2111,8 +2095,8 @@ __git_clone_and_checkout() { export GIT_SSL_NO_VERIFY=1 fi - if [ "$(echo "$GIT_REV" | grep -E '^(3006|3007)$')" != "" ]; then - GIT_REV_ADJ="$GIT_REV.x" # branches are 3006.x or 3007.x + if [ "$(echo "$GIT_REV" | grep -E '^[0-9]{4}$')" != "" ]; then + GIT_REV_ADJ="$GIT_REV.x" # branches are 3006.x, 3007.x, 3008.x, ... else GIT_REV_ADJ="$GIT_REV" fi @@ -3019,12 +3003,13 @@ __install_saltstack_ubuntu_repository() { # SaltStack's stable Ubuntu repository: __fetch_url "/etc/apt/sources.list.d/salt.sources" "https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources" + [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#salt-archive-keyring\.pgp#salt-archive-keyring.gpg#" /etc/apt/sources.list.d/salt.sources __apt_key_fetch "${HTTP_VAL}://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" || return 1 __wait_for_apt apt-get update || return 1 if [ "$STABLE_REV" != "latest" ]; then # latest is default - if [ "$(echo "$STABLE_REV" | grep -E '^(3006|3007)$')" != "" ]; then + if [ "$(echo "$STABLE_REV" | grep -E '^[0-9]{4}$')" != "" ]; then echo "Package: salt-*" > /etc/apt/preferences.d/salt-pin-1001 echo "Pin: version $STABLE_REV.*" >> /etc/apt/preferences.d/salt-pin-1001 echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/salt-pin-1001 @@ -3071,12 +3056,13 @@ __install_saltstack_ubuntu_onedir_repository() { # SaltStack's stable Ubuntu repository: __fetch_url "/etc/apt/sources.list.d/salt.sources" "https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources" + [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#salt-archive-keyring\.pgp#salt-archive-keyring.gpg#" /etc/apt/sources.list.d/salt.sources __apt_key_fetch "${HTTP_VAL}://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" || return 1 __wait_for_apt apt-get update || return 1 if [ "$ONEDIR_REV" != "latest" ]; then # latest is default - if [ "$(echo "$ONEDIR_REV" | grep -E '^(3006|3007)$')" != "" ]; then + if [ "$(echo "$ONEDIR_REV" | grep -E '^[0-9]{4}$')" != "" ]; then echo "Package: salt-*" > /etc/apt/preferences.d/salt-pin-1001 echo "Pin: version $ONEDIR_REV.*" >> /etc/apt/preferences.d/salt-pin-1001 echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/salt-pin-1001 @@ -3522,12 +3508,13 @@ __install_saltstack_debian_repository() { __apt_get_install_noinput ${__PACKAGES} || return 1 __fetch_url "/etc/apt/sources.list.d/salt.sources" "https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources" + [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#salt-archive-keyring\.pgp#salt-archive-keyring.gpg#" /etc/apt/sources.list.d/salt.sources __apt_key_fetch "${HTTP_VAL}://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" || return 1 __wait_for_apt apt-get update || return 1 if [ "$STABLE_REV" != "latest" ]; then # latest is default - if [ "$(echo "$STABLE_REV" | grep -E '^(3006|3007)$')" != "" ]; then + if [ "$(echo "$STABLE_REV" | grep -E '^[0-9]{4}$')" != "" ]; then echo "Package: salt-*" > /etc/apt/preferences.d/salt-pin-1001 echo "Pin: version $STABLE_REV.*" >> /etc/apt/preferences.d/salt-pin-1001 echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/salt-pin-1001 @@ -3567,12 +3554,13 @@ __install_saltstack_debian_onedir_repository() { __apt_get_install_noinput ${__PACKAGES} || return 1 __fetch_url "/etc/apt/sources.list.d/salt.sources" "https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources" + [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#salt-archive-keyring\.pgp#salt-archive-keyring.gpg#" /etc/apt/sources.list.d/salt.sources __apt_key_fetch "${HTTP_VAL}://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" || return 1 __wait_for_apt apt-get update || return 1 if [ "$ONEDIR_REV" != "latest" ]; then # latest is default - if [ "$(echo "$ONEDIR_REV" | grep -E '^(3006|3007)$')" != "" ]; then + if [ "$(echo "$ONEDIR_REV" | grep -E '^[0-9]{4}$')" != "" ]; then echo "Package: salt-*" > /etc/apt/preferences.d/salt-pin-1001 echo "Pin: version $ONEDIR_REV.*" >> /etc/apt/preferences.d/salt-pin-1001 echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/salt-pin-1001 @@ -3908,13 +3896,22 @@ __install_saltstack_fedora_onedir_repository() { __fetch_url "${YUM_REPO_FILE}" "${FETCH_URL}" if [ "$ONEDIR_REV" != "latest" ]; then # 3006.x is default, and latest for 3006.x branch - if [ "$(echo "$ONEDIR_REV" | grep -E '^(3006|3007)$')" != "" ]; then - # latest version for branch 3006 | 3007 + if [ "$(echo "$ONEDIR_REV" | grep -E '^[0-9]{4}$')" != "" ]; then + # major version — enable the appropriate repo branch REPO_REV_MAJOR=$(echo "$ONEDIR_REV" | cut -d '.' -f 1) if [ "$REPO_REV_MAJOR" -eq "3007" ]; then # Enable the Salt 3007 STS repo dnf config-manager --set-disable salt-repo-* dnf config-manager --set-enabled salt-repo-3007-sts + elif [ "$REPO_REV_MAJOR" -eq "3006" ]; then + # Enable the Salt 3006 LTS repo; disable others so salt-repo-latest + # (pointing to 3008+) does not take precedence + dnf config-manager --set-disable salt-repo-* + dnf config-manager --set-enabled salt-repo-3006-lts + else + # 3008+ — use the latest repo + dnf config-manager --set-disable salt-repo-* + dnf config-manager --set-enabled salt-repo-latest fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version @@ -4150,7 +4147,7 @@ install_fedora_onedir() { STABLE_REV=$ONEDIR_REV #install_fedora_stable || return 1 - if [ "$(echo "$STABLE_REV" | grep -E '^(3006|3007)$')" != "" ]; then + if [ "$(echo "$STABLE_REV" | grep -E '^[0-9]{4}$')" != "" ]; then # Major version Salt, config and repo already setup MINOR_VER_STRG="" elif [ "$(echo "$STABLE_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then @@ -4229,13 +4226,22 @@ __install_saltstack_rhel_onedir_repository() { __fetch_url "${YUM_REPO_FILE}" "${FETCH_URL}" if [ "$ONEDIR_REV" != "latest" ]; then # 3006.x is default, and latest for 3006.x branch - if [ "$(echo "$ONEDIR_REV" | grep -E '^(3006|3007)$')" != "" ]; then - # latest version for branch 3006 | 3007 + if [ "$(echo "$ONEDIR_REV" | grep -E '^[0-9]{4}$')" != "" ]; then + # major version — enable the appropriate repo branch REPO_REV_MAJOR=$(echo "$ONEDIR_REV" | cut -d '.' -f 1) if [ "$REPO_REV_MAJOR" -eq "3007" ]; then # Enable the Salt 3007 STS repo yum config-manager --set-disable salt-repo-* yum config-manager --set-enabled salt-repo-3007-sts + elif [ "$REPO_REV_MAJOR" -eq "3006" ]; then + # Enable the Salt 3006 LTS repo; disable others so salt-repo-latest + # (pointing to 3008+) does not take precedence + yum config-manager --set-disable salt-repo-* + yum config-manager --set-enabled salt-repo-3006-lts + else + # 3008+ — use the latest repo + yum config-manager --set-disable salt-repo-* + yum config-manager --set-enabled salt-repo-latest fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version @@ -4306,7 +4312,7 @@ install_centos_stable_deps() { install_centos_stable() { - if [ "$(echo "$STABLE_REV" | grep -E '^(3006|3007)$')" != "" ]; then + if [ "$(echo "$STABLE_REV" | grep -E '^[0-9]{4}$')" != "" ]; then # Major version Salt, config and repo already setup MINOR_VER_STRG="" elif [ "$(echo "$STABLE_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then @@ -4526,7 +4532,7 @@ install_centos_onedir_deps() { install_centos_onedir() { - if [ "$(echo "$ONEDIR_REV" | grep -E '^(3006|3007)$')" != "" ]; then + if [ "$(echo "$ONEDIR_REV" | grep -E '^[0-9]{4}$')" != "" ]; then # Major version Salt, config and repo already setup MINOR_VER_STRG="" elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then @@ -5649,9 +5655,9 @@ install_amazon_linux_ami_2_deps() { ## __fetch_url "${YUM_REPO_FILE}" "${FETCH_URL}" # shellcheck disable=SC2129 if [ "$STABLE_REV" != "latest" ]; then - # 3006.x is default, and latest for 3006.x branch - if [ "$(echo "$STABLE_REV" | grep -E '^(3006|3007)$')" != "" ]; then - # latest version for branch 3006 | 3007 + # major version or specific minor version + if [ "$(echo "$STABLE_REV" | grep -E '^[0-9]{4}$')" != "" ]; then + # major version REPO_REV_MAJOR=$(echo "$STABLE_REV" | cut -d '.' -f 1) if [ "$REPO_REV_MAJOR" -eq "3007" ]; then # Enable the Salt 3007 STS repo @@ -5665,8 +5671,8 @@ install_amazon_linux_ami_2_deps() { echo "gpgcheck=1" >> "${YUM_REPO_FILE}" echo "exclude=*3006* *3008* *3009* *3010*" >> "${YUM_REPO_FILE}" echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" - else - # Salt 3006 repo + elif [ "$REPO_REV_MAJOR" -eq "3006" ]; then + # Salt 3006 LTS repo echo "[salt-repo-3006-lts]" > "${YUM_REPO_FILE}" echo "name=Salt Repo for Salt v3006 LTS" >> "${YUM_REPO_FILE}" echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" @@ -5677,6 +5683,17 @@ install_amazon_linux_ami_2_deps() { echo "gpgcheck=1" >> "${YUM_REPO_FILE}" echo "exclude=*3007* *3008* *3009* *3010*" >> "${YUM_REPO_FILE}" echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" + else + # 3008+ — use the latest repo + echo "[salt-repo-latest]" > "${YUM_REPO_FILE}" + echo "name=Salt Repo for Salt LATEST release" >> "${YUM_REPO_FILE}" + echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" + echo "skip_if_unavailable=True" >> "${YUM_REPO_FILE}" + echo "priority=10" >> "${YUM_REPO_FILE}" + echo "enabled=1" >> "${YUM_REPO_FILE}" + echo "enabled_metadata=1" >> "${YUM_REPO_FILE}" + echo "gpgcheck=1" >> "${YUM_REPO_FILE}" + echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" fi elif [ "$(echo "$STABLE_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version @@ -5739,9 +5756,9 @@ install_amazon_linux_ami_2_onedir_deps() { ## __fetch_url "${YUM_REPO_FILE}" "${FETCH_URL}" # shellcheck disable=SC2129 if [ "$ONEDIR_REV" != "latest" ]; then - # 3006.x is default, and latest for 3006.x branch - if [ "$(echo "$ONEDIR_REV" | grep -E '^(3006|3007)$')" != "" ]; then - # latest version for branch 3006 | 3007 + # major version or specific minor version + if [ "$(echo "$ONEDIR_REV" | grep -E '^[0-9]{4}$')" != "" ]; then + # major version REPO_REV_MAJOR=$(echo "$ONEDIR_REV" | cut -d '.' -f 1) if [ "$REPO_REV_MAJOR" -eq "3007" ]; then # Enable the Salt 3007 STS repo @@ -5755,8 +5772,8 @@ install_amazon_linux_ami_2_onedir_deps() { echo "gpgcheck=1" >> "${YUM_REPO_FILE}" echo "exclude=*3006* *3008* *3009* *3010*" >> "${YUM_REPO_FILE}" echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" - else - # Salt 3006 repo + elif [ "$REPO_REV_MAJOR" -eq "3006" ]; then + # Salt 3006 LTS repo echo "[salt-repo-3006-lts]" > "${YUM_REPO_FILE}" echo "name=Salt Repo for Salt v3006 LTS" >> "${YUM_REPO_FILE}" echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" @@ -5767,6 +5784,17 @@ install_amazon_linux_ami_2_onedir_deps() { echo "gpgcheck=1" >> "${YUM_REPO_FILE}" echo "exclude=*3007* *3008* *3009* *3010*" >> "${YUM_REPO_FILE}" echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" + else + # 3008+ — use the latest repo + echo "[salt-repo-latest]" > "${YUM_REPO_FILE}" + echo "name=Salt Repo for Salt LATEST release" >> "${YUM_REPO_FILE}" + echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" + echo "skip_if_unavailable=True" >> "${YUM_REPO_FILE}" + echo "priority=10" >> "${YUM_REPO_FILE}" + echo "enabled=1" >> "${YUM_REPO_FILE}" + echo "enabled_metadata=1" >> "${YUM_REPO_FILE}" + echo "gpgcheck=1" >> "${YUM_REPO_FILE}" + echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version @@ -5921,9 +5949,9 @@ install_amazon_linux_ami_2023_onedir_deps() { ## __fetch_url "${YUM_REPO_FILE}" "${FETCH_URL}" # shellcheck disable=SC2129 if [ "$ONEDIR_REV" != "latest" ]; then - # 3006.x is default, and latest for 3006.x branch - if [ "$(echo "$ONEDIR_REV" | grep -E '^(3006|3007)$')" != "" ]; then - # latest version for branch 3006 | 3007 + # major version or specific minor version + if [ "$(echo "$ONEDIR_REV" | grep -E '^[0-9]{4}$')" != "" ]; then + # major version REPO_REV_MAJOR=$(echo "$ONEDIR_REV" | cut -d '.' -f 1) if [ "$REPO_REV_MAJOR" -eq "3007" ]; then # Enable the Salt 3007 STS repo @@ -5937,8 +5965,8 @@ install_amazon_linux_ami_2023_onedir_deps() { echo "gpgcheck=1" >> "${YUM_REPO_FILE}" echo "exclude=*3006* *3008* *3009* *3010*" >> "${YUM_REPO_FILE}" echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" - else - # Salt 3006 repo + elif [ "$REPO_REV_MAJOR" -eq "3006" ]; then + # Salt 3006 LTS repo echo "[salt-repo-3006-lts]" > "${YUM_REPO_FILE}" echo "name=Salt Repo for Salt v3006 LTS" >> "${YUM_REPO_FILE}" echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" @@ -5949,6 +5977,17 @@ install_amazon_linux_ami_2023_onedir_deps() { echo "gpgcheck=1" >> "${YUM_REPO_FILE}" echo "exclude=*3007* *3008* *3009* *3010*" >> "${YUM_REPO_FILE}" echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" + else + # 3008+ — use the latest repo + echo "[salt-repo-latest]" > "${YUM_REPO_FILE}" + echo "name=Salt Repo for Salt LATEST release" >> "${YUM_REPO_FILE}" + echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" + echo "skip_if_unavailable=True" >> "${YUM_REPO_FILE}" + echo "priority=10" >> "${YUM_REPO_FILE}" + echo "enabled=1" >> "${YUM_REPO_FILE}" + echo "enabled_metadata=1" >> "${YUM_REPO_FILE}" + echo "gpgcheck=1" >> "${YUM_REPO_FILE}" + echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version @@ -6461,9 +6500,9 @@ __install_saltstack_vmware_photon_os_onedir_repository() { ## __fetch_url "${YUM_REPO_FILE}" "${FETCH_URL}" # shellcheck disable=SC2129 if [ "$ONEDIR_REV" != "latest" ]; then - # 3006.x is default, and latest for 3006.x branch - if [ "$(echo "$ONEDIR_REV" | grep -E '^(3006|3007)$')" != "" ]; then - # latest version for branch 3006 | 3007 + # major version or specific minor version + if [ "$(echo "$ONEDIR_REV" | grep -E '^[0-9]{4}$')" != "" ]; then + # major version REPO_REV_MAJOR=$(echo "$ONEDIR_REV" | cut -d '.' -f 1) if [ "$REPO_REV_MAJOR" -eq "3007" ]; then # Enable the Salt 3007 STS repo @@ -6479,8 +6518,8 @@ __install_saltstack_vmware_photon_os_onedir_repository() { echo "gpgcheck=1" >> "${YUM_REPO_FILE}" echo "exclude=*3006* *3008* *3009* *3010*" >> "${YUM_REPO_FILE}" echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" - else - # Salt 3006 repo + elif [ "$REPO_REV_MAJOR" -eq "3006" ]; then + # Salt 3006 LTS repo echo "[salt-repo-3006-lts]" > "${YUM_REPO_FILE}" echo "name=Salt Repo for Salt v3006 LTS" >> "${YUM_REPO_FILE}" echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" @@ -6491,6 +6530,17 @@ __install_saltstack_vmware_photon_os_onedir_repository() { echo "gpgcheck=1" >> "${YUM_REPO_FILE}" echo "exclude=*3007* *3008* *3009* *3010*" >> "${YUM_REPO_FILE}" echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" + else + # 3008+ — use the latest repo + echo "[salt-repo-latest]" > "${YUM_REPO_FILE}" + echo "name=Salt Repo for Salt LATEST release" >> "${YUM_REPO_FILE}" + echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" + echo "skip_if_unavailable=True" >> "${YUM_REPO_FILE}" + echo "priority=10" >> "${YUM_REPO_FILE}" + echo "enabled=1" >> "${YUM_REPO_FILE}" + echo "enabled_metadata=1" >> "${YUM_REPO_FILE}" + echo "gpgcheck=1" >> "${YUM_REPO_FILE}" + echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${YUM_REPO_FILE}" fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version @@ -6654,11 +6704,7 @@ install_vmware_photon_os_git() { install_vmware_photon_os_git_deps - if [ -f "${_SALT_GIT_CHECKOUT_DIR}/salt/syspaths.py" ]; then - ${_PYEXE} setup.py --salt-config-dir="$_SALT_ETC_DIR" --salt-cache-dir="${_SALT_CACHE_DIR}" ${SETUP_PY_INSTALL_ARGS} install --prefix=/usr || return 1 - else - ${_PYEXE} setup.py ${SETUP_PY_INSTALL_ARGS} install --prefix=/usr || return 1 - fi + __install_salt_from_repo "${_PYEXE}" || return 1 return 0 } @@ -6785,7 +6831,7 @@ install_vmware_photon_os_onedir() { STABLE_REV=$ONEDIR_REV _GENERIC_PKG_VERSION="" - if [ "$(echo "$STABLE_REV" | grep -E '^(3006|3007)$')" != "" ]; then + if [ "$(echo "$STABLE_REV" | grep -E '^[0-9]{4}$')" != "" ]; then # Major version Salt, config and repo already setup __get_packagesite_onedir_latest "$STABLE_REV" || return 1 MINOR_VER_STRG="-$_GENERIC_PKG_VERSION" @@ -6853,9 +6899,9 @@ __check_and_refresh_suse_pkg_repo() { ZYPPER_REPO_FILE="/etc/zypp/repos.d/salt.repo" # shellcheck disable=SC2129 if [ "$ONEDIR_REV" != "latest" ]; then - # 3006.x is default, and latest for 3006.x branch - if [ "$(echo "$ONEDIR_REV" | grep -E '^(3006|3007)$')" != "" ]; then - # latest version for branch 3006 | 3007 + # major version or specific minor version + if [ "$(echo "$ONEDIR_REV" | grep -E '^[0-9]{4}$')" != "" ]; then + # major version REPO_REV_MAJOR=$(echo "$ONEDIR_REV" | cut -d '.' -f 1) if [ "$REPO_REV_MAJOR" -eq "3007" ]; then # Enable the Salt 3007 STS repo @@ -6870,8 +6916,8 @@ __check_and_refresh_suse_pkg_repo() { echo "gpgcheck=1" >> "${ZYPPER_REPO_FILE}" echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${ZYPPER_REPO_FILE}" zypper addlock "salt-* < 3007" && zypper addlock "salt-* >= 3008" - else - # Salt 3006 repo + elif [ "$REPO_REV_MAJOR" -eq "3006" ]; then + # Salt 3006 LTS repo echo "[salt-repo-3006-lts]" > "${ZYPPER_REPO_FILE}" echo "name=Salt Repo for Salt v3006 LTS" >> "${ZYPPER_REPO_FILE}" echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${ZYPPER_REPO_FILE}" @@ -6883,6 +6929,19 @@ __check_and_refresh_suse_pkg_repo() { echo "gpgcheck=1" >> "${ZYPPER_REPO_FILE}" echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${ZYPPER_REPO_FILE}" zypper addlock "salt-* < 3006" && zypper addlock "salt-* >= 3007" + else + # 3008+ — use the latest repo + REPO_REV_MAJOR_PLUS=$((REPO_REV_MAJOR + 1)) + echo "[salt-repo-latest]" > "${ZYPPER_REPO_FILE}" + echo "name=Salt Repo for Salt LATEST release" >> "${ZYPPER_REPO_FILE}" + echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${ZYPPER_REPO_FILE}" + echo "skip_if_unavailable=True" >> "${ZYPPER_REPO_FILE}" + echo "priority=10" >> "${ZYPPER_REPO_FILE}" + echo "enabled=1" >> "${ZYPPER_REPO_FILE}" + echo "enabled_metadata=1" >> "${ZYPPER_REPO_FILE}" + echo "gpgcheck=1" >> "${ZYPPER_REPO_FILE}" + echo "gpgkey=https://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" >> "${ZYPPER_REPO_FILE}" + zypper addlock "salt-* < ${REPO_REV_MAJOR}" && zypper addlock "salt-* >= ${REPO_REV_MAJOR_PLUS}" fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version @@ -7045,7 +7104,7 @@ install_opensuse_onedir_deps() { } install_opensuse_stable() { - if [ "$(echo "$STABLE_REV" | grep -E '^(3006|3007)$')" != "" ]; then + if [ "$(echo "$STABLE_REV" | grep -E '^[0-9]{4}$')" != "" ]; then # Major version Salt, config and repo already setup MINOR_VER_STRG="" elif [ "$(echo "$STABLE_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then @@ -7513,7 +7572,7 @@ __gentoo_pre_dep() { # Enable Python 3.10 target for Salt 3006 or later, otherwise 3.7 as previously, using GIT if [ "${ITYPE}" = "git" ]; then GIT_REV_MAJOR=$(echo "${GIT_REV}" | awk -F "." '{print $1}') - if [ "${GIT_REV_MAJOR}" = "v3006" ] || [ "${GIT_REV_MAJOR}" = "v3007" ]; then + if echo "${GIT_REV_MAJOR}" | grep -qE '^v[0-9]{4}$'; then EXTRA_PYTHON_TARGET=python3_10 else # assume pre-3006, so leave it as Python 3.7 @@ -7915,7 +7974,7 @@ __macosx_get_packagesite_onedir() { SALT_MACOS_PKGDIR_URL="https://${_REPO_URL}/${_ONEDIR_TYPE}/macos" if [ "$(echo "$_ONEDIR_REV" | grep -E '^(latest)$')" != "" ]; then __macosx_get_packagesite_onedir_latest || return 1 - elif [ "$(echo "$_ONEDIR_REV" | grep -E '^(3006|3007)$')" != "" ]; then + elif [ "$(echo "$_ONEDIR_REV" | grep -E '^[0-9]{4}$')" != "" ]; then # need to get latest for major version __macosx_get_packagesite_onedir_latest "$_ONEDIR_REV" || return 1 elif [ "$(echo "$_ONEDIR_REV" | grep -E '^([3-9][0-9]{3}(\.[0-9]*)?)')" != "" ]; then From 763e60f4cb058692a294e3651552847bad71c0ec Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Sat, 11 Jul 2026 02:45:46 -0600 Subject: [PATCH 100/469] Document master grains cache reorg requires refresh_grains after upgrade (#69775) 3008.0 split the master's combined per-minion cache blob into dedicated `grains`/`pillar`/`mine` cache banks (#68030). Cached data from a pre-3008 master isn't migrated, so a minion's grains stay invisible to the master (breaking -G/grain targeting, mine.get, and cached pillar/grains lookups) until the minion re-syncs. This wasn't called out anywhere, so admins hit it blind on every 3006.x/3007.x -> 3008.x master upgrade. Add a warning to the 3008.0 release notes explaining the cache reorganization and the saltutil.refresh_grains workaround, with a forward pointer from the upcoming 3008.3 notes so admins upgrading today also see it. --- changelog/68030.changed.md | 1 + .../releases/templates/3008.0.md.template | 12 +++++++++++ .../releases/templates/3008.3.md.template | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 changelog/68030.changed.md create mode 100644 doc/topics/releases/templates/3008.3.md.template diff --git a/changelog/68030.changed.md b/changelog/68030.changed.md new file mode 100644 index 000000000000..4f9fb5d21af7 --- /dev/null +++ b/changelog/68030.changed.md @@ -0,0 +1 @@ +Documented that upgrading a master from 3006.x/3007.x to 3008.x requires running ``saltutil.refresh_grains`` on minions due to the minion data cache reorganization (grains/pillar/mine split into dedicated cache banks). diff --git a/doc/topics/releases/templates/3008.0.md.template b/doc/topics/releases/templates/3008.0.md.template index cf56e9d352bf..6d4a86a607bf 100644 --- a/doc/topics/releases/templates/3008.0.md.template +++ b/doc/topics/releases/templates/3008.0.md.template @@ -57,6 +57,18 @@ conceptual overview, the [tutorial](../resources/tutorial) for a guide](../resources/authoring/index) for shipping your own resource type. +## Minion Data Cache Reorganized — Refresh Grains After Upgrading the Master +> :warning: **Upgrade Notice**:
+The master's on-disk minion data cache was reorganized in 3008.0: grains, +pillar, and mine data now live in dedicated cache banks instead of a single +combined per-minion blob. Cached data from a pre-3008 master is not migrated +automatically. After upgrading the master, run +``salt '*' saltutil.refresh_grains`` (or wait for minions' next scheduled +pillar/highstate refresh) so minions repopulate the master's grains cache. +Until then, grain-based targeting (``-G``), ``mine.get``, and cached +pillar/grains lookups may return stale or empty results for minions that +haven't re-synced since the upgrade. + + +## Upgrading a Master to 3008.x +> :warning: **Reminder**:
+If your master is being upgraded from 3006.x/3007.x, see the +[3008.0 release notes](3008.0.md#minion-data-cache-reorganized-refresh-grains-after-upgrading-the-master) +about the minion data cache reorganization — run +``salt '*' saltutil.refresh_grains`` on minions after the upgrade. + + +## Changelog +{{ changelog }} From 3bb6709bf34485f8b74b5a59b73ff776ab31c862 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 12 Jul 2026 17:16:44 -0400 Subject: [PATCH 101/469] Fix spurious "Output was trimmed" message in archive.extracted (#69770) * Fix spurious "Output was trimmed" message in archive.extracted archive.extracted appended ". Output was trimmed to {} number of lines" unconditionally whenever files were extracted, even though trimming only happens when trim_output is set. With the default trim_output=False this produced the misleading comment "Output was trimmed to False number of lines" while nothing was trimmed. Guard the append with the same trim_output check that gates the actual trimming so the message only appears when output was really trimmed. Fixes #59570 * Use an f-string for the trimmed-output message Per review on #69770. --- changelog/59570.fixed.md | 1 + salt/states/archive.py | 6 +- tests/pytests/unit/states/test_archive.py | 116 ++++++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 changelog/59570.fixed.md diff --git a/changelog/59570.fixed.md b/changelog/59570.fixed.md new file mode 100644 index 000000000000..49ec605833c5 --- /dev/null +++ b/changelog/59570.fixed.md @@ -0,0 +1 @@ +Fixed archive.extracted appending "Output was trimmed to False number of lines" when trim_output was left at its default and no output was actually trimmed. The message is now only added when trimming really occurs. diff --git a/salt/states/archive.py b/salt/states/archive.py index e99556f1eaf6..000737871ef4 100644 --- a/salt/states/archive.py +++ b/salt/states/archive.py @@ -1646,9 +1646,9 @@ def extracted( name, ) _add_explanation(ret, source_hash_trigger, contents_missing) - ret["comment"] += ". Output was trimmed to {} number of lines".format( - trim_output - ) + if trim_output: + trim_msg = f". Output was trimmed to {trim_output} number of lines" + ret["comment"] += trim_msg ret["result"] = True else: diff --git a/tests/pytests/unit/states/test_archive.py b/tests/pytests/unit/states/test_archive.py index b5350bc2dba5..7759f5a76282 100644 --- a/tests/pytests/unit/states/test_archive.py +++ b/tests/pytests/unit/states/test_archive.py @@ -290,6 +290,122 @@ def test_tar_bsdtar_with_trim_output(): assert ret["comment"].endswith("Output was trimmed to 1 number of lines") +def test_tar_bsdtar_without_trim_output_59570(): + """ + Direct-altitude regression test for #59570. + + When extraction actually happens but trim_output is left at its default + (the ``trim_output=False`` parameter default), no output is trimmed, so + the "Output was trimmed to ... number of lines" message must NOT be + appended. Previously it was appended unconditionally, producing the + nonsensical "Output was trimmed to False number of lines". + """ + bsdtar = MagicMock(return_value="tar (bsdtar)") + source = "/tmp/foo.tar.gz" + mock_false = MagicMock(return_value=False) + mock_true = MagicMock(return_value=True) + state_single_mock = MagicMock(return_value={"local": {"result": True}}) + run_all = MagicMock( + return_value={"retcode": 0, "stdout": "stdout", "stderr": "stderr"} + ) + mock_source_list = MagicMock(return_value=(source, None)) + list_mock = MagicMock( + return_value={ + "dirs": [], + "files": ["stderr"], + "links": [], + "top_level_dirs": [], + "top_level_files": ["stderr"], + "top_level_links": [], + } + ) + isfile_mock = MagicMock(side_effect=_isfile_side_effect) + + with patch.dict( + archive.__salt__, + { + "cmd.run": bsdtar, + "file.directory_exists": mock_false, + "file.file_exists": mock_false, + "state.single": state_single_mock, + "file.makedirs": mock_true, + "cmd.run_all": run_all, + "archive.list": list_mock, + "file.source_list": mock_source_list, + }, + ), patch.dict(archive.__states__, {"file.directory": mock_true}), patch.object( + os.path, "isfile", isfile_mock + ), patch( + "salt.utils.path.which", MagicMock(return_value=True) + ): + # trim_output intentionally omitted -> uses the default (False) + ret = archive.extracted( + os.path.join(os.sep + "tmp", "out"), + source, + options="xvzf", + enforce_toplevel=False, + keep_source=True, + ) + assert ret["result"] is True + assert ret["changes"]["extracted_files"] == ["stderr"] + assert "Output was trimmed" not in ret["comment"] + + +def test_tar_bsdtar_with_trim_output_zero(): + """ + Peripheral coverage for #59570: an explicit falsy trim_output (0) means + "do not trim", so the trimmed-output message must also be suppressed. + """ + bsdtar = MagicMock(return_value="tar (bsdtar)") + source = "/tmp/foo.tar.gz" + mock_false = MagicMock(return_value=False) + mock_true = MagicMock(return_value=True) + state_single_mock = MagicMock(return_value={"local": {"result": True}}) + run_all = MagicMock( + return_value={"retcode": 0, "stdout": "stdout", "stderr": "stderr"} + ) + mock_source_list = MagicMock(return_value=(source, None)) + list_mock = MagicMock( + return_value={ + "dirs": [], + "files": ["stderr"], + "links": [], + "top_level_dirs": [], + "top_level_files": ["stderr"], + "top_level_links": [], + } + ) + isfile_mock = MagicMock(side_effect=_isfile_side_effect) + + with patch.dict( + archive.__salt__, + { + "cmd.run": bsdtar, + "file.directory_exists": mock_false, + "file.file_exists": mock_false, + "state.single": state_single_mock, + "file.makedirs": mock_true, + "cmd.run_all": run_all, + "archive.list": list_mock, + "file.source_list": mock_source_list, + }, + ), patch.dict(archive.__states__, {"file.directory": mock_true}), patch.object( + os.path, "isfile", isfile_mock + ), patch( + "salt.utils.path.which", MagicMock(return_value=True) + ): + ret = archive.extracted( + os.path.join(os.sep + "tmp", "out"), + source, + options="xvzf", + enforce_toplevel=False, + keep_source=True, + trim_output=0, + ) + assert ret["changes"]["extracted_files"] == ["stderr"] + assert "Output was trimmed" not in ret["comment"] + + def test_extracted_when_if_missing_path_exists(): """ When if_missing exists, we should exit without making any changes. From 1101fc629d5c104aba725d8249c5b83cb65134bb Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 12 Jul 2026 17:20:39 -0400 Subject: [PATCH 102/469] Use shutil.move in seed.apply_ to support cross-filesystem seeding (#69773) seed.apply_ relocated the generated minion config and keys into the mounted image with os.rename, which raises OSError EXDEV ("Invalid cross-device link") when the temp source and destination are on different mounts. Switch the three moves to shutil.move, which falls back to copy+unlink across filesystem boundaries. shutil is already imported and the destinations are explicit file paths, so behaviour is unchanged on the same-filesystem path. Fixes #55348 --- changelog/55348.fixed.md | 1 + salt/modules/seed.py | 6 +- tests/pytests/unit/modules/test_seed.py | 120 ++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 changelog/55348.fixed.md diff --git a/changelog/55348.fixed.md b/changelog/55348.fixed.md new file mode 100644 index 000000000000..879df804098a --- /dev/null +++ b/changelog/55348.fixed.md @@ -0,0 +1 @@ +Fix seed.apply_ to use shutil.move so relocating the minion config and keys works across filesystems (avoids OSError EXDEV / cross-device link). diff --git a/salt/modules/seed.py b/salt/modules/seed.py index 657c7bccd64c..dd3ef4f89214 100644 --- a/salt/modules/seed.py +++ b/salt/modules/seed.py @@ -174,13 +174,13 @@ def apply_( pki_dir = minion_config["pki_dir"] if not os.path.isdir(os.path.join(mpt, pki_dir.lstrip("/"))): __salt__["file.makedirs"](os.path.join(mpt, pki_dir.lstrip("/"), "")) - os.rename( + shutil.move( cfg_files["privkey"], os.path.join(mpt, pki_dir.lstrip("/"), "minion.pem") ) - os.rename( + shutil.move( cfg_files["pubkey"], os.path.join(mpt, pki_dir.lstrip("/"), "minion.pub") ) - os.rename(cfg_files["config"], os.path.join(mpt, "etc/salt/minion")) + shutil.move(cfg_files["config"], os.path.join(mpt, "etc/salt/minion")) res = True elif install: log.info("Attempting to install salt-minion to %s", mpt) diff --git a/tests/pytests/unit/modules/test_seed.py b/tests/pytests/unit/modules/test_seed.py index f3ccf609871a..806d88200f7a 100644 --- a/tests/pytests/unit/modules/test_seed.py +++ b/tests/pytests/unit/modules/test_seed.py @@ -99,3 +99,123 @@ def test_apply_(): umount_mock.assert_called_once_with( "/mountpoint", "target", "type" ) + + +def test_apply_moves_config_and_keys_with_shutil_move_55348(): + """ + Issue #55348: when salt-minion is already installed on the image + (``_check_install`` returns True), apply_() relocates the generated + minion config and keys into place. It must use shutil.move -- which + falls back to copy+unlink across filesystem boundaries -- rather than + os.rename, which raises OSError EXDEV ("Invalid cross-device link") + when the temp source and its destination live on different mounts. + + Drives the ``_check_install`` is True branch with apply_() called using + its production defaults. os.rename is stubbed to raise EXDEV to prove the + code path no longer depends on it. + """ + cfg_files = {"config": "C", "privkey": "K", "pubkey": "P"} + minion_config = {"pki_dir": "/etc/salt/pki/minion"} + salt_mock = { + "file.stats": MagicMock(return_value={"type": "dir", "target": "target"}), + "file.makedirs": MagicMock(), + } + with patch.dict(seed.__salt__, salt_mock), patch.object( + seed, "_mount", return_value="/mountpoint" + ), patch.object(os, "makedirs", MagicMock()), patch.object( + seed, "mkconfig", return_value=cfg_files + ), patch.object( + seed, "_check_install", return_value=True + ), patch( + "salt.config.minion_config", return_value=minion_config + ), patch.object( + os.path, "isdir", return_value=True + ), patch.object( + seed, "_umount", return_value=None + ), patch.object( + shutil, "move", MagicMock() + ) as move_mock, patch.object( + os, "rename", MagicMock(side_effect=OSError("Invalid cross-device link")) + ) as rename_mock: + assert seed.apply_("path") is True + move_mock.assert_any_call( + "K", os.path.join("/mountpoint", "etc/salt/pki/minion", "minion.pem") + ) + move_mock.assert_any_call( + "P", os.path.join("/mountpoint", "etc/salt/pki/minion", "minion.pub") + ) + move_mock.assert_any_call("C", os.path.join("/mountpoint", "etc/salt/minion")) + assert move_mock.call_count == 3 + rename_mock.assert_not_called() + + +def test_apply_pre_installed_branch_returns_true_55348(): + """ + Inverse / must-not-regress guard for issue #55348. With the file move + stubbed to succeed, the pre-installed branch must return True and unmount + the image. This passes both WITH and WITHOUT the fix because os.rename and + shutil.move are both stubbed to succeed -- it asserts only the branch's + success/unmount contract, not which primitive performs the move (that is + the direct test's job), so it guards the happy path against regression. + """ + cfg_files = {"config": "C", "privkey": "K", "pubkey": "P"} + minion_config = {"pki_dir": "/etc/salt/pki/minion"} + salt_mock = { + "file.stats": MagicMock(return_value={"type": "dir", "target": "target"}), + "file.makedirs": MagicMock(), + } + with patch.dict(seed.__salt__, salt_mock), patch.object( + seed, "_mount", return_value="/mountpoint" + ), patch.object(os, "makedirs", MagicMock()), patch.object( + seed, "mkconfig", return_value=cfg_files + ), patch.object( + seed, "_check_install", return_value=True + ), patch( + "salt.config.minion_config", return_value=minion_config + ), patch.object( + os.path, "isdir", return_value=True + ), patch.object( + seed, "_umount", return_value=None + ) as umount_mock, patch.object( + shutil, "move", MagicMock() + ), patch.object( + os, "rename", MagicMock() + ): + assert seed.apply_("path") is True + umount_mock.assert_called_once_with("/mountpoint", "target", "dir") + + +def test_apply_creates_pki_dir_when_missing_55348(): + """ + Peripheral coverage of the touched _check_install branch: when the pki + directory does not yet exist on the image, apply_() creates it via + file.makedirs before moving the keys into place. + """ + cfg_files = {"config": "C", "privkey": "K", "pubkey": "P"} + minion_config = {"pki_dir": "/etc/salt/pki/minion"} + makedirs_mock = MagicMock() + salt_mock = { + "file.stats": MagicMock(return_value={"type": "dir", "target": "target"}), + "file.makedirs": makedirs_mock, + } + with patch.dict(seed.__salt__, salt_mock), patch.object( + seed, "_mount", return_value="/mountpoint" + ), patch.object(os, "makedirs", MagicMock()), patch.object( + seed, "mkconfig", return_value=cfg_files + ), patch.object( + seed, "_check_install", return_value=True + ), patch( + "salt.config.minion_config", return_value=minion_config + ), patch.object( + os.path, "isdir", return_value=False + ), patch.object( + seed, "_umount", return_value=None + ), patch.object( + shutil, "move", MagicMock() + ), patch.object( + os, "rename", MagicMock() + ): + assert seed.apply_("path") is True + makedirs_mock.assert_called_once_with( + os.path.join("/mountpoint", "etc/salt/pki/minion", "") + ) From 513074daf3119a33ead4a43e07edd5665f568dc6 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 12 Jul 2026 17:23:16 -0400 Subject: [PATCH 103/469] Fix poudriere jail functions for purely numeric jail names (#69772) is_jail compared the parsed jail token (always a str) against the name argument. The salt CLI YAML-parses a numeric positional argument into an int, so is_jail never matched purely numeric jail names, breaking every caller that gates on it (create_jail, update_jail, delete_jail, info_jail, bulk_build). Coerce the name to a string before comparing. Fixes #61082 --- changelog/61082.fixed.md | 1 + salt/modules/poudriere.py | 2 +- tests/pytests/unit/modules/test_poudriere.py | 65 ++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 changelog/61082.fixed.md diff --git a/changelog/61082.fixed.md b/changelog/61082.fixed.md new file mode 100644 index 000000000000..6e4ba67d56e5 --- /dev/null +++ b/changelog/61082.fixed.md @@ -0,0 +1 @@ +Fixed poudriere jail functions failing on purely numeric jail names by coercing the name to a string in is_jail diff --git a/salt/modules/poudriere.py b/salt/modules/poudriere.py index 5b7e88ecab2c..e201bddce2e0 100644 --- a/salt/modules/poudriere.py +++ b/salt/modules/poudriere.py @@ -63,7 +63,7 @@ def is_jail(name): """ jails = list_jails() for jail in jails: - if jail.split()[0] == name: + if jail.split()[0] == str(name): return True return False diff --git a/tests/pytests/unit/modules/test_poudriere.py b/tests/pytests/unit/modules/test_poudriere.py index 899d553a6bba..1548293e5899 100644 --- a/tests/pytests/unit/modules/test_poudriere.py +++ b/tests/pytests/unit/modules/test_poudriere.py @@ -28,6 +28,71 @@ def test_is_jail(): assert not poudriere.is_jail("SALT") +def test_is_jail_numeric_61082(): + """ + A purely numeric jail name must be matched even though the salt CLI + YAML-parses the positional argument into an int before it reaches + is_jail (e.g. ``salt-call poudriere.is_jail 13`` passes int 13). + + Regression test for #61082. + """ + # Realistic ``poudriere jails -l`` output from the bug report. + jail_list = "\n".join( + [ + "12 12.2-RELEASE-p9 amd64 ftp 2021-07-13 07:35:16 /var/poudriere/jails/12", + "13 13.0-RELEASE-p4 amd64 ftp 2021-10-20 08:10:06 /var/poudriere/jails/13", + "13-arm-oncourse 13.0-RELEASE-p4 arm64.aarch64 ftp 2021-10-20 08:11:59 /var/poudriere/jails/13-arm-oncourse", + ] + ) + mock = MagicMock(return_value=jail_list) + with patch.dict(poudriere.__salt__, {"cmd.run": mock}), patch( + "salt.modules.poudriere._check_config_exists", MagicMock(return_value=True) + ): + # int 13, exactly as the CLI passes ``poudriere.is_jail 13`` + assert poudriere.is_jail(13) is True + + +def test_is_jail_numeric_absent_61082(): + """ + A numeric jail name that is not present must still return False. This + passes with and without the fix; it guards the str() coercion against + turning every numeric lookup into a false positive. + """ + jail_list = "\n".join( + [ + "12 12.2-RELEASE-p9 amd64 ftp 2021-07-13 07:35:16 /var/poudriere/jails/12", + "13 13.0-RELEASE-p4 amd64 ftp 2021-10-20 08:10:06 /var/poudriere/jails/13", + ] + ) + mock = MagicMock(return_value=jail_list) + with patch.dict(poudriere.__salt__, {"cmd.run": mock}), patch( + "salt.modules.poudriere._check_config_exists", MagicMock(return_value=True) + ): + # int 99 is absent -> must not become a false positive + assert poudriere.is_jail(99) is False + + +def test_is_jail_numeric_prefixed_string_61082(): + """ + A string jail name whose first token starts with digits (e.g. + ``13-arm-oncourse``) already worked before the fix and must keep working. + Peripheral coverage for the is_jail token comparison. + """ + jail_list = "\n".join( + [ + "13 13.0-RELEASE-p4 amd64 ftp 2021-10-20 08:10:06 /var/poudriere/jails/13", + "13-arm-oncourse 13.0-RELEASE-p4 arm64.aarch64 ftp 2021-10-20 08:11:59 /var/poudriere/jails/13-arm-oncourse", + ] + ) + mock = MagicMock(return_value=jail_list) + with patch.dict(poudriere.__salt__, {"cmd.run": mock}), patch( + "salt.modules.poudriere._check_config_exists", MagicMock(return_value=True) + ): + assert poudriere.is_jail("13-arm-oncourse") is True + # a numeric name passed as a string also matches + assert poudriere.is_jail("13") is True + + def test_make_pkgng_aware(): """ Test if it make jail ``jname`` pkgng aware. From 7808cddf7262a481810e77ba69427cf4c3b4472d Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 13 Jul 2026 16:57:41 -0400 Subject: [PATCH 104/469] Fix managing users on NAPALM proxy minions (#62170) (#69792) Two failures made `netusers.managed` / `users.set_users` unusable on NAPALM (proxy) minions: - `netusers.managed` passes its optional `defaults` to `_expand_users` as `common_users`, then does `copy.deepcopy(common_users).update(...)`. With no `defaults` (the common case) that is `copy.deepcopy(None).update(...)`, which raises `AttributeError: 'NoneType' object has no attribute 'update'`. - `users.set_users` / `users.delete_users` call `net.load_template("set_users", ...)` with a bare template name. `net.load_template` used to route bare names into NAPALM's own renderer, but that path was removed in the Sodium release (#57370) -- whose own deprecation warning explicitly told `netusers`/`netntp`/ `netsnmp` users they could ignore it. The bare name now falls through to the fileserver as `Local file source set_users does not exist`. `_expand_users` now treats a missing `defaults` as `{}`. `set_users` / `delete_users` resolve the NAPALM-shipped per-driver template (`set_users.j2` / `delete_users.j2`, which NAPALM still ships, searched along the driver class MRO) to an absolute path and render it through the Salt pipeline, and return a clear message when a driver ships no such template. Because the crash previously masked it, `managed` now refuses to proceed when the expanded user set is empty rather than removing (and committing) every account on the device -- an admin-lockout foot-gun, e.g. an empty pillar lookup. Validated end-to-end against a live Juniper EX3400 (Junos 23.4R2) over NETCONF: users are rendered, committed, confirmed on-device, and removed again. --- changelog/62170.fixed.md | 10 ++ salt/modules/napalm_users.py | 61 +++++++- salt/states/netusers.py | 20 ++- .../pytests/unit/modules/napalm/test_users.py | 141 +++++++++++++++++- tests/pytests/unit/states/test_netusers.py | 53 +++++++ 5 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 changelog/62170.fixed.md create mode 100644 tests/pytests/unit/states/test_netusers.py diff --git a/changelog/62170.fixed.md b/changelog/62170.fixed.md new file mode 100644 index 000000000000..deff7f3f0b8a --- /dev/null +++ b/changelog/62170.fixed.md @@ -0,0 +1,10 @@ +Fixed managing users on NAPALM (proxy) minions. ``netusers.managed`` no longer +raises ``AttributeError: 'NoneType' object has no attribute 'update'`` when the +state declares no ``defaults``, and ``users.set_users`` / ``users.delete_users`` +no longer fail with ``Local file source set_users does not exist``. The bare +template names these functions pass to ``net.load_template`` stopped resolving +when native NAPALM template support was removed in the Sodium release (that +removal was meant to spare the ``netusers`` state module); they now resolve the +NAPALM-shipped per-driver template to an absolute path and render it through the +Salt pipeline. ``netusers.managed`` also now refuses to proceed when it would +manage an empty set of users, rather than removing every account on the device. diff --git a/salt/modules/napalm_users.py b/salt/modules/napalm_users.py index 406030971021..b743646d434f 100644 --- a/salt/modules/napalm_users.py +++ b/salt/modules/napalm_users.py @@ -19,7 +19,9 @@ .. versionadded:: 2016.11.0 """ +import inspect import logging +import os.path # import NAPALM utils import salt.utils.napalm @@ -53,6 +55,39 @@ def __virtual__(): # helper functions -- will not be exported # ---------------------------------------------------------------------------------------------------------------------- + +def _napalm_template_path(napalm_device, template_name): + """ + Return the absolute path to a NAPALM-shipped Jinja template (e.g. + ``set_users``) for the driver backing this proxy, or ``None`` if the driver + does not ship one. + + NAPALM keeps these config templates in a ``templates`` directory next to + each driver module and resolves them by walking the driver class MRO + (concrete driver first, then its bases). ``net.load_template`` used to route + bare template names into NAPALM's own renderer, but that path was removed in + the Sodium release; resolving the template to an absolute path lets the + still-supported Salt rendering pipeline render it instead. + """ + driver = napalm_device.get("DRIVER") if napalm_device else None + if driver is None: + return None + for klass in type(driver).__mro__: + try: + module_file = inspect.getfile(klass) + except (TypeError, OSError): + # Built-in types (e.g. ``object``) raise TypeError; classes without + # an on-disk source (``__main__``, frozen) raise OSError. Neither + # can ship a template dir, so move on. + continue + candidate = os.path.join( + os.path.dirname(module_file), "templates", f"{template_name}.j2" + ) + if os.path.isfile(candidate): + return candidate + return None + + # ---------------------------------------------------------------------------------------------------------------------- # callable functions # ---------------------------------------------------------------------------------------------------------------------- @@ -132,8 +167,19 @@ def set_users( """ # pylint: disable=undefined-variable + template_path = _napalm_template_path(napalm_device, "set_users") + if template_path is None: + driver_name = napalm_device.get("DRIVER_NAME") if napalm_device else None + return { + "result": False, + "out": None, + "comment": ( + f"The 'set_users' template is not available for the" + f" '{driver_name}' driver." + ), + } return __salt__["net.load_template"]( - "set_users", + template_path, users=users, test=test, commit=commit, @@ -174,8 +220,19 @@ def delete_users( """ # pylint: disable=undefined-variable + template_path = _napalm_template_path(napalm_device, "delete_users") + if template_path is None: + driver_name = napalm_device.get("DRIVER_NAME") if napalm_device else None + return { + "result": False, + "out": None, + "comment": ( + f"The 'delete_users' template is not available for the" + f" '{driver_name}' driver." + ), + } return __salt__["net.load_template"]( - "delete_users", + template_path, users=users, test=test, commit=commit, diff --git a/salt/states/netusers.py b/salt/states/netusers.py index 350fe5b471cc..49eb73adf7cd 100644 --- a/salt/states/netusers.py +++ b/salt/states/netusers.py @@ -68,7 +68,10 @@ def _ordered_dict_to_dict(probes): def _expand_users(device_users, common_users): """Creates a longer list of accepted users on the device.""" - expected_users = copy.deepcopy(common_users) + # ``common_users`` (the state's ``defaults`` argument) is optional, so it is + # ``None`` whenever the user does not declare any defaults. Treat that the + # same as an empty mapping rather than crashing on ``None.update()``. + expected_users = copy.deepcopy(common_users) if common_users else {} expected_users.update(device_users) return expected_users @@ -319,6 +322,21 @@ def managed(name, users=None, defaults=None): defaults = _ordered_dict_to_dict(defaults) expected_users = _expand_users(users, defaults) + + if not expected_users: + # Neither ``users`` nor ``defaults`` yielded anyone to manage. Because + # this is a declarative state, proceeding would remove *every* account + # configured on the device -- a likely lockout, e.g. when a pillar + # lookup renders to an empty mapping. Refuse rather than wipe. See + # #62170: previously an unset ``defaults`` crashed here, which happened + # to mask this case. + ret["comment"] = ( + "No users were provided to manage. Refusing to proceed, as this" + " would remove every user configured on the device. Check the" + " state's 'users' and 'defaults' (and any pillar data behind them)." + ) + return ret + valid, message = _check_users(expected_users) if not valid: # check and clean diff --git a/tests/pytests/unit/modules/napalm/test_users.py b/tests/pytests/unit/modules/napalm/test_users.py index f55a649aa7bf..b37a6b1d5ec3 100644 --- a/tests/pytests/unit/modules/napalm/test_users.py +++ b/tests/pytests/unit/modules/napalm/test_users.py @@ -37,19 +37,152 @@ def test_config(): assert ret["out"] == napalm_test_support.TEST_USERS.copy() -def test_set_users(): +class _BaseDriver: + pass + + +class _ConcreteDriver(_BaseDriver): + pass + + +def _getfile_map(mapping): + """ + Build an ``inspect.getfile`` replacement that returns a distinct path per + class and raises (like the real one) for anything not in the map -- notably + ``object``, so the loop's exception-continue is genuinely exercised. + """ + + def fake_getfile(klass): + try: + return mapping[klass] + except KeyError: + raise TypeError(f"{klass!r} is a built-in class") + + return fake_getfile + + +def test_napalm_template_path_walks_mro_to_base(tmp_path): + """ + #62170: templates can be inherited -- the concrete driver ships none but a + base class does. The resolver must walk the MRO (concrete -> base) and skip + ``object`` (which raises from getfile) rather than stopping at the first + class. + """ + concrete_dir = tmp_path / "concrete" + concrete_dir.mkdir() + base_tpl = tmp_path / "base" / "templates" + base_tpl.mkdir(parents=True) + (base_tpl / "set_users.j2").write_text("system { }") + + device = {"DRIVER": _ConcreteDriver()} + getfile = _getfile_map( + { + _ConcreteDriver: str(concrete_dir / "driver.py"), + _BaseDriver: str(tmp_path / "base" / "base.py"), + } + ) + with patch("salt.modules.napalm_users.inspect.getfile", side_effect=getfile): + resolved = napalm_users._napalm_template_path(device, "set_users") + assert resolved == str(base_tpl / "set_users.j2") + + +def test_napalm_template_path_missing_returns_none(tmp_path): + """ + Drivers that do not ship a given template anywhere in the MRO (e.g. ios has + no user templates) resolve to ``None`` rather than an unusable path -- and + the ``object`` -> exception step must not escape the helper. + """ + (tmp_path / "concrete").mkdir() + (tmp_path / "base").mkdir() + device = {"DRIVER": _ConcreteDriver()} + getfile = _getfile_map( + { + _ConcreteDriver: str(tmp_path / "concrete" / "driver.py"), + _BaseDriver: str(tmp_path / "base" / "base.py"), + } + ) + with patch("salt.modules.napalm_users.inspect.getfile", side_effect=getfile): + assert napalm_users._napalm_template_path(device, "set_users") is None + # No device / driver at all is handled too. + assert napalm_users._napalm_template_path({}, "set_users") is None + assert napalm_users._napalm_template_path(None, "set_users") is None + + +def test_set_users_routes_resolved_template(): + """ + #62170: set_users must hand the resolved absolute template path (not the + bare "set_users" name, which no longer resolves) to net.load_template. + """ + resolved = "/opt/napalm/junos/templates/set_users.j2" + load_template = MagicMock(return_value={"result": True, "comment": "", "out": None}) + template_path = MagicMock(return_value=resolved) + with patch( + "salt.utils.napalm.get_device", + MagicMock(return_value=napalm_test_support.MockNapalmDevice()), + ), patch.object(napalm_users, "_napalm_template_path", template_path), patch.dict( + napalm_users.__salt__, {"net.load_template": load_template} + ): + ret = napalm_users.set_users({"mircea": {"level": 1}}, test=True, commit=False) + assert ret == {"result": True, "comment": "", "out": None} + # It must ask for the "set_users" template, not "delete_users" (guards the + # copy-paste between the two near-identical functions). + assert template_path.call_args[0][1] == "set_users" + load_template.assert_called_once() + args, kwargs = load_template.call_args + assert args[0] == resolved + assert kwargs["users"] == {"mircea": {"level": 1}} + assert kwargs["test"] is True + assert kwargs["commit"] is False + # The open proxy device is threaded through so the load reuses the session. + assert "inherit_napalm_device" in kwargs + + +def test_delete_users_routes_resolved_template(): + """ + #62170: delete_users resolves and uses delete_users.j2 the same way. + """ + resolved = "/opt/napalm/junos/templates/delete_users.j2" + load_template = MagicMock(return_value={"result": True, "comment": "", "out": None}) + template_path = MagicMock(return_value=resolved) + with patch( + "salt.utils.napalm.get_device", + MagicMock(return_value=napalm_test_support.MockNapalmDevice()), + ), patch.object(napalm_users, "_napalm_template_path", template_path), patch.dict( + napalm_users.__salt__, {"net.load_template": load_template} + ): + ret = napalm_users.delete_users({"mircea": {}}) + assert ret == {"result": True, "comment": "", "out": None} + assert template_path.call_args[0][1] == "delete_users" + load_template.assert_called_once() + args, kwargs = load_template.call_args + assert args[0] == resolved + assert "inherit_napalm_device" in kwargs + + +def test_set_users_no_template_for_driver(): + """ + When the driver ships no such template, set_users returns a clear error + instead of leaking the confusing "Local file source set_users does not + exist" message from the fileserver. + """ with patch( "salt.utils.napalm.get_device", MagicMock(return_value=napalm_test_support.MockNapalmDevice()), + ), patch.object( + napalm_users, "_napalm_template_path", MagicMock(return_value=None) ): ret = napalm_users.set_users({"mircea": {}}) - assert ret["result"] is False + assert ret["result"] is False + assert "not available" in ret["comment"] -def test_delete_users(): +def test_delete_users_no_template_for_driver(): with patch( "salt.utils.napalm.get_device", MagicMock(return_value=napalm_test_support.MockNapalmDevice()), + ), patch.object( + napalm_users, "_napalm_template_path", MagicMock(return_value=None) ): ret = napalm_users.delete_users({"mircea": {}}) - assert ret["result"] is False + assert ret["result"] is False + assert "not available" in ret["comment"] diff --git a/tests/pytests/unit/states/test_netusers.py b/tests/pytests/unit/states/test_netusers.py new file mode 100644 index 000000000000..0ffb38abe137 --- /dev/null +++ b/tests/pytests/unit/states/test_netusers.py @@ -0,0 +1,53 @@ +""" +Unit tests for the netusers state. +""" + +import pytest + +import salt.states.netusers as netusers + + +@pytest.fixture +def configure_loader_modules(): + return {netusers: {}} + + +def test_expand_users_without_defaults(): + """ + Regression test for #62170. + + ``netusers.managed`` passes its ``defaults`` argument through to + ``_expand_users`` as ``common_users``. That argument is optional, so it is + ``None`` whenever the SLS does not declare any defaults -- the common case. + ``_expand_users`` must treat that as "no defaults" instead of crashing with + ``AttributeError: 'NoneType' object has no attribute 'update'``. + """ + users = {"admin": {"level": 15, "password": "$1$xyz", "sshkeys": []}} + assert netusers._expand_users(users, None) == users + + +def test_managed_refuses_to_wipe_all_users(): + """ + #62170 safety guard: when neither ``users`` nor ``defaults`` yields anyone + to manage, ``managed`` must refuse instead of removing every account on the + device (which the declarative diff would otherwise do). It must bail out + before touching the device. + """ + ret = netusers.managed("t", users={}, defaults=None) + assert ret["result"] is False + assert ret["changes"] == {} + assert "remove every user" in ret["comment"] + + +def test_expand_users_merges_defaults(): + """ + When defaults are provided they are merged with the per-device users, and + the per-device definition wins on a key collision. + """ + defaults = {"admin": {"level": 1}, "operator": {"level": 5}} + users = {"admin": {"level": 15}, "restricted": {"level": 1}} + assert netusers._expand_users(users, defaults) == { + "admin": {"level": 15}, + "operator": {"level": 5}, + "restricted": {"level": 1}, + } From c984ca7cf44693663ddc6981cbd9997176a83547 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 13 Jul 2026 16:58:29 -0400 Subject: [PATCH 105/469] Fix napalm_rpc_map override, netmiko error, and napalm_formula arg bugs (#69797) * Fix napalm_rpc_map override, netmiko error, and napalm_formula arg bugs napalm_mod: - rpc: napalm_map = config.get(...); napalm_map.update(default_map) let the built-in defaults clobber the user's napalm_rpc_map override (and mutated the config object). Start from the defaults and layer the user map on top. - netmiko_args: netmiko_device_type_map[__grains__["os"]] raised a raw KeyError for an os not in the map (community/custom drivers). Raise a clear CommandExecutionError naming the driver and the config option instead. napalm_formula: - container_path ignored its key/container/delim arguments (called _container_path(model) with defaults), so e.g. delim='//' was silently dropped. Forward them. - render_field read __grains__["os"] directly, raising KeyError when the os grain is absent. Use __grains__.get("os"). * Add changelog for #69797 --- changelog/69797.fixed.md | 7 +++ salt/modules/napalm_formula.py | 4 +- salt/modules/napalm_mod.py | 16 +++++-- .../unit/modules/napalm/test_formula.py | 17 +++++++ tests/pytests/unit/modules/napalm/test_mod.py | 48 +++++++++++++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 changelog/69797.fixed.md diff --git a/changelog/69797.fixed.md b/changelog/69797.fixed.md new file mode 100644 index 000000000000..57aeb765b6ad --- /dev/null +++ b/changelog/69797.fixed.md @@ -0,0 +1,7 @@ +Fixed four bugs in the ``napalm_mod`` and ``napalm_formula`` execution modules. +``napalm.rpc`` now honours a user-supplied ``napalm_rpc_map`` override instead of +letting the built-in defaults clobber it; ``napalm.netmiko_args`` raises a clear +error (rather than a raw ``KeyError``) for an ``os`` grain with no Netmiko device +type; ``napalm_formula.container_path`` now honours its ``key``/``container``/``delim`` +arguments; and ``napalm_formula.render_field`` no longer raises ``KeyError`` when the +``os`` grain is absent. diff --git a/salt/modules/napalm_formula.py b/salt/modules/napalm_formula.py index c69d376cd602..24660386e740 100644 --- a/salt/modules/napalm_formula.py +++ b/salt/modules/napalm_formula.py @@ -90,7 +90,7 @@ def container_path(model, key=None, container=None, delim=DEFAULT_TARGET_DELIM): - interfaces:interface:Ethernet1:subinterfaces:subinterface:0:config - interfaces:interface:Ethernet2:config """ - return list(_container_path(model)) + return list(_container_path(model, key=key, container=container, delim=delim)) def setval(key, val, dict_=None, delim=DEFAULT_TARGET_DELIM): @@ -287,7 +287,7 @@ def render_field(dictionary, field, prepend=None, append=None, quotes=False, **o if prepend is None: prepend = field.replace("_", "-") if append is None: - if __grains__["os"] in ("junos",): + if __grains__.get("os") in ("junos",): append = ";" else: append = "" diff --git a/salt/modules/napalm_mod.py b/salt/modules/napalm_mod.py index 84a40ad04ebb..32a959c1482a 100644 --- a/salt/modules/napalm_mod.py +++ b/salt/modules/napalm_mod.py @@ -529,7 +529,14 @@ def netmiko_args(**kwargs): netmiko_device_type_map.update( __salt__["config.get"]("netmiko_device_type_map", {}) ) - kwargs["device_type"] = netmiko_device_type_map[__grains__["os"]] + os_grain = __grains__.get("os") + if os_grain not in netmiko_device_type_map: + raise CommandExecutionError( + "Unable to map the '{}' NAPALM driver to a Netmiko device type. " + "Please add it to the netmiko_device_type_map configuration option " + "/ Pillar.".format(os_grain) + ) + kwargs["device_type"] = netmiko_device_type_map[os_grain] return kwargs @@ -1406,8 +1413,11 @@ def rpc(command, **kwargs): "eos": "napalm.pyeapi_run_commands", "nxos": "napalm.nxos_api_rpc", } - napalm_map = __salt__["config.get"]("napalm_rpc_map", {}) - napalm_map.update(default_map) + # User-supplied napalm_rpc_map entries must override the built-in defaults + # (the old order let default_map win), and we must not mutate the object + # config.get returns; start from the defaults and layer the user map on top. + napalm_map = dict(default_map) + napalm_map.update(__salt__["config.get"]("napalm_rpc_map", {})) fun = napalm_map.get(__grains__["os"], "napalm.netmiko_commands") return __salt__[fun](command, **kwargs) diff --git a/tests/pytests/unit/modules/napalm/test_formula.py b/tests/pytests/unit/modules/napalm/test_formula.py index 07a61783f4a0..31f2317f266c 100644 --- a/tests/pytests/unit/modules/napalm/test_formula.py +++ b/tests/pytests/unit/modules/napalm/test_formula.py @@ -196,3 +196,20 @@ def test_render_fields(): ) ret = napalm_formula.render_fields(config, "mtu", "description", quotes=True) assert ret == expected_render + + +def test_container_path_uses_delim(set_model): + # Regression: container_path dropped its delim (and key/container), always + # using the default ':'. With delim='//' no ':' should appear in the paths. + with patch("salt.utils.napalm.is_proxy", MagicMock(return_value=True)): + ret = napalm_formula.container_path(set_model.copy(), delim="//") + assert "interfaces//interface//Ethernet1//config" in ret + assert not any(":" in path for path in ret) + + +def test_render_field_no_os_grain(): + # 'os' grain absent must not raise KeyError; no junos trailing ';'. + config = {"description": "Interface description"} + with patch.dict(napalm_formula.__grains__, {}, clear=True): + ret = napalm_formula.render_field(config, "description", quotes=True) + assert ret == 'description "Interface description"' diff --git a/tests/pytests/unit/modules/napalm/test_mod.py b/tests/pytests/unit/modules/napalm/test_mod.py index 5b693c2de2a2..2ea1a14a4518 100644 --- a/tests/pytests/unit/modules/napalm/test_mod.py +++ b/tests/pytests/unit/modules/napalm/test_mod.py @@ -8,6 +8,7 @@ import salt.modules.napalm_mod as napalm_mod import tests.support.napalm as napalm_test_support +from salt.exceptions import CommandExecutionError from tests.support.mock import MagicMock, patch log = logging.getLogger(__file__) @@ -206,3 +207,50 @@ def test_config_kwargs_werid_transport_port(): ret = napalm_mod.pyeapi_nxos_api_args(kwargs=test_kwargs) assert ret["transport"] == "nxos_protocol" assert ret["port"] == 2080 + + +def test_rpc_user_map_overrides_default(): + # A user-supplied napalm_rpc_map entry must win over the built-in default + # (the old order let default_map clobber it), without mutating the config. + user_map = {"junos": "napalm.custom_rpc"} + custom = MagicMock(return_value="custom-result") + with patch.dict( + napalm_mod.__salt__, + { + "config.get": MagicMock(return_value=user_map), + "napalm.custom_rpc": custom, + }, + ), patch.dict(napalm_mod.__grains__, {"os": "junos"}): + # Call the undecorated body; the proxy_napalm_wrap decorator would try to + # open a real device (this fix is in the function body, not the wrapper). + ret = napalm_mod.rpc.__wrapped__("show version") + custom.assert_called_once_with("show version") + assert ret == "custom-result" + # the config object returned by config.get must not be mutated with defaults + assert user_map == {"junos": "napalm.custom_rpc"} + + +def test_netmiko_args_unknown_os_raises_clean_error(): + # An os grain not in the map (custom/community driver, no user override) + # must raise a clear CommandExecutionError, not a raw KeyError. + napalm_opts = { + "HOSTNAME": "device", + "USERNAME": "user", + "PASSWORD": "pass", + "TIMEOUT": 60, + "OPTIONAL_ARGS": {}, + } + with patch( + "salt.utils.napalm.get_device_opts", MagicMock(return_value=napalm_opts) + ), patch.object(napalm_mod, "HAS_NETMIKO", True), patch.object( + napalm_mod, "_get_netmiko_args", MagicMock(return_value={}) + ), patch.dict( + napalm_mod.__salt__, {"config.get": MagicMock(return_value={})} + ), patch.dict( + napalm_mod.__grains__, {"os": "customdriver"} + ): + with pytest.raises(CommandExecutionError) as exc: + napalm_mod.netmiko_args.__wrapped__() + # Specifically the "no device type for this driver" error (naming the os), + # not the earlier "netmiko is not installed" gate. + assert "customdriver" in str(exc.value) From 10432bbd6a0f27bc83d157901dd808e33f42bedc Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 13 Jul 2026 17:01:07 -0400 Subject: [PATCH 106/469] Invalidate grains cache in saltutil.refresh_grains (#69747) * Invalidate grains cache in saltutil.refresh_grains With grains_cache enabled, saltutil.refresh_grains reloaded grains via refresh_pillar/refresh_modules without invalidating the on-disk grains cache, so the reload re-read the stale grains.cache.p and the refresh was a no-op (#55667). sync_grains already dropped the cache on a grains sync; extract that into a _clear_grains_cache helper and call it from refresh_grains before the reload. Fixes #55667 * Add functional test for refresh_grains cache invalidation Per review on #69747: proves the real end-to-end behaviour (a changed grain value actually takes effect after saltutil.refresh_grains when grains_cache is enabled) through the real grains loader and minion_mods loader, not mocks. Pin- proofed: fails without the fix (the stale cache survives). --- changelog/55667.fixed.md | 1 + salt/modules/saltutil.py | 32 +++++++--- .../functional/modules/test_saltutil.py | 49 +++++++++++++++ tests/pytests/unit/modules/test_saltutil.py | 60 +++++++++++++++++++ 4 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 changelog/55667.fixed.md diff --git a/changelog/55667.fixed.md b/changelog/55667.fixed.md new file mode 100644 index 000000000000..4bcee6eb30f7 --- /dev/null +++ b/changelog/55667.fixed.md @@ -0,0 +1 @@ +Fixed ``saltutil.refresh_grains`` being a no-op when ``grains_cache`` is enabled; it now invalidates the on-disk grains cache before reloading so refreshed grain values take effect. diff --git a/salt/modules/saltutil.py b/salt/modules/saltutil.py index dabeae6d9f1a..0cf6bf1803e5 100644 --- a/salt/modules/saltutil.py +++ b/salt/modules/saltutil.py @@ -99,6 +99,23 @@ def _get_top_file_envs(): return envs +def _clear_grains_cache(): + """ + Remove the on-disk grains cache (``grains.cache.p``) so the next grains + load regenerates it. No-op when grains caching is disabled or the cache + file is absent. + """ + if not __opts__.get("grains_cache"): + return + cache_file = os.path.join(__opts__["cachedir"], "grains.cache.p") + if not os.path.isfile(cache_file): + return + try: + os.remove(cache_file) + except OSError: + log.error("Could not remove grains cache!") + + def _sync(form, saltenv=None, extmod_whitelist=None, extmod_blacklist=None): """ Sync the given directory in the given environment @@ -119,15 +136,8 @@ def _sync(form, saltenv=None, extmod_whitelist=None, extmod_blacklist=None): mod_file = os.path.join(__opts__["cachedir"], "module_refresh") with salt.utils.files.fopen(mod_file, "a"): pass - if ( - form == "grains" - and __opts__.get("grains_cache") - and os.path.isfile(os.path.join(__opts__["cachedir"], "grains.cache.p")) - ): - try: - os.remove(os.path.join(__opts__["cachedir"], "grains.cache.p")) - except OSError: - log.error("Could not remove grains cache!") + if form == "grains": + _clear_grains_cache() return ret @@ -401,6 +411,10 @@ def refresh_grains(**kwargs): clean_pillar_cache = kwargs.pop("clean_pillar_cache", False) if kwargs: salt.utils.args.invalid_kwargs(kwargs) + # Invalidate the on-disk grains cache so the reload below regenerates + # grains instead of re-reading stale cached values. Without this, + # saltutil.refresh_grains is a no-op when grains_cache is enabled (#55667). + _clear_grains_cache() # Modules and pillar need to be refreshed in case grains changes affected # them, and the module refresh process reloads the grains and assigns the # newly-reloaded grains to each execution module's __grains__ dunder. diff --git a/tests/pytests/functional/modules/test_saltutil.py b/tests/pytests/functional/modules/test_saltutil.py index f9e72a9f73e6..020c8ca37559 100644 --- a/tests/pytests/functional/modules/test_saltutil.py +++ b/tests/pytests/functional/modules/test_saltutil.py @@ -56,3 +56,52 @@ def test__get_top_file_envs(modules, get_top, destroy): assert get_top.called # Ensure destroy is getting called assert destroy.called + + +def test_refresh_grains_regenerates_cached_grain_value( + minion_opts, tmp_path, monkeypatch +): + """ + Functional regression test for #55667. + + With ``grains_cache`` enabled, ``salt.loader.grains`` serves grain values + from the on-disk cache without re-running the grain functions. + ``saltutil.refresh_grains`` must invalidate that cache so a changed grain + value actually takes effect on the next load -- the real end-to-end + behaviour the unit tests only approximate. Exercised through the real + ``minion_mods`` loader and the real grains loader; only the orthogonal + pillar refresh is mocked (it just avoids master auth and does not touch the + grains cache). Without the fix the cache survives, the stale value persists, + and the final assertion fails. + """ + # A custom grain whose value we drive via an environment variable, so we can + # change "the source" between loads without touching the grains cache. + grains_dir = tmp_path / "grains" + grains_dir.mkdir() + (grains_dir / "refresh55667.py").write_text( + "import os\n\n\n" + "def refresh55667():\n" + ' return {"refresh55667_grain": os.environ.get("REFRESH55667_CTL", "")}\n' + ) + minion_opts["cachedir"] = str(tmp_path) + minion_opts["grains_cache"] = True + minion_opts["grains_dirs"] = [str(grains_dir)] + cache_file = tmp_path / "grains.cache.p" + + # First load runs the grain and writes the on-disk cache. + monkeypatch.setenv("REFRESH55667_CTL", "before") + assert salt.loader.grains(minion_opts)["refresh55667_grain"] == "before" + assert cache_file.is_file() + + # The source changes, but a plain load still serves the stale cached value. + monkeypatch.setenv("REFRESH55667_CTL", "after") + assert salt.loader.grains(minion_opts)["refresh55667_grain"] == "before" + + # refresh_grains (real module, real __opts__) invalidates the cache. + modules = salt.loader.minion_mods(minion_opts, context={}) + with patch("salt.modules.saltutil.refresh_pillar"): + modules["saltutil.refresh_grains"]() + assert not cache_file.exists() + + # The refreshed grain value now takes effect. + assert salt.loader.grains(minion_opts)["refresh55667_grain"] == "after" diff --git a/tests/pytests/unit/modules/test_saltutil.py b/tests/pytests/unit/modules/test_saltutil.py index cdace558db9b..2259d57c6be2 100644 --- a/tests/pytests/unit/modules/test_saltutil.py +++ b/tests/pytests/unit/modules/test_saltutil.py @@ -121,6 +121,66 @@ def test_refresh_grains_clean_pillar_cache_with_refresh_false(): refresh_modules.assert_called() +def test_refresh_grains_clears_grains_cache_when_enabled(minion_opts, tmp_path): + """ + Regression test for #55667. + + With ``grains_cache`` enabled, ``saltutil.refresh_grains`` must invalidate + the on-disk grains cache (``grains.cache.p``) so the subsequent reload + regenerates grains instead of re-reading the stale cached values. This pins + the bug: without the fix ``refresh_grains`` never touches the cache file, so + it survives and this assertion fails. + """ + minion_opts["grains_cache"] = True + minion_opts["cachedir"] = str(tmp_path) + cache_file = tmp_path / "grains.cache.p" + cache_file.write_bytes(b"stale grains") + with patch("salt.modules.saltutil.refresh_pillar"): + saltutil.refresh_grains() + assert not cache_file.exists() + + +def test_refresh_grains_keeps_grains_cache_when_disabled(minion_opts, tmp_path): + """ + Inverse of #55667. + + When ``grains_cache`` is disabled there is no cache to invalidate, so + ``refresh_grains`` must not remove a same-named file that happens to exist. + """ + minion_opts["grains_cache"] = False + minion_opts["cachedir"] = str(tmp_path) + cache_file = tmp_path / "grains.cache.p" + cache_file.write_bytes(b"unrelated") + with patch("salt.modules.saltutil.refresh_pillar"): + saltutil.refresh_grains() + assert cache_file.exists() + + +def test_clear_grains_cache_branches(minion_opts, tmp_path): + """ + Guard the shared helper used by both refresh_grains and _sync: it removes + the cache only when grains_cache is enabled, and is a no-op when disabled or + when the cache file is absent. + """ + minion_opts["cachedir"] = str(tmp_path) + cache_file = tmp_path / "grains.cache.p" + + # disabled -> file preserved + minion_opts["grains_cache"] = False + cache_file.write_bytes(b"x") + saltutil._clear_grains_cache() + assert cache_file.exists() + + # enabled -> file removed + minion_opts["grains_cache"] = True + saltutil._clear_grains_cache() + assert not cache_file.exists() + + # enabled but no file -> no error + saltutil._clear_grains_cache() + assert not cache_file.exists() + + def test_sync_grains_default_clean_pillar_cache(): with patch("salt.modules.saltutil._sync"): with patch("salt.modules.saltutil.refresh_pillar") as refresh_pillar: From c14ba90b3de88bde503151417682c15fd760fb14 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 13 Jul 2026 17:02:00 -0400 Subject: [PATCH 107/469] Fix cp.push error message to name the real file_recv_max_size setting (#69767) The cp.push transfer-failure diagnostic told operators to check a 'file_recv_size_max' master setting, which does not exist. The correct key is 'file_recv_max_size'. Corrected the message wording and added tests covering the send-failure branch. Fixes #58121 --- changelog/58121.fixed.md | 1 + salt/modules/cp.py | 2 +- tests/pytests/unit/modules/test_cp.py | 108 ++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 changelog/58121.fixed.md diff --git a/changelog/58121.fixed.md b/changelog/58121.fixed.md new file mode 100644 index 000000000000..4c8ebcbfdaf4 --- /dev/null +++ b/changelog/58121.fixed.md @@ -0,0 +1 @@ +Corrected the cp.push transfer-failure error message to reference the real master setting ``file_recv_max_size`` instead of the non-existent ``file_recv_size_max``. diff --git a/salt/modules/cp.py b/salt/modules/cp.py index 892317e49f8c..533c3854b1b7 100644 --- a/salt/modules/cp.py +++ b/salt/modules/cp.py @@ -1010,7 +1010,7 @@ def push(path, keep_symlinks=False, upload_path=None, remove_source=False): log.error( "cp.push Failed transfer failed. Ensure master has " "'file_recv' set to 'True' and that the file " - "is not larger than the 'file_recv_size_max' " + "is not larger than the 'file_recv_max_size' " "setting on the master." ) return ret diff --git a/tests/pytests/unit/modules/test_cp.py b/tests/pytests/unit/modules/test_cp.py index 3cdb4f11ae90..a634d1be540c 100644 --- a/tests/pytests/unit/modules/test_cp.py +++ b/tests/pytests/unit/modules/test_cp.py @@ -202,3 +202,111 @@ def test_push(): id="abc", ) ) + + +def test_push_send_failure_error_message_58121(): + """ + When the master rejects the transfer (channel.send() returns falsy), + cp.push logs guidance that must reference the real master setting + 'file_recv_max_size', not the non-existent 'file_recv_size_max'. + """ + filename = "/saltines/test.file" + if salt.utils.platform.is_windows(): + filename = "C:\\saltines\\test.file" + with patch( + "salt.modules.cp.os.path", + MagicMock(isfile=Mock(return_value=True), wraps=cp.os.path), + ), patch( + "salt.modules.cp.os.path", + MagicMock(getsize=MagicMock(return_value=10), wraps=cp.os.path), + ), patch.multiple( + "salt.modules.cp", + _auth=MagicMock(**{"return_value.gen_token.return_value": "token"}), + __opts__=salt.loader.dunder.__opts__.with_default( + {"id": "abc", "file_buffer_size": 10} + ), + ), patch( + "salt.utils.files.fopen", mock_open(read_data=b"content") + ), patch( + "salt.channel.client.ReqChannel.factory", MagicMock() + ) as req_channel_factory_mock, patch( + "salt.modules.cp.log" + ) as log_mock: + # Force the send-failure branch: channel.send() -> falsy. + req_channel_factory_mock().__enter__.return_value.send.return_value = False + + # Production-exact call shape: cp.push(path) with the default + # keep_symlinks/upload_path/remove_source flags. + cp.push(filename) + + log_mock.error.assert_called_once() + error_message = log_mock.error.call_args.args[0] + # Positive: the message names the setting that actually exists. + assert "file_recv_max_size" in error_message + # Inverse / must-not-regress: the old, non-existent key is gone. + assert "file_recv_size_max" not in error_message + + +def test_push_send_failure_returns_send_result_58121(): + """ + Peripheral coverage: on transfer failure cp.push returns the falsy value + returned by channel.send() (the ``return ret`` path). Independent of the + error-message wording, so it is a stable guard on the failure branch. + """ + filename = "/saltines/test.file" + if salt.utils.platform.is_windows(): + filename = "C:\\saltines\\test.file" + with patch( + "salt.modules.cp.os.path", + MagicMock(isfile=Mock(return_value=True), wraps=cp.os.path), + ), patch( + "salt.modules.cp.os.path", + MagicMock(getsize=MagicMock(return_value=10), wraps=cp.os.path), + ), patch.multiple( + "salt.modules.cp", + _auth=MagicMock(**{"return_value.gen_token.return_value": "token"}), + __opts__=salt.loader.dunder.__opts__.with_default( + {"id": "abc", "file_buffer_size": 10} + ), + ), patch( + "salt.utils.files.fopen", mock_open(read_data=b"content") + ), patch( + "salt.channel.client.ReqChannel.factory", MagicMock() + ) as req_channel_factory_mock: + req_channel_factory_mock().__enter__.return_value.send.return_value = False + + assert cp.push(filename) is False + + +def test_push_success_logs_no_error_58121(): + """ + Inverse case that passes with and without the fix: a successful transfer + (channel.send() truthy) must not emit the failure error at all, so the + typo correction does not introduce a spurious error log on the happy path. + """ + filename = "/saltines/test.file" + if salt.utils.platform.is_windows(): + filename = "C:\\saltines\\test.file" + with patch( + "salt.modules.cp.os.path", + MagicMock(isfile=Mock(return_value=True), wraps=cp.os.path), + ), patch( + "salt.modules.cp.os.path", + MagicMock(getsize=MagicMock(return_value=10), wraps=cp.os.path), + ), patch.multiple( + "salt.modules.cp", + _auth=MagicMock(**{"return_value.gen_token.return_value": "token"}), + __opts__=salt.loader.dunder.__opts__.with_default( + {"id": "abc", "file_buffer_size": 10} + ), + ), patch( + "salt.utils.files.fopen", mock_open(read_data=b"content") + ), patch( + "salt.channel.client.ReqChannel.factory", MagicMock() + ), patch( + "salt.modules.cp.log" + ) as log_mock: + response = cp.push(filename) + + assert response is True, response + log_mock.error.assert_not_called() From c1b1954f00353a09f54cd56894113985f143a447 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 13 Jul 2026 17:06:33 -0400 Subject: [PATCH 108/469] Paginate DigitalOcean destroy_dns_records and drop Py2 decode (#69766) destroy_dns_records issued a single unpaginated request for a domain's DNS records, so the DigitalOcean API returned only the first page (20 records by default) and any matching record beyond page 1 was never deleted. Walk every page and accumulate the records before matching, mirroring the pagination idiom already used elsewhere in this driver. Also drop the leftover Python 2 r["name"].decode() call, which raised AttributeError on Python 3 for every domain that has records. Fixes #55143 --- changelog/55143.fixed.md | 1 + salt/cloud/clouds/digitalocean.py | 34 ++++-- .../unit/cloud/clouds/test_digitalocean.py | 103 ++++++++++++++++++ 3 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 changelog/55143.fixed.md diff --git a/changelog/55143.fixed.md b/changelog/55143.fixed.md new file mode 100644 index 000000000000..71d9b5bae580 --- /dev/null +++ b/changelog/55143.fixed.md @@ -0,0 +1 @@ +Fixed the DigitalOcean cloud driver so destroy_dns_records paginates through every page of DNS records instead of only the first page, and dropped a Python 2 ``.decode()`` call that crashed record matching on Python 3. diff --git a/salt/cloud/clouds/digitalocean.py b/salt/cloud/clouds/digitalocean.py index 6929195c9ddd..fb378b3318af 100644 --- a/salt/cloud/clouds/digitalocean.py +++ b/salt/cloud/clouds/digitalocean.py @@ -966,16 +966,34 @@ def destroy_dns_records(fqdn): domain = ".".join(fqdn.split(".")[-2:]) hostname = ".".join(fqdn.split(".")[:-2]) # TODO: remove this when the todo on 754 is available - try: - response = query(method="domains", droplet_id=domain, command="records") - except SaltCloudSystemExit: - log.debug("Failed to find domains.") - return False - log.debug("found DNS records: %s", pprint.pformat(response)) - records = response["domain_records"] + fetch = True + page = 1 + records = [] + + # The DigitalOcean API paginates DNS records (20 per page by default), so + # walk every page and accumulate the results, otherwise records past the + # first page are never seen and their entries are never deleted. + while fetch: + try: + response = query( + method="domains", + droplet_id=domain, + command="records?page=" + str(page) + "&per_page=200", + ) + except SaltCloudSystemExit: + log.debug("Failed to find domains.") + return False + log.debug("found DNS records: %s", pprint.pformat(response)) + records.extend(response["domain_records"]) + + page += 1 + try: + fetch = "next" in response["links"]["pages"] + except KeyError: + fetch = False if records: - record_ids = [r["id"] for r in records if r["name"].decode() == hostname] + record_ids = [r["id"] for r in records if r["name"] == hostname] log.debug("deleting DNS record IDs: %s", record_ids) for id_ in record_ids: try: diff --git a/tests/pytests/unit/cloud/clouds/test_digitalocean.py b/tests/pytests/unit/cloud/clouds/test_digitalocean.py index ec9494573640..4b223f27ac76 100644 --- a/tests/pytests/unit/cloud/clouds/test_digitalocean.py +++ b/tests/pytests/unit/cloud/clouds/test_digitalocean.py @@ -11,6 +11,7 @@ from salt.cloud.clouds import digitalocean from salt.exceptions import SaltCloudSystemExit +from tests.support.mock import MagicMock, patch log = logging.getLogger(__name__) @@ -24,3 +25,105 @@ def test_reboot_no_call(): digitalocean.reboot(name="fake_name") assert "The reboot action must be called with -a or --action." == str(excinfo.value) + + +def test_destroy_dns_records_pagination_55143(): + """ + destroy_dns_records must walk every page of DNS records, not just the + first, so a matching record that lives past page 1 is still deleted. + + Regression test for https://github.com/saltstack/salt/issues/55143: the + DigitalOcean API paginates records (20 per page by default), and the driver + only ever requested the first page, so records past it were never matched + or deleted. destroy() calls destroy_dns_records(name) with the minion name, + which splits into domain="example.com" / hostname="www" here. + """ + # Page 1: 20 non-matching records plus a "next" link so the loop pages on. + page1 = { + "domain_records": [{"id": i, "name": "other"} for i in range(1, 21)], + "links": { + "pages": { + "next": "https://api.digitalocean.com/v2/domains/example.com/records?page=2", + "last": "https://api.digitalocean.com/v2/domains/example.com/records?page=2", + } + }, + "meta": {"total": 21}, + } + # Page 2: the matching record, with no further links so the loop stops. + page2 = { + "domain_records": [{"id": 42, "name": "www"}], + "meta": {"total": 21}, + } + + def fake_query( + method=None, droplet_id=None, command=None, http_method="get", args=None + ): + # command == "records" is the unpaginated call the buggy driver made. + if command == "records" or command.startswith("records?page=1"): + return page1 + if command.startswith("records?page=2"): + return page2 + if command.startswith("records/") and http_method == "delete": + return True + raise AssertionError(f"unexpected query command={command!r}") + + query_mock = MagicMock(side_effect=fake_query) + with patch.object(digitalocean, "query", query_mock): + digitalocean.destroy_dns_records("www.example.com") + + commands = [call.kwargs.get("command") for call in query_mock.call_args_list] + # The loop must have paged past page 1. + assert any(c and c.startswith("records?page=2") for c in commands) + # The page-2 record was matched and deleted. + query_mock.assert_any_call( + method="domains", + droplet_id="example.com", + command="records/42", + http_method="delete", + ) + # Non-matching page-1 records must never be deleted. + for i in range(1, 21): + assert f"records/{i}" not in commands, f"non-matching record {i} was deleted" + + +def test_destroy_dns_records_no_matching_records_55143(): + """ + Inverse of the pagination fix: a domain whose record set is empty must + result in zero deletions. This passes with and without the fix -- an empty + record set yields no deletions regardless of how many pages are walked -- + so it guards against the paginated fix ever over-deleting. + """ + empty_page = {"domain_records": [], "meta": {"total": 0}} + + def fake_query( + method=None, droplet_id=None, command=None, http_method="get", args=None + ): + if command.startswith("records/"): + raise AssertionError("no record should be deleted") + return empty_page + + query_mock = MagicMock(side_effect=fake_query) + with patch.object(digitalocean, "query", query_mock): + digitalocean.destroy_dns_records("www.example.com") + + delete_calls = [ + call + for call in query_mock.call_args_list + if call.kwargs.get("http_method") == "delete" + ] + assert delete_calls == [] + + +def test_destroy_dns_records_domain_lookup_failure_55143(): + """ + Peripheral coverage of the touched function: when the record lookup raises + SaltCloudSystemExit (e.g. the domain is not managed by DigitalOcean), + destroy_dns_records returns False and attempts no deletions. + """ + query_mock = MagicMock(side_effect=SaltCloudSystemExit("boom")) + with patch.object(digitalocean, "query", query_mock): + result = digitalocean.destroy_dns_records("www.example.com") + + assert result is False + for call in query_mock.call_args_list: + assert call.kwargs.get("http_method", "get") != "delete" From 0e2be36965b38b6014d2071d162eb124a11902b9 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 13 Jul 2026 17:09:14 -0400 Subject: [PATCH 109/469] Terminate at.at() stdin with a trailing newline (#69768) at.at() piped the command to `at` without a trailing newline. Distro-patched `at` on Fedora/RHEL/CentOS (Fedora at-3.2.2-shell.patch, BZ 486844) writes its job delimiter immediately after the last stdin byte, so with no trailing newline the delimiter concatenates onto the final command (e.g. `shutdown -r nowmarcinDELIMITER7f94e9f4`), producing a job that never runs. Append a single newline after building stdin, covering both the tagged and untagged branches. This matches the newline a normal `echo cmd | at now` pipe already sends, so it is benign on Debian/Ubuntu/BSD. Fixes #58510 --- changelog/58510.fixed.md | 1 + salt/modules/at.py | 5 ++ tests/pytests/unit/modules/test_at.py | 74 +++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 changelog/58510.fixed.md diff --git a/changelog/58510.fixed.md b/changelog/58510.fixed.md new file mode 100644 index 000000000000..64540a971cde --- /dev/null +++ b/changelog/58510.fixed.md @@ -0,0 +1 @@ +Terminate the stdin piped to `at` with a trailing newline so distro-patched `at` (Fedora/RHEL) no longer concatenates its job delimiter onto the last command diff --git a/salt/modules/at.py b/salt/modules/at.py index 449e0f795236..27db4b16d026 100644 --- a/salt/modules/at.py +++ b/salt/modules/at.py @@ -261,6 +261,11 @@ def at(*args, **kwargs): # pylint: disable=C0103 stdin = "### SALT: {}\n{}".format(kwargs["tag"], " ".join(args[1:])) else: stdin = " ".join(args[1:]) + # Ensure the command is terminated with a newline. Distro-patched at + # (Fedora/RHEL, BZ 486844) appends its job delimiter immediately after the + # last stdin byte; without a trailing newline the marker concatenates onto + # the final command and produces a job that never executes. + stdin += "\n" cmd = [binary, args[0]] cmd_kwargs = {"stdin": stdin, "python_shell": False} diff --git a/tests/pytests/unit/modules/test_at.py b/tests/pytests/unit/modules/test_at.py index da5be7f4b0ec..25fa2be1255f 100644 --- a/tests/pytests/unit/modules/test_at.py +++ b/tests/pytests/unit/modules/test_at.py @@ -230,3 +230,77 @@ def test_atc(): with patch.object(at, "_cmd", return_value="101\tThu Dec 11 19:48:47 2014 A B"): assert at.atc(101) == "101\tThu Dec 11 19:48:47 2014 A B" + + +def test_at_stdin_trailing_newline_58510(atq_output): + """ + at.at() must terminate the stdin piped to ``at`` with a trailing newline + for both the tagged (``tag=`` kwarg) and untagged branches. + + Distro-patched at (Fedora/RHEL, BZ 486844) appends its job delimiter + immediately after the last stdin byte, so without a trailing newline the + marker concatenates onto the final command and produces a job that never + executes. Regression test for issue #58510. + """ + with patch("salt.modules.at.atq", MagicMock(return_value=atq_output)): + with patch.object(salt.utils.path, "which", return_value=True): + with patch.dict(at.__grains__, {"os_family": "RedHat", "os": "Linux"}): + # Tagged branch, production-exact: + # salt '*' at.at 12:05am '/sbin/reboot' tag=reboot + tag_mock = MagicMock(return_value="job 101") + with patch.dict(at.__salt__, {"cmd.run": tag_mock}): + at.at("12:05am", "/sbin/reboot", tag="reboot") + assert tag_mock.call_args.kwargs["stdin"].endswith("\n") + + # Untagged branch: + # salt '*' at.at 12:05am '/sbin/reboot' + notag_mock = MagicMock(return_value="job 101") + with patch.dict(at.__salt__, {"cmd.run": notag_mock}): + at.at("12:05am", "/sbin/reboot") + assert notag_mock.call_args.kwargs["stdin"].endswith("\n") + + +def test_at_stdin_payload_preserved_58510(atq_output): + """ + Inverse guard for issue #58510: appending the trailing newline must not + alter or duplicate the command payload. With trailing newlines stripped the + stdin must equal exactly what at.at() built before the fix, and a single + newline must not become a double newline. This passes with and without the + fix, so it fails a fix that mangles the payload instead of only appending a + newline. + """ + with patch("salt.modules.at.atq", MagicMock(return_value=atq_output)): + with patch.object(salt.utils.path, "which", return_value=True): + with patch.dict(at.__grains__, {"os_family": "RedHat", "os": "Linux"}): + tag_mock = MagicMock(return_value="job 101") + with patch.dict(at.__salt__, {"cmd.run": tag_mock}): + at.at("12:05am", "/sbin/reboot", tag="reboot") + tag_stdin = tag_mock.call_args.kwargs["stdin"] + assert tag_stdin.rstrip("\n") == "### SALT: reboot\n/sbin/reboot" + assert not tag_stdin.endswith("\n\n") + + notag_mock = MagicMock(return_value="job 101") + with patch.dict(at.__salt__, {"cmd.run": notag_mock}): + at.at("12:05am", "/sbin/reboot") + notag_stdin = notag_mock.call_args.kwargs["stdin"] + assert notag_stdin.rstrip("\n") == "/sbin/reboot" + assert not notag_stdin.endswith("\n\n") + + +def test_at_passes_cmd_and_runas_58510(atq_output): + """ + Peripheral coverage of the command construction in at.at() around the + touched stdin assembly: the timespec is passed as the second element of the + command list, cmd.run is invoked with python_shell=False, and an explicit + runas is forwarded. + """ + with patch("salt.modules.at.atq", MagicMock(return_value=atq_output)): + with patch.object(salt.utils.path, "which", return_value=True): + with patch.dict(at.__grains__, {"os_family": "RedHat", "os": "Linux"}): + mock = MagicMock(return_value="job 101") + with patch.dict(at.__salt__, {"cmd.run": mock}): + at.at("12:05am", "/sbin/reboot", tag="reboot", runas="jim") + cmd_arg = mock.call_args.args[0] + assert cmd_arg[1] == "12:05am" + assert mock.call_args.kwargs["python_shell"] is False + assert mock.call_args.kwargs["runas"] == "jim" From 70c8e4d705cd33fb31b6d7089bbd60607b09503a Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 13 Jul 2026 18:18:30 -0700 Subject: [PATCH 110/469] Fix Codecov CLI installation: keybase.io PGP key gone (#69801) The `Install Codecov CLI` step fetches the Codecov signing key from `https://keybase.io/codecovsecurity/pgp_keys.asc`, which now returns HTTP 404 (Keybase is effectively dead post-Zoom acquisition). This breaks the `Combine Code Coverage` job in `ci.yml` and the equivalent step in `depcheck.yml` on every push and PR. Swap the URL for `https://uploader.codecov.io/verification.gpg`, which returns HTTP 200 with the Codecov Uploader Verification Key (keyid `27034E7FDB850E0BBC2C62FF806BB28AED779869`) -- the same key that signs the `codecov.SHA256SUM.sig` the workflow already downloads and verifies. Fixes #69800 --- .github/workflows/ci.yml | 2 +- .github/workflows/depcheck.yml | 2 +- .github/workflows/templates/ci.yml.jinja | 2 +- changelog/69800.fixed.md | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog/69800.fixed.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ee0a8cedd50..8e8f0ce63229 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -571,7 +571,7 @@ jobs: # We can't yet use tokenless uploads with the codecov CLI # python3 -m pip install codecov-cli # - curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --import + curl -s https://uploader.codecov.io/verification.gpg | gpg --no-default-keyring --import curl -Os https://uploader.codecov.io/latest/linux/codecov curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig diff --git a/.github/workflows/depcheck.yml b/.github/workflows/depcheck.yml index c5e8ffb59c92..1852a5df1476 100644 --- a/.github/workflows/depcheck.yml +++ b/.github/workflows/depcheck.yml @@ -569,7 +569,7 @@ jobs: # We can't yet use tokenless uploads with the codecov CLI # python3 -m pip install codecov-cli # - curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --import + curl -s https://uploader.codecov.io/verification.gpg | gpg --no-default-keyring --import curl -Os https://uploader.codecov.io/latest/linux/codecov curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig diff --git a/.github/workflows/templates/ci.yml.jinja b/.github/workflows/templates/ci.yml.jinja index 0456bace8202..6d07d1e49640 100644 --- a/.github/workflows/templates/ci.yml.jinja +++ b/.github/workflows/templates/ci.yml.jinja @@ -355,7 +355,7 @@ # We can't yet use tokenless uploads with the codecov CLI # python3 -m pip install codecov-cli # - curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --import + curl -s https://uploader.codecov.io/verification.gpg | gpg --no-default-keyring --import curl -Os https://uploader.codecov.io/latest/linux/codecov curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig diff --git a/changelog/69800.fixed.md b/changelog/69800.fixed.md new file mode 100644 index 000000000000..06d643d51b4d --- /dev/null +++ b/changelog/69800.fixed.md @@ -0,0 +1 @@ +Fix Codecov CLI installation step by replacing dead keybase.io PGP key URL. From 2fa36fd09f9ed60dfde8a6c3c64e2c5c05b1ed37 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Tue, 14 Jul 2026 13:23:49 -0700 Subject: [PATCH 111/469] Fix sql_base ext_pillar crashing on str/bytes JSON rows (#69638) When as_json is True, sql_base.process_results assumed the database driver already decoded the single JSON column to a Python dict. That holds for psycopg2 with the json type and some PyMySQL configurations, but MySQLdb (and some PyMySQL setups) return the column as str or bytes, so dictupdate.update raised TypeError: Cannot update using non-dict types in dictupdate.update() for every row. This regressed when the multi-row merge fix in 0678ce80d46 replaced the per-row assignment with a merge. Decode str/bytes rows via json.loads before merging, and raise a clear TypeError if the decoded value is not a dict. Fixes #63684 --- changelog/63684.fixed.md | 1 + salt/pillar/sql_base.py | 20 +++++- tests/pytests/unit/pillar/test_sql_base.py | 75 ++++++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 changelog/63684.fixed.md diff --git a/changelog/63684.fixed.md b/changelog/63684.fixed.md new file mode 100644 index 000000000000..86b29e882903 --- /dev/null +++ b/changelog/63684.fixed.md @@ -0,0 +1 @@ +Fixed ``sql_base`` ext_pillar with ``as_json: True`` crashing with ``TypeError: Cannot update using non-dict types in dictupdate.update()`` when the database driver returns JSON columns as ``str`` or ``bytes`` (for example MySQLdb and some PyMySQL configurations). The row is now JSON-decoded before merging. diff --git a/salt/pillar/sql_base.py b/salt/pillar/sql_base.py index 2b702488d7e9..e73fc53fe1c7 100644 --- a/salt/pillar/sql_base.py +++ b/salt/pillar/sql_base.py @@ -199,6 +199,7 @@ """ import abc +import json import logging from collections import OrderedDict @@ -346,10 +347,23 @@ def process_results(self, rows): # crd is the Current Return Data level, to make this non-recursive. crd = self.focus - # We have just one field without any key, assume returned row is already a dict - # aka JSON storage + # We have just one field without any key, assume returned row is a + # JSON document (aka JSON storage). Some database drivers (for + # example MySQLdb and some PyMySQL configurations) return JSON + # columns as ``str`` or ``bytes`` rather than as a pre-decoded + # ``dict``, so decode the value first if needed. if self.as_json and self.num_fields == 1: - crd = update(crd, ret[0], merge_lists=self.as_list) + row = ret[0] + if isinstance(row, (bytes, bytearray)): + row = row.decode("utf-8") + if isinstance(row, str): + row = json.loads(row) + if not isinstance(row, dict): + raise TypeError( + "as_json rows must decode to a dict, got " + f"{type(row).__name__}" + ) + crd = update(crd, row, merge_lists=self.as_list) continue # Walk and create dicts above the final layer diff --git a/tests/pytests/unit/pillar/test_sql_base.py b/tests/pytests/unit/pillar/test_sql_base.py index 8126d0868c06..0d1853ea251c 100644 --- a/tests/pytests/unit/pillar/test_sql_base.py +++ b/tests/pytests/unit/pillar/test_sql_base.py @@ -42,3 +42,78 @@ def test_process_results_as_json(as_list): "c": {"d": [4, 5], "e": 6, "g": 8}, "f": [{"g": 7, "h": "test"}], } + + +@pytest.mark.parametrize("as_list", [True, False]) +def test_process_results_as_json_string_rows(as_list): + """ + Regression test for #63684: MySQLdb (and some PyMySQL configurations) + return JSON columns as ``str`` rather than as pre-decoded dicts. + ``process_results`` must decode string rows before merging so it does + not raise ``TypeError`` from ``dictupdate.update``. + """ + return_data = FakeExtPillar() + return_data.as_list = as_list + return_data.as_json = True + return_data.with_lists = None + return_data.enter_root(None) + return_data.process_fields(["json_data"], 0) + test_rows = [ + ('{"a": [1]}',), + ('{"b": [2, 3]}',), + ('{"a": [4]}',), + ('{"c": {"d": [4, 5], "e": 6}}',), + ('{"f": [{"g": 7, "h": "test"}], "c": {"g": 8}}',), + ] + return_data.process_results(test_rows) + assert return_data.result == { + "a": [1, 4] if as_list else [4], + "b": [2, 3], + "c": {"d": [4, 5], "e": 6, "g": 8}, + "f": [{"g": 7, "h": "test"}], + } + + +@pytest.mark.parametrize("as_list", [True, False]) +def test_process_results_as_json_bytes_rows(as_list): + """ + Regression test for #63684: some driver/charset combinations return JSON + columns as ``bytes``. ``process_results`` must decode bytes rows before + merging so it does not raise ``TypeError`` from ``dictupdate.update``. + """ + return_data = FakeExtPillar() + return_data.as_list = as_list + return_data.as_json = True + return_data.with_lists = None + return_data.enter_root(None) + return_data.process_fields(["json_data"], 0) + test_rows = [ + (b'{"a": [1]}',), + (b'{"b": [2, 3]}',), + (b'{"a": [4]}',), + (b'{"c": {"d": [4, 5], "e": 6}}',), + (b'{"f": [{"g": 7, "h": "test"}], "c": {"g": 8}}',), + ] + return_data.process_results(test_rows) + assert return_data.result == { + "a": [1, 4] if as_list else [4], + "b": [2, 3], + "c": {"d": [4, 5], "e": 6, "g": 8}, + "f": [{"g": 7, "h": "test"}], + } + + +def test_process_results_as_json_non_dict_string_row_raises(): + """ + Regression test for #63684: if a JSON row decodes to a non-dict value + (e.g. a scalar), raise a clear ``TypeError`` instead of blowing up + deep inside ``dictupdate.update``. + """ + return_data = FakeExtPillar() + return_data.as_list = False + return_data.as_json = True + return_data.with_lists = None + return_data.enter_root(None) + return_data.process_fields(["json_data"], 0) + with pytest.raises(TypeError): + return_data.process_results([("42",)]) From c485a658e589ffa5836c5a557caaadabf6ca4e59 Mon Sep 17 00:00:00 2001 From: Denis Safronenkov Date: Tue, 14 Jul 2026 23:35:18 +0300 Subject: [PATCH 112/469] Fix batch mode treating error payloads as minion IDs (#68672) Transport-level error payloads (e.g. {"error": "...", "failed": True}) were treated as valid minion returns in gather_minions(), _poll_iterators(), and _discover_late_minions(), causing spurious "Minion 'error' failed to respond" messages, KeyError: 'ret' crashes, and incorrect batch tracking. Also fixes the Python expression bug ("minions" and "jid") in ret which evaluated to only "jid" in ret, causing discovery payloads to be misidentified when the "minions" key was absent. Fixes #46876, #48509, #50238, #60724. Co-authored-by: Daniel A. Wozniak --- changelog/68672.fixed.md | 1 + salt/cli/batch.py | 34 ++++++++- tests/pytests/unit/cli/test_batch.py | 102 +++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 changelog/68672.fixed.md diff --git a/changelog/68672.fixed.md b/changelog/68672.fixed.md new file mode 100644 index 000000000000..dc10001dbe57 --- /dev/null +++ b/changelog/68672.fixed.md @@ -0,0 +1 @@ +Fix `salt` batch mode incorrectly treating transport-level error payloads as minion IDs, preventing spurious `Minion 'error' failed to respond` messages and hardening duplicate return handling. diff --git a/salt/cli/batch.py b/salt/cli/batch.py index fa3761871ed1..14b7c833556e 100644 --- a/salt/cli/batch.py +++ b/salt/cli/batch.py @@ -86,13 +86,15 @@ def gather_minions(self): fret = set() nret = set() for ret in ping_gen: - if ("minions" and "jid") in ret: + if "minions" in ret and "jid" in ret: for minion in ret["minions"]: nret.add(minion) continue else: try: m = next(iter(ret.keys())) + if not isinstance(m, str) or m == "error": + continue except StopIteration: if not self.quiet: salt.utils.stringutils.print_cli( @@ -376,7 +378,19 @@ def _poll_iterators(self, iters, minion_tracker, raw_mode, raw_by_minion): break continue if raw_mode: + if "data" not in part or part.get("error"): + log.debug( + "Skipping error payload in batch return (raw mode): %s", + part, + ) + continue minion_id = part["data"]["id"] + if not isinstance(minion_id, str) or minion_id == "error": + log.debug( + "Skipping error payload in batch return (raw mode): %s", + part, + ) + continue raw_by_minion[minion_id] = part new_returns[minion_id] = { "ret": part["data"].get("return"), @@ -392,7 +406,19 @@ def _poll_iterators(self, iters, minion_tracker, raw_mode, raw_by_minion): " probably a duplicate key".format(minion_id) ) else: + if "error" in part: + log.debug( + "Skipping error payload in batch return: %s", + part, + ) + continue for minion_id, mret in part.items(): + if not isinstance(minion_id, str): + log.debug( + "Skipping non-string key in batch return: %s", + part, + ) + continue raw_by_minion[minion_id] = copy.copy(mret) new_returns[minion_id] = mret if minion_id in minion_tracker[queue]["minions"]: @@ -424,6 +450,12 @@ def _discover_late_minions(self, state): minion_id = next(iter(ping_ret.keys())) except StopIteration: break + if not isinstance(minion_id, str) or minion_id == "error": + log.debug( + "Skipping error payload in late-minion discovery: %s", + ping_ret, + ) + continue if minion_id not in state["all_minions"]: state["all_minions"].append(minion_id) state["pending"].append(minion_id) diff --git a/tests/pytests/unit/cli/test_batch.py b/tests/pytests/unit/cli/test_batch.py index a50af52dc4f8..a1e3796be9b6 100644 --- a/tests/pytests/unit/cli/test_batch.py +++ b/tests/pytests/unit/cli/test_batch.py @@ -746,3 +746,105 @@ def _make_iter(*args, **kwargs): results = list(Batch.run(batch)) assert len(results) == 1 assert next(iter(results[0][0].values())) is True + + +def test_gather_minions_ignores_error_payload(batch): + """ + Transport-level error payloads (e.g. ``{"error": "...", "jid": "..."}`` + or ``{"error": "Authentication failure"}``) must not be treated as + minion IDs in gather_minions(). + + Regression for issues #46876, #48509, #50238, #60724. + """ + ping_returns = [ + # Transport-level error payload — must be ignored + {"error": "Authentication failure", "jid": "20260101000000"}, + # Legit discovery payload — emitted before individual pings + {"minions": ["minion1"], "jid": "20260101000001"}, + # Individual minion ping reply + {"minion1": {"ret": True}}, + ] + + batch.local.cmd_iter = MagicMock(return_value=iter(ping_returns)) + + batch.opts.update( + { + "tgt": "*", + "tgt_type": "glob", + "timeout": 5, + "gather_job_timeout": 5, + } + ) + + minions, _, _ = batch.gather_minions() + + assert "error" not in minions + assert minions == ["minion1"] + + +def test_gather_minions_fixes_minions_jid_check(batch): + """ + The Python expression ``("minions" and "jid") in ret`` evaluates to + ``"jid" in ret`` — a bug that causes the discovery payload to be + processed as a minion return rather than skipped. + + The fix replaces it with ``"minions" in ret and "jid" in ret`` so + the full-list discovery packet is handled correctly. + """ + # A payload that has "jid" but NOT "minions" — the old buggy check + # would have treated it as a discovery payload; the fixed check must + # fall through to the else branch. + ping_returns = [ + {"jid": "20260101000000"}, # has "jid", no "minions" — old code would skip + {"minion1": {"ret": True}}, + ] + + batch.local.cmd_iter = MagicMock(return_value=iter(ping_returns)) + + batch.opts.update( + { + "tgt": "*", + "tgt_type": "glob", + "timeout": 5, + "gather_job_timeout": 5, + } + ) + + minions, _, _ = batch.gather_minions() + + # minion1 must be discovered; the jid-only dict must not be treated as a + # discovery packet that feeds "minion1" into nret (it has no "minions" key). + assert "minion1" in minions + + +def test_run_ignores_error_payload_in_cmd_returns(batch): + """ + When ``cmd_iter_no_block`` yields a transport-level error dict + (keyed by ``"error"`` instead of a real minion ID) the batch run + must silently skip it rather than treating ``"error"`` as a minion. + + Regression for the ``KeyError: 'ret'`` crash described in #46876. + """ + batch.opts = { + "batch": "1", + "timeout": 5, + "fun": "test.ping", + "arg": [], + "gather_job_timeout": 5, + } + batch.gather_minions = MagicMock(return_value=[["minion1"], [], []]) + + def _make_iter(*args, **kwargs): + # First yield is a transport-level error payload + yield {"error": "Publish failed", "failed": True} + # Second yield is the real minion return + yield {"minion1": {"ret": True, "retcode": 0}} + + batch.local.cmd_iter_no_block = MagicMock(side_effect=_make_iter) + batch.local.event.get_event = MagicMock(return_value=None) + + results = list(Batch.run(batch)) + + returned_minions = [next(iter(d.keys())) for d, _rc in results] + assert "error" not in returned_minions + assert "minion1" in returned_minions From 4decf8db50eeefc97118fa51e79181bf5ccb8e3f Mon Sep 17 00:00:00 2001 From: Alan Somers Date: Tue, 14 Jul 2026 14:56:00 -0600 Subject: [PATCH 113/469] [master] Don't crash if dmesg contains non-utf8 characters (#66764) * Don't crash if dmesg contains non-utf8 characters On FreeBSD Salt scrapes /var/run/dmesg.boot to set the "cpu_flags" grain. But it's possible for that file to contain non-UTF-8 characters. Skipping over such characters is better than crashing. Signed-off-by: Alan Somers * Add regression test for non-UTF-8 dmesg in _bsd_cpudata Covers the FreeBSD startup crash fixed in this PR: when /var/run/dmesg.boot contains non-UTF-8 bytes, loading the cpu_flags grain previously raised UnicodeDecodeError. The new test feeds a dmesg.boot with embedded 0xff/0xfe bytes through _bsd_cpudata and asserts the readable Features= line is still extracted. --------- Signed-off-by: Alan Somers Co-authored-by: Daniel A. Wozniak --- changelog/66764.fixed.md | 1 + salt/grains/core.py | 4 ++- tests/pytests/unit/grains/test_core.py | 48 ++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 changelog/66764.fixed.md diff --git a/changelog/66764.fixed.md b/changelog/66764.fixed.md new file mode 100644 index 000000000000..8d7834eace8e --- /dev/null +++ b/changelog/66764.fixed.md @@ -0,0 +1 @@ +Fix a crash on startup on FreeBSD when /var/run/dmesg.boot contains non-UTF8 characters. diff --git a/salt/grains/core.py b/salt/grains/core.py index ef9ac8f9f3b4..d7f9ffe8328e 100644 --- a/salt/grains/core.py +++ b/salt/grains/core.py @@ -460,7 +460,9 @@ def _bsd_cpudata(osdata): if osdata["kernel"] == "FreeBSD" and os.path.isfile("/var/run/dmesg.boot"): grains["cpu_flags"] = [] # TODO: at least it needs to be tested for BSD other then FreeBSD - with salt.utils.files.fopen("/var/run/dmesg.boot", "r") as _fp: + with salt.utils.files.fopen( + "/var/run/dmesg.boot", "r", encoding="utf8", errors="ignore" + ) as _fp: cpu_here = False for line in _fp: if line.startswith("CPU: "): diff --git a/tests/pytests/unit/grains/test_core.py b/tests/pytests/unit/grains/test_core.py index 094229fc2dd7..00e3ea4f5ee8 100644 --- a/tests/pytests/unit/grains/test_core.py +++ b/tests/pytests/unit/grains/test_core.py @@ -4958,6 +4958,54 @@ def test__bsd_cpudata_freebsd(): ] +def test__bsd_cpudata_freebsd_non_utf8(tmp_path): + """ + Regression test for #66764. + + /var/run/dmesg.boot can contain non-UTF-8 bytes (e.g. when a connected + device exposes a serial number with non-UTF-8 characters). Loading the + "cpu_flags" grain on FreeBSD must not raise UnicodeDecodeError in that + case; the offending bytes should be skipped and the readable CPU + features still extracted. + """ + boot = tmp_path / "dmesg.boot" + # The CPU: line contains non-UTF-8 bytes (0xff, 0xfe) that would crash + # a strict utf-8 decode. The Features= line is valid ASCII and must + # still be parsed. + boot.write_bytes( + b"CPU: Intel(R) Test CPU \xff\xfe garbage\n" + b' Origin="GenuineIntel"\n' + b" Features=0x1\n" + b"real memory = 0\n" + ) + + osdata = {"kernel": "FreeBSD"} + mock_cmd_run = ["1", "amd64", "Intel(R) Test CPU"] + + # Delegate to the real open() so the encoding/errors kwargs added by + # the fix are actually exercised against the non-UTF-8 bytes on disk. + # Using open() directly here (rather than salt.utils.files.fopen) is + # intentional: salt.utils.files.fopen is what we are patching. + def _real_fopen(_path, *args, **kwargs): + return open( # pylint: disable=resource-leakage,unspecified-encoding + str(boot), *args, **kwargs + ) + + with patch("salt.utils.path.which", return_value="/sbin/sysctl"): + with patch.dict( + core.__salt__, + {"cmd.run": MagicMock(side_effect=mock_cmd_run)}, + ): + with patch("os.path.isfile", return_value=True): + with patch("salt.utils.files.fopen", side_effect=_real_fopen): + # The pre-fix code raised UnicodeDecodeError here. + ret = core._bsd_cpudata(osdata) + + assert "cpu_flags" in ret + assert ret["cpu_flags"] == ["FPU", "VME", "DE"] + assert ret["num_cpus"] == 1 + + def test__bsd_cpudata_netbsd(): """ test _bsd_cpudata for NetBSD From 99c9d9d1ba513abd495b2d9a9050003920e63895 Mon Sep 17 00:00:00 2001 From: Victor Zhestkov Date: Tue, 14 Jul 2026 23:17:26 +0200 Subject: [PATCH 114/469] [master] Handle disconnects from with ZeroMQ and make it re-resolve master IP (#66760) * Make minion reconnecting on changing master IP with zeromq transport * Add changelog entry * Set master_tries default to -1 * Fix the test with setting proper master_tries which should be used for such case * Fix the tests --- changelog/66760.added.md | 1 + salt/config/__init__.py | 4 ++-- salt/transport/zeromq.py | 13 +++++++++++++ .../integration/minion/test_return_retries.py | 1 + tests/pytests/scenarios/multimaster/conftest.py | 4 ++++ tests/pytests/unit/test_minion.py | 1 + 6 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 changelog/66760.added.md diff --git a/changelog/66760.added.md b/changelog/66760.added.md new file mode 100644 index 000000000000..4a0c53675f84 --- /dev/null +++ b/changelog/66760.added.md @@ -0,0 +1 @@ +Added possibility for the minion to reconnect to the master on it's IP address change with using ZeroMQ diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 6e32516235a3..cea2c69d4f2d 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -79,7 +79,7 @@ else: _DFLT_IPC_MODE = "ipc" _DFLT_FQDNS_GRAINS = False - _MASTER_TRIES = 1 + _MASTER_TRIES = -1 _MASTER_USER = salt.utils.user.get_user() @@ -1406,7 +1406,7 @@ def _gather_buffer_space(): "username": None, "password": None, "zmq_filtering": False, - "zmq_monitor": False, + "zmq_monitor": True, "cache_sreqs": True, "cmd_safe": True, "sudo_user": "", diff --git a/salt/transport/zeromq.py b/salt/transport/zeromq.py index 84046009b70e..8be7462fe4d8 100644 --- a/salt/transport/zeromq.py +++ b/salt/transport/zeromq.py @@ -325,6 +325,12 @@ async def connect( master_pub_uri, ) self._socket.connect(master_pub_uri) + if ( + hasattr(self, "_monitor") + and self._monitor is not None + and disconnect_callback is not None + ): + self._monitor.disconnect_callback = disconnect_callback if connect_callback: await connect_callback(True) @@ -1637,6 +1643,12 @@ def monitor_callback(self, msg): log.debug("ZeroMQ event: %s", evt) if evt["event"] == zmq.EVENT_MONITOR_STOPPED: self.stop() + elif evt["event"] == zmq.EVENT_DISCONNECTED: + if ( + hasattr(self, "disconnect_callback") + and self.disconnect_callback is not None + ): + self.disconnect_callback() def stop(self): if self._socket is None: @@ -1652,6 +1664,7 @@ def stop(self): pass self._socket = None self._running.clear() + self._monitor_socket.close() self._monitor_socket = None log.trace("Event monitor done!") diff --git a/tests/pytests/integration/minion/test_return_retries.py b/tests/pytests/integration/minion/test_return_retries.py index 37573662539e..8ca8ea53805f 100644 --- a/tests/pytests/integration/minion/test_return_retries.py +++ b/tests/pytests/integration/minion/test_return_retries.py @@ -18,6 +18,7 @@ def salt_minion_retry(salt_master, salt_minion_id): "fips_mode": FIPS_TESTRUN, "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", "signing_algorithm": "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1", + "zmq_monitor": False, } factory = salt_master.salt_minion_daemon( random_string("retry-minion-"), diff --git a/tests/pytests/scenarios/multimaster/conftest.py b/tests/pytests/scenarios/multimaster/conftest.py index e5358e75c8ff..7ba77a0402d8 100644 --- a/tests/pytests/scenarios/multimaster/conftest.py +++ b/tests/pytests/scenarios/multimaster/conftest.py @@ -157,6 +157,8 @@ def mm_master_2_salt_cli(salt_mm_master_2): def _salt_mm_minion_1(_salt_mm_master_1, _salt_mm_master_2): config_defaults = { "transport": _salt_mm_master_1.config["transport"], + "zmq_monitor": False, + "master_tries": 1, } mm_master_1_port = _salt_mm_master_1.config["ret_port"] @@ -218,6 +220,8 @@ def salt_mm_minion_1(_salt_mm_minion_1, salt_mm_master_1, salt_mm_master_2): def _salt_mm_minion_2(_salt_mm_master_1, _salt_mm_master_2): config_defaults = { "transport": _salt_mm_master_1.config["transport"], + "zmq_monitor": False, + "master_tries": 1, } mm_master_1_port = _salt_mm_master_1.config["ret_port"] diff --git a/tests/pytests/unit/test_minion.py b/tests/pytests/unit/test_minion.py index 11784d4b4f81..6cea8e95a462 100644 --- a/tests/pytests/unit/test_minion.py +++ b/tests/pytests/unit/test_minion.py @@ -1640,6 +1640,7 @@ async def test_master_type_failover(minion_opts): "master": ["master1", "master2"], "__role": "", "retry_dns": 0, + "master_tries": 1, } ) From 40341c5df2b49b437747edb1b34a53c8f3a3bacc Mon Sep 17 00:00:00 2001 From: Charles Strahan Date: Tue, 14 Jul 2026 16:39:07 -0500 Subject: [PATCH 115/469] Fix manage.status/up/down reporting dead minions as up (#69592) manage._ping calls LocalClient.get_cli_event_returns to gather test.ping returns and counts every yielded minion id as alive. Since 3008.0 (commit 4075aeb096) the get_cli_event_returns default for expect_minions flipped False -> True, so the gather now yields a {"out": "no_return", "ret": "Minion did not return..."} placeholder for every non-responder. _ping does not distinguish that placeholder from a real return, so non-responders are added to the "up" set and "down" ends up (effectively) always empty -- every key-accepted minion reports as up, dead or not. The expect_minions=True default is correct for the salt CLI (it wants per-target timeout rows), but manage._ping wants actual liveness. Pass expect_minions=False from _ping so it counts only real returns, restoring 3006/3007 behavior: dead minions correctly report in "down". Fixes #69582 --- changelog/69582.fixed.md | 1 + salt/runners/manage.py | 8 +++++ tests/pytests/unit/runners/test_manage.py | 43 +++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 changelog/69582.fixed.md diff --git a/changelog/69582.fixed.md b/changelog/69582.fixed.md new file mode 100644 index 000000000000..5af9d927dec7 --- /dev/null +++ b/changelog/69582.fixed.md @@ -0,0 +1 @@ +Fixed `manage.status`, `manage.up`, and `manage.down` reporting unresponsive minions as up. Since 3007.0 `manage._ping` gathered `test.ping` returns with `get_cli_event_returns(expect_minions=True)`, whose per-target timeout placeholders were counted as returns, so every key-accepted minion landed in `up` and `down` was always empty. `_ping` now requests only real returns (`expect_minions=False`), so dead minions are correctly reported as down. diff --git a/salt/runners/manage.py b/salt/runners/manage.py index 52e54bcdb918..816fea75cf52 100644 --- a/salt/runners/manage.py +++ b/salt/runners/manage.py @@ -52,6 +52,14 @@ def _ping(tgt, tgt_type, timeout, gather_job_timeout): tgt, tgt_type, gather_job_timeout=gather_job_timeout, + # Request actual returns only. With expect_minions=True (the + # get_cli_event_returns default since 3007.0), the gather yields a + # {"out": "no_return", "ret": "Minion did not return..."} placeholder + # for every non-responder. _ping counts every yielded minion id as + # "returned", so those placeholders would land non-responders in the + # "up" list (and leave "down" empty). The runner wants liveness, not + # the CLI's per-target timeout rows, so opt out of the placeholders. + expect_minions=False, ): if fn_ret: diff --git a/tests/pytests/unit/runners/test_manage.py b/tests/pytests/unit/runners/test_manage.py index 9f300a7674d4..369933e2335b 100644 --- a/tests/pytests/unit/runners/test_manage.py +++ b/tests/pytests/unit/runners/test_manage.py @@ -1,6 +1,14 @@ import pytest from salt.runners import manage +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return { + manage: {"__opts__": {"conf_file": "", "timeout": 5, "gather_job_timeout": 10}} + } def test_deprecation_58638(): @@ -18,3 +26,38 @@ def test_deprecation_58638(): assert str(no_show_ipv4).startswith( "list_state() got an unexpected keyword argument 'show_ipv4'" ) + + +def test_status_reports_unresponsive_minion_as_down(): + """ + manage.status/up/down must classify a key-accepted but unresponsive minion + as down, not up. + + Regression (3007.0): _ping gathers test.ping returns via + LocalClient.get_cli_event_returns and counts every yielded minion id as a + return. When get_cli_event_returns is called with expect_minions=True (its + default since 3007.0), the gather yields a + ``{"out": "no_return", "ret": "Minion did not return..."}`` placeholder for + every non-responder, so _ping counted non-responders as up and "down" was + always empty. _ping must request only real returns (expect_minions=False). + """ + mock_client = MagicMock() + mock_client.run_job.return_value = { + "jid": "20260101000000000000", + "minions": ["alive-minion", "dead-minion"], + } + mock_client._get_timeout.return_value = 5 + # With expect_minions=False, only the responder yields a return; the + # non-responder produces no entry (no timeout placeholder). + mock_client.get_cli_event_returns.return_value = iter( + [{"alive-minion": {"ret": True}}] + ) + + with patch("salt.client.get_local_client") as get_local_client: + get_local_client.return_value.__enter__.return_value = mock_client + result = manage.status(tgt="*") + + assert result == {"up": ["alive-minion"], "down": ["dead-minion"]} + # The fix: _ping must opt out of the per-target timeout placeholders. + _, kwargs = mock_client.get_cli_event_returns.call_args + assert kwargs.get("expect_minions") is False From fb1f214ff7688d2960901359ad9b426c585749c3 Mon Sep 17 00:00:00 2001 From: scott-sturdivant <125936926+scott-sturdivant@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:43:52 -0600 Subject: [PATCH 116/469] Allow runas env retrieval to not block. (#63912) * Allow runas env retrieval to not block. * Add regression test for runas env retrieval timeout Pins the behavior added in this PR: when the env-retrieval subprocess hangs and trips the new 10s timeout in _run(), the resulting subprocess.TimeoutExpired is caught and routed into the existing "Environment could not be retrieved" error path, so _run continues with an empty runas env instead of propagating the exception. The test mocks subprocess.Popen so .communicate() raises TimeoutExpired, mocks TimedProc to short-circuit the actual command execution, asserts the documented log.error fires, and asserts _run returns normally. Against the pre-PR baseline of cmdmod.py the test fails with TimeoutExpired propagating out of _run, confirming it discriminates the fix. Refs: #63901 Co-authored-by: scott-sturdivant --------- Co-authored-by: Daniel A. Wozniak Co-authored-by: scott-sturdivant --- changelog/63901.fixed.md | 1 + salt/modules/cmdmod.py | 21 ++++-- tests/pytests/unit/modules/test_cmdmod.py | 81 +++++++++++++++++++++++ 3 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 changelog/63901.fixed.md diff --git a/changelog/63901.fixed.md b/changelog/63901.fixed.md new file mode 100644 index 000000000000..d754fc377119 --- /dev/null +++ b/changelog/63901.fixed.md @@ -0,0 +1 @@ +Do not allow runas env retrieval to block. diff --git a/salt/modules/cmdmod.py b/salt/modules/cmdmod.py index d259a2d72227..5fe699f375f8 100644 --- a/salt/modules/cmdmod.py +++ b/salt/modules/cmdmod.py @@ -607,15 +607,22 @@ def _run( msg = f"env command: {env_cmd}" log.debug(log_callback(msg)) - env_bytes, env_encoded_err = subprocess.Popen( - env_cmd, - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE, - ).communicate(salt.utils.stringutils.to_bytes(py_code)) + try: + env_bytes, env_encoded_err = subprocess.Popen( + env_cmd, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + ).communicate(salt.utils.stringutils.to_bytes(py_code), timeout=10) + except subprocess.TimeoutExpired: + marker_count = 0 + env_encoded_err = None + env_bytes = None + else: + marker_count = env_bytes.count(marker_b) + if salt.utils.pkg.check_bundled(): os.remove(fp.name) - marker_count = env_bytes.count(marker_b) if marker_count == 0: # Possibly PAM prevented the login log.error( diff --git a/tests/pytests/unit/modules/test_cmdmod.py b/tests/pytests/unit/modules/test_cmdmod.py index 9d5fcf264ba2..a4eedfb9d80c 100644 --- a/tests/pytests/unit/modules/test_cmdmod.py +++ b/tests/pytests/unit/modules/test_cmdmod.py @@ -299,6 +299,87 @@ def test_run_user_not_available(): cmdmod._run("foo", "bar", runas="baz") +@pytest.mark.skip_on_windows +def test_run_runas_env_retrieval_timeout(caplog): + """ + Regression test for issue #63901 / PR #63912. + + When ``runas`` is supplied, ``_run`` shells out to fetch the user's + environment by piping a Python snippet through ``su``/``sudo`` and + reading the result back with ``subprocess.Popen.communicate()``. On + misconfigured PAM stacks (e.g. WINBIND), that subprocess can hang + indefinitely. + + The fix adds ``timeout=10`` to that ``communicate()`` call and routes + a ``subprocess.TimeoutExpired`` into the existing "Environment could + not be retrieved" branch so execution continues with an empty + runas env rather than wedging the minion. + + This test pins that behavior: the ``TimeoutExpired`` is swallowed, + the documented log.error is emitted, and ``_run`` proceeds to + invoke the actual command via ``TimedProc`` instead of raising. + """ + import subprocess as _subprocess + + mock_true = MagicMock(return_value=True) + + # subprocess.Popen used for env retrieval; .communicate() must raise + # TimeoutExpired to drive the new except branch. + env_popen_instance = MagicMock() + env_popen_instance.communicate.side_effect = _subprocess.TimeoutExpired( + cmd=["su", "-", "baz", "-c"], timeout=10 + ) + env_popen_cls = MagicMock(return_value=env_popen_instance) + + # After env retrieval falls back to empty env, the actual command runs + # via salt.utils.timed_subprocess.TimedProc -- mock that out so the + # test does not execute a real subprocess. + mock_timed_proc = MockTimedProc(stdout=b"ok\n", stderr=b"") + + # pwd.getpwnam must succeed so the runas user is considered valid. + fake_pw = MagicMock(pw_name="baz", pw_shell="/bin/sh") + + with patch("salt.modules.cmdmod._is_valid_shell", mock_true), patch( + "salt.utils.platform.is_windows", MagicMock(return_value=False) + ), patch("os.path.isfile", mock_true), patch("os.access", mock_true), patch( + "os.path.isabs", mock_true + ), patch( + "os.path.isdir", mock_true + ), patch( + "pwd.getpwnam", MagicMock(return_value=fake_pw) + ), patch( + "pwd.getpwall", MagicMock(return_value=[fake_pw]) + ), patch( + "salt.utils.pkg.check_bundled", MagicMock(return_value=False) + ), patch.dict( + cmdmod.__grains__, {"os": "Linux", "os_family": "Debian"}, clear=False + ), patch( + "subprocess.Popen", env_popen_cls + ), patch( + "salt.utils.timed_subprocess.TimedProc", + MagicMock(return_value=mock_timed_proc), + ): + with caplog.at_level(logging.ERROR, logger="salt.modules.cmdmod"): + # Must not raise; TimeoutExpired must be caught inside _run. + ret = cmdmod._run("echo hi", "bar", runas="baz", python_shell=True) + + # The fix routes the TimeoutExpired into the existing "Environment + # could not be retrieved" error log. + assert any( + "Environment could not be retrieved for user" in record.getMessage() + and "baz" in record.getMessage() + for record in caplog.records + ), ( + "Expected 'Environment could not be retrieved' log.error to fire " + "when env-retrieval subprocess times out; got: " + f"{[r.getMessage() for r in caplog.records]}" + ) + + # Sanity: _run returned the dict shape callers expect (no exception + # propagated past the timeout handler). + assert isinstance(ret, dict) + + def test_run_zero_umask(): """ Tests error raised when umask is set to zero From 9d56ab1e94139cb9a8664cd9b4539d1c5e209c40 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Tue, 14 Jul 2026 14:46:53 -0700 Subject: [PATCH 117/469] Fix mac_brew_pkg.homebrew_prefix triggering su on every invocation (#69674) homebrew_prefix() passes runas= unconditionally to cmdmod.run. On macOS, cmdmod.run wraps the command in `su -l -c ...` whenever runas is truthy, even when is the current process user. On a non-root TTY invocation this prompts for a password; on a non-TTY invocation it prints `su: Sorry` and a `Command 'brew' failed with return code: 1` line on every salt-ssh startup. Only forward runas when the brew binary is owned by a different user than the current process. When the owner matches the current user, short-circuit to runas=None so the su wrap is skipped. Defensively tolerate getpass.getuser() raising in exotic environments (empty passwd db, some container images) by falling back to the pre-fix behavior. Fixes #69027 --- changelog/69027.fixed.md | 1 + salt/modules/mac_brew_pkg.py | 83 ++++++++ .../pytests/unit/modules/test_mac_brew_pkg.py | 186 +++++++++++++++++- 3 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 changelog/69027.fixed.md diff --git a/changelog/69027.fixed.md b/changelog/69027.fixed.md new file mode 100644 index 000000000000..1adbe83b8f9f --- /dev/null +++ b/changelog/69027.fixed.md @@ -0,0 +1 @@ +Fixed ``mac_brew_pkg.homebrew_prefix()`` triggering a ``su`` password prompt (or ``su: Sorry`` error) on every invocation when the ``brew`` binary is owned by the current user. The probe now only passes ``runas=`` to ``cmdmod.run`` when the brew binary owner differs from the current process user, avoiding the unconditional ``su -l`` wrap on macOS. diff --git a/salt/modules/mac_brew_pkg.py b/salt/modules/mac_brew_pkg.py index c02962fa3e5a..17f159c97b10 100644 --- a/salt/modules/mac_brew_pkg.py +++ b/salt/modules/mac_brew_pkg.py @@ -9,7 +9,9 @@ """ import copy +import getpass import logging +import os import salt.utils.data import salt.utils.functools @@ -93,6 +95,33 @@ def _tap(tap, runas=None): return True +def _homebrew_os_bin(): + """ + Fetch PATH binary brew full path eg: /usr/local/bin/brew (symbolic link) + """ + + original_path = os.environ.get("PATH") + try: + # Add "/opt/homebrew" temporary to the PATH for Apple Silicon if + # the PATH does not include "/opt/homebrew" + current_path = original_path or "" + homebrew_path = "/opt/homebrew/bin" + if homebrew_path not in current_path.split(os.path.pathsep): + extended_path = os.path.pathsep.join([current_path, homebrew_path]) + os.environ["PATH"] = extended_path.lstrip(os.path.pathsep) + + # Search for the brew executable in the current PATH + brew = salt.utils.path.which("brew") + finally: + # Restore original PATH + if original_path is None: + del os.environ["PATH"] + else: + os.environ["PATH"] = original_path + + return brew + + def _homebrew_bin(): """ Returns the full path to the homebrew binary in the PATH @@ -138,6 +167,60 @@ def _list_pkgs_from_context(versions_as_list): return ret +def homebrew_prefix(): + """ + Returns the full path to the homebrew prefix. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.homebrew_prefix + """ + + # If HOMEBREW_PREFIX env variable is present, use it + env_homebrew_prefix = "HOMEBREW_PREFIX" + if env_homebrew_prefix in os.environ: + log.debug("%s is set. Using it for homebrew prefix.", env_homebrew_prefix) + return os.environ[env_homebrew_prefix] + + # Try brew --prefix otherwise + try: + log.debug("Trying to find homebrew prefix by running 'brew --prefix'") + + brew = _homebrew_os_bin() + if brew is not None: + # Check if the found brew command is the right one + import salt.modules.cmdmod + import salt.modules.file + + runas = salt.modules.file.get_user(brew) + # Only pass runas when the brew binary is owned by a different + # user than the current process. On macOS, ``cmdmod.run`` with a + # truthy ``runas`` wraps the command in ``su -l -c ...`` + # unconditionally, which triggers a password prompt (or + # ``su: Sorry`` on non-tty invocations) even when the target user + # is the current user. See #69027. + try: + if runas == getpass.getuser(): + runas = None + except Exception: # pylint: disable=broad-except + # getpass.getuser() can raise on unusual environments (e.g. + # empty passwd db); fall back to sending runas as-is. + pass + ret = salt.modules.cmdmod.run( + "brew --prefix", runas=runas, output_loglevel="trace", raise_err=True + ) + + return ret + except CommandExecutionError as exc: + log.debug( + "Unable to find homebrew prefix by running 'brew --prefix'. Error: %s", exc + ) + + return None + + def list_pkgs(versions_as_list=False, **kwargs): """ List the packages currently installed in a dict:: diff --git a/tests/pytests/unit/modules/test_mac_brew_pkg.py b/tests/pytests/unit/modules/test_mac_brew_pkg.py index b01c9db382ea..69f5b5c71a3d 100644 --- a/tests/pytests/unit/modules/test_mac_brew_pkg.py +++ b/tests/pytests/unit/modules/test_mac_brew_pkg.py @@ -2,6 +2,7 @@ :codeauthor: Nicole Thomas """ +import os import textwrap import pytest @@ -23,8 +24,13 @@ def TAPS_LIST(): @pytest.fixture -def HOMEBREW_BIN(): - return "/usr/local/bin/brew" +def HOMEBREW_PREFIX(): + return "/opt/homebrew" + + +@pytest.fixture +def HOMEBREW_BIN(HOMEBREW_PREFIX): + return HOMEBREW_PREFIX + "/bin/brew" @pytest.fixture @@ -433,14 +439,186 @@ def test_tap(TAPS_LIST, HOMEBREW_BIN): assert mac_brew._tap("homebrew/test") +# 'homebrew_prefix' function tests: 4 + + +def test_homebrew_prefix_env(HOMEBREW_PREFIX): + """ + Test the path to the homebrew prefix by looking + at the HOMEBREW_PREFIX environment variable. + """ + mock_env = os.environ.copy() + mock_env["HOMEBREW_PREFIX"] = HOMEBREW_PREFIX + + with patch.dict(os.environ, mock_env): + assert mac_brew.homebrew_prefix() == HOMEBREW_PREFIX + + +def test_homebrew_prefix_command(HOMEBREW_PREFIX, HOMEBREW_BIN): + """ + Test the path to the homebrew prefix by running + the brew --prefix command when the HOMEBREW_PREFIX + environment variable is not set. + """ + mock_env = os.environ.copy() + if "HOMEBREW_PREFIX" in mock_env: + del mock_env["HOMEBREW_PREFIX"] + + with patch.dict(os.environ, mock_env): + with patch( + "salt.modules.cmdmod.run", MagicMock(return_value=HOMEBREW_PREFIX) + ), patch("salt.modules.file.get_user", MagicMock(return_value="foo")), patch( + "salt.modules.mac_brew_pkg._homebrew_os_bin", + MagicMock(return_value=HOMEBREW_BIN), + ): + assert mac_brew.homebrew_prefix() == HOMEBREW_PREFIX + + +def test_homebrew_prefix_returns_none(): + """ + Tests that homebrew_prefix returns None when + all attempts fail. + """ + + mock_env = os.environ.copy() + if "HOMEBREW_PREFIX" in mock_env: + del mock_env["HOMEBREW_PREFIX"] + + with patch.dict(os.environ, mock_env, clear=True): + with patch( + "salt.modules.mac_brew_pkg._homebrew_os_bin", MagicMock(return_value=None) + ): + assert mac_brew.homebrew_prefix() is None + + +def test_homebrew_prefix_returns_none_even_with_execution_errors(): + """ + Tests that homebrew_prefix returns None when + all attempts fail even with command execution errors. + """ + + mock_env = os.environ.copy() + if "HOMEBREW_PREFIX" in mock_env: + del mock_env["HOMEBREW_PREFIX"] + + with patch.dict(os.environ, mock_env, clear=True): + with patch( + "salt.modules.cmdmod.run", MagicMock(side_effect=CommandExecutionError) + ), patch( + "salt.modules.mac_brew_pkg._homebrew_os_bin", + MagicMock(return_value=None), + ): + assert mac_brew.homebrew_prefix() is None + + +def test_homebrew_prefix_no_su_when_brew_owner_is_current_user( + HOMEBREW_PREFIX, HOMEBREW_BIN +): + """ + Regression test for #69027. + + ``homebrew_prefix()`` used to pass ``runas=`` + unconditionally to ``cmdmod.run``, which on macOS wraps the command in + ``su -l -c ...`` even when ```` is the current user. That + triggers a password prompt (or a "su: Sorry" error on every non-tty + invocation) on every salt-ssh call as a non-root user whose Homebrew is + owned by themselves. + + ``runas`` must be ``None`` when the brew binary owner equals the current + process user, so the ``su`` wrap is skipped. + """ + mock_env = os.environ.copy() + if "HOMEBREW_PREFIX" in mock_env: + del mock_env["HOMEBREW_PREFIX"] + + current_user = "brewowner" + run_mock = MagicMock(return_value=HOMEBREW_PREFIX) + with patch.dict(os.environ, mock_env, clear=True): + with patch("salt.modules.cmdmod.run", run_mock), patch( + "salt.modules.file.get_user", MagicMock(return_value=current_user) + ), patch( + "salt.modules.mac_brew_pkg._homebrew_os_bin", + MagicMock(return_value=HOMEBREW_BIN), + ), patch( + "getpass.getuser", MagicMock(return_value=current_user) + ): + assert mac_brew.homebrew_prefix() == HOMEBREW_PREFIX + + assert run_mock.called, "cmdmod.run should have been invoked" + _, kwargs = run_mock.call_args + assert kwargs.get("runas") is None, ( + "homebrew_prefix() must not pass runas= to cmdmod.run; " + "on macOS this wraps the probe in `su -l` and triggers a password " + "prompt (issue #69027)" + ) + + +def test_homebrew_prefix_still_uses_runas_when_brew_owned_by_other_user( + HOMEBREW_PREFIX, HOMEBREW_BIN +): + """ + Complement to the #69027 regression test: when the brew binary is owned + by a different user than the current process user, ``runas`` must still + be forwarded so ``cmdmod.run`` invokes ``brew --prefix`` as the owner. + """ + mock_env = os.environ.copy() + if "HOMEBREW_PREFIX" in mock_env: + del mock_env["HOMEBREW_PREFIX"] + + run_mock = MagicMock(return_value=HOMEBREW_PREFIX) + with patch.dict(os.environ, mock_env, clear=True): + with patch("salt.modules.cmdmod.run", run_mock), patch( + "salt.modules.file.get_user", MagicMock(return_value="brewowner") + ), patch( + "salt.modules.mac_brew_pkg._homebrew_os_bin", + MagicMock(return_value=HOMEBREW_BIN), + ), patch( + "getpass.getuser", MagicMock(return_value="someoneelse") + ): + assert mac_brew.homebrew_prefix() == HOMEBREW_PREFIX + + _, kwargs = run_mock.call_args + assert kwargs.get("runas") == "brewowner" + + +# '_homebrew_os_bin' function tests: 1 + + +def test_homebrew_os_bin_fallback_apple_silicon(): + """ + Test the path to the homebrew executable for Apple Silicon. + + This test checks that even if the PATH does not contain + the default Homebrew's prefix for the Apple Silicon + architecture, it is appended. + """ + + # Ensure Homebrew's prefix for Apple Silicon is not present in the PATH + mock_env = os.environ.copy() + mock_env["PATH"] = "/usr/local/bin:/usr/bin" + + apple_silicon_homebrew_path = "/opt/homebrew/bin" + apple_silicon_homebrew_bin = f"{apple_silicon_homebrew_path}/brew" + + def mock_utils_path_which(*args): + if apple_silicon_homebrew_path in os.environ.get("PATH", "").split( + os.path.pathsep + ): + return apple_silicon_homebrew_bin + return None + + with patch("salt.utils.path.which", mock_utils_path_which): + assert mac_brew._homebrew_os_bin() == apple_silicon_homebrew_bin + + # '_homebrew_bin' function tests: 1 -def test_homebrew_bin(HOMEBREW_BIN): +def test_homebrew_bin(HOMEBREW_PREFIX, HOMEBREW_BIN): """ Tests the path to the homebrew binary """ - mock_path = MagicMock(return_value="/usr/local") + mock_path = MagicMock(return_value=HOMEBREW_PREFIX) with patch("salt.utils.path.which", MagicMock(return_value=HOMEBREW_BIN)): with patch.dict(mac_brew.__salt__, {"cmd.run": mock_path}): assert mac_brew._homebrew_bin() == HOMEBREW_BIN From d598dab0d108234cc74ccab4d86cfc38a3c5960e Mon Sep 17 00:00:00 2001 From: "Jamie (Bear) Murphy" <1613241+ITJamie@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:47:34 +0100 Subject: [PATCH 118/469] dockercompose v2 (#63051) * Rewrite dockercompose module to use python-on-whales Replace docker-compose v1 (EOL) with python-on-whales as the backend. Squashed from PR #63051 (author: ITJamie). Refs #62743 * Gate python_on_whales backend behind opt-in config for LTS Add a `dockercompose:use_python_on_whales` minion config flag that selects the `python_on_whales` (docker compose v2) backend. On 3006.x/3007.x/3008.x the legacy `compose` library remains the default so existing users are not broken by the new dependency or by any behavioural differences between the two implementations. If the flag is set but `python_on_whales` is not installed, log a warning and fall back to the legacy backend rather than failing. The default flips to `python_on_whales` in 3009, at which point the flag becomes a deprecated no-op. Also swap `project.ps(all=True)` for `project.compose.ps(all=True)` in the v2 branch of the lifecycle helpers so the container listing is compose- scoped rather than host-scoped. Consolidate the two changelog entries (62743, 63051) into a single 63051 entry describing the opt-in behaviour. --------- Co-authored-by: Daniel A. Wozniak --- changelog/63051.added.md | 1 + salt/modules/dockercompose.py | 389 +++++++++++++----- .../unit/modules/test_dockercompose.py | 363 ++++++++++++++++ 3 files changed, 647 insertions(+), 106 deletions(-) create mode 100644 changelog/63051.added.md create mode 100644 tests/pytests/unit/modules/test_dockercompose.py diff --git a/changelog/63051.added.md b/changelog/63051.added.md new file mode 100644 index 000000000000..aee1868ee1a9 --- /dev/null +++ b/changelog/63051.added.md @@ -0,0 +1 @@ +Added optional `python_on_whales` backend for the `dockercompose` module. Enable it by setting `dockercompose: {use_python_on_whales: True}` in the minion config. The legacy `compose` library remains the default on 3006.x/3007.x/3008.x; the default flips to `python_on_whales` in 3009. diff --git a/salt/modules/dockercompose.py b/salt/modules/dockercompose.py index bb71049d9ea0..63535e224db8 100644 --- a/salt/modules/dockercompose.py +++ b/salt/modules/dockercompose.py @@ -5,7 +5,7 @@ :maintainer: Jean Praloran :maturity: new -:depends: docker-compose>=1.5 +:depends: docker-compose>=1.5 or python_on_whales :platform: all Introduction @@ -23,7 +23,33 @@ Installation Prerequisites -------------------------- -This execution module requires at least version 1.4.0 of both docker-compose_ and +The module supports two backends: + +- The legacy ``compose`` (``docker-compose`` v1) Python library. This is the + **default** backend on 3006.x/3007.x/3008.x for backwards compatibility. +- The ``python_on_whales`` library which drives ``docker compose`` (v2, the + Docker CLI plugin). This backend is **opt-in** and is selected only when the + minion configuration sets ``dockercompose:use_python_on_whales: True`` and + the ``python_on_whales`` package is importable. + +Example minion config to enable the v2 backend: + +.. code-block:: yaml + + dockercompose: + use_python_on_whales: True + +If the flag is set but ``python_on_whales`` is not installed, a warning is +logged and the module falls back to the legacy ``compose`` library so the +minion continues to function. + +.. versionchanged:: 3009.0 + + The default backend flips to ``python_on_whales`` (docker compose v2). + ``dockercompose:use_python_on_whales`` becomes a deprecated no-op and will + be removed in a subsequent release. + +The legacy backend requires at least version 1.4.0 of both docker-compose_ and Docker_. docker-compose can easily be installed using :py:func:`pip.install `: @@ -103,6 +129,7 @@ ------------------------------- """ +import importlib.util import inspect import logging import os @@ -123,6 +150,11 @@ except ImportError: HAS_DOCKERCOMPOSE = False +try: + HAS_PYTHON_ON_WHALES = importlib.util.find_spec("python_on_whales") is not None +except ImportError: + HAS_PYTHON_ON_WHALES = False + try: from compose.project import OneOffFilter @@ -141,6 +173,8 @@ def __virtual__(): + if HAS_PYTHON_ON_WHALES: + return __virtualname__ if HAS_DOCKERCOMPOSE: match = re.match(VERSION_RE, str(compose.__version__)) if match: @@ -150,8 +184,32 @@ def __virtual__(): return ( False, "The dockercompose execution module not loaded: " - "compose python library not available.", + "compose python library or python_on_whales library not available.", + ) + + +def _use_python_on_whales(): + """ + Return True when the ``python_on_whales`` backend should be used. + + The backend is opt-in via the minion config key + ``dockercompose:use_python_on_whales``. When the flag is set but the + ``python_on_whales`` package is not importable, a warning is logged and + ``False`` is returned so the module falls back to the legacy ``compose`` + library instead of failing hard. + """ + requested = bool( + __salt__["config.get"]("dockercompose:use_python_on_whales", False) ) + if not requested: + return False + if not HAS_PYTHON_ON_WHALES: + log.warning( + "dockercompose:use_python_on_whales is set but the python_on_whales " + "package is not installed; falling back to the legacy compose library." + ) + return False + return True def __standardize_result(status, message, data=None, debug_msg=None): @@ -319,13 +377,18 @@ def __load_project_from_file_path(file_path): :param path: :return: """ - try: - project = get_project( - project_dir=os.path.dirname(file_path), - config_path=[os.path.basename(file_path)], - ) - except Exception as inst: # pylint: disable=broad-except - return __handle_except(inst) + if _use_python_on_whales(): + from python_on_whales import DockerClient + + project = DockerClient(compose_files=[file_path]) + else: + try: + project = get_project( + project_dir=os.path.dirname(file_path), + config_path=[os.path.basename(file_path)], + ) + except Exception as inst: # pylint: disable=broad-except + return __handle_except(inst) return project @@ -482,6 +545,40 @@ def create(path, docker_compose): ) +def create_command(path, service_names=None): + """ + Create (but does not start) containers, networks, volumes from the docker-compose file, + service_names is a python list, if omitted creates all containers + + path + Path where the docker-compose file is stored on the server + service_names + If specified will create only the containers for the specified services + + CLI Example: + + .. code-block:: bash + + salt myminion dockercompose.pull /path/where/docker-compose/stored + salt myminion dockercompose.pull /path/where/docker-compose/stored '[janus]' + """ + + project = __load_project(path) + if isinstance(project, dict): + return project + else: + try: + if _use_python_on_whales(): + project.compose.create(services=service_names, quiet=True) + else: + project.create(service_names) + except Exception as inst: # pylint: disable=broad-except + return __handle_except(inst) + return __standardize_result( + True, "creating containers via docker-compose succeeded", None, None + ) + + def pull(path, service_names=None): """ Pull image for containers in the docker-compose file, service_names is a @@ -505,7 +602,10 @@ def pull(path, service_names=None): return project else: try: - project.pull(service_names) + if _use_python_on_whales(): + project.compose.pull(services=service_names, quiet=True) + else: + project.pull(service_names) except Exception as inst: # pylint: disable=broad-except return __handle_except(inst) return __standardize_result( @@ -540,7 +640,10 @@ def build(path, service_names=None): return project else: try: - project.build(service_names) + if _use_python_on_whales(): + project.compose.build(services=service_names, quiet=True) + else: + project.build(service_names) except Exception as inst: # pylint: disable=broad-except return __handle_except(inst) return __standardize_result( @@ -574,16 +677,24 @@ def restart(path, service_names=None): return project else: try: - project.restart(service_names) - if debug: - for container in project.containers(): - if ( - service_names is None - or container.get("Name")[1:] in service_names - ): - container.inspect_if_not_inspected() - debug_ret[container.get("Name")] = container.inspect() - result[container.get("Name")] = "restarted" + if _use_python_on_whales(): + project.compose.restart(services=service_names, quiet=True) + for container in project.compose.ps(all=True): + if service_names is None or container.name in service_names: + if debug: + debug_ret[container.name] = dict(container.state) + result[container.name] = "restarted" + else: + project.restart(service_names) + if debug: + for container in project.containers(): + if ( + service_names is None + or container.get("Name")[1:] in service_names + ): + container.inspect_if_not_inspected() + debug_ret[container.get("Name")] = container.inspect() + result[container.get("Name")] = "restarted" except Exception as inst: # pylint: disable=broad-except return __handle_except(inst) return __standardize_result( @@ -616,16 +727,24 @@ def stop(path, service_names=None): return project else: try: - project.stop(service_names) - if debug: - for container in project.containers(stopped=True): - if ( - service_names is None - or container.get("Name")[1:] in service_names - ): - container.inspect_if_not_inspected() - debug_ret[container.get("Name")] = container.inspect() - result[container.get("Name")] = "stopped" + if _use_python_on_whales(): + project.compose.stop(services=service_names) + for container in project.compose.ps(all=True): + if service_names is None or container.name in service_names: + if debug: + debug_ret[container.name] = dict(container.state) + result[container.name] = "stopped" + else: + project.stop(service_names) + if debug: + for container in project.containers(stopped=True): + if ( + service_names is None + or container.get("Name")[1:] in service_names + ): + container.inspect_if_not_inspected() + debug_ret[container.get("Name")] = container.inspect() + result[container.get("Name")] = "stopped" except Exception as inst: # pylint: disable=broad-except return __handle_except(inst) return __standardize_result( @@ -658,16 +777,24 @@ def pause(path, service_names=None): return project else: try: - project.pause(service_names) - if debug: - for container in project.containers(): - if ( - service_names is None - or container.get("Name")[1:] in service_names - ): - container.inspect_if_not_inspected() - debug_ret[container.get("Name")] = container.inspect() - result[container.get("Name")] = "paused" + if _use_python_on_whales(): + project.compose.pause(services=service_names) + for container in project.compose.ps(all=True): + if service_names is None or container.name in service_names: + if debug: + debug_ret[container.name] = dict(container.state) + result[container.name] = "paused" + else: + project.pause(service_names) + if debug: + for container in project.containers(): + if ( + service_names is None + or container.get("Name")[1:] in service_names + ): + container.inspect_if_not_inspected() + debug_ret[container.get("Name")] = container.inspect() + result[container.get("Name")] = "paused" except Exception as inst: # pylint: disable=broad-except return __handle_except(inst) return __standardize_result( @@ -700,16 +827,24 @@ def unpause(path, service_names=None): return project else: try: - project.unpause(service_names) - if debug: - for container in project.containers(): - if ( - service_names is None - or container.get("Name")[1:] in service_names - ): - container.inspect_if_not_inspected() - debug_ret[container.get("Name")] = container.inspect() - result[container.get("Name")] = "unpaused" + if _use_python_on_whales(): + project.compose.unpause(services=service_names) + for container in project.compose.ps(all=True): + if service_names is None or container.name in service_names: + if debug: + debug_ret[container.name] = dict(container.state) + result[container.name] = "unpaused" + else: + project.unpause(service_names) + if debug: + for container in project.containers(): + if ( + service_names is None + or container.get("Name")[1:] in service_names + ): + container.inspect_if_not_inspected() + debug_ret[container.get("Name")] = container.inspect() + result[container.get("Name")] = "unpaused" except Exception as inst: # pylint: disable=broad-except return __handle_except(inst) return __standardize_result( @@ -742,16 +877,24 @@ def start(path, service_names=None): return project else: try: - project.start(service_names) - if debug: - for container in project.containers(): - if ( - service_names is None - or container.get("Name")[1:] in service_names - ): - container.inspect_if_not_inspected() - debug_ret[container.get("Name")] = container.inspect() - result[container.get("Name")] = "started" + if _use_python_on_whales(): + project.compose.start(services=service_names) + for container in project.compose.ps(all=True): + if service_names is None or container.name in service_names: + if debug: + debug_ret[container.name] = dict(container.state) + result[container.name] = "started" + else: + project.start(service_names) + if debug: + for container in project.containers(): + if ( + service_names is None + or container.get("Name")[1:] in service_names + ): + container.inspect_if_not_inspected() + debug_ret[container.get("Name")] = container.inspect() + result[container.get("Name")] = "started" except Exception as inst: # pylint: disable=broad-except return __handle_except(inst) return __standardize_result( @@ -784,16 +927,24 @@ def kill(path, service_names=None): return project else: try: - project.kill(service_names) - if debug: - for container in project.containers(stopped=True): - if ( - service_names is None - or container.get("Name")[1:] in service_names - ): - container.inspect_if_not_inspected() - debug_ret[container.get("Name")] = container.inspect() - result[container.get("Name")] = "killed" + if _use_python_on_whales(): + project.compose.kill(services=service_names) + for container in project.compose.ps(all=True): + if service_names is None or container.name in service_names: + if debug: + debug_ret[container.name] = dict(container.state) + result[container.name] = "killed" + else: + project.kill(service_names) + if debug: + for container in project.containers(stopped=True): + if ( + service_names is None + or container.get("Name")[1:] in service_names + ): + container.inspect_if_not_inspected() + debug_ret[container.get("Name")] = container.inspect() + result[container.get("Name")] = "killed" except Exception as inst: # pylint: disable=broad-except return __handle_except(inst) return __standardize_result( @@ -818,13 +969,15 @@ def rm(path, service_names=None): salt myminion dockercompose.rm /path/where/docker-compose/stored salt myminion dockercompose.rm /path/where/docker-compose/stored '[janus]' """ - project = __load_project(path) if isinstance(project, dict): return project else: try: - project.remove_stopped(service_names) + if _use_python_on_whales(): + project.compose.rm(services=service_names) + else: + project.remove_stopped(service_names) except Exception as inst: # pylint: disable=broad-except return __handle_except(inst) return __standardize_result( @@ -851,29 +1004,44 @@ def ps(path): if isinstance(project, dict): return project else: - if USE_FILTERCLASS: - containers = sorted( - project.containers(None, stopped=True) - + project.containers(None, OneOffFilter.only), - key=attrgetter("name"), - ) + if _use_python_on_whales(): + containers = project.compose.ps() + for container in containers: + command = "; ".join(container.config.cmd) + exposed_ports = container.config.exposed_ports + if len(command) > 80: + command = f"{command[:26]} ..." + result[container.name] = { + "id": container.id, + "name": container.name, + "command": command, + "state": container.state.status, + "ports": exposed_ports, + } else: - containers = sorted( - project.containers(None, stopped=True) - + project.containers(None, one_off=True), - key=attrgetter("name"), - ) - for container in containers: - command = container.human_readable_command - if len(command) > 30: - command = f"{command[:26]} ..." - result[container.name] = { - "id": container.id, - "name": container.name, - "command": command, - "state": container.human_readable_state, - "ports": container.human_readable_ports, - } + if USE_FILTERCLASS: + containers = sorted( + project.containers(None, stopped=True) + + project.containers(None, OneOffFilter.only), + key=attrgetter("name"), + ) + else: + containers = sorted( + project.containers(None, stopped=True) + + project.containers(None, one_off=True), + key=attrgetter("name"), + ) + for container in containers: + command = container.human_readable_command + if len(command) > 30: + command = f"{command[:26]} ..." + result[container.name] = { + "id": container.id, + "name": container.name, + "command": command, + "state": container.human_readable_state, + "ports": container.human_readable_ports, + } return __standardize_result(True, "Listing docker-compose containers", result, None) @@ -898,20 +1066,29 @@ def up(path, service_names=None): debug_ret = {} project = __load_project(path) + result = {} if isinstance(project, dict): return project else: try: - result = _get_convergence_plans(project, service_names) - ret = project.up(service_names) - if debug: - for container in ret: - if ( - service_names is None - or container.get("Name")[1:] in service_names - ): - container.inspect_if_not_inspected() - debug_ret[container.get("Name")] = container.inspect() + if _use_python_on_whales(): + project.compose.up(services=service_names, detach=True, quiet=True) + for container in project.compose.ps(all=True): + if service_names is None or container.name in service_names: + result[container.name] = container.state.status + if debug: + debug_ret[container.name] = dict(container.state) + else: + result = _get_convergence_plans(project, service_names) + ret = project.up(service_names) + if debug: + for container in ret: + if ( + service_names is None + or container.get("Name")[1:] in service_names + ): + container.inspect_if_not_inspected() + debug_ret[container.get("Name")] = container.inspect() except Exception as inst: # pylint: disable=broad-except return __handle_except(inst) return __standardize_result( diff --git a/tests/pytests/unit/modules/test_dockercompose.py b/tests/pytests/unit/modules/test_dockercompose.py new file mode 100644 index 000000000000..6620fc6125de --- /dev/null +++ b/tests/pytests/unit/modules/test_dockercompose.py @@ -0,0 +1,363 @@ +""" +Unit tests for salt.modules.dockercompose + +Tests cover the file-management functions that do not require a running +Docker daemon, verifying the YAML read/write/parse logic and the service +definition helpers. The python_on_whales / legacy-compose import paths are +controlled via patched module-level booleans so the tests run without either +library installed. + +The ``__load_project_from_file_path`` private helper is mocked throughout +because it is the only code path that actually needs a Docker daemon or the +python_on_whales library. +""" + +import os +import textwrap + +import pytest + +import salt.modules.dockercompose as dockercompose +from tests.support.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +SIMPLE_COMPOSE = textwrap.dedent( + """\ + version: '3' + services: + web: + image: nginx:latest + db: + image: postgres:14 + """ +) + +# Sentinel object returned by mocked __load_project_from_file_path. +# Any non-dict value satisfies the ``isinstance(project, dict)`` guard +# used throughout the module. +FAKE_PROJECT = MagicMock(name="fake_docker_project") + +# Full dotted path to the private helper that touches the Docker daemon. +_LOAD_PROJECT_PATH = ( + "salt.modules.dockercompose._DockerCompose__load_project_from_file_path" +) +# The helper is a module-level function accessed via the dunder-mangled name +# inside the module; we need the actual attribute name as seen from outside. +_LOAD_PROJECT_ATTR = "salt.modules.dockercompose.__load_project_from_file_path" + + +def _patch_project(return_value=FAKE_PROJECT): + """Return a context-manager that replaces __load_project_from_file_path.""" + # The function is a plain module-level function (not a class method), so + # patch it by its public module path. + return patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=return_value, + create=True, + ) + + +@pytest.fixture +def configure_loader_modules(): + return {dockercompose: {}} + + +# --------------------------------------------------------------------------- +# __virtual__ tests +# --------------------------------------------------------------------------- + + +def test_virtual_loads_with_python_on_whales(): + with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", True): + result = dockercompose.__virtual__() + assert result == "dockercompose" + + +def test_virtual_loads_with_legacy_compose(): + compose_mock = MagicMock() + compose_mock.__version__ = "1.29.0" + with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", False): + with patch.object(dockercompose, "HAS_DOCKERCOMPOSE", True): + with patch.object(dockercompose, "compose", compose_mock, create=True): + result = dockercompose.__virtual__() + assert result == "dockercompose" + + +def test_virtual_fails_without_either_library(): + with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", False): + with patch.object(dockercompose, "HAS_DOCKERCOMPOSE", False): + result = dockercompose.__virtual__() + assert result is not True + assert isinstance(result, tuple) + assert result[0] is False + + +# --------------------------------------------------------------------------- +# _use_python_on_whales opt-in gate tests +# --------------------------------------------------------------------------- + + +def test_use_python_on_whales_defaults_to_false(): + """Default behaviour: config flag unset → legacy backend, even if library present.""" + salt_dunder = {"config.get": MagicMock(return_value=False)} + with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", True): + with patch.dict(dockercompose.__salt__, salt_dunder, clear=True): + assert dockercompose._use_python_on_whales() is False + salt_dunder["config.get"].assert_called_once_with( + "dockercompose:use_python_on_whales", False + ) + + +def test_use_python_on_whales_opt_in_true(): + """Flag set + library installed → v2 backend selected.""" + salt_dunder = {"config.get": MagicMock(return_value=True)} + with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", True): + with patch.dict(dockercompose.__salt__, salt_dunder, clear=True): + assert dockercompose._use_python_on_whales() is True + + +def test_use_python_on_whales_flag_set_but_library_missing_falls_back(caplog): + """Flag set but python_on_whales missing → warn and fall back to legacy.""" + import logging + + salt_dunder = {"config.get": MagicMock(return_value=True)} + with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", False): + with patch.dict(dockercompose.__salt__, salt_dunder, clear=True): + with caplog.at_level(logging.WARNING, logger="salt.modules.dockercompose"): + assert dockercompose._use_python_on_whales() is False + assert any( + "python_on_whales" in rec.message and "falling back" in rec.message + for rec in caplog.records + ) + + +def test_use_python_on_whales_library_present_flag_unset(): + """python_on_whales installed but flag unset → legacy backend (opt-in only).""" + salt_dunder = {"config.get": MagicMock(return_value=False)} + with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", True): + with patch.dict(dockercompose.__salt__, salt_dunder, clear=True): + assert dockercompose._use_python_on_whales() is False + + +# --------------------------------------------------------------------------- +# create() tests +# --------------------------------------------------------------------------- + + +def test_create_with_valid_content(tmp_path): + """create() writes the compose file and reports success.""" + dest = str(tmp_path) + with patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=FAKE_PROJECT, + create=True, + ): + result = dockercompose.create(dest, SIMPLE_COMPOSE) + assert result["status"] is True + assert "Successfully created" in result["message"] + written = os.path.join(dest, "docker-compose.yml") + assert os.path.isfile(written) + + +def test_create_with_empty_content(): + """create() returns a failure when no content is supplied.""" + result = dockercompose.create("/some/path", "") + assert result["status"] is False + assert "valid docker-compose file" in result["message"] + + +# --------------------------------------------------------------------------- +# get() tests +# --------------------------------------------------------------------------- + + +def test_get_returns_file_contents(tmp_path): + """get() returns the raw compose YAML when the file exists and is valid.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text(SIMPLE_COMPOSE) + + with patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=FAKE_PROJECT, + create=True, + ): + result = dockercompose.get(str(tmp_path)) + + assert result["status"] is True + assert "docker-compose.yml" in result["return"] + + +def test_get_returns_failure_for_missing_path(tmp_path): + """get() returns a failure when the path has no compose file.""" + result = dockercompose.get(str(tmp_path / "nonexistent")) + assert result["status"] is False + + +# --------------------------------------------------------------------------- +# service_create() tests +# --------------------------------------------------------------------------- + + +def test_service_create_adds_new_service(tmp_path): + """service_create() adds a new service definition to the compose file.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text(SIMPLE_COMPOSE) + definition = "image: redis:7\nports:\n - '6379:6379'\n" + + with patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=FAKE_PROJECT, + create=True, + ): + result = dockercompose.service_create(str(tmp_path), "cache", definition) + + assert result["status"] is True + assert "cache" in result["message"] + content = compose_file.read_text() + assert "cache" in content + assert "redis" in content + + +def test_service_create_rejects_duplicate(tmp_path): + """service_create() fails when the service already exists.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text(SIMPLE_COMPOSE) + + with patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=FAKE_PROJECT, + create=True, + ): + result = dockercompose.service_create( + str(tmp_path), "web", "image: nginx:alpine" + ) + + assert result["status"] is False + assert "already exists" in result["message"] + + +# --------------------------------------------------------------------------- +# service_upsert() tests +# --------------------------------------------------------------------------- + + +def test_service_upsert_adds_service(tmp_path): + """service_upsert() adds a service that does not yet exist.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text(SIMPLE_COMPOSE) + + with patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=FAKE_PROJECT, + create=True, + ): + result = dockercompose.service_upsert( + str(tmp_path), "queue", "image: rabbitmq:3" + ) + + assert result["status"] is True + content = compose_file.read_text() + assert "queue" in content + + +# --------------------------------------------------------------------------- +# service_remove() tests +# --------------------------------------------------------------------------- + + +def test_service_remove_deletes_existing_service(tmp_path): + """service_remove() removes an existing service from the compose file.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text(SIMPLE_COMPOSE) + + with patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=FAKE_PROJECT, + create=True, + ): + result = dockercompose.service_remove(str(tmp_path), "db") + + assert result["status"] is True + content = compose_file.read_text() + assert "db:" not in content + assert "web:" in content + + +def test_service_remove_rejects_missing_service(tmp_path): + """service_remove() fails gracefully when the service does not exist.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text(SIMPLE_COMPOSE) + + with patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=FAKE_PROJECT, + create=True, + ): + result = dockercompose.service_remove(str(tmp_path), "nonexistent") + + assert result["status"] is False + assert "did not exists" in result["message"] + + +# --------------------------------------------------------------------------- +# service_set_tag() tests +# --------------------------------------------------------------------------- + + +def test_service_set_tag_updates_image_tag(tmp_path): + """service_set_tag() replaces the image tag for the named service.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text(SIMPLE_COMPOSE) + + with patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=FAKE_PROJECT, + create=True, + ): + result = dockercompose.service_set_tag(str(tmp_path), "web", "1.25") + + assert result["status"] is True + content = compose_file.read_text() + assert "nginx:1.25" in content + + +def test_service_set_tag_fails_for_missing_service(tmp_path): + """service_set_tag() returns failure when the service is not found.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text(SIMPLE_COMPOSE) + + with patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=FAKE_PROJECT, + create=True, + ): + result = dockercompose.service_set_tag(str(tmp_path), "ghost", "1.0") + + assert result["status"] is False + + +def test_service_set_tag_fails_for_service_without_image(tmp_path): + """service_set_tag() returns failure when the service has no 'image' key.""" + compose_content = textwrap.dedent( + """\ + version: '3' + services: + builder: + build: . + """ + ) + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text(compose_content) + + with patch( + "salt.modules.dockercompose.__load_project_from_file_path", + return_value=FAKE_PROJECT, + create=True, + ): + result = dockercompose.service_set_tag(str(tmp_path), "builder", "2.0") + + assert result["status"] is False + assert "image" in result["message"] From 66fd95a813cb5fd3a888d0cc2b882a8d3f78b91d Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Tue, 14 Jul 2026 17:48:52 -0400 Subject: [PATCH 119/469] Fix inline template_source crash and ignored commit_at in napalm_network (#69795) * Fix inline template_source crash and ignored commit_at in napalm_network - load_template ran the salt://-prefix precheck as template_name.startswith(...) even when template_name is None (rendering an inline template_source), crashing with AttributeError. Run that precheck only for a single string template_name; None (inline source) and lists are handled separately. - _config_logic scheduled a commit with get_time_at(time_in=commit_in, time_at=commit_in), so commit_at (commit at an absolute time) was ignored. Pass time_at=commit_at, matching the revert path. * Add changelog for #69795 * Reformat test_network.py with black --- changelog/69795.fixed.md | 5 +++ salt/modules/napalm_network.py | 8 +++- .../unit/modules/napalm/test_network.py | 37 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 changelog/69795.fixed.md diff --git a/changelog/69795.fixed.md b/changelog/69795.fixed.md new file mode 100644 index 000000000000..f143cdc18c03 --- /dev/null +++ b/changelog/69795.fixed.md @@ -0,0 +1,5 @@ +Fixed two bugs in the ``napalm_network`` execution module. ``net.load_template`` +no longer crashes with ``AttributeError: 'NoneType' object has no attribute +'startswith'`` when rendering an inline ``template_source`` (no +``template_name``), and ``_config_logic`` now honours ``commit_at`` when +scheduling a commit instead of passing ``commit_in`` for both times. diff --git a/salt/modules/napalm_network.py b/salt/modules/napalm_network.py index d760d8319516..fb35b1eb0879 100644 --- a/salt/modules/napalm_network.py +++ b/salt/modules/napalm_network.py @@ -243,7 +243,7 @@ def _config_logic( # and there are changes to commit if commit_in or commit_at: commit_time = __utils__["timeutil.get_time_at"]( - time_in=commit_in, time_at=commit_in + time_in=commit_in, time_at=commit_at ) # schedule job scheduled_job_name = f"__napalm_commit_{current_jid}" @@ -1957,7 +1957,11 @@ def load_template( salt_render_prefixes = ("salt://", "http://", "https://", "ftp://") salt_render = False file_exists = False - if not isinstance(template_name, (tuple, list)): + # Only a single, named template goes through the salt:// / file precheck. + # ``template_name`` is ``None`` when rendering an inline ``template_source``, + # and calling ``None.startswith(...)`` here raised ``AttributeError``; a list + # of names is handled further down. + if isinstance(template_name, str): for salt_render_prefix in salt_render_prefixes: if not salt_render: salt_render = salt_render or template_name.startswith( diff --git a/tests/pytests/unit/modules/napalm/test_network.py b/tests/pytests/unit/modules/napalm/test_network.py index 2ce2d6e621b9..8243b4604b91 100644 --- a/tests/pytests/unit/modules/napalm/test_network.py +++ b/tests/pytests/unit/modules/napalm/test_network.py @@ -185,6 +185,43 @@ def test_load_template(): assert ret["out"] is None +def test_load_template_inline_source(): + # Rendering an inline ``template_source`` passes template_name=None; the + # salt:// precheck used to call ``None.startswith`` and crash. + with patch( + "salt.utils.napalm.get_device", + MagicMock(return_value=napalm_test_support.MockNapalmDevice()), + ), patch.dict( + napalm_network.__salt__, + {"file.apply_template_on_contents": MagicMock(return_value="new config")}, + ): + ret = napalm_network.load_template(template_source="system { host-name r1; }") + assert ret["result"] + + +def test_load_config_commit_at_uses_absolute_time(): + # Regression: commit_at was passed to get_time_at as ``time_at=commit_in``, + # so scheduling a commit at an absolute time was silently ignored. + get_time_at = MagicMock(return_value="2026-07-11T02:00:00") + with patch( + "salt.utils.napalm.get_device", + MagicMock(return_value=napalm_test_support.MockNapalmDevice()), + ), patch.dict(napalm_network.__opts__, {"id": "test-minion"}), patch.dict( + napalm_network.__utils__, {"timeutil.get_time_at": get_time_at} + ), patch.dict( + napalm_network.__salt__, + { + "schedule.add": MagicMock(return_value={"result": True, "comment": ""}), + "schedule.save": MagicMock(return_value={"result": True, "comment": ""}), + }, + ): + napalm_network.load_config(text="new config", commit_at="2026-07-11T02:00:00") + get_time_at.assert_called_once() + _, kwargs = get_time_at.call_args + assert kwargs["time_at"] == "2026-07-11T02:00:00" + assert kwargs["time_in"] is None + + def test_commit(): with patch( "salt.utils.napalm.get_device", From e781f5fe0d3ec0a7f90bcb860dddb9e26e70738c Mon Sep 17 00:00:00 2001 From: Twangboy Date: Mon, 20 Jul 2026 12:45:19 -0600 Subject: [PATCH 120/469] Add python.run and python.script execution and state modules Add a new `python` execution module (`python.run`, `python.script`) and paired `python` state module that run Python code and scripts using the same interpreter that is running the Salt minion/master, rather than whatever `python`/`python3` resolves to on PATH. This makes it possible to reliably target Salt's own bundled/onedir interpreter regardless of what's installed on the system. Fixes: #69836 --- changelog/69836.added.md | 1 + doc/ref/modules/all/index.rst | 1 + doc/ref/modules/all/salt.modules.python.rst | 7 + doc/ref/states/all/index.rst | 1 + doc/ref/states/all/salt.states.python.rst | 7 + salt/modules/python.py | 411 ++++++++++++++++++++ salt/states/python.py | 331 ++++++++++++++++ tests/pytests/unit/modules/test_python.py | 123 ++++++ tests/pytests/unit/states/test_python.py | 135 +++++++ 9 files changed, 1017 insertions(+) create mode 100644 changelog/69836.added.md create mode 100644 doc/ref/modules/all/salt.modules.python.rst create mode 100644 doc/ref/states/all/salt.states.python.rst create mode 100644 salt/modules/python.py create mode 100644 salt/states/python.py create mode 100644 tests/pytests/unit/modules/test_python.py create mode 100644 tests/pytests/unit/states/test_python.py diff --git a/changelog/69836.added.md b/changelog/69836.added.md new file mode 100644 index 000000000000..f3c01f933acc --- /dev/null +++ b/changelog/69836.added.md @@ -0,0 +1 @@ +Add `python.run` and `python.script` execution and state modules to run Python code and scripts using the same Python interpreter that is running Salt. diff --git a/doc/ref/modules/all/index.rst b/doc/ref/modules/all/index.rst index cd4546d17bfb..98cd3b853a25 100644 --- a/doc/ref/modules/all/index.rst +++ b/doc/ref/modules/all/index.rst @@ -173,6 +173,7 @@ execution modules pw_group pw_user pyenv + python quota rabbitmq rbac_solaris diff --git a/doc/ref/modules/all/salt.modules.python.rst b/doc/ref/modules/all/salt.modules.python.rst new file mode 100644 index 000000000000..7e9e69f85cd4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.python.rst @@ -0,0 +1,7 @@ +.. _python-module: + +salt.modules.python +==================== + +.. automodule:: salt.modules.python + :members: diff --git a/doc/ref/states/all/index.rst b/doc/ref/states/all/index.rst index b4e31e28c015..baa872db6a35 100644 --- a/doc/ref/states/all/index.rst +++ b/doc/ref/states/all/index.rst @@ -87,6 +87,7 @@ state modules process proxy pyenv + python quota rabbitmq_cluster rabbitmq_plugin diff --git a/doc/ref/states/all/salt.states.python.rst b/doc/ref/states/all/salt.states.python.rst new file mode 100644 index 000000000000..1c1f948b2568 --- /dev/null +++ b/doc/ref/states/all/salt.states.python.rst @@ -0,0 +1,7 @@ +.. _python-state: + +salt.states.python +==================== + +.. automodule:: salt.states.python + :members: diff --git a/salt/modules/python.py b/salt/modules/python.py new file mode 100644 index 000000000000..61103e070964 --- /dev/null +++ b/salt/modules/python.py @@ -0,0 +1,411 @@ +""" +Run commands and scripts using the same Python interpreter that is running +Salt itself. + +Salt's packages bundle their own "onedir" Python build, separate from +whatever Python (if any) is installed on the system. :py:func:`python.run +` and :py:func:`python.script +` always target that interpreter - +:py:data:`sys.executable` - rather than whatever ``python``/``python3`` +happens to resolve to on ``PATH``. +""" + +import logging +import os +import shutil +import sys + +import salt.utils.args +import salt.utils.files +import salt.utils.platform +import salt.utils.url +from salt.exceptions import SaltInvocationError + +log = logging.getLogger(__name__) + +__virtualname__ = "python" + + +def __virtual__(): + return __virtualname__ + + +def _get_python_executable(): + """ + Return the path to the Python interpreter currently running Salt. + """ + return os.path.normpath(sys.executable) + + +def run( + command=None, + args=None, + cwd=None, + stdin=None, + runas=None, + group=None, + env=None, + clean_env=False, + rstrip=True, + umask=None, + output_encoding=None, + output_loglevel="debug", + log_callback=None, + hide_output=False, + timeout=None, + reset_system_locale=True, + ignore_retcode=False, + use_vt=False, + bg=False, + password=None, + success_retcodes=None, + success_stdout=None, + success_stderr=None, + **kwargs, +): + """ + Run a snippet of Python code, or pass raw arguments to the interpreter, + using the same Python that is running Salt. + + command + A string of Python code to execute, passed to the interpreter as + ``-c command``. + + args + Additional arguments to pass to the interpreter. Can be a list, or a + string which will be split using shell-like syntax. If ``command`` + is not specified, ``args`` is used as the full argument list handed + to the interpreter, which makes it possible to invoke things like + ``-m some_module``. + + cwd + The directory from which to execute the command. Defaults to the + home directory of the user specified by ``runas`` (or the user + under which Salt is running if ``runas`` is not specified). + + stdin + A string of standard input can be specified for the command to be + run using the ``stdin`` parameter. + + runas + Specify an alternate user to run the command. The default behavior + is to run as the user under which Salt is running. + + group + Group to run the command as. Not currently supported on Windows. + + password + Windows only. Required when specifying ``runas``. This parameter + will be ignored on non-Windows platforms. + + env + Environment variables to be set prior to execution. + + clean_env + Attempt to clean out all other Salt-related environment variables. + + rstrip + Strip all whitespace off the end of output before it is returned. + + umask + The umask (in octal) to use when running the command. + + output_encoding + Control the encoding used to decode the command's output. + + output_loglevel : debug + Control the loglevel at which the output from the command is + logged to the minion log. + + log_callback + A callback function that can be used to further process the + output/return message of the command. + + hide_output : False + If ``True``, suppress stdout and stderr in the return data. + + timeout + If the command has not terminated after timeout seconds, send the + subprocess sigterm, and if sigterm is ignored, follow up with + sigkill. + + reset_system_locale + Resets the system locale prior to executing the command. + + ignore_retcode + If the exit code of the command is nonzero, this is treated as an + error condition, and the output from the command will be logged to + the minion log. Pass this argument as ``True`` to skip logging the + output if the command has a nonzero exit code. + + use_vt + Use VT utils (saltstack) to stream the command output more + interactively to the console and the logs. This is experimental. + + bg + If ``True``, run command in background and do not await or deliver + its results. + + success_retcodes + A list of non-zero return codes that should be considered a + success. If the return code matches any in the list, it will be + overridden with zero. + + success_stdout + A list of strings that when found in standard out should be + considered a success. + + success_stderr + A list of strings that when found in standard error should be + considered a success. + + CLI Example: + + .. code-block:: bash + + salt '*' python.run command="print('hello world')" + salt '*' python.run args="-m json.tool foo.json" + """ + python_exe = _get_python_executable() + + if isinstance(args, str): + args = salt.utils.args.shlex_split(args) + + cmd_list = [python_exe] + if command is not None: + cmd_list.extend(["-c", command]) + if args: + cmd_list.extend(args) + + if len(cmd_list) == 1: + raise SaltInvocationError("Must specify either 'command' or 'args'") + + return __salt__["cmd.run_all"]( + cmd_list, + cwd=cwd, + stdin=stdin, + runas=runas, + group=group, + python_shell=False, + env=env, + clean_env=clean_env, + rstrip=rstrip, + umask=umask, + output_encoding=output_encoding, + output_loglevel=output_loglevel, + log_callback=log_callback, + hide_output=hide_output, + timeout=timeout, + reset_system_locale=reset_system_locale, + ignore_retcode=ignore_retcode, + use_vt=use_vt, + bg=bg, + password=password, + success_retcodes=success_retcodes, + success_stdout=success_stdout, + success_stderr=success_stderr, + **kwargs, + ) + + +def script( + source, + args=None, + cwd=None, + stdin=None, + runas=None, + group=None, + env=None, + template=None, + umask=None, + output_encoding=None, + output_loglevel="debug", + log_callback=None, + hide_output=False, + timeout=None, + reset_system_locale=True, + saltenv=None, + use_vt=False, + bg=False, + password=None, + success_retcodes=None, + success_stdout=None, + success_stderr=None, + **kwargs, +): + """ + Download a Python script from the master (or another supported + location) and execute it with the same Python interpreter that is + running Salt, regardless of the script's shebang line, executable bit, + or what ``python``/``python3`` resolves to on ``PATH``. + + source + The location of the script to download. If the file is located on + the master in the directory named spam, and is called eggs, the + source string is ``salt://spam/eggs``. + + args + String or list of command line args to pass to the script. + + cwd + The directory from which to execute the command. Defaults to the + home directory of the user specified by ``runas`` (or the user + under which Salt is running if ``runas`` is not specified). + + stdin + A string of standard input can be specified for the command to be + run using the ``stdin`` parameter. + + runas + Specify an alternate user to run the script as. The default + behavior is to run as the user under which Salt is running. + + group + Group to run the script as. Not currently supported on Windows. + + password + Windows only. Required when specifying ``runas``. This parameter + will be ignored on non-Windows platforms. + + env + Environment variables to be set prior to execution. + + template + If this setting is applied then the named templating engine will + be used to render the downloaded file. Currently jinja, mako, and + wempy are supported. + + umask + The umask (in octal) to use when running the command. + + output_encoding + Control the encoding used to decode the command's output. + + output_loglevel : debug + Control the loglevel at which the output from the command is + logged to the minion log. + + log_callback + A callback function that can be used to further process the + output/return message of the command. + + hide_output : False + If ``True``, suppress stdout and stderr in the return data. + + timeout + If the command has not terminated after timeout seconds, send the + subprocess sigterm, and if sigterm is ignored, follow up with + sigkill. + + reset_system_locale + Resets the system locale prior to executing the command. + + saltenv : base + The Salt environment to use to resolve ``source``. + + use_vt + Use VT utils (saltstack) to stream the command output more + interactively to the console and the logs. This is experimental. + + bg + If ``True``, run command in background and do not await or deliver + its results. + + success_retcodes + A list of non-zero return codes that should be considered a + success. If the return code matches any in the list, it will be + overridden with zero. + + success_stdout + A list of strings that when found in standard out should be + considered a success. + + success_stderr + A list of strings that when found in standard error should be + considered a success. + + CLI Example: + + .. code-block:: bash + + salt '*' python.script salt://scripts/runme.py + salt '*' python.script salt://scripts/runme.py 'arg1 arg2 "arg 3"' + """ + if saltenv is None: + try: + saltenv = __opts__.get("saltenv", "base") + except NameError: + saltenv = "base" + + def _cleanup_tempfile(path): + try: + __salt__["file.remove"](path) + except Exception as exc: # pylint: disable=broad-except + log.error("python.script: Unable to clean tempfile '%s': %s", path, exc) + + path = salt.utils.files.mkstemp( + dir=cwd, suffix=os.path.splitext(salt.utils.url.split_env(source)[0])[1] + ) + + if template: + fn_ = __salt__["cp.get_template"](source, path, template, saltenv, **kwargs) + if not fn_: + _cleanup_tempfile(path) + return { + "pid": 0, + "retcode": 1, + "stdout": "", + "stderr": "", + "cache_error": True, + } + else: + fn_ = __salt__["cp.cache_file"](source, saltenv) + if not fn_: + _cleanup_tempfile(path) + return { + "pid": 0, + "retcode": 1, + "stdout": "", + "stderr": "", + "cache_error": True, + } + shutil.copyfile(fn_, path) + + if not salt.utils.platform.is_windows() and runas: + os.chown(path, __salt__["file.user_to_uid"](runas), -1) + + if isinstance(args, str): + args = salt.utils.args.shlex_split(args) + + python_exe = _get_python_executable() + cmd_list = [python_exe, path] + if args: + cmd_list.extend(args) + + ret = __salt__["cmd.run_all"]( + cmd_list, + cwd=cwd, + stdin=stdin, + runas=runas, + group=group, + python_shell=False, + env=env, + umask=umask, + output_encoding=output_encoding, + output_loglevel=output_loglevel, + log_callback=log_callback, + timeout=timeout, + reset_system_locale=reset_system_locale, + use_vt=use_vt, + bg=bg, + password=password, + success_retcodes=success_retcodes, + success_stdout=success_stdout, + success_stderr=success_stderr, + **kwargs, + ) + _cleanup_tempfile(path) + + if hide_output: + ret["stdout"] = ret["stderr"] = "" + return ret diff --git a/salt/states/python.py b/salt/states/python.py new file mode 100644 index 000000000000..baffe3a8cac5 --- /dev/null +++ b/salt/states/python.py @@ -0,0 +1,331 @@ +""" +Execution of Python code and scripts using Salt's own interpreter +================================================================== + +The python state module runs Python code or scripts using the same +interpreter that is running Salt, rather than whatever ``python``/ +``python3`` happens to resolve to on the target's ``PATH``. + +A simple example to execute a snippet of Python code: + +.. code-block:: yaml + + write-marker-file: + python.run: + - name: open('/tmp/salt-marker', 'w').close() + +Download and run a script with the running Salt interpreter: + +.. code-block:: yaml + + run-my-script: + python.script: + - source: salt://scripts/runme.py + - args: arg1 arg2 +""" + +import copy +import logging +import os + +from salt.exceptions import CommandExecutionError + +log = logging.getLogger(__name__) + +__virtualname__ = "python" + + +def __virtual__(): + return __virtualname__ + + +def run( + name, + args=None, + cwd=None, + runas=None, + password=None, + env=None, + output_loglevel="debug", + hide_output=False, + timeout=None, + ignore_timeout=False, + use_vt=False, + success_retcodes=None, + success_stdout=None, + success_stderr=None, + **kwargs, +): + """ + Run a snippet of Python code, using the same interpreter that is + running Salt, if certain circumstances are met. + + name + The Python code to execute. + + args + Additional arguments to pass to the interpreter (string or list). + Only used if ``name`` should not be treated as the ``-c`` command, + e.g. for ``-m module`` invocations. + + cwd + The directory from which to execute the code. Defaults to the home + directory of the user specified by ``runas`` (or the user under + which Salt is running if ``runas`` is not specified). + + runas + The user name (or uid) to run the code as. + + password + Windows only. Required when specifying ``runas``. This parameter + will be ignored on non-Windows platforms. + + env + A list of environment variables to be set prior to execution. + + output_loglevel : debug + Control the loglevel at which the output from the command is + logged to the minion log. + + hide_output : False + Suppress stdout and stderr in the state's results. + + timeout + If the command has not terminated after timeout seconds, send the + subprocess sigterm, and if sigterm is ignored, follow up with + sigkill. + + ignore_timeout + Ignore the timeout of commands, which is useful for running nohup + processes. + + use_vt + Use VT utils (saltstack) to stream the command output more + interactively to the console and the logs. This is experimental. + + success_retcodes + A list of non-zero return codes that should be considered a + success. + + success_stdout + A list of strings that when found in standard out should be + considered a success. + + success_stderr + A list of strings that when found in standard error should be + considered a success. + """ + ret = {"name": name, "changes": {}, "result": False, "comment": ""} + + if env is not None and not isinstance(env, (list, dict)): + ret["comment"] = "Invalidly-formatted 'env' parameter. See documentation." + return ret + + cmd_kwargs = copy.deepcopy(kwargs) + cmd_kwargs.update( + { + "args": args, + "cwd": cwd, + "runas": runas, + "password": password, + "env": env, + "use_vt": use_vt, + "output_loglevel": output_loglevel, + "hide_output": hide_output, + "success_retcodes": success_retcodes, + "success_stdout": success_stdout, + "success_stderr": success_stderr, + } + ) + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f'Python code "{name}" would have been executed' + return ret + + if cwd and not os.path.isdir(cwd): + ret["comment"] = f'Desired working directory "{cwd}" is not available' + return ret + + try: + cmd_all = __salt__["python.run"](command=name, timeout=timeout, **cmd_kwargs) + except CommandExecutionError as err: + ret["comment"] = str(err) + return ret + + ret["changes"] = cmd_all + ret["result"] = not bool(cmd_all["retcode"]) + ret["comment"] = f'Python code "{name}" run' + + if ignore_timeout: + trigger = "Timed out after" + if ret["changes"].get("retcode") == 1 and trigger in ret["changes"].get( + "stdout", "" + ): + ret["changes"]["retcode"] = 0 + ret["result"] = True + + if __opts__["test"] and cmd_all["retcode"] == 0 and ret["changes"]: + ret["result"] = None + return ret + + +def script( + name, + source=None, + template=None, + cwd=None, + runas=None, + password=None, + env=None, + timeout=None, + use_vt=False, + output_loglevel="debug", + hide_output=False, + defaults=None, + context=None, + success_retcodes=None, + success_stdout=None, + success_stderr=None, + **kwargs, +): + """ + Download a Python script and execute it with the same interpreter that + is running Salt. + + source + The location of the script to download. If the file is located on + the master in the directory named spam, and is called eggs, the + source string is ``salt://spam/eggs``. + + name + Either "script arg1 arg2 arg3..." (if ``source`` is also given) or + a source "salt://...". + + template + If this setting is applied then the named templating engine will + be used to render the downloaded file. Currently jinja, mako, and + wempy are supported. + + cwd + The directory from which to execute the script. Defaults to the + home directory of the user specified by ``runas`` (or the user + under which Salt is running if ``runas`` is not specified). + + runas + Specify an alternate user to run the script as. The default + behavior is to run as the user under which Salt is running. + + password + Windows only. Required when specifying ``runas``. This parameter + will be ignored on non-Windows platforms. + + env + A list of environment variables to be set prior to execution. + + timeout + If the command has not terminated after timeout seconds, send the + subprocess sigterm, and if sigterm is ignored, follow up with + sigkill. + + use_vt + Use VT utils (saltstack) to stream the command output more + interactively to the console and the logs. This is experimental. + + output_loglevel : debug + Control the loglevel at which the output from the command is + logged to the minion log. + + hide_output : False + Suppress stdout and stderr in the state's results. + + context + Overrides default context variables passed to the template. + + defaults + Default context passed to the template. + + success_retcodes + A list of non-zero return codes that should be considered a + success. + + success_stdout + A list of strings that when found in standard out should be + considered a success. + + success_stderr + A list of strings that when found in standard error should be + considered a success. + """ + ret = {"name": name, "changes": {}, "result": False, "comment": ""} + + if env is not None and not isinstance(env, (list, dict)): + ret["comment"] = "Invalidly-formatted 'env' parameter. See documentation." + return ret + + if context and not isinstance(context, dict): + ret["comment"] = ( + "Invalidly-formatted 'context' parameter. Must be formed as a dict." + ) + return ret + if defaults and not isinstance(defaults, dict): + ret["comment"] = ( + "Invalidly-formatted 'defaults' parameter. Must be formed as a dict." + ) + return ret + + tmpctx = defaults if defaults else {} + if context: + tmpctx.update(context) + + cmd_kwargs = copy.deepcopy(kwargs) + cmd_kwargs.update( + { + "runas": runas, + "password": password, + "env": env, + "cwd": cwd, + "template": template, + "timeout": timeout, + "output_loglevel": output_loglevel, + "hide_output": hide_output, + "use_vt": use_vt, + "context": tmpctx, + "saltenv": __env__, + "success_retcodes": success_retcodes, + "success_stdout": success_stdout, + "success_stderr": success_stderr, + } + ) + + if source is None: + source = name + + if not cmd_kwargs.get("args", None) and len(name.split()) > 1: + cmd_kwargs.update({"args": name.split(" ", 1)[1]}) + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Python script '{name}' would have been executed" + return ret + + if cwd and not os.path.isdir(cwd): + ret["comment"] = f'Desired working directory "{cwd}" is not available' + return ret + + try: + cmd_all = __salt__["python.script"](source, **cmd_kwargs) + except CommandExecutionError as err: + ret["comment"] = str(err) + return ret + + ret["changes"] = cmd_all + ret["result"] = not bool(cmd_all["retcode"]) + if ret.get("changes", {}).get("cache_error"): + ret["comment"] = f"Unable to cache script {source} from saltenv '{__env__}'" + else: + ret["comment"] = f"Python script '{name}' run" + + if __opts__["test"] and cmd_all["retcode"] == 0 and ret["changes"]: + ret["result"] = None + return ret diff --git a/tests/pytests/unit/modules/test_python.py b/tests/pytests/unit/modules/test_python.py new file mode 100644 index 000000000000..4dd7b8bf0190 --- /dev/null +++ b/tests/pytests/unit/modules/test_python.py @@ -0,0 +1,123 @@ +""" +Unit tests for the salt.modules.python module +""" + +import os +import sys + +import pytest + +import salt.modules.python as python +from salt.exceptions import SaltInvocationError +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(minion_opts): + return {python: {"__opts__": minion_opts}} + + +def test_get_python_executable(): + assert python._get_python_executable() == os.path.normpath(sys.executable) + + +def test_run_with_command(): + run_all_mock = MagicMock(return_value={"retcode": 0, "stdout": "", "stderr": ""}) + with patch.dict(python.__salt__, {"cmd.run_all": run_all_mock}): + python.run(command="print(1)") + + call_args = run_all_mock.call_args + cmd_list = call_args[0][0] + assert cmd_list == [python._get_python_executable(), "-c", "print(1)"] + assert call_args[1]["python_shell"] is False + + +def test_run_with_args_only(): + run_all_mock = MagicMock(return_value={"retcode": 0, "stdout": "", "stderr": ""}) + with patch.dict(python.__salt__, {"cmd.run_all": run_all_mock}): + python.run(args=["-m", "json.tool", "foo.json"]) + + cmd_list = run_all_mock.call_args[0][0] + assert cmd_list == [ + python._get_python_executable(), + "-m", + "json.tool", + "foo.json", + ] + + +def test_run_with_string_args(): + run_all_mock = MagicMock(return_value={"retcode": 0, "stdout": "", "stderr": ""}) + with patch.dict(python.__salt__, {"cmd.run_all": run_all_mock}): + python.run(command="print(1)", args="foo bar") + + cmd_list = run_all_mock.call_args[0][0] + assert cmd_list == [python._get_python_executable(), "-c", "print(1)", "foo", "bar"] + + +def test_run_no_command_no_args_raises(): + with pytest.raises(SaltInvocationError): + python.run() + + +def test_script_cache_success(): + run_all_mock = MagicMock(return_value={"retcode": 0, "stdout": "", "stderr": ""}) + cache_file_mock = MagicMock(return_value="/cache/path/myscript.py") + remove_mock = MagicMock() + salt_dunder = { + "cmd.run_all": run_all_mock, + "cp.cache_file": cache_file_mock, + "file.remove": remove_mock, + "file.user_to_uid": MagicMock(return_value=0), + } + with patch.dict(python.__salt__, salt_dunder), patch( + "shutil.copyfile", MagicMock() + ): + ret = python.script("salt://myscript.py", args=["foo", "bar"]) + + assert ret["retcode"] == 0 + cache_file_mock.assert_called_once() + run_all_mock.assert_called_once() + cmd_list = run_all_mock.call_args[0][0] + assert cmd_list[0] == python._get_python_executable() + assert cmd_list[-2:] == ["foo", "bar"] + remove_mock.assert_called_once() + + +def test_script_cache_error(): + cache_file_mock = MagicMock(return_value=False) + remove_mock = MagicMock() + run_all_mock = MagicMock() + salt_dunder = { + "cmd.run_all": run_all_mock, + "cp.cache_file": cache_file_mock, + "file.remove": remove_mock, + } + with patch.dict(python.__salt__, salt_dunder): + ret = python.script("salt://myscript.py") + + assert ret == { + "pid": 0, + "retcode": 1, + "stdout": "", + "stderr": "", + "cache_error": True, + } + run_all_mock.assert_not_called() + + +def test_script_with_template(): + run_all_mock = MagicMock(return_value={"retcode": 0, "stdout": "", "stderr": ""}) + get_template_mock = MagicMock(return_value="/cache/path/myscript.py") + remove_mock = MagicMock() + salt_dunder = { + "cmd.run_all": run_all_mock, + "cp.get_template": get_template_mock, + "file.remove": remove_mock, + } + with patch.dict(python.__salt__, salt_dunder): + ret = python.script("salt://myscript.py", template="jinja") + + assert ret["retcode"] == 0 + get_template_mock.assert_called_once() + run_all_mock.assert_called_once() diff --git a/tests/pytests/unit/states/test_python.py b/tests/pytests/unit/states/test_python.py new file mode 100644 index 000000000000..c48dc0828916 --- /dev/null +++ b/tests/pytests/unit/states/test_python.py @@ -0,0 +1,135 @@ +""" +Unit tests for the salt.states.python module +""" + +import pytest + +import salt.states.python as python +from salt.exceptions import CommandExecutionError +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return {python: {"__env__": "base", "__opts__": {"test": False}}} + + +def test_run_test_mode(): + name = "print(1)" + with patch.dict(python.__opts__, {"test": True}): + run_mock = MagicMock() + with patch.dict(python.__salt__, {"python.run": run_mock}): + ret = python.run(name) + + assert ret["result"] is None + run_mock.assert_not_called() + + +def test_run_invalid_env(): + name = "print(1)" + ret = python.run(name, env="not-a-list-or-dict") + assert ret["result"] is False + assert "env" in ret["comment"] + + +def test_run_success(): + name = "print(1)" + run_mock = MagicMock(return_value={"retcode": 0, "stdout": "", "stderr": ""}) + with patch.dict(python.__salt__, {"python.run": run_mock}): + ret = python.run(name) + + assert ret["result"] is True + run_mock.assert_called_once() + assert run_mock.call_args[1]["command"] == name + + +def test_run_failure(): + name = "raise ValueError()" + run_mock = MagicMock(return_value={"retcode": 1, "stdout": "", "stderr": ""}) + with patch.dict(python.__salt__, {"python.run": run_mock}): + ret = python.run(name) + + assert ret["result"] is False + + +def test_run_exception(): + name = "print(1)" + run_mock = MagicMock(side_effect=CommandExecutionError("boom")) + with patch.dict(python.__salt__, {"python.run": run_mock}): + ret = python.run(name) + + assert ret["result"] is False + assert ret["comment"] == "boom" + + +def test_run_cwd_not_dir(): + name = "print(1)" + ret = python.run(name, cwd="/this/path/does/not/exist") + assert ret["result"] is False + assert "not available" in ret["comment"] + + +def test_script_test_mode(): + name = "salt://myscript.py" + with patch.dict(python.__opts__, {"test": True}): + script_mock = MagicMock() + with patch.dict(python.__salt__, {"python.script": script_mock}): + ret = python.script(name) + + assert ret["result"] is None + script_mock.assert_not_called() + + +def test_script_invalid_env(): + name = "salt://myscript.py" + ret = python.script(name, env="not-a-list-or-dict") + assert ret["result"] is False + assert "env" in ret["comment"] + + +def test_script_invalid_context(): + name = "salt://myscript.py" + ret = python.script(name, context="not-a-dict") + assert ret["result"] is False + assert "context" in ret["comment"] + + +def test_script_invalid_defaults(): + name = "salt://myscript.py" + ret = python.script(name, defaults="not-a-dict") + assert ret["result"] is False + assert "defaults" in ret["comment"] + + +def test_script_success(): + name = "salt://myscript.py" + script_mock = MagicMock(return_value={"retcode": 0, "stdout": "", "stderr": ""}) + with patch.dict(python.__salt__, {"python.script": script_mock}): + ret = python.script(name) + + assert ret["result"] is True + script_mock.assert_called_once() + + +def test_script_cache_error_comment(): + name = "salt://myscript.py" + script_mock = MagicMock( + return_value={ + "retcode": 1, + "stdout": "", + "stderr": "", + "cache_error": True, + } + ) + with patch.dict(python.__salt__, {"python.script": script_mock}): + ret = python.script(name) + + assert ret["result"] is False + assert "Unable to cache script" in ret["comment"] + + +def test_script_cwd_not_dir(): + name = "salt://myscript.py" + ret = python.script(name, cwd="/this/path/does/not/exist") + assert ret["result"] is False + assert "not available" in ret["comment"] From 2ccaa61dccf009d6b610a9d0ee9d2fa45289114f Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Wed, 22 Jul 2026 01:24:19 -0700 Subject: [PATCH 121/469] Fix concurrent state.* runs when queue=True and JID sorts high (#69826) `state.apply queue=True` (and every `state.*` function that honors the `queue` argument) is documented to guarantee at most one `state.*` execution per minion at a time. In practice, when several state runs were published to the same minion in rapid succession, more than one could dispatch concurrently to the `proc/` directory instead of serializing through `queues//state_queue/`. The root cause was the JID string comparison in `salt.utils.state.check_prior_running_states`: it only treated *strictly older* JIDs as blocking, so any concurrently running state whose JID sorted higher than the current one was silently ignored. The result was two active proc entries whenever `_check_queue` for a new job happened to run before the peer subprocess's proc file was on disk. `check_prior_running_states` now blocks on any real running state.* process (non-zero PID) regardless of JID ordering, while queued placeholders (`pid == 0`, produced by scanning the state_queue / job_queue directories) continue to use the FIFO comparison so the state-queue processor can dequeue the oldest queued JID without deadlocking on its younger siblings. Fixes #69825 --- changelog/69825.fixed.md | 6 ++ salt/utils/state.py | 23 +++++-- .../pytests/unit/modules/state/test_state.py | 60 +++++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 changelog/69825.fixed.md diff --git a/changelog/69825.fixed.md b/changelog/69825.fixed.md new file mode 100644 index 000000000000..a9fc754642b3 --- /dev/null +++ b/changelog/69825.fixed.md @@ -0,0 +1,6 @@ +Fixed ``state.apply queue=True`` allowing more than one concurrent ``state.*`` +execution when the new job's JID sorted lexically higher than an already-running +job's JID. ``check_prior_running_states`` now blocks on any real running +state.* process regardless of JID ordering, while still allowing the state +queue processor to dequeue the oldest queued placeholder without deadlocking +on younger queued siblings. diff --git a/salt/utils/state.py b/salt/utils/state.py index 7a9106411f5d..9c9b0a8eb1b3 100644 --- a/salt/utils/state.py +++ b/salt/utils/state.py @@ -218,12 +218,23 @@ def check_prior_running_states(opts, jid, active_jobs): if str(data_jid) == str(jid): continue - # Only block if the other job is OLDER than the current one. - # This ensures FIFO ordering and prevents deadlocks where two - # jobs block each other. - # Salt JIDs are usually timestamp-based strings (e.g. 20230524100000) - # which sort correctly as strings OR ints. - if str(data_jid) < str(jid): + # A real running state.* job (non-zero PID) must always block, + # regardless of how its JID sorts relative to ours. Comparing by + # JID here would let a concurrently running job whose JID sorts + # *higher* than ours slip past the check, breaking the "one + # state run at a time per minion" guarantee (issue #69825). + # + # Queued placeholder entries (pid == 0, produced by scanning the + # queue directories above) represent jobs that have not yet + # started. For those, block only when the placeholder's JID + # sorts before ours so the queue processor can dequeue the + # oldest queued JID without deadlocking on younger siblings. + # Salt JIDs are usually timestamp-based strings (e.g. + # 20230524100000) which sort correctly as strings OR ints. + pid = data.get("pid") + if pid: + ret.append(data) + elif str(data_jid) < str(jid): ret.append(data) except (ValueError, TypeError): continue diff --git a/tests/pytests/unit/modules/state/test_state.py b/tests/pytests/unit/modules/state/test_state.py index d4c3bf656afc..2229635b06a9 100644 --- a/tests/pytests/unit/modules/state/test_state.py +++ b/tests/pytests/unit/modules/state/test_state.py @@ -1387,3 +1387,63 @@ def test_check_prior_running_states_reads_state_queue( # Since mock_listdir returns the same for both calls in this mock setup, # it finds the same file twice. assert len(result) == 2 + + def test_check_prior_running_states_blocks_on_higher_jid_running(self): + """ + Regression test for issue #69825. + + A concurrently running state.* job whose JID sorts *higher* than the + current JID must still block the current job. The previous + ``str(data_jid) < str(jid)`` filter only counted strictly older JIDs, + which allowed two state.* runs to dispatch concurrently on a single + minion when their JID mint order and their per-subprocess queue-check + order disagreed. + """ + opts = {"cachedir": "/tmp/does-not-exist-69825"} + # Simulate a real running state.* job (non-zero PID) whose JID is + # higher (numerically/lexically greater) than the current JID. + active_jobs = [ + { + "jid": "20260718005610738474", + "fun": "state.apply", + "pid": 12345, + } + ] + current_jid = "20260718005610231848" + + result = salt.utils.state.check_prior_running_states( + opts, current_jid, active_jobs + ) + + assert len(result) == 1, ( + "A running state.* job with a higher JID must block the current" + " job to preserve the 'one state run per minion' guarantee." + ) + assert result[0]["jid"] == "20260718005610738474" + + def test_check_prior_running_states_ignores_higher_jid_queued_placeholder( + self, + ): + """ + Companion invariant for issue #69825. + + Queued (not yet running) entries -- represented by a placeholder + with ``pid == 0`` -- should only block the current job when they + sort *before* it, so the state-queue processor can safely dequeue + the oldest queued JID without deadlocking on younger siblings. + """ + opts = {"cachedir": "/tmp/does-not-exist-69825"} + # Two placeholder queued entries: one older, one newer than us. + active_jobs = [ + {"jid": "20260718005609000000", "fun": "state.apply", "pid": 0}, + {"jid": "20260718005611000000", "fun": "state.apply", "pid": 0}, + ] + current_jid = "20260718005610000000" + + result = salt.utils.state.check_prior_running_states( + opts, current_jid, active_jobs + ) + + # Only the strictly older queued placeholder should block. + assert len(result) == 1 + assert result[0]["jid"] == "20260718005609000000" From 78e28558ebc4125007c5ae5f78b855f934ab5400 Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Wed, 22 Jul 2026 02:25:24 -0600 Subject: [PATCH 122/469] Stop building 32-bit (x86) Windows packages (#69839) Several build dependencies are dropping 32-bit Windows support, so remove the x86 leg from the Windows build matrix and reject it as a valid --arch choice for `tools pkg build windows`. Windows test jobs already only ran against amd64, so no test changes are needed. --- tools/ci.py | 1 - tools/pkg/build.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/ci.py b/tools/ci.py index 1439f4c3e606..534cab7c544a 100644 --- a/tools/ci.py +++ b/tools/ci.py @@ -159,7 +159,6 @@ def _build_matrix(os_kind, linux_arm_runner): if os_kind == "windows": _matrix = [ {"arch": "amd64"}, - {"arch": "x86"}, ] elif os_kind == "macos": _matrix.append({"arch": "arm64"}) diff --git a/tools/pkg/build.py b/tools/pkg/build.py index 368e0590d5a4..0286f57a4787 100644 --- a/tools/pkg/build.py +++ b/tools/pkg/build.py @@ -522,7 +522,7 @@ def macos( }, "arch": { "help": "The architecture to build the package for", - "choices": ("x86", "amd64"), + "choices": ("amd64",), "required": True, }, "sign": { From f93a8495c38387255e63ab16e3e98cfb901ee31a Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 22 Jul 2026 04:26:04 -0400 Subject: [PATCH 123/469] Fix silent and crashing bugs in the netsnmp and netntp states (#69794) * Fix silent and crashing bugs in the netsnmp and netntp states netsnmp: - _expand_config did ``defaults.update(config)`` and crashed with ``AttributeError: 'NoneType' object has no attribute 'update'`` whenever the state declared no ``defaults`` (the common case; the netusers twin of #62170). - _clear_community_details had ``community_details.get["mode"] = ...``, a typo subscripting the bound ``.get`` method that raised ``TypeError`` for every community given in the documented dict form; ``mode`` is also defaulted now. - _create_diff's third branch was ``elif not fun(curr)``, unreachable once the first two are past, so a valid->valid change (e.g. location "A" -> "B") matched no branch, was dropped from the diff, and the state reported success without pushing the change. netntp: - _check resolved names into ``ip_only_peers`` then did ``peers = ip_only_peers`` (rebinding a local), so the resolved addresses were discarded and name-based peers never converged; it now rewrites the list in place, keeps an unresolvable entry (no resolver) instead of dropping it, and tolerates any ``dns.exception.DNSException`` rather than only ``NoAnswer``. - managed masked a device-retrieve failure (which produces no changes) as "Device configured properly."; it now reports result=False for a failure with nothing staged, while still letting the commit path handle a partial change. Both state modules had no unit tests; add tests/pytests/unit/states/test_netsnmp.py and test_netntp.py covering each fix and the behaviour-preserving cases. * Add changelog for #69794 --- changelog/69794.fixed.md | 8 +++ salt/states/netntp.py | 31 ++++++++-- salt/states/netsnmp.py | 32 +++++++++-- tests/pytests/unit/states/test_netntp.py | 69 +++++++++++++++++++++++ tests/pytests/unit/states/test_netsnmp.py | 63 +++++++++++++++++++++ 5 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 changelog/69794.fixed.md create mode 100644 tests/pytests/unit/states/test_netntp.py create mode 100644 tests/pytests/unit/states/test_netsnmp.py diff --git a/changelog/69794.fixed.md b/changelog/69794.fixed.md new file mode 100644 index 000000000000..1aa63c43997a --- /dev/null +++ b/changelog/69794.fixed.md @@ -0,0 +1,8 @@ +Fixed several bugs in the ``netsnmp`` and ``netntp`` NAPALM states. ``netsnmp`` +no longer crashes with ``AttributeError: 'NoneType' object has no attribute +'update'`` when no ``defaults`` are declared, no longer raises ``TypeError`` on a +dict-form SNMP community, and no longer silently drops (and reports success for) +a changed ``location``/``contact``/``chassis_id``. ``netntp`` now actually +converts domain-name peers/servers to IP addresses instead of discarding the +resolved values, and no longer reports a device-retrieval failure as +"Device configured properly.". diff --git a/salt/states/netntp.py b/salt/states/netntp.py index f5bc732b82e7..a3c20aee6283 100644 --- a/salt/states/netntp.py +++ b/salt/states/netntp.py @@ -38,6 +38,7 @@ HAS_NETADDR = False try: + import dns.exception # pylint: disable=no-name-in-module import dns.resolver # pylint: disable=no-name-in-module HAS_DNSRESOLVER = True @@ -115,19 +116,28 @@ def _check(peers): # if not a valid IP Address # will try to see if it is a nameserver and resolve it if not HAS_DNSRESOLVER: - continue # without the dns resolver cannot populate the list of NTP entities based on their nameserver - # so we'll move on + # without the dns resolver we cannot resolve the name; keep the + # entry as specified and let the device validate it on load + ip_only_peers.append(peer) + continue dns_reply = [] try: # try to see if it is a valid NS dns_reply = dns.resolver.query(peer) - except dns.resolver.NoAnswer: - # no a valid DNS entry either + except dns.exception.DNSException: + # not a resolvable name either (NoAnswer, NXDOMAIN, Timeout, + # NoNameservers, ...); treat the input as invalid rather than + # letting the DNS error abort the whole state run return False for dns_ip in dns_reply: ip_only_peers.append(str(dns_ip)) - peers = ip_only_peers + # Rewrite the caller's list in place with the resolved addresses. ``_check`` + # is documented to transform domain names into IP addresses, but the old + # ``peers = ip_only_peers`` only rebound the local name, so the resolved + # values were discarded and domain-name peers never converged (the device + # reports IPs, the desired list kept the names, so the diff never emptied). + peers[:] = ip_only_peers return True @@ -187,6 +197,7 @@ def _check_diff_and_configure(fun_name, peers_servers, name="peers"): _ret["comment"] = "Cannot retrieve NTP {what} from the device: {reason}".format( what=name, reason=ntp_list_output.get("comment") ) + _ret["successfully_changed"] = False return _ret configured_ntp_list = set(ntp_list_output.get("out", {})) @@ -367,6 +378,16 @@ def managed(name, peers=None, servers=None): ret.update({"changes": changes}) + if not successfully_changed and not expected_config_change: + # A failure with nothing staged (e.g. the device retrieve failed, which + # is the case that previously fell through to the "no changes -> + # configured properly" branch below and was reported as result=True). + # Report it. When something *was* staged before a later step failed + # (expected_config_change), fall through so the existing commit path + # still deals with the candidate rather than leaving it dangling. + ret.update({"result": False, "comment": comment}) + return ret + if not (changes or expected_config_change): ret.update({"result": True, "comment": "Device configured properly."}) return ret diff --git a/salt/states/netsnmp.py b/salt/states/netsnmp.py index f18dbd8b44ff..09a131333879 100644 --- a/salt/states/netsnmp.py +++ b/salt/states/netsnmp.py @@ -17,6 +17,7 @@ .. versionadded:: 2016.11.0 """ +import copy import logging import salt.utils.json @@ -72,8 +73,14 @@ def _expand_config(config, defaults): Completed the values of the expected config for the edge cases with the default values. """ - defaults.update(config) - return defaults + # ``defaults`` (the state's optional ``defaults`` argument) is ``None`` when + # unset, and ``config`` may be too; treat either as an empty mapping rather + # than crashing on ``None.update()`` (the netusers twin of #62170). deepcopy + # so the nested community detail dicts are not aliased into the caller's + # data -- ``_clear_community_details`` mutates them in place downstream. + expected = copy.deepcopy(defaults) if defaults else {} + expected.update(copy.deepcopy(config) if config else {}) + return expected def _valid_dict(dic): @@ -108,7 +115,12 @@ def _clear_community_details(community_details): for key in ["acl", "mode"]: _str_elem(community_details, key) - _mode = community_details.get["mode"] = community_details.get("mode").lower() + # NB: ``community_details.get["mode"]`` was a typo for + # ``community_details["mode"]`` -- it subscripted the bound ``.get`` method + # and raised ``TypeError`` for every dict-form community. ``mode`` may also + # be absent (``_str_elem`` drops an invalid value), so default it. + _mode = (community_details.get("mode") or "ro").lower() + community_details["mode"] = _mode if _mode in _COMMUNITY_MODE_MAP: community_details["mode"] = _COMMUNITY_MODE_MAP.get(_mode) @@ -203,9 +215,14 @@ def _create_diff(diff, fun, key, prev, curr): if not fun(prev): _create_diff_action(diff, "added", key, curr) - elif fun(prev) and not fun(curr): - _create_diff_action(diff, "removed", key, prev) elif not fun(curr): + _create_diff_action(diff, "removed", key, prev) + else: + # Both previous and current values are valid and -- since _compute_diff + # only calls this when they differ -- not equal: the value changed. The + # old ``elif not fun(curr)`` here was unreachable, so a valid->valid + # change (e.g. location "A" -> "B") was silently dropped from the diff + # and the state reported success without pushing it. _create_diff_action(diff, "updated", key, curr) @@ -222,6 +239,11 @@ def _compute_diff(existing, expected): for key in ["community"]: # for the moment only onen if existing.get(key) != expected.get(key): + # NOTE: the whole community mapping is diffed as one opaque value, so + # a change lands in "updated" and is applied via snmp.update_config + # (add/modify). Removing an individual community from a multi-entry + # set is not expressed here and is left for a follow-up that diffs + # communities individually. _create_diff(diff, _valid_dict, key, existing.get(key), expected.get(key)) return diff diff --git a/tests/pytests/unit/states/test_netntp.py b/tests/pytests/unit/states/test_netntp.py new file mode 100644 index 000000000000..9a634518a19f --- /dev/null +++ b/tests/pytests/unit/states/test_netntp.py @@ -0,0 +1,69 @@ +""" +Unit tests for the netntp state. +""" + +import pytest + +import salt.states.netntp as netntp +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return {netntp: {"__salt__": {}, "__opts__": {"test": False}}} + + +def test_check_rejects_non_list(): + assert netntp._check("192.0.2.1") is False + + +def test_check_resolves_in_place(): + # ``_check`` is documented to transform names into IP addresses; the resolved + # values must replace the caller's list in place (the old ``peers = ...`` + # only rebound the local, discarding them). + peers = ["192.0.2.1", "192.0.2.2"] + # netaddr may be absent in the test env, so IPAddress is not always bound + # in the module namespace; create=True lets us patch it regardless. + with patch("salt.states.netntp.HAS_NETADDR", True), patch( + "salt.states.netntp.IPAddress", create=True, side_effect=lambda p: f"ip:{p}" + ): + result = netntp._check(peers) + assert result is True + assert peers == ["ip:192.0.2.1", "ip:192.0.2.2"] + + +def test_check_keeps_unresolvable_without_resolver(): + # An entry that is neither an IP nor resolvable (no DNS resolver available) + # is kept as specified, not silently dropped from the desired list. + class _AddrErr(Exception): + pass + + def _raise(peer): + raise _AddrErr(peer) + + peers = ["ntp.example.com"] + with patch("salt.states.netntp.HAS_NETADDR", True), patch( + "salt.states.netntp.HAS_DNSRESOLVER", False + ), patch("salt.states.netntp.AddrFormatError", _AddrErr, create=True), patch( + "salt.states.netntp.IPAddress", create=True, side_effect=_raise + ): + result = netntp._check(peers) + assert result is True + assert peers == ["ntp.example.com"] + + +def test_managed_reports_retrieval_failure(): + # A device-retrieval failure must surface as result=False, not be masked as + # "Device configured properly." by the no-changes branch. + ntp_peers = MagicMock(return_value={"result": False, "comment": "boom"}) + with patch.dict(netntp.__salt__, {"ntp.peers": ntp_peers}): + ret = netntp.managed("t", peers=["192.0.2.1"]) + assert ret["result"] is False + assert "Cannot retrieve NTP peers" in ret["comment"] + + +def test_managed_no_args_is_noop(): + # Neither peers nor servers supplied -> exit without touching the device. + ret = netntp.managed("t") + assert ret["result"] is False + assert ret["changes"] == {} diff --git a/tests/pytests/unit/states/test_netsnmp.py b/tests/pytests/unit/states/test_netsnmp.py new file mode 100644 index 000000000000..2a905d88789f --- /dev/null +++ b/tests/pytests/unit/states/test_netsnmp.py @@ -0,0 +1,63 @@ +""" +Unit tests for the netsnmp state. +""" + +import pytest + +import salt.states.netsnmp as netsnmp + + +@pytest.fixture +def configure_loader_modules(): + return {netsnmp: {}} + + +def test_expand_config_without_defaults(): + # The state's optional ``defaults`` is None when unset -- must not crash. + assert netsnmp._expand_config({"location": "DC1"}, None) == {"location": "DC1"} + + +def test_expand_config_merges_defaults(): + # Per-config values win over defaults on a key collision. + assert netsnmp._expand_config( + {"location": "DC1"}, {"contact": "noc", "location": "old"} + ) == {"contact": "noc", "location": "DC1"} + + +def test_clear_community_details_normalizes_mode(): + # ``read-write``/``write`` -> ``rw``; case-folded; the old ``get["mode"]`` + # typo raised TypeError for every one of these. + assert netsnmp._clear_community_details({"mode": "read-write"})["mode"] == "rw" + assert netsnmp._clear_community_details({"mode": "RO"})["mode"] == "ro" + # Missing mode -> default read-only. + assert netsnmp._clear_community_details({})["mode"] == "ro" + # Unrecognised mode -> default read-only. + assert netsnmp._clear_community_details({"mode": "bogus"})["mode"] == "ro" + + +def test_compute_diff_updated_value_not_dropped(): + # location "OldTown" -> "NewTown": both valid strings. Regression: the old + # dead ``elif not fun(curr)`` branch dropped this, so the change was never + # pushed and the state falsely reported success. + diff = netsnmp._compute_diff({"location": "OldTown"}, {"location": "NewTown"}) + assert diff == {"updated": {"location": "NewTown"}} + + +def test_compute_diff_added_and_removed(): + assert netsnmp._compute_diff({}, {"location": "DC1"}) == { + "added": {"location": "DC1"} + } + assert netsnmp._compute_diff({"location": "DC1"}, {}) == { + "removed": {"location": "DC1"} + } + + +def test_compute_diff_community_updated(): + # The community mapping is diffed via _valid_dict; a mode change on an + # existing community is a valid-dict -> valid-dict update and must land in + # "updated" (exercises the else branch for the dict case, not just str). + diff = netsnmp._compute_diff( + {"community": {"public": {"mode": "ro"}}}, + {"community": {"public": {"mode": "rw"}}}, + ) + assert diff == {"updated": {"community": {"public": {"mode": "rw"}}}} From 82f14ade90e69b1196db3edf866d4124c9e0fc38 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 22 Jul 2026 04:28:53 -0400 Subject: [PATCH 124/469] Fix grafana4_datasource.present spurious update under test=True (#69762) present() decided the up-to-date/update case with a plain dict equality (data == datasource). The datasource returned by grafana4.get_datasource always carries server-managed keys (id, orgId, readOnly) that the desired body never contains, so the comparison was never True and test mode always reported "Datasource X will be updated" even when a live run made no change. Decide with deep_diff using the same ignore list the update path already uses, so test mode predicts exactly what a live run does and the now-reachable up-to-date branch correctly returns result=True. Fixes #54122 --- changelog/54122.fixed.md | 1 + salt/states/grafana4_datasource.py | 13 +- .../unit/states/test_grafana4_datasource.py | 200 ++++++++++++++++++ 3 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 changelog/54122.fixed.md create mode 100644 tests/pytests/unit/states/test_grafana4_datasource.py diff --git a/changelog/54122.fixed.md b/changelog/54122.fixed.md new file mode 100644 index 000000000000..3a884763dd9a --- /dev/null +++ b/changelog/54122.fixed.md @@ -0,0 +1 @@ +Fixed grafana4_datasource.present reporting a spurious update under test=True for an unchanged existing data source diff --git a/salt/states/grafana4_datasource.py b/salt/states/grafana4_datasource.py index b3b2503071c7..9b2f2b1ccfa8 100644 --- a/salt/states/grafana4_datasource.py +++ b/salt/states/grafana4_datasource.py @@ -167,16 +167,25 @@ def present( if key not in datasource: datasource[key] = None - if data == datasource: + # Grafana returns server-managed keys (id, orgId, readOnly) that our "data" + # dict never contains, so a plain "data == datasource" comparison is never + # True for an existing datasource. Decide with the same diff the update path + # uses, ignoring those server-managed keys, so test mode agrees with a live + # run instead of always reporting a spurious update. + changes = deep_diff(datasource, data, ignore=["id", "orgId", "readOnly"]) + + if not changes: + ret["result"] = True ret["comment"] = f"Data source {name} already up-to-date" return ret if __opts__["test"]: ret["comment"] = f"Datasource {name} will be updated" + ret["changes"] = changes return ret __salt__["grafana4.update_datasource"](datasource["id"], profile=profile, **data) ret["result"] = True - ret["changes"] = deep_diff(datasource, data, ignore=["id", "orgId", "readOnly"]) + ret["changes"] = changes ret["comment"] = f"Data source {name} updated" return ret diff --git a/tests/pytests/unit/states/test_grafana4_datasource.py b/tests/pytests/unit/states/test_grafana4_datasource.py new file mode 100644 index 000000000000..2540a97563bd --- /dev/null +++ b/tests/pytests/unit/states/test_grafana4_datasource.py @@ -0,0 +1,200 @@ +import pytest + +import salt.states.grafana4_datasource as grafana4_datasource +from tests.support.mock import MagicMock, patch + +profile = { + "grafana_url": "http://grafana", + "grafana_token": "token", + "grafana_timeout": 3, +} + + +@pytest.fixture +def configure_loader_modules(): + return {grafana4_datasource: {"__opts__": {"test": False}}} + + +def _desired(): + """ + Build the datasource body exactly as present() does via _get_json_data, + with all the keyword arguments present() forwards (Nones included). + """ + return grafana4_datasource._get_json_data( + name="test", + type="prometheus", + url="http://localhost:8080", + access="proxy", + user=None, + password=None, + database=None, + basicAuth=None, + basicAuthUser=None, + basicAuthPassword=None, + tlsAuth=None, + jsonData=None, + isDefault=False, + withCredentials=None, + typeLogoUrl=None, + ) + + +def _stored(**overrides): + """ + Simulate what grafana4.get_datasource returns: the desired body plus the + server-managed keys Grafana always adds (id, orgId, readOnly). + """ + stored = _desired() + stored.update({"id": 1, "orgId": 1, "readOnly": False}) + stored.update(overrides) + return stored + + +def test_present_unchanged_test_mode_54122(): + """ + test=True must not report an update for an existing, unchanged datasource. + + Regression test for issue #54122: get_datasource returns server-managed + keys (id, orgId, readOnly) that the desired body never carries, so the old + "data == datasource" check was never True and test mode always claimed the + datasource "will be updated" even though a live run made no changes. + """ + stored = _stored() + update = MagicMock() + with patch.dict( + grafana4_datasource.__salt__, + { + "grafana4.get_datasource": MagicMock(return_value=stored), + "grafana4.update_datasource": update, + }, + ), patch.dict(grafana4_datasource.__opts__, {"test": True}): + ret = grafana4_datasource.present( + "test", + "prometheus", + "http://localhost:8080", + access="proxy", + is_default=False, + profile=profile, + ) + assert ret["result"] is True + assert ret["comment"] == "Data source test already up-to-date" + assert ret["changes"] == {} + update.assert_not_called() + + +def test_present_changed_test_mode(): + """ + Inverse / must-not-regress: a real change (url differs) must still be + reported as pending under test=True, with result left as None and no + live update performed. Passes with and without the #54122 fix. + """ + stored = _stored(url="http://OLD:8080") + update = MagicMock() + with patch.dict( + grafana4_datasource.__salt__, + { + "grafana4.get_datasource": MagicMock(return_value=stored), + "grafana4.update_datasource": update, + }, + ), patch.dict(grafana4_datasource.__opts__, {"test": True}): + ret = grafana4_datasource.present( + "test", + "prometheus", + "http://NEW:8080", + access="proxy", + is_default=False, + profile=profile, + ) + assert ret["result"] is None + assert ret["comment"] == "Datasource test will be updated" + update.assert_not_called() + + +def test_present_unchanged_live(): + """ + Live run (test=False) on an unchanged datasource must be a no-op: result + True, empty changes, and update_datasource never invoked. + """ + stored = _stored() + update = MagicMock() + with patch.dict( + grafana4_datasource.__salt__, + { + "grafana4.get_datasource": MagicMock(return_value=stored), + "grafana4.update_datasource": update, + }, + ), patch.dict(grafana4_datasource.__opts__, {"test": False}): + ret = grafana4_datasource.present( + "test", + "prometheus", + "http://localhost:8080", + access="proxy", + is_default=False, + profile=profile, + ) + assert ret["result"] is True + assert ret["comment"] == "Data source test already up-to-date" + assert ret["changes"] == {} + update.assert_not_called() + + +def test_present_changed_live(): + """ + Live run (test=False) with a real change updates the datasource: it calls + update_datasource with the stored id, reports the diff in changes, and + returns result True. + """ + stored = _stored(url="http://OLD:8080") + update = MagicMock() + with patch.dict( + grafana4_datasource.__salt__, + { + "grafana4.get_datasource": MagicMock(return_value=stored), + "grafana4.update_datasource": update, + }, + ), patch.dict(grafana4_datasource.__opts__, {"test": False}): + ret = grafana4_datasource.present( + "test", + "prometheus", + "http://NEW:8080", + access="proxy", + is_default=False, + profile=profile, + ) + assert ret["result"] is True + assert ret["comment"] == "Data source test updated" + assert ret["changes"] == { + "old": {"url": "http://OLD:8080"}, + "new": {"url": "http://NEW:8080"}, + } + update.assert_called_once() + assert update.call_args[0][0] == 1 + + +def test_present_absent_creates_in_test_mode(): + """ + Peripheral coverage: when the datasource does not exist yet, test mode + reports creation and does not touch update_datasource. + """ + create = MagicMock() + update = MagicMock() + with patch.dict( + grafana4_datasource.__salt__, + { + "grafana4.get_datasource": MagicMock(return_value={}), + "grafana4.create_datasource": create, + "grafana4.update_datasource": update, + }, + ), patch.dict(grafana4_datasource.__opts__, {"test": True}): + ret = grafana4_datasource.present( + "test", + "prometheus", + "http://localhost:8080", + access="proxy", + is_default=False, + profile=profile, + ) + assert ret["result"] is None + assert ret["comment"] == "Datasource test will be created" + create.assert_not_called() + update.assert_not_called() From ee516dc801f4938e56c3a538ddee98e2b13c5a16 Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Wed, 22 Jul 2026 16:43:28 -0600 Subject: [PATCH 125/469] Fix concurrent state.* runs when queue=True and JID sorts high (#69826) (#69851) `state.apply queue=True` (and every `state.*` function that honors the `queue` argument) is documented to guarantee at most one `state.*` execution per minion at a time. In practice, when several state runs were published to the same minion in rapid succession, more than one could dispatch concurrently to the `proc/` directory instead of serializing through `queues//state_queue/`. The root cause was the JID string comparison in `salt.utils.state.check_prior_running_states`: it only treated *strictly older* JIDs as blocking, so any concurrently running state whose JID sorted higher than the current one was silently ignored. The result was two active proc entries whenever `_check_queue` for a new job happened to run before the peer subprocess's proc file was on disk. `check_prior_running_states` now blocks on any real running state.* process (non-zero PID) regardless of JID ordering, while queued placeholders (`pid == 0`, produced by scanning the state_queue / job_queue directories) continue to use the FIFO comparison so the state-queue processor can dequeue the oldest queued JID without deadlocking on its younger siblings. Fixes #69825 Co-authored-by: Daniel Wozniak --- changelog/69825.fixed.md | 6 ++ salt/utils/state.py | 23 +++++-- .../pytests/unit/modules/state/test_state.py | 60 +++++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 changelog/69825.fixed.md diff --git a/changelog/69825.fixed.md b/changelog/69825.fixed.md new file mode 100644 index 000000000000..a9fc754642b3 --- /dev/null +++ b/changelog/69825.fixed.md @@ -0,0 +1,6 @@ +Fixed ``state.apply queue=True`` allowing more than one concurrent ``state.*`` +execution when the new job's JID sorted lexically higher than an already-running +job's JID. ``check_prior_running_states`` now blocks on any real running +state.* process regardless of JID ordering, while still allowing the state +queue processor to dequeue the oldest queued placeholder without deadlocking +on younger queued siblings. diff --git a/salt/utils/state.py b/salt/utils/state.py index 15b633a35e53..4820122cd8e4 100644 --- a/salt/utils/state.py +++ b/salt/utils/state.py @@ -206,12 +206,23 @@ def check_prior_running_states(opts, jid, active_jobs): if str(data_jid) == str(jid): continue - # Only block if the other job is OLDER than the current one. - # This ensures FIFO ordering and prevents deadlocks where two - # jobs block each other. - # Salt JIDs are usually timestamp-based strings (e.g. 20230524100000) - # which sort correctly as strings OR ints. - if str(data_jid) < str(jid): + # A real running state.* job (non-zero PID) must always block, + # regardless of how its JID sorts relative to ours. Comparing by + # JID here would let a concurrently running job whose JID sorts + # *higher* than ours slip past the check, breaking the "one + # state run at a time per minion" guarantee (issue #69825). + # + # Queued placeholder entries (pid == 0, produced by scanning the + # queue directories above) represent jobs that have not yet + # started. For those, block only when the placeholder's JID + # sorts before ours so the queue processor can dequeue the + # oldest queued JID without deadlocking on younger siblings. + # Salt JIDs are usually timestamp-based strings (e.g. + # 20230524100000) which sort correctly as strings OR ints. + pid = data.get("pid") + if pid: + ret.append(data) + elif str(data_jid) < str(jid): ret.append(data) except (ValueError, TypeError): continue diff --git a/tests/pytests/unit/modules/state/test_state.py b/tests/pytests/unit/modules/state/test_state.py index d37d0c22ea43..e5f4cc4935eb 100644 --- a/tests/pytests/unit/modules/state/test_state.py +++ b/tests/pytests/unit/modules/state/test_state.py @@ -1378,3 +1378,63 @@ def test_check_prior_running_states_reads_state_queue( # Since mock_listdir returns the same for both calls in this mock setup, # it finds the same file twice. assert len(result) == 2 + + def test_check_prior_running_states_blocks_on_higher_jid_running(self): + """ + Regression test for issue #69825. + + A concurrently running state.* job whose JID sorts *higher* than the + current JID must still block the current job. The previous + ``str(data_jid) < str(jid)`` filter only counted strictly older JIDs, + which allowed two state.* runs to dispatch concurrently on a single + minion when their JID mint order and their per-subprocess queue-check + order disagreed. + """ + opts = {"cachedir": "/tmp/does-not-exist-69825"} + # Simulate a real running state.* job (non-zero PID) whose JID is + # higher (numerically/lexically greater) than the current JID. + active_jobs = [ + { + "jid": "20260718005610738474", + "fun": "state.apply", + "pid": 12345, + } + ] + current_jid = "20260718005610231848" + + result = salt.utils.state.check_prior_running_states( + opts, current_jid, active_jobs + ) + + assert len(result) == 1, ( + "A running state.* job with a higher JID must block the current" + " job to preserve the 'one state run per minion' guarantee." + ) + assert result[0]["jid"] == "20260718005610738474" + + def test_check_prior_running_states_ignores_higher_jid_queued_placeholder( + self, + ): + """ + Companion invariant for issue #69825. + + Queued (not yet running) entries -- represented by a placeholder + with ``pid == 0`` -- should only block the current job when they + sort *before* it, so the state-queue processor can safely dequeue + the oldest queued JID without deadlocking on younger siblings. + """ + opts = {"cachedir": "/tmp/does-not-exist-69825"} + # Two placeholder queued entries: one older, one newer than us. + active_jobs = [ + {"jid": "20260718005609000000", "fun": "state.apply", "pid": 0}, + {"jid": "20260718005611000000", "fun": "state.apply", "pid": 0}, + ] + current_jid = "20260718005610000000" + + result = salt.utils.state.check_prior_running_states( + opts, current_jid, active_jobs + ) + + # Only the strictly older queued placeholder should block. + assert len(result) == 1 + assert result[0]["jid"] == "20260718005609000000" From f237b6f036a823d7129027652b7742e2ec4f1ee6 Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Wed, 22 Jul 2026 16:53:22 -0600 Subject: [PATCH 126/469] Patch vendored tornado for CVE-2026-49855 (gzip decompression bomb) (#69849) _GzipMessageDelegate.data_received tracked only the compressed size of gzip response bodies against max_body_size, not the decompressed size. A malicious server could return a small, highly-compressed response that expands to gigabytes on the client, exhausting memory. Track the cumulative decompressed size and raise HTTPInputError once it exceeds max_body_size, matching the check already done for compressed sizes elsewhere in http1connection.py. --- changelog/69848.fixed.md | 4 ++++ salt/ext/tornado/http1connection.py | 11 +++++++++-- .../tornado/test/simple_httpclient_test.py | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 changelog/69848.fixed.md diff --git a/changelog/69848.fixed.md b/changelog/69848.fixed.md new file mode 100644 index 000000000000..6fb6304e6c03 --- /dev/null +++ b/changelog/69848.fixed.md @@ -0,0 +1,4 @@ +Patch the vendored tornado ``_GzipMessageDelegate`` for CVE-2026-49855: the +cumulative size of decompressed gzip response bodies is now checked against +``max_body_size``, preventing a malicious server from exhausting client +memory with a small, highly-compressed response (a "gzip bomb"). diff --git a/salt/ext/tornado/http1connection.py b/salt/ext/tornado/http1connection.py index edec43dc7615..febd1b25313b 100644 --- a/salt/ext/tornado/http1connection.py +++ b/salt/ext/tornado/http1connection.py @@ -151,7 +151,8 @@ def read_response(self, delegate): been read. """ if self.params.decompress: - delegate = _GzipMessageDelegate(delegate, self.params.chunk_size) + delegate = _GzipMessageDelegate(delegate, self.params.chunk_size, + self._max_body_size) return self._read_message(delegate) @gen.coroutine @@ -625,9 +626,11 @@ def _read_body_until_close(self, delegate): class _GzipMessageDelegate(httputil.HTTPMessageDelegate): """Wraps an `HTTPMessageDelegate` to decode ``Content-Encoding: gzip``. """ - def __init__(self, delegate, chunk_size): + def __init__(self, delegate, chunk_size, max_body_size): self._delegate = delegate self._chunk_size = chunk_size + self._max_body_size = max_body_size + self._decompressed_body_size = 0 self._decompressor = None def headers_received(self, start_line, headers): @@ -649,6 +652,10 @@ def data_received(self, chunk): decompressed = self._decompressor.decompress( compressed_data, self._chunk_size) if decompressed: + self._decompressed_body_size += len(decompressed) + if self._decompressed_body_size > self._max_body_size: + raise httputil.HTTPInputError( + "decompressed body too large") ret = self._delegate.data_received(decompressed) if ret is not None: yield ret diff --git a/salt/ext/tornado/test/simple_httpclient_test.py b/salt/ext/tornado/test/simple_httpclient_test.py index 31b8c8f692ed..6559108e76b5 100644 --- a/salt/ext/tornado/test/simple_httpclient_test.py +++ b/salt/ext/tornado/test/simple_httpclient_test.py @@ -737,6 +737,25 @@ def test_large_body(self): self.assertEqual(response.code, 599) +class GzipBombTest(AsyncHTTPTestCase): + # Regression test for CVE-2026-49855: a small gzip-compressed response + # that decompresses far past max_body_size must be rejected instead of + # being buffered in full. + def get_app(self): + class BombHandler(RequestHandler): + def get(self): + self.write("a" * 1024 * 1024 * 10) + + return Application([('/bomb', BombHandler)], gzip=True) + + def get_http_client(self): + return SimpleAsyncHTTPClient(io_loop=self.io_loop, max_body_size=1024 * 64) + + def test_gzip_bomb_rejected(self): + response = self.fetch('/bomb') + self.assertEqual(response.code, 599) + + class MaxBufferSizeTest(AsyncHTTPTestCase): def get_app(self): From 00e1f355820765aa8972a014ca8031c601b54ab1 Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Wed, 22 Jul 2026 16:58:49 -0600 Subject: [PATCH 127/469] Add winrepo_installer_cache_expire to clean up cached installer files (#69841) Salt never removed installer/uninstaller files downloaded by pkg.install and pkg.remove, causing the minion cache to grow unbounded (#69817). Add an opt-in winrepo_installer_cache_expire minion option that removes tracked installer cache files older than the configured age each time pkg.refresh_db runs. Disabled by default. --- changelog/69817.added.md | 1 + doc/ref/configuration/minion.rst | 25 ++++ salt/config/__init__.py | 2 + salt/modules/win_pkg.py | 115 ++++++++++++++++ tests/pytests/unit/modules/test_win_pkg.py | 151 +++++++++++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 changelog/69817.added.md diff --git a/changelog/69817.added.md b/changelog/69817.added.md new file mode 100644 index 000000000000..e4b2044b209b --- /dev/null +++ b/changelog/69817.added.md @@ -0,0 +1 @@ +Added `winrepo_installer_cache_expire` minion config option to automatically remove cached winrepo installer/uninstaller files older than a configurable age each time `pkg.refresh_db` runs, preventing the minion cache from growing unbounded. Disabled by default. diff --git a/doc/ref/configuration/minion.rst b/doc/ref/configuration/minion.rst index 17298548815c..d0f075aca03a 100644 --- a/doc/ref/configuration/minion.rst +++ b/doc/ref/configuration/minion.rst @@ -3783,6 +3783,31 @@ the metadata will be refreshed. winrepo_cache_expire_max: 86400 +.. conf_minion:: winrepo_installer_cache_expire + +``winrepo_installer_cache_expire`` +----------------------------------- + +.. versionadded:: 3006.28 + +Default: ``0`` + +Every time :py:func:`pkg.refresh_db ` runs, +installer/uninstaller files cached on the minion by +:py:func:`pkg.install ` and +:py:func:`pkg.remove ` that are older than this +many seconds will be removed, to keep them from accumulating indefinitely on +the minion's disk. If set to ``0`` (the default), no cached installer files +are ever removed. + +This is separate from ``winrepo_cache_expire_min``/``winrepo_cache_expire_max`` +above, which only control refresh timing of the windows repo metadata +database, not the downloaded installer/uninstaller files themselves. + +.. code-block:: yaml + + winrepo_installer_cache_expire: 2592000 # 30 days + .. conf_minion:: winrepo_source_dir ``winrepo_source_dir`` diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 3fa73573d724..00754e0b5d42 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -749,6 +749,7 @@ def _gather_buffer_space(): # be, we'll just skip type-checking. "winrepo_cache_expire_max": int, "winrepo_cache_expire_min": int, + "winrepo_installer_cache_expire": int, "winrepo_remotes": list, "winrepo_remotes_ng": list, "winrepo_ssl_verify": bool, @@ -1251,6 +1252,7 @@ def _gather_buffer_space(): "winrepo_cachefile": "winrepo.p", "winrepo_cache_expire_max": 604800, "winrepo_cache_expire_min": 1800, + "winrepo_installer_cache_expire": 0, "winrepo_remotes": ["https://github.com/saltstack/salt-winrepo.git"], "winrepo_remotes_ng": ["https://github.com/saltstack/salt-winrepo-ng.git"], "winrepo_branch": "master", diff --git a/salt/modules/win_pkg.py b/salt/modules/win_pkg.py index 0b9e6c074784..b02e67656668 100644 --- a/salt/modules/win_pkg.py +++ b/salt/modules/win_pkg.py @@ -988,6 +988,17 @@ def refresh_db(**kwargs): should be called to ensure the minion has the latest information about packages available to it. + .. note:: + Each time this function runs, cached installer/uninstaller files + (downloaded by `pkg.install`/`pkg.remove`) that are older than + `winrepo_installer_cache_expire` seconds are also removed, to keep + them from accumulating indefinitely on the minion. This is disabled + by default; set `winrepo_installer_cache_expire` to a nonzero number + of seconds to opt in. This is separate from + `winrepo_cache_expire_min`/`winrepo_cache_expire_max`, which only + control refresh timing of the package metadata database, not the + downloaded installer files themselves. + .. warning:: Directories and files fetched from (`/srv/salt/win/repo-ng`) will be processed in alphabetical order. If @@ -1069,6 +1080,10 @@ def refresh_db(**kwargs): "Failed to clear one or more winrepo cache files", info={"failed": failed} ) + # Remove expired cached installer/uninstaller files, if the user has + # opted in via winrepo_installer_cache_expire + _clean_installer_cache(saltenv) + # Clear the cache so that newly copied package definitions will be picked up fileserver = salt.fileserver.Fileserver(__opts__) load = {"saltenv": saltenv, "fsbackend": None} @@ -1163,6 +1178,103 @@ def _get_repo_details(saltenv): return repo_details(winrepo_source_dir, local_dest, winrepo_file, winrepo_age) +def _installer_cache_file(saltenv): + """ + Return the path to the file used to track installer/uninstaller files + that have been cached by ``pkg.install``/``pkg.remove`` for the given + saltenv, so they can later be expired by ``_clean_installer_cache``. + """ + return os.path.join(_get_repo_details(saltenv).local_dest, "installer_cache.p") + + +def _track_cached_installer(saltenv, path): + """ + Record that ``path`` was cached by pkg.install/pkg.remove so that it can + be expired later on, if the user has opted in via + ``winrepo_installer_cache_expire``. + """ + if not __opts__.get("winrepo_installer_cache_expire"): + return + + cache_file = _installer_cache_file(saltenv) + cached = set() + try: + with salt.utils.files.fopen(cache_file, "rb") as fp_: + cached = set(salt.payload.loads(fp_.read()) or []) + except OSError as exc: + if exc.errno != errno.ENOENT: + log.error("Failed to read %s: %s", cache_file, exc) + + if path in cached: + return + + cached.add(path) + try: + with salt.utils.files.fopen(cache_file, "wb") as fp_: + fp_.write(salt.payload.dumps(list(cached))) + except OSError as exc: + log.error("Failed to write %s: %s", cache_file, exc) + + +def _clean_installer_cache(saltenv): + """ + Remove installer/uninstaller files cached by pkg.install/pkg.remove that + are older than ``winrepo_installer_cache_expire`` seconds. Disabled + (no-op) unless that option is set to a truthy value, so this is opt-in + and does not change default behavior. + + Only files that this module itself cached (tracked via + ``_track_cached_installer``) are ever removed here; the rest of the + minion's ``extrn_files`` cache, which may be used by other + modules/states, is left untouched. + """ + expire = __opts__.get("winrepo_installer_cache_expire") + if not expire: + return + + cache_file = _installer_cache_file(saltenv) + try: + with salt.utils.files.fopen(cache_file, "rb") as fp_: + cached = set(salt.payload.loads(fp_.read()) or []) + except OSError as exc: + if exc.errno != errno.ENOENT: + log.error("Failed to read %s: %s", cache_file, exc) + return + + if not cached: + return + + threshold = time.time() - expire + remaining = set() + for path in cached: + try: + mtime = os.path.getmtime(path) + except OSError as exc: + if exc.errno != errno.ENOENT: + log.error("Failed to get age of %s: %s", path, exc) + remaining.add(path) + # File no longer exists, drop it from the tracked set + continue + + if mtime < threshold: + try: + os.remove(path) + log.debug("Removed expired winrepo installer cache file: %s", path) + except OSError as exc: + if exc.errno != errno.ENOENT: + log.error("Failed to remove %s: %s", path, exc) + remaining.add(path) + else: + remaining.add(path) + + if remaining != cached: + try: + with salt.utils.files.fopen(cache_file, "wb") as fp_: + fp_.write(salt.payload.dumps(list(remaining))) + except OSError as exc: + log.error("Failed to write %s: %s", cache_file, exc) + + def genrepo(**kwargs): """ Generate package metadata db based on files within the winrepo_source_dir @@ -1795,6 +1907,7 @@ def install(name=None, refresh=False, pkgs=None, **kwargs): log.error("Unable to cache %s", cache_file) ret[pkg_name] = {"failed to cache cache_file": cache_file} continue + _track_cached_installer(saltenv, cached_file) # If version is "latest" we always cache because "cp.is_cached" only # checks that the file exists, not that is has changed @@ -1828,6 +1941,7 @@ def install(name=None, refresh=False, pkgs=None, **kwargs): ) ret[pkg_name] = {"unable to cache": installer} continue + _track_cached_installer(saltenv, cached_pkg) else: # Run the installer directly (not hosted on salt:, https:, etc.) cached_pkg = installer @@ -2262,6 +2376,7 @@ def remove(name=None, pkgs=None, **kwargs): log.error("Unable to cache %s", uninstaller) ret[pkgname] = {"unable to cache": uninstaller} continue + _track_cached_installer(saltenv, cached_pkg) else: # Run the uninstaller directly (not hosted on salt:, https:, etc.) diff --git a/tests/pytests/unit/modules/test_win_pkg.py b/tests/pytests/unit/modules/test_win_pkg.py index 4d0bd0595108..c0136bb99909 100644 --- a/tests/pytests/unit/modules/test_win_pkg.py +++ b/tests/pytests/unit/modules/test_win_pkg.py @@ -11,7 +11,9 @@ import salt.modules.cp as cp import salt.modules.pkg_resource as pkg_resource import salt.modules.win_pkg as win_pkg +import salt.payload import salt.utils.data +import salt.utils.files import salt.utils.platform import salt.utils.win_reg as win_reg from salt.exceptions import MinionError @@ -1040,3 +1042,152 @@ def test_get_package_info_uses_opts_saltenv(): ): win_pkg.get_package_info("chrome") mock_get_package_info.assert_called_once_with(name="chrome", saltenv="prod") + + +def test_track_cached_installer_noop_when_disabled(tmp_path): + """ + _track_cached_installer must not write a manifest when + winrepo_installer_cache_expire is disabled (the default). + """ + cache_file = tmp_path / "installer_cache.p" + with patch.dict( + win_pkg.__opts__, {"winrepo_installer_cache_expire": 0} + ), patch.object( + win_pkg, "_installer_cache_file", MagicMock(return_value=str(cache_file)) + ): + win_pkg._track_cached_installer("base", "C:\\fake\\path.exe") + assert not cache_file.exists() + + +def test_track_cached_installer_writes_manifest(tmp_path): + """ + _track_cached_installer must persist newly cached paths when the + feature is enabled. + """ + cache_file = tmp_path / "installer_cache.p" + with patch.dict( + win_pkg.__opts__, {"winrepo_installer_cache_expire": 2592000} + ), patch.object( + win_pkg, "_installer_cache_file", MagicMock(return_value=str(cache_file)) + ): + win_pkg._track_cached_installer("base", "C:\\fake\\path.exe") + assert cache_file.exists() + with salt.utils.files.fopen(str(cache_file), "rb") as fp_: + cached = salt.payload.loads(fp_.read()) + assert list(cached) == ["C:\\fake\\path.exe"] + + +def test_clean_installer_cache_noop_when_disabled(tmp_path): + """ + _clean_installer_cache must not remove anything when + winrepo_installer_cache_expire is disabled (the default). + """ + cache_file = tmp_path / "installer_cache.p" + with salt.utils.files.fopen(str(cache_file), "wb") as fp_: + fp_.write(salt.payload.dumps(["C:\\fake\\old.exe"])) + + mock_remove = MagicMock() + with patch.dict( + win_pkg.__opts__, {"winrepo_installer_cache_expire": 0} + ), patch.object( + win_pkg, "_installer_cache_file", MagicMock(return_value=str(cache_file)) + ), patch.object( + win_pkg.os, "remove", mock_remove + ): + win_pkg._clean_installer_cache("base") + mock_remove.assert_not_called() + + +def test_clean_installer_cache_removes_expired_entries(tmp_path): + """ + _clean_installer_cache must remove only tracked files older than + winrepo_installer_cache_expire seconds, leaving fresh entries in the + manifest and untouched on disk. + """ + cache_file = tmp_path / "installer_cache.p" + old_path = "C:\\fake\\old.exe" + fresh_path = "C:\\fake\\fresh.exe" + with salt.utils.files.fopen(str(cache_file), "wb") as fp_: + fp_.write(salt.payload.dumps([old_path, fresh_path])) + + now = 2_000_000 + expire = 1_000 + mtimes = {old_path: now - expire - 1, fresh_path: now - expire + 1} + mock_remove = MagicMock() + with patch.dict( + win_pkg.__opts__, {"winrepo_installer_cache_expire": expire} + ), patch.object( + win_pkg, "_installer_cache_file", MagicMock(return_value=str(cache_file)) + ), patch.object( + win_pkg.time, "time", MagicMock(return_value=now) + ), patch.object( + win_pkg.os.path, "getmtime", MagicMock(side_effect=lambda p: mtimes[p]) + ), patch.object( + win_pkg.os, "remove", mock_remove + ): + win_pkg._clean_installer_cache("base") + + mock_remove.assert_called_once_with(old_path) + with salt.utils.files.fopen(str(cache_file), "rb") as fp_: + remaining = salt.payload.loads(fp_.read()) + assert list(remaining) == [fresh_path] + + +def test_clean_installer_cache_drops_missing_files(tmp_path): + """ + _clean_installer_cache must silently drop manifest entries for files + that no longer exist, without raising or attempting to remove them. + """ + cache_file = tmp_path / "installer_cache.p" + missing_path = "C:\\fake\\gone.exe" + with salt.utils.files.fopen(str(cache_file), "wb") as fp_: + fp_.write(salt.payload.dumps([missing_path])) + + mock_remove = MagicMock() + + def _raise_enoent(_path): + raise OSError(2, "No such file or directory") + + with patch.dict( + win_pkg.__opts__, {"winrepo_installer_cache_expire": 1000} + ), patch.object( + win_pkg, "_installer_cache_file", MagicMock(return_value=str(cache_file)) + ), patch.object( + win_pkg.os.path, "getmtime", MagicMock(side_effect=_raise_enoent) + ), patch.object( + win_pkg.os, "remove", mock_remove + ): + win_pkg._clean_installer_cache("base") + + mock_remove.assert_not_called() + with salt.utils.files.fopen(str(cache_file), "rb") as fp_: + remaining = salt.payload.loads(fp_.read()) + assert list(remaining) == [] + + +def test_refresh_db_calls_clean_installer_cache(tmp_path): + """ + refresh_db() must sweep expired installer cache entries every time it + runs (the sweep itself is a no-op unless the user opted in). + """ + repo_details = win_pkg.collections.namedtuple( + "RepoDetails", + ("winrepo_source_dir", "local_dest", "winrepo_file", "winrepo_age"), + )("salt://win/repo-ng/", str(tmp_path), str(tmp_path / "winrepo.p"), 0) + + mock_clean = MagicMock() + mock_fileserver = MagicMock() + with patch.object( + win_pkg, "_get_repo_details", MagicMock(return_value=repo_details) + ), patch.object(win_pkg, "_clean_installer_cache", mock_clean), patch.object( + win_pkg, "genrepo", MagicMock(return_value={}) + ), patch.object( + win_pkg.salt.fileserver, "Fileserver", MagicMock(return_value=mock_fileserver) + ), patch.dict( + win_pkg.__salt__, {"cp.cache_dir": MagicMock(return_value=[])} + ), patch.dict( + win_pkg.__opts__, {"cachedir": str(tmp_path)} + ): + win_pkg.refresh_db(saltenv="base") + + mock_clean.assert_called_once_with("base") From caf77575722fa9f33d02fe9185f8920cca4022c8 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Wed, 22 Jul 2026 16:34:32 -0700 Subject: [PATCH 128/469] [3008.x] Fix master MWorkerQueue FD leak + TCP MessageClient close leak + rest_cherrypy RAM session leak (#69847) (#69850) * Cover salt CLI on master hosts in the ZMQ identity gate A salt CLI invoked from a master host loads /etc/salt/master and inherits __role=master, so the role-only gate in AsyncReqMessageClient falls through and every connection to the master's MWorkerQueue ROUTER gets libzmq's default per-connection random routing-id -- accepted, but the underlying socket FD is never released. One FD leaks per CLI invocation. Add salt._process_role with a process-level is_cli() flag flipped from salt.scripts at each CLI entry point, and consult it before the role check so a CLI is recognised even when __role is set. * Tear down TCP MessageClient synchronously on close() The old close() scheduled check_close on the IOLoop and polled send_future_map at 1 s intervals; a single orphaned in-flight future (e.g. an awaiting coroutine cancelled by CherryPy mid-request) kept the map non-empty forever, so the whole MessageClient graph (Unpacker, IOStream, LazyLoaders reachable via self) stayed alive. Under salt-api load this leaked ~18 MessageClient objects/s. Close synchronously: cancel any pending futures with SaltReqTimeoutError, then tear the tcp client and stream down before returning. Also guard connect() against clobbering _closing/_closed after close() has run so a late reconnect from _stream_return cannot revive a torn-down client. * Add changelog entry for #69847 * Stop persisting empty RAM sessions in rest_cherrypy salt_auth_tool's ``"token" not in cherrypy.session`` check touches the session on every request, so CherryPy's built-in sessions tool inserts an empty ``{}`` entry into RamSession.cache for every anonymous or bad-token request. Under sustained unauth traffic (observed: ~50 req/s on a stress rig) the cache grew to ~1.9M entries and ~950MB RSS on the rest_cherrypy worker child in ~11h, and RamSession.clean_up allocated ~84MB per pass just copying the dict. Subclass RamSession to skip _save when self._data is empty, and wire it in via ``tools.sessions.storage_class`` on LowDataAdapter._cp_config (all other endpoints inherit). Legitimate logins -- which set session["token"] -- persist normally. --- changelog/69847.fixed.md | 1 + salt/_process_role.py | 43 +++++++ salt/netapi/rest_cherrypy/app.py | 43 +++++++ salt/scripts.py | 7 ++ salt/transport/tcp.py | 61 ++++++--- salt/transport/zeromq.py | 36 +++--- .../unit/netapi/cherrypy/test_session_leak.py | 116 ++++++++++++++++++ tests/pytests/unit/test_process_role.py | 60 +++++++++ tests/pytests/unit/transport/test_tcp.py | 114 ++++++++++++++++- tests/pytests/unit/transport/test_zeromq.py | 93 ++++++++++++++ 10 files changed, 537 insertions(+), 37 deletions(-) create mode 100644 changelog/69847.fixed.md create mode 100644 salt/_process_role.py create mode 100644 tests/pytests/unit/netapi/cherrypy/test_session_leak.py create mode 100644 tests/pytests/unit/test_process_role.py diff --git a/changelog/69847.fixed.md b/changelog/69847.fixed.md new file mode 100644 index 000000000000..0111231acad5 --- /dev/null +++ b/changelog/69847.fixed.md @@ -0,0 +1 @@ +Fixed several master, minion and salt-api resource leaks observed under sustained load: `salt-master`'s `MWorkerQueue` no longer leaks a file descriptor per `salt` CLI invocation from the master host (a stable ZMQ routing identity is now applied when the current process was launched via a salt CLI entry point, in addition to the existing `__role`-based gate), and the TCP transport `MessageClient` now tears down synchronously on `close()` -- cancelling any pending request futures with `SaltReqTimeoutError` and clearing the reconnect race that kept `_stream_return` running past shutdown -- so `salt-api` no longer accumulates orphaned `MessageClient` graphs under CherryPy request churn. diff --git a/salt/_process_role.py b/salt/_process_role.py new file mode 100644 index 000000000000..e7710d730623 --- /dev/null +++ b/salt/_process_role.py @@ -0,0 +1,43 @@ +""" +Process-level markers describing how the current salt Python process was +invoked, populated by :mod:`salt.scripts` at CLI entry and consumed +downstream by code that needs to distinguish CLI-invocation from daemon +behavior. + +The only current consumer is the ZMQ identity gate in +:mod:`salt.transport.zeromq`. A salt CLI that runs from a master host +(e.g. ``salt '*' test.ping`` executed on the master) loads +``/etc/salt/master`` and therefore inherits ``__role=master`` in its +opts, indistinguishable from the master daemon itself. Without a mark +set here the identity gate would leave that connection with libzmq's +default per-connection random routing-id, which the master's +``MWorkerQueue`` ROUTER accepts but never frees the underlying socket +FD for -- leaking one FD per CLI invocation. + +Why not sniff ``sys.argv[0]``? Entry-point wrappers and frozen binaries +rewrite argv; an explicit mark set once from the entry point is +unambiguous and easy to control in tests. +""" + +_IS_CLI = False + + +def is_cli(): + """ + ``True`` iff the current process was invoked through a salt CLI + entry point (``salt``, ``salt-call``, ``salt-cp``, ``salt-key``, + ``salt-run``, ``salt-cloud``). Daemon processes + (``salt-master``, ``salt-minion``, ``salt-syndic``, ``salt-api``, + ``salt-proxy``) return ``False``. + """ + return _IS_CLI + + +def mark_as_cli(): + """ + Record that the current process is running as a salt CLI tool. + Called from :mod:`salt.scripts` at the top of each CLI entry + function. Idempotent; safe to call more than once. + """ + global _IS_CLI + _IS_CLI = True diff --git a/salt/netapi/rest_cherrypy/app.py b/salt/netapi/rest_cherrypy/app.py index de6e6d63c007..f2fe73546a52 100644 --- a/salt/netapi/rest_cherrypy/app.py +++ b/salt/netapi/rest_cherrypy/app.py @@ -627,6 +627,48 @@ cpstats = None logger.warning("Import of cherrypy.cpstats failed.") + +class _NoEmptyRamSession(cherrypy.lib.sessions.RamSession): + """ + ``RamSession`` variant that refuses to persist sessions with no + user data. + + salt-api uses cherrypy sessions solely as a bag to stash the salt + auth token after a successful ``/login`` -- every downstream tool + (``salt_auth_tool``, the various ``LowDataAdapter`` handlers) reads + ``cherrypy.session["token"]``. A request that never sets that key + -- e.g. an anonymous POST that will end up as 401, or a + ``client=runner`` call whose X-Auth-Token doesn't match any stored + session because the master hasn't seen a login for it -- has no + reason to leave a session entry in ``RamSession.cache``. + + CherryPy nevertheless does: touching ``cherrypy.session`` (which + ``salt_auth_tool``'s ``"token" not in cherrypy.session`` check + always does) marks the session as loaded, so ``save()`` inserts an + empty ``{}`` entry into the class-level cache dict. Under + high-rate unauthenticated login-attempt or bad-token traffic -- + e.g. any wide-scale scanner, or a stress rig hitting salt-api + faster than PAM can accept -- the cache grew unboundedly (observed: + 1.88M entries after 11h at ~50 req/s, ~950 MB RSS on the CherryPy + worker child, ~60 MB/hr steady leak). Each of those entries is + also visited by ``clean_up()`` every ``clean_freq`` minutes, so + cleanup itself becomes an O(n) allocation-heavy pass -- memray + showed ``RamSession.clean_up`` allocating 84 MB per invocation. + + Skipping ``_save`` for empty ``_data`` means the anonymous / + bad-token requests still get a ``Session`` object for the duration + of the request (so ``cherrypy.session[...]`` calls in tool code + keep working), but the session is never inserted into the cache + and dies with the request. Legitimate logins (which set + ``session["token"] = ...``) persist normally. + """ + + def _save(self, expiration_time): + if not self._data: + return + super()._save(expiration_time) + + try: # Imports related to websocket from . import event_processor @@ -1166,6 +1208,7 @@ class LowDataAdapter: _cp_config = { "tools.salt_token.on": True, "tools.sessions.on": True, + "tools.sessions.storage_class": _NoEmptyRamSession, "tools.sessions.timeout": 60 * 10, # 10 hours # 'tools.autovary.on': True, "tools.hypermedia_out.on": True, diff --git a/salt/scripts.py b/salt/scripts.py index 7c5e2f32743e..b2002b1dc6c7 100644 --- a/salt/scripts.py +++ b/salt/scripts.py @@ -16,6 +16,7 @@ from random import randint import salt.defaults.exitcodes +from salt._process_role import mark_as_cli from salt.exceptions import SaltClientError, SaltReqTimeoutError, SaltSystemExit log = logging.getLogger(__name__) @@ -482,6 +483,7 @@ def salt_key(): """ Manage the authentication keys with salt-key. """ + mark_as_cli() import salt.cli.key try: @@ -497,6 +499,7 @@ def salt_cp(): Publish commands to the salt system from the command line on the master. """ + mark_as_cli() import salt.cli.cp client = salt.cli.cp.SaltCPCli() @@ -509,6 +512,7 @@ def salt_call(): Directly call a salt command in the modules, does not require a running salt minion to run. """ + mark_as_cli() _pin_multiprocessing_fork() import salt.cli.call @@ -524,6 +528,7 @@ def salt_run(): """ Execute a salt convenience routine. """ + mark_as_cli() import salt.cli.run if "" in sys.path: @@ -560,6 +565,7 @@ def salt_cloud(): """ The main function for salt-cloud """ + mark_as_cli() try: # Late-imports for CLI performance import salt.cloud @@ -600,6 +606,7 @@ def salt_main(): Publish commands to the salt system from the command line on the master. """ + mark_as_cli() import salt.cli.salt if "" in sys.path: diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index 642804aa6ecc..b8a49ae27553 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -993,26 +993,44 @@ def __init__( self.backoff = opts.get("tcp_reconnect_backoff", 1) - # TODO: timeout inflight sessions def close(self): + # Under salt-api load memray showed 18 MessageClient objects + # leaking per second (see analysis of the +5.8 GB/h post-inflection + # phase on the TCP-transport stress soak). The previous + # implementation of ``close()`` scheduled ``check_close`` on the + # IOLoop and polled ``send_future_map`` at 1s intervals for it to + # empty, only actually closing the transport after that. Under + # sustained load a single orphaned in-flight future -- e.g. because + # the awaiting coroutine was cancelled by cherrypy mid-request -- + # kept ``send_future_map`` non-empty forever, so ``check_close`` + # never converged and the whole MessageClient graph (Unpacker, + # IOStream, LazyLoaders reachable via ``self``) stayed alive. + # Additionally the ``_stream_return`` coroutine holds ``self`` + # implicitly via its ``self.X`` accesses, so ``__del__`` never + # fired either. + # + # Close synchronously: any caller of ``close()`` has told us they + # no longer need the pending replies, so cancel their in-flight + # futures with a timeout error (rather than orphaning them), then + # tear the stream down immediately. ``_stream_return`` will see + # ``_closed=True`` on its next resume (via StreamClosedError as + # the stream closes) and exit its loop, releasing the last strong + # reference to ``self``. if self._closing or self._closed: return self._closing = True - if not self.send_future_map: - self.io_loop.call_later(0, self.check_close) - else: - self.io_loop.call_later(1, self.check_close) - - def check_close(self): - if not self.send_future_map: - self._tcp_client.close() - if self._stream: - self._stream.close() - self._stream = None - self._closed = True - self._closing = False - else: - self.io_loop.call_later(1, self.check_close) + for future in list(self.send_future_map.values()): + if not future.done(): + future.set_exception( + SaltReqTimeoutError("MessageClient closed with pending requests") + ) + self.send_future_map = {} + self._tcp_client.close() + if self._stream: + self._stream.close() + self._stream = None + self._closed = True + self._closing = False # pylint: disable=W1701 def __del__(self): @@ -1049,11 +1067,18 @@ async def getstream(self, **kwargs): return stream async def connect(self): + # If ``close()`` ran while we were awaiting ``getstream()`` (for + # example after ``_stream_return`` saw a StreamClosedError and + # called us to reconnect), don't clobber the close flags. The + # earlier unconditional reset of ``_closing``/``_closed`` here + # raced with ``close()`` and kept ``_stream_return`` running past + # the intended shutdown, which is one of the causes of the + # MessageClient leak under salt-api load. + if self._closing or self._closed: + return if self._stream is None: self._stream = await self.getstream() if self._stream: - self._closing = False - self._closed = False if not self._stream_return_running: return_task = self.asyncio_loop.create_task(self._stream_return()) if self.connect_callback: diff --git a/salt/transport/zeromq.py b/salt/transport/zeromq.py index 84046009b70e..05d9fe8d290b 100644 --- a/salt/transport/zeromq.py +++ b/salt/transport/zeromq.py @@ -30,6 +30,7 @@ import zmq.eventloop.future import zmq.eventloop.zmqstream +import salt._process_role import salt.payload import salt.transport.base import salt.utils.asynchronous @@ -1124,23 +1125,28 @@ def _init_socket(self): # this, the master's libzmq peer-id hashtable grows unbounded # under sustained CLI churn (about 6 MB/min in stress). # - # Only do this for salt CLI tools (which do NOT set ``__role`` in - # opts). All long-lived daemons -- minion, syndic, master -- - # open multiple AsyncReqMessageClient instances concurrently from - # a single process: the minion at startup for auth + pillar + - # file requests, the syndic when relaying multiple downstream - # minions' returns upstream, and a master when forwarding to - # peer masters. Giving them all the same stable identity would - # cause ROUTER_HANDOVER on the upstream ROUTER to silently drop - # any reply still in flight to the previous REQ as each new one - # arrived, hanging startup and breaking syndic relays. Their - # own REQ churn is bounded anyway (one peer per daemon), so they - # can keep using libzmq's default per-connection random - # routing-ids. + # Only do this for salt CLI tools and long-lived minion/syndic + # daemons. ``salt-master`` daemons open multiple concurrent + # AsyncReqMessageClient instances (peer-master forwarding, + # engines, etc.) and must keep libzmq's default per-connection + # random routing-ids -- giving them a shared stable identity + # would cause ROUTER_HANDOVER on the upstream ROUTER to + # silently drop any reply still in flight. + # + # A CLI invocation is detected via ``salt._process_role.is_cli`` + # (flipped by ``salt.scripts`` at entry) *not* via ``__role``: + # when a salt CLI runs from a master host it loads + # ``/etc/salt/master`` and inherits ``__role=master``, so a + # role-only gate would fall through and each connection would + # get a random routing-id -- which the master's MWorkerQueue + # ROUTER accepts but never frees the underlying socket FD for. + # The ``not _role`` branch remains as a fallback for bare CLI + # invocations where ``__role`` was never populated (older + # embedded uses, tests, etc.). _role = self.opts.get("__role") _minion_id = self.opts.get("id") - if not _role: - role = _minion_id or "clir" + if salt._process_role.is_cli() or not _role: + role = _role or _minion_id or "clir" try: uid = os.getuid() except AttributeError: # Windows diff --git a/tests/pytests/unit/netapi/cherrypy/test_session_leak.py b/tests/pytests/unit/netapi/cherrypy/test_session_leak.py new file mode 100644 index 000000000000..ddd1d80662e3 --- /dev/null +++ b/tests/pytests/unit/netapi/cherrypy/test_session_leak.py @@ -0,0 +1,116 @@ +""" +Unit tests for ``salt.netapi.rest_cherrypy.app._NoEmptyRamSession``. +""" + +import datetime + +import pytest + +import salt.netapi.rest_cherrypy.app as cherrypy_app + +pytest.importorskip("cherrypy") + + +@pytest.fixture +def clean_cache(): + """ + Empty ``RamSession.cache``/``locks`` before and after each test and + restore whatever entries were there. + """ + import cherrypy.lib.sessions as sessions + + saved_cache = sessions.RamSession.cache + saved_locks = sessions.RamSession.locks + sessions.RamSession.cache = {} + sessions.RamSession.locks = {} + try: + yield sessions + finally: + sessions.RamSession.cache = saved_cache + sessions.RamSession.locks = saved_locks + + +def _make_session(id="sid-1", data=None): + sess = cherrypy_app._NoEmptyRamSession(id=id) + sess._data = data or {} + sess.loaded = True + return sess + + +def test_noemptyramsession_is_ramsession_subclass(): + import cherrypy.lib.sessions as sessions + + assert issubclass(cherrypy_app._NoEmptyRamSession, sessions.RamSession) + + +def test_empty_session_is_not_persisted(clean_cache): + sess = _make_session(id="empty-sid", data={}) + + expiration = datetime.datetime.now() + datetime.timedelta(hours=10) + sess._save(expiration) + + # Unauthenticated / no-op requests should not leave a cache entry + # behind: this is the fix for the RamSession.cache pileup that + # drove the salt-api rest_cherrypy RSS leak under sustained + # anonymous / bad-token traffic. + assert "empty-sid" not in clean_cache.RamSession.cache + assert len(clean_cache.RamSession.cache) == 0 + + +def test_populated_session_is_persisted(clean_cache): + sess = _make_session(id=None, data={"token": "abc123"}) + real_id = sess.id + assert real_id # Session._regenerate() picks a fresh random id + + expiration = datetime.datetime.now() + datetime.timedelta(hours=10) + sess._save(expiration) + + # A real logged-in session (with the salt auth token stashed in + # session["token"]) must still be persisted so ``salt_auth_tool`` + # can find it on the next request. + assert real_id in clean_cache.RamSession.cache + data, exp = clean_cache.RamSession.cache[real_id] + assert data == {"token": "abc123"} + assert exp == expiration + + +def test_full_save_path_skips_empty_data(clean_cache): + """ + Exercise the full ``Session.save()`` path (not just ``_save``): a + session that was loaded but never had any data written to it must + not end up in the cache when saved via CherryPy's own machinery. + """ + sess = cherrypy_app._NoEmptyRamSession(id="pipeline-sid") + # Simulate ``salt_auth_tool``'s ``"token" not in cherrypy.session`` + # touch: load() is called, sets ``loaded=True`` and leaves _data={}. + sess.load() + assert sess.loaded is True + assert sess._data == {} + sess.timeout = 60 * 10 + sess.save() + + assert "pipeline-sid" not in clean_cache.RamSession.cache + + +def test_full_save_path_persists_populated_data(clean_cache): + sess = cherrypy_app._NoEmptyRamSession(id=None) + real_id = sess.id + sess.load() + sess["token"] = "salt-tok-xyz" + assert sess.loaded is True + sess.timeout = 60 * 10 + sess.save() + + assert real_id in clean_cache.RamSession.cache + data, _ = clean_cache.RamSession.cache[real_id] + assert data == {"token": "salt-tok-xyz"} + + +def test_lowdataadapter_configures_the_noempty_session_class(): + # Regression guard: without this, CherryPy defaults back to + # ``RamSession`` and every touched-but-not-written session gets + # cached again. + assert ( + cherrypy_app.LowDataAdapter._cp_config["tools.sessions.storage_class"] + is cherrypy_app._NoEmptyRamSession + ) diff --git a/tests/pytests/unit/test_process_role.py b/tests/pytests/unit/test_process_role.py new file mode 100644 index 000000000000..e7888801578c --- /dev/null +++ b/tests/pytests/unit/test_process_role.py @@ -0,0 +1,60 @@ +""" +Unit tests for ``salt._process_role``. +""" + +import subprocess +import sys +import textwrap + +import pytest + +import salt._process_role + + +@pytest.fixture +def clean_role(): + """Save and restore the module-level ``_IS_CLI`` flag.""" + original = salt._process_role._IS_CLI + salt._process_role._IS_CLI = False + try: + yield + finally: + salt._process_role._IS_CLI = original + + +def test_is_cli_default_false(clean_role): + assert salt._process_role.is_cli() is False + + +def test_mark_as_cli_sets_flag(clean_role): + salt._process_role.mark_as_cli() + assert salt._process_role.is_cli() is True + + +def test_mark_as_cli_is_idempotent(clean_role): + salt._process_role.mark_as_cli() + salt._process_role.mark_as_cli() + assert salt._process_role.is_cli() is True + + +def test_flag_defaults_false_in_fresh_interpreter(): + """ + A fresh Python process that imports the module without invoking a + salt CLI entry point must observe ``is_cli() == False``. This + guards against anything module-level (imports, side effects) flipping + the flag on for daemon processes. + """ + code = textwrap.dedent( + """ + import salt._process_role + print("cli" if salt._process_role.is_cli() else "daemon") + """ + ) + proc = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + check=True, + text=True, + timeout=60, + ) + assert proc.stdout.strip() == "daemon" diff --git a/tests/pytests/unit/transport/test_tcp.py b/tests/pytests/unit/transport/test_tcp.py index 69df83fa1263..ae3a2154f90e 100644 --- a/tests/pytests/unit/transport/test_tcp.py +++ b/tests/pytests/unit/transport/test_tcp.py @@ -224,11 +224,14 @@ async def test_message_client_cleanup_on_close(client_socket, temp_salt_master): assert client._stream is not None client.close() - assert client._closed is False - assert client._closing is True - assert client._stream is not None - await asyncio.sleep(0.1) + # ``close()`` now tears down synchronously (see the block comment + # above the added tests further down): the transport, stream and + # pending futures are cleared before returning so a caller can rely + # on the client being fully closed the moment ``close()`` returns. + # Previously ``close()`` scheduled a poll-loop on the IOLoop and + # only actually closed the stream after ``send_future_map`` drained, + # which under load could hang forever. assert client._closed is True assert client._closing is False assert client._stream is None @@ -1037,3 +1040,106 @@ def close(self): assert all(client.closed for client in clients) assert server.clients == set() assert server._closing is True + + +# --------------------------------------------------------------------------- +# MessageClient synchronous close. +# +# The previous close() scheduled ``check_close`` on the IOLoop and polled +# ``send_future_map`` at 1 s intervals for it to empty, only actually +# tearing the transport down once no in-flight sends remained. A single +# orphaned future -- e.g. an awaiting coroutine cancelled by CherryPy +# mid-request -- kept the map non-empty forever, so under salt-api load +# MessageClient objects (with their Unpacker + IOStream + LazyLoader +# graphs) leaked at ~18/s. close() now runs synchronously: it cancels +# pending futures with SaltReqTimeoutError, closes the tcp client and +# stream, and sets ``_closed=True`` before returning. connect() then +# refuses to reset ``_closing``/``_closed`` if the client was closed +# while ``getstream`` was awaiting, so a late reconnect from +# ``_stream_return`` cannot revive a torn-down client. +# --------------------------------------------------------------------------- + + +def _make_message_client(minion_opts): + return salt.transport.tcp.MessageClient(minion_opts, "127.0.0.1", 4506) + + +def test_message_client_close_synchronously_tears_down(minion_opts): + client = _make_message_client(minion_opts) + fake_stream = MagicMock() + fake_stream.closed.return_value = False + client._stream = fake_stream + client._tcp_client = MagicMock() + + client.close() + + assert client._closed is True + assert client._closing is False + assert client._stream is None + client._tcp_client.close.assert_called_once_with() + fake_stream.close.assert_called_once_with() + + +def test_message_client_close_cancels_pending_futures(minion_opts): + client = _make_message_client(minion_opts) + client._tcp_client = MagicMock() + client._stream = MagicMock() + + pending = asyncio.get_event_loop_policy().new_event_loop().create_future() + done = asyncio.get_event_loop_policy().new_event_loop().create_future() + done.set_result("already-done") + client.send_future_map = {1: pending, 2: done} + + try: + client.close() + + assert pending.done() is True + assert isinstance(pending.exception(), salt.exceptions.SaltReqTimeoutError) + # A future that was already resolved before close() must not be + # touched. + assert done.done() is True + assert done.result() == "already-done" + assert client.send_future_map == {} + assert client._closed is True + finally: + pending.get_loop().close() + done.get_loop().close() + + +def test_message_client_close_is_idempotent(minion_opts): + client = _make_message_client(minion_opts) + client._tcp_client = MagicMock() + client._stream = MagicMock() + + client.close() + client.close() + + client._tcp_client.close.assert_called_once_with() + + +async def test_message_client_connect_noop_after_close(minion_opts): + """ + If ``close()`` runs while ``connect()`` is awaiting ``getstream()`` + (e.g. ``_stream_return`` saw StreamClosedError and called us to + reconnect), connect() must not clobber the close flags -- otherwise + _stream_return keeps running past the intended shutdown and the + client stays reachable. + """ + client = _make_message_client(minion_opts) + client._tcp_client = MagicMock() + + client.close() + assert client._closed is True + + async def _should_not_be_called(*args, **kwargs): + raise AssertionError( + "getstream() must not run when connect() is called on a closed client" + ) + + client.getstream = _should_not_be_called + + await client.connect() + + assert client._closed is True + assert client._closing is False + assert client._stream is None diff --git a/tests/pytests/unit/transport/test_zeromq.py b/tests/pytests/unit/transport/test_zeromq.py index 29e1c0aed21f..17c8196be058 100644 --- a/tests/pytests/unit/transport/test_zeromq.py +++ b/tests/pytests/unit/transport/test_zeromq.py @@ -2528,3 +2528,96 @@ def test_backoff_timer(): next_iteration += next_iteration * percent * ourcount assert ourcount == 39 assert backoff() == maximum + + +# --------------------------------------------------------------------------- +# AsyncReqMessageClient ZMQ identity gate. +# +# A salt CLI process invoked from a master host loads /etc/salt/master +# and therefore inherits __role=master, which used to make it +# indistinguishable from the master daemon at the point where +# AsyncReqMessageClient decides whether to set a stable routing identity. +# The role-only gate would then fall through and every CLI connection to +# the master's MWorkerQueue ROUTER got libzmq's default per-connection +# random routing-id -- which the master's ROUTER accepts but never frees +# the underlying socket FD for. ``salt._process_role.is_cli()`` now +# overrides the role gate so the identity is set even when __role is +# ``master`` in opts. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def clean_process_role(): + """Save and restore the module-level ``_IS_CLI`` flag.""" + import salt._process_role + + original = salt._process_role._IS_CLI + salt._process_role._IS_CLI = False + try: + yield salt._process_role + finally: + salt._process_role._IS_CLI = original + + +def _connected_client_identity(opts): + client = salt.transport.zeromq.AsyncReqMessageClient(opts, "tcp://127.0.0.1:4506") + client.connect() + try: + return client.socket.getsockopt(zmq.IDENTITY) + finally: + client.close() + + +def test_reqclient_identity_set_when_cli_on_master_host( + minion_opts, clean_process_role +): + """ + A salt CLI running on a master host inherits __role=master from the + master config it loads. Once salt.scripts has flipped is_cli() to + True the identity gate must still fire, so the socket gets the + stable ``salt-req/master/...`` identity and the master's MWorkerQueue + ROUTER can reuse the routing-id slot on reconnect. + """ + clean_process_role.mark_as_cli() + minion_opts["__role"] = "master" + + identity = _connected_client_identity(minion_opts) + + assert identity.startswith(b"salt-req/master/"), identity + + +def test_reqclient_identity_not_set_for_master_daemon(minion_opts, clean_process_role): + """ + A genuine master daemon (is_cli() False, __role=master) must NOT + get a shared stable identity: multiple concurrent + AsyncReqMessageClient instances in the master process (peer-master + forwarding, engines, etc.) would otherwise all share a routing-id + and ROUTER_HANDOVER on the upstream ROUTER would silently drop any + reply still in flight. The socket must fall through with libzmq's + default (empty) IDENTITY so libzmq assigns a random per-connection + routing-id. + """ + assert clean_process_role.is_cli() is False + minion_opts["__role"] = "master" + + identity = _connected_client_identity(minion_opts) + + assert identity == b"" + + +def test_reqclient_identity_set_for_bare_cli_without_role( + minion_opts, clean_process_role +): + """ + Historical fallback: if ``__role`` was never populated (older + embedded uses, tests, etc.) the gate still fires -- this matches + the pre-existing behavior and is why the ``not _role`` branch stays + in the code. + """ + assert clean_process_role.is_cli() is False + minion_opts.pop("__role", None) + minion_opts["id"] = "cli-caller" + + identity = _connected_client_identity(minion_opts) + + assert identity.startswith(b"salt-req/cli-caller/"), identity From a222739b196bbb89c2cb3dc75d138eeb5716619a Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Wed, 22 Jul 2026 17:42:37 -0600 Subject: [PATCH 129/469] Bump packaged pip from 25.2 to 26.1.2, drop urllib3 hand-patch (#69853) pip 25.2's vendored urllib3 (1.26.20) carries two CVEs (CVE-2025-66418, CVE-2026-21441), so tools/pkg/build.py worked around this by downloading pip 25.2, hand-patching its vendored urllib3/_version.py and urllib3/response.py with unified diffs stored in pkg/patches/pip-urllib3/, and force-installing that patched wheel into every onedir build (macOS standalone, onedir_dependencies, and salt_onedir, which also covers the debian/rpm/windows packages that consume its output). pip 26.1.2 already vendors a genuine urllib3 2.6.3 containing the real upstream fixes for both CVEs, making the hand-patch unnecessary and, since it's a unified diff against pip 25.2's exact 1.26.20 source, unable to apply cleanly to 26.1.2 anyway. Replace _build_patched_pip_wheel/_patch_pip_wheel_urllib3/_apply_unified_diff with a plain _download_pip_wheel() that pulls pip==26.1.2, update the three call sites, and remove pkg/patches/pip-urllib3/. tests/pytests/pkg/integration/test_pip_urllib3_patch.py only existed to verify the hand-patch was applied; delete it rather than keep assertions pinned to pip's internal vendoring choices. requirements/constraints.txt still pins pip==25.2 for the dev/lint tooling venvs (unrelated to what ships in packages, and previously reverted from 26.0.1 due to a Python 3.14 pre-commit hook regression) -- only its stale comment referencing the now-deleted patch directory is corrected here. Fixes #69852 --- changelog/69852.fixed.md | 1 + pkg/patches/pip-urllib3/_version.py.patch | 31 --- pkg/patches/pip-urllib3/response.py.patch | 64 ----- requirements/constraints.txt | 15 +- .../pkg/integration/test_pip_urllib3_patch.py | 91 ------- tools/pkg/build.py | 224 ++++-------------- 6 files changed, 50 insertions(+), 376 deletions(-) create mode 100644 changelog/69852.fixed.md delete mode 100644 pkg/patches/pip-urllib3/_version.py.patch delete mode 100644 pkg/patches/pip-urllib3/response.py.patch delete mode 100644 tests/pytests/pkg/integration/test_pip_urllib3_patch.py diff --git a/changelog/69852.fixed.md b/changelog/69852.fixed.md new file mode 100644 index 000000000000..acaab4f4a69d --- /dev/null +++ b/changelog/69852.fixed.md @@ -0,0 +1 @@ +Updated the pip shipped in Salt's packaged onedir builds from 25.2 to 26.1.2. This removes the need for Salt's temporary hand-patch of pip's vendored urllib3 (CVE-2025-66418, CVE-2026-21441), since pip 26.1.2 already ships a genuine, upstream-fixed urllib3 2.6.3. diff --git a/pkg/patches/pip-urllib3/_version.py.patch b/pkg/patches/pip-urllib3/_version.py.patch deleted file mode 100644 index 6eca20d59475..000000000000 --- a/pkg/patches/pip-urllib3/_version.py.patch +++ /dev/null @@ -1,31 +0,0 @@ ---- a/pip/_vendor/urllib3/_version.py -+++ b/pip/_vendor/urllib3/_version.py -@@ -1,2 +1,26 @@ --# This file is protected via CODEOWNERS --__version__ = "1.26.20" -+# This file is a Salt-maintained security patch of pip's vendored urllib3. -+# -+# The underlying code is urllib3 1.26.20 (the version vendored by pip 25.2) -+# with the following CVE fixes backported from upstream urllib3 2.6.3: -+# -+# CVE-2025-66418 (GHSA-gm62-xv2j-4w53): Unbounded Content-Encoding -+# decompression chain -- MultiDecoder now enforces a 5-link limit. -+# Upstream fix: urllib3 2.6.0 (commit 24d7b67). -+# -+# CVE-2026-21441 (GHSA-38jv-5279-wg99): drain_conn unnecessarily -+# decompressed the full body of HTTP redirect responses, creating a -+# decompression-bomb vector. Fixed by adding _has_decoded_content -+# tracking and only decoding in drain_conn when decoding was already -+# in progress. -+# Upstream fix: urllib3 2.6.3 (commit 8864ac4). -+# -+# CVE-2025-66471 (GHSA-2xpw-w6gg-jr37): Decompression bomb in the -+# streaming API via max_length parameter. NOT backported -- requires a -+# full 2.x streaming infrastructure refactor. Ubuntu did not backport -+# this to 1.26.x either. pip maintainers confirmed pip is not -+# affected because all pip network calls use decode_content=False. -+# -+# The version string "2.6.3" reflects the highest upstream release from -+# which fixes have been backported. The underlying API remains urllib3 -+# 1.26.x -- this is NOT a port to urllib3 2.x. -+__version__ = "2.6.3" diff --git a/pkg/patches/pip-urllib3/response.py.patch b/pkg/patches/pip-urllib3/response.py.patch deleted file mode 100644 index 4bd47c69c053..000000000000 --- a/pkg/patches/pip-urllib3/response.py.patch +++ /dev/null @@ -1,64 +0,0 @@ ---- a/pip/_vendor/urllib3/response.py -+++ b/pip/_vendor/urllib3/response.py -@@ -129,8 +129,18 @@ - they were applied. - """ - -+ # Maximum allowed number of chained HTTP encodings in the -+ # Content-Encoding header. CVE-2025-66418 (GHSA-gm62-xv2j-4w53). -+ max_decode_links = 5 -+ - def __init__(self, modes): -- self._decoders = [_get_decoder(m.strip()) for m in modes.split(",")] -+ encodings = [m.strip() for m in modes.split(",")] -+ if len(encodings) > self.max_decode_links: -+ raise DecodeError( -+ "Too many content encodings in the chain: " -+ "%d > %d" % (len(encodings), self.max_decode_links) -+ ) -+ self._decoders = [_get_decoder(e) for e in encodings] - - def flush(self): - return self._decoders[0].flush() -@@ -222,6 +232,9 @@ - self.reason = reason - self.strict = strict - self.decode_content = decode_content -+ # CVE-2026-21441: tracks whether content decoding has been -+ # initiated so drain_conn can skip decompression on redirects. -+ self._has_decoded_content = False - self.retries = retries - self.enforce_content_length = enforce_content_length - self.auto_close = auto_close -@@ -286,7 +299,11 @@ - Unread data in the HTTPResponse connection blocks the connection from being released back to the pool. - """ - try: -- self.read() -+ self.read( -+ # CVE-2026-21441: Do not spend resources decoding the -+ # content unless decoding has already been initiated. -+ decode_content=self._has_decoded_content, -+ ) - except (HTTPError, SocketError, BaseSSLError, HTTPException): - pass - -@@ -394,11 +411,18 @@ - Decode the data passed in and potentially flush the decoder. - """ - if not decode_content: -+ # CVE-2026-21441: guard against toggling after decoding started. -+ if self._has_decoded_content: -+ raise RuntimeError( -+ "Calling read(decode_content=False) is not supported after " -+ "read(decode_content=True) was called." -+ ) - return data - - try: - if self._decoder: - data = self._decoder.decompress(data) -+ self._has_decoded_content = True - except self.DECODER_ERROR_CLASSES as e: - content_encoding = self.headers.get("content-encoding", "").lower() - raise DecodeError( diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 3fa7e6e23ca0..ce5d546b5285 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -18,13 +18,14 @@ setuptools >= 78.1.1 # propagates to PEP 517 build envs since pip 22.1, so capping here keeps # build envs on the pre-split 9.x series for every source build. setuptools-scm < 10 -# pip 25.2 is the version that relenv's onedir ships with, and that -# tools/pkg/build.py downloads + patches in pkg/patches/pip-urllib3/. -# Bumping past 25.2 here causes the noxfile bootstrap pip install in -# the lint-pre-commit hook to upgrade the just-installed 25.2 inside -# the pre-commit hook venv on Python 3.14, which leaves the venv in a -# corrupted state because pip 26.0.1's vendored pygments wheel is -# missing the modeline submodule on cpython 3.14. Stay on 25.2. +# This pin is for the dev/lint tooling venvs only -- tools/pkg/build.py +# pins the packaged/shipped pip independently (currently 26.1.2) and is +# unaffected by this value. Bumping past 25.2 here causes the noxfile +# bootstrap pip install in the lint-pre-commit hook to upgrade the +# just-installed 25.2 inside the pre-commit hook venv on Python 3.14, +# which leaves the venv in a corrupted state because pip 26.0.1's +# vendored pygments wheel is missing the modeline submodule on cpython +# 3.14. Stay on 25.2. pip == 25.2 markdown-it-py < 3.0.0; python_version == "3.9" # myst-docutils 4.x (the latest supporting Python 3.10) requires diff --git a/tests/pytests/pkg/integration/test_pip_urllib3_patch.py b/tests/pytests/pkg/integration/test_pip_urllib3_patch.py deleted file mode 100644 index 13563abe6740..000000000000 --- a/tests/pytests/pkg/integration/test_pip_urllib3_patch.py +++ /dev/null @@ -1,91 +0,0 @@ -import pathlib -import re -import subprocess -import zipfile - -import pytest - -PATCHED_URLLIB3_VERSION = "2.6.3" - - -@pytest.fixture(autouse=True) -def skip_on_prev_version(install_salt): - """ - Skip urllib3 patch tests when running against the previous (downgraded) - Salt version, which does not contain the CVE backports. - """ - if install_salt.use_prev_version: - pytest.skip("urllib3 CVE patch is not present in the previous Salt version") - - -def _site_packages(install_salt) -> pathlib.Path: - """Return the site-packages directory for the installed Salt Python.""" - ret = subprocess.run( - install_salt.binary_paths["python"] - + [ - "-c", - "import pip, pathlib; print(pathlib.Path(pip.__file__).parent.parent)", - ], - capture_output=True, - text=True, - check=False, - ) - assert ret.returncode == 0, ret.stderr - return pathlib.Path(ret.stdout.strip()) - - -def test_pip_vendored_urllib3_version(install_salt): - """ - Verify that pip's vendored urllib3 in the installed Salt package - reports the security-patched version string. - """ - ret = subprocess.run( - install_salt.binary_paths["python"] - + [ - "-c", - "import pip._vendor.urllib3; print(pip._vendor.urllib3.__version__)", - ], - capture_output=True, - text=True, - check=False, - ) - assert ret.returncode == 0, ret.stderr - version = ret.stdout.strip() - assert ( - version == PATCHED_URLLIB3_VERSION - ), f"pip's vendored urllib3 is {version!r}; expected {PATCHED_URLLIB3_VERSION!r}" - - -def test_virtualenv_embedded_pip_wheel_urllib3_version(install_salt): - """ - Verify that the pip wheel bundled inside virtualenv's seed/wheels/embed - directory also contains the security-patched urllib3. New virtualenvs - seeded from this wheel will inherit the CVE fixes. - """ - site_packages = _site_packages(install_salt) - embed_dir = site_packages / "virtualenv" / "seed" / "wheels" / "embed" - - if not embed_dir.is_dir(): - pytest.skip(f"virtualenv embed directory not found: {embed_dir}") - - pip_wheels = sorted(embed_dir.glob("pip-*.whl")) - if not pip_wheels: - pytest.skip(f"No pip wheel found in {embed_dir}") - - pip_wheel = pip_wheels[-1] - with zipfile.ZipFile(pip_wheel) as zf: - try: - with zf.open("pip/_vendor/urllib3/_version.py") as f: - content = f.read().decode("utf-8") - except KeyError: - pytest.fail( - f"pip/_vendor/urllib3/_version.py not found inside {pip_wheel.name}" - ) - - match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) - assert match, f"Could not parse __version__ from {pip_wheel.name}" - version = match.group(1) - assert version == PATCHED_URLLIB3_VERSION, ( - f"Embedded pip wheel {pip_wheel.name} contains urllib3 {version!r}; " - f"expected {PATCHED_URLLIB3_VERSION!r}" - ) diff --git a/tools/pkg/build.py b/tools/pkg/build.py index 0286f57a4787..303d1a32101e 100644 --- a/tools/pkg/build.py +++ b/tools/pkg/build.py @@ -5,10 +5,7 @@ # pylint: disable=resource-leakage,broad-except from __future__ import annotations -import base64 -import csv import hashlib -import io import json import logging import os @@ -28,177 +25,43 @@ log = logging.getLogger(__name__) -# Cached path to the patched pip wheel built by _build_patched_pip_wheel. +# Cached path to the pip wheel downloaded by _download_pip_wheel. # None until first call; reused across all build steps in the same process. -_PATCHED_PIP_WHEEL: pathlib.Path | None = None +_DOWNLOADED_PIP_WHEEL: pathlib.Path | None = None -def _apply_unified_diff(original_text: str, patch_text: str) -> str: +def _download_pip_wheel(ctx: Context) -> pathlib.Path: """ - Apply a unified diff patch to *original_text* and return the result. + Download pip==26.1.2 into a temporary directory and return the path to + the wheel. The result is cached for the lifetime of the current process + so subsequent calls are free. - This is a minimal pure-Python applier sufficient for the well-formed, - non-fuzzy patches stored in pkg/patches/pip-urllib3/. It handles the - standard unified diff hunk format produced by difflib.unified_diff and - GNU diff, including the '\\' (no newline at end of file) marker. + pip 26.1.2 vendors urllib3 2.6.3, which already contains upstream fixes + for CVE-2025-66418 and CVE-2026-21441 -- no patching is needed. """ - orig_lines = original_text.splitlines(True) - result: list[str] = [] - orig_idx = 0 - - patch_lines = patch_text.splitlines(True) - i = 0 - - # Skip the file-header lines (--- / +++) before the first hunk. - while i < len(patch_lines) and not patch_lines[i].startswith("@@"): - i += 1 - - while i < len(patch_lines): - line = patch_lines[i] - if line.startswith("@@"): - m = re.match(r"^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@", line) - if not m: - i += 1 - continue - orig_start = int(m.group(1)) - 1 # convert 1-based → 0-based - - # Copy unchanged original lines that precede this hunk. - result.extend(orig_lines[orig_idx:orig_start]) - orig_idx = orig_start - i += 1 - - # Process hunk body lines. - while i < len(patch_lines): - hunk_line = patch_lines[i] - if hunk_line.startswith("@@"): - break # next hunk starts - if hunk_line.startswith("+"): - result.append(hunk_line[1:]) - elif hunk_line.startswith("-"): - orig_idx += 1 - elif hunk_line.startswith(" "): - result.append(orig_lines[orig_idx]) - orig_idx += 1 - # "\\" → "No newline at end of file" marker; skip. - i += 1 - else: - i += 1 - - # Copy any original lines that follow the last hunk. - result.extend(orig_lines[orig_idx:]) - return "".join(result) - - -def _patch_pip_wheel_urllib3(wheel_path: pathlib.Path) -> None: - """ - Rewrite *wheel_path* in-place so that the urllib3 vendored inside pip - contains the Salt security backports defined in pkg/patches/pip-urllib3/. - - Patches applied (unified diff format): - response.py.patch — CVE-2025-66418, CVE-2026-21441 - _version.py.patch — version bumped to "2.6.3" - - Each patch is applied to the file as extracted from the wheel, so the - original sources do not need to be stored in the repository. The wheel's - RECORD file is updated with correct sha256 hashes and sizes for the two - patched files so that the installed dist-info stays valid. - """ - patches_dir = tools.utils.REPO_ROOT / "pkg" / "patches" / "pip-urllib3" - patch_map = { - "pip/_vendor/urllib3/response.py": ( - patches_dir / "response.py.patch" - ).read_text(encoding="utf-8"), - "pip/_vendor/urllib3/_version.py": ( - patches_dir / "_version.py.patch" - ).read_text(encoding="utf-8"), - } - - def _record_hash(content: bytes) -> str: - digest = hashlib.sha256(content).digest() - return "sha256=" + base64.urlsafe_b64encode(digest).decode().rstrip("=") - - tmp_path = wheel_path.with_suffix(".tmp.whl") - try: - with zipfile.ZipFile(wheel_path, "r") as zin: - with zipfile.ZipFile( - tmp_path, "w", compression=zipfile.ZIP_DEFLATED - ) as zout: - record_name: str | None = None - record_rows: list[list[str]] = [] - patched: dict[str, bytes] = {} - - for item in zin.infolist(): - if item.filename.endswith(".dist-info/RECORD"): - record_name = item.filename - raw = zin.read(item.filename).decode("utf-8") - record_rows = list(csv.reader(raw.splitlines())) - continue # written last after we know the new hashes - if item.filename in patch_map: - original = zin.read(item.filename).decode("utf-8") - patched_text = _apply_unified_diff( - original, patch_map[item.filename] - ) - patched_bytes = patched_text.encode("utf-8") - patched[item.filename] = patched_bytes - zout.writestr(item, patched_bytes) - else: - zout.writestr(item, zin.read(item.filename)) - - # Update RECORD rows for patched files and write it back. - if record_name: - new_rows = [] - for row in record_rows: - if len(row) >= 1 and row[0] in patched: - content = patched[row[0]] - new_rows.append( - [row[0], _record_hash(content), str(len(content))] - ) - else: - new_rows.append(row) - buf = io.StringIO() - csv.writer(buf).writerows(new_rows) - zout.writestr(record_name, buf.getvalue()) - - tmp_path.replace(wheel_path) - except Exception: - tmp_path.unlink(missing_ok=True) - raise - - -def _build_patched_pip_wheel(ctx: Context) -> pathlib.Path: - """ - Download pip==25.2 into a temporary directory, patch its vendored urllib3, - and return the path to the patched wheel. The result is cached for the - lifetime of the current process so subsequent calls are free. - """ - global _PATCHED_PIP_WHEEL - if _PATCHED_PIP_WHEEL is not None: - return _PATCHED_PIP_WHEEL - - tmpdir = pathlib.Path(tempfile.mkdtemp(prefix="salt-pip-patch-")) - ctx.info("Downloading pip==25.2 for urllib3 security patching ...") - # Drop PIP_CONSTRAINT for this single call: the constraints file - # pins pip to a newer version (e.g. 26.0.1) but the urllib3 patches - # in pkg/patches/pip-urllib3/ are written against pip 25.2's - # vendored urllib3 1.26.20 and would not apply to whatever urllib3 - # the newer pip vendors. Leaving PIP_CONSTRAINT set causes - # ResolutionImpossible. + global _DOWNLOADED_PIP_WHEEL + if _DOWNLOADED_PIP_WHEEL is not None: + return _DOWNLOADED_PIP_WHEEL + + tmpdir = pathlib.Path(tempfile.mkdtemp(prefix="salt-pip-download-")) + ctx.info("Downloading pip==26.1.2 ...") + # Drop PIP_CONSTRAINT for this single call: requirements/constraints.txt + # pins pip to an older version for the dev/lint tooling venvs, which + # would conflict with explicitly requesting pip==26.1.2 here. download_env = {k: v for k, v in os.environ.items() if k != "PIP_CONSTRAINT"} ctx.run( sys.executable, "-m", "pip", "download", - "pip==25.2", + "pip==26.1.2", "--no-deps", "--dest", str(tmpdir), env=download_env, ) wheel = next(tmpdir.glob("pip-*.whl")) - ctx.info(f"Patching urllib3 CVEs inside {wheel.name} ...") - _patch_pip_wheel_urllib3(wheel) - _PATCHED_PIP_WHEEL = wheel + _DOWNLOADED_PIP_WHEEL = wheel return wheel @@ -471,19 +334,19 @@ def macos( ctx.info("Installing salt into the relenv python") ctx.run("./install_salt.sh") - # Patch pip's vendored urllib3 in the standalone macOS build. - # install_salt.sh uses the relenv pip but does not upgrade it, so we - # install the security-patched pip wheel and replace the copy that - # virtualenv embeds so that new environments also get the fixed pip. + # Upgrade pip in the standalone macOS build. install_salt.sh uses the + # relenv pip but does not upgrade it, so install the pinned version + # and replace the copy that virtualenv embeds so that new + # environments also seed from it. build_env = checkout / "pkg" / "macos" / "build" / "opt" / "salt" python_bin = build_env / "bin" / "python3" - patched_pip = _build_patched_pip_wheel(ctx) - ctx.run(str(python_bin), "-m", "pip", "install", str(patched_pip)) + pip_wheel = _download_pip_wheel(ctx) + ctx.run(str(python_bin), "-m", "pip", "install", str(pip_wheel)) for old_pip in (build_env / "lib").glob( "python*/site-packages/virtualenv/seed/wheels/embed/pip-*.whl" ): old_pip.unlink() - shutil.copy(str(patched_pip), str(old_pip.parent / patched_pip.name)) + shutil.copy(str(pip_wheel), str(old_pip.parent / pip_wheel.name)) if sign: ctx.info("Signing binaries") @@ -883,19 +746,14 @@ def onedir_dependencies( "wheel", env=env, ) - # Install pip from the security-patched wheel instead of pulling from PyPI, - # so that pip's vendored urllib3 never contains the vulnerable version. - # --force-reinstall is required because relenv ships with pip pre-installed - # at the same version (25.2), so without it pip would skip the install as - # "already satisfied" and leave the unpatched copy in site-packages. - # PIP_CONSTRAINT is dropped for this single call because the constraints - # file pins pip to a newer version (e.g. 26.0.1) for the requirements - # install below, but here we are intentionally installing the older - # patched 25.2 wheel. Leaving PIP_CONSTRAINT set produces a - # ResolutionImpossible between "user requested pip 25.2" and the - # constraint. - patched_pip = _build_patched_pip_wheel(ctx) - patched_env = {k: v for k, v in env.items() if k != "PIP_CONSTRAINT"} + # Install the pinned pip version instead of leaving relenv's bundled + # copy in place. --force-reinstall is required because relenv ships + # with pip pre-installed, so without it pip would skip the install as + # "already satisfied". PIP_CONSTRAINT is dropped for this single call + # because requirements/constraints.txt pins pip to an older version for + # the dev/lint tooling, which would conflict with the newer pip + # explicitly requested here. + pip_env = {k: v for k, v in env.items() if k != "PIP_CONSTRAINT"} ctx.run( str(python_bin), "-m", @@ -903,8 +761,8 @@ def onedir_dependencies( "install", "--force-reinstall", "--no-deps", - str(patched_pip), - env=patched_env, + "pip==26.1.2", + env=pip_env, ) ctx.run( str(python_bin), @@ -1151,7 +1009,7 @@ def errfn(fn, path, err): tools.utils.REPO_ROOT / "requirements" / "constraints.txt" ) # Download setuptools and wheel normally; pip is handled separately below - # so that the security-patched wheel is used instead of the PyPI version. + # so that the pinned version is used instead of whatever PyPI resolves. ctx.run( str(python_executable), "-m", @@ -1162,10 +1020,10 @@ def errfn(fn, path, err): "--dest", str(embed_dir), ) - # Copy the security-patched pip wheel into the embed directory so that - # virtualenv seeds new environments with pip that has the urllib3 fixes. - patched_pip = _build_patched_pip_wheel(ctx) - shutil.copy(str(patched_pip), str(embed_dir / patched_pip.name)) + # Copy the pinned pip wheel into the embed directory so that virtualenv + # seeds new environments with it. + pip_wheel = _download_pip_wheel(ctx) + shutil.copy(str(pip_wheel), str(embed_dir / pip_wheel.name)) # Update __init__.py with the new versions From a2d36eeb72fb923a1abbac8ff0250f8021d5e458 Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Thu, 23 Jul 2026 05:03:54 -0600 Subject: [PATCH 130/469] Patch vendored tornado for CVE-2026-49853 (auth/cookie header leak on cross-origin redirect) (#69846) SimpleAsyncHTTPClient forwarded the Authorization/Cookie headers and auth_username/auth_password to a different origin when following a redirect, because the redirected request's headers were only ever a shallow-copy alias of the original request's headers. Explicitly copy the headers before mutating them, and strip credentials when the redirect target's scheme/host/port differs from the original request. Fixing the aliasing bug also exposed a latent issue: the existing Content-Length/Content-Type/etc. stripping for 302/303-to-GET redirects deleted from the original request's headers instead of the new request's, which only worked by accident because of the aliasing. Both now correctly target the new request. --- changelog/69845.fixed.md | 4 ++ salt/ext/tornado/simple_httpclient.py | 16 ++++++- .../tornado/test/simple_httpclient_test.py | 42 ++++++++++++++++++- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 changelog/69845.fixed.md diff --git a/changelog/69845.fixed.md b/changelog/69845.fixed.md new file mode 100644 index 000000000000..4c4b1b4299c0 --- /dev/null +++ b/changelog/69845.fixed.md @@ -0,0 +1,4 @@ +Patch the vendored tornado ``SimpleAsyncHTTPClient`` for CVE-2026-49853: the +``Authorization`` and ``Cookie`` headers, along with ``auth_username`` and +``auth_password``, are no longer forwarded to a different origin when +following an HTTP redirect. diff --git a/salt/ext/tornado/simple_httpclient.py b/salt/ext/tornado/simple_httpclient.py index 8938fe14edb4..592f7f543919 100644 --- a/salt/ext/tornado/simple_httpclient.py +++ b/salt/ext/tornado/simple_httpclient.py @@ -510,10 +510,24 @@ def finish(self): if self._should_follow_redirect(): assert isinstance(self.request, _RequestProxy) new_request = copy.copy(self.request.request) + new_request.headers = self.request.headers.copy() new_request.url = urlparse.urljoin(self.request.url, self.headers["Location"]) new_request.max_redirects = self.request.max_redirects - 1 del new_request.headers["Host"] + # CVE-2026-49853: don't forward credentials to a different + # origin when following a redirect. + parsed_orig_url = urlparse.urlsplit(original_request.url) + parsed_new_url = urlparse.urlsplit(new_request.url) + if (parsed_orig_url.scheme != parsed_new_url.scheme or + parsed_orig_url.netloc != parsed_new_url.netloc): + new_request.auth_username = None + new_request.auth_password = None + for h in ["Authorization", "Cookie"]: + try: + del new_request.headers[h] + except KeyError: + pass # http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.4 # Client SHOULD make a GET request after a 303. # According to the spec, 302 should be followed by the same @@ -527,7 +541,7 @@ def finish(self): for h in ["Content-Length", "Content-Type", "Content-Encoding", "Transfer-Encoding"]: try: - del self.request.headers[h] + del new_request.headers[h] except KeyError: pass new_request.original_request = original_request diff --git a/salt/ext/tornado/test/simple_httpclient_test.py b/salt/ext/tornado/test/simple_httpclient_test.py index 6559108e76b5..c09071674145 100644 --- a/salt/ext/tornado/test/simple_httpclient_test.py +++ b/salt/ext/tornado/test/simple_httpclient_test.py @@ -12,7 +12,7 @@ import ssl import sys -from salt.ext.tornado.escape import to_unicode +from salt.ext.tornado.escape import to_unicode, url_escape from salt.ext.tornado import gen from salt.ext.tornado.httpclient import AsyncHTTPClient from salt.ext.tornado.httputil import HTTPHeaders, ResponseStartLine @@ -661,6 +661,46 @@ def test_port_mapping(self): self.assertEqual(response.body, b'Hello world!') +class HeaderEchoHandler(RequestHandler): + def get(self): + self.finish("%s|%s" % (self.request.headers.get("Authorization", ""), + self.request.headers.get("Cookie", ""))) + + +class CrossOriginRedirectTestCase(AsyncHTTPTestCase): + # Regression test for CVE-2026-49853: Authorization/Cookie headers + # (and auth_username/auth_password) must not be forwarded to a + # different origin when following a redirect. + def setUp(self): + super(CrossOriginRedirectTestCase, self).setUp() + self.http_client = SimpleAsyncHTTPClient( + self.io_loop, + hostname_mapping={'other.example.com': '127.0.0.1'}) + + def get_app(self): + return Application([ + url("/redirect", RedirectHandler), + url("/echo_headers", HeaderEchoHandler), + ]) + + def test_cross_origin_redirect_strips_auth_and_cookie(self): + target = 'http://other.example.com:%d/echo_headers' % self.get_http_port() + response = self.fetch( + '/redirect?url=%s' % url_escape(target), + auth_username='foo', auth_password='bar', + headers=HTTPHeaders({"Cookie": "session=secret"})) + response.rethrow() + self.assertEqual(response.body, b"|") + + def test_same_origin_redirect_keeps_auth_and_cookie(self): + response = self.fetch( + '/redirect?url=%s' % url_escape('/echo_headers'), + auth_username='foo', auth_password='bar', + headers=HTTPHeaders({"Cookie": "session=secret"})) + response.rethrow() + self.assertEqual(response.body, b"Basic Zm9vOmJhcg==|session=secret") + + class ResolveTimeoutTestCase(AsyncHTTPTestCase): def setUp(self): # Dummy Resolver subclass that never invokes its callback. From 773da6034872cdd46f6f06bd0de6667d657b345b Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Thu, 23 Jul 2026 05:04:22 -0600 Subject: [PATCH 131/469] Bump packaged pip from 25.2 to 26.1.2, drop urllib3 hand-patch (#69854) pip 25.2's vendored urllib3 (1.26.20) carries two CVEs (CVE-2025-66418, CVE-2026-21441), so tools/pkg/build.py worked around this by downloading pip 25.2, hand-patching its vendored urllib3/_version.py and urllib3/response.py with unified diffs stored in pkg/patches/pip-urllib3/, and force-installing that patched wheel into every onedir build (macOS standalone, onedir_dependencies, and salt_onedir, which also covers the debian/rpm/windows packages that consume its output). pip 26.1.2 already vendors a genuine urllib3 2.6.3 containing the real upstream fixes for both CVEs, making the hand-patch unnecessary and, since it's a unified diff against pip 25.2's exact 1.26.20 source, unable to apply cleanly to 26.1.2 anyway. Replace _build_patched_pip_wheel/_patch_pip_wheel_urllib3/_apply_unified_diff with a plain _download_pip_wheel() that pulls pip==26.1.2, update the three call sites, and remove pkg/patches/pip-urllib3/. tests/pytests/pkg/integration/test_pip_urllib3_patch.py only existed to verify the hand-patch was applied; delete it rather than keep assertions pinned to pip's internal vendoring choices. requirements/constraints.txt's pip == 26.0.1 dev/lint tooling pin is untouched here -- it's already past 25.2 and unrelated to the packaged pip this change bumps. Fixes #69852 --- changelog/69852.fixed.md | 1 + pkg/patches/pip-urllib3/_version.py.patch | 31 --- pkg/patches/pip-urllib3/response.py.patch | 64 ----- .../pkg/integration/test_pip_urllib3_patch.py | 91 ------- tools/pkg/build.py | 224 ++++-------------- 5 files changed, 42 insertions(+), 369 deletions(-) create mode 100644 changelog/69852.fixed.md delete mode 100644 pkg/patches/pip-urllib3/_version.py.patch delete mode 100644 pkg/patches/pip-urllib3/response.py.patch delete mode 100644 tests/pytests/pkg/integration/test_pip_urllib3_patch.py diff --git a/changelog/69852.fixed.md b/changelog/69852.fixed.md new file mode 100644 index 000000000000..acaab4f4a69d --- /dev/null +++ b/changelog/69852.fixed.md @@ -0,0 +1 @@ +Updated the pip shipped in Salt's packaged onedir builds from 25.2 to 26.1.2. This removes the need for Salt's temporary hand-patch of pip's vendored urllib3 (CVE-2025-66418, CVE-2026-21441), since pip 26.1.2 already ships a genuine, upstream-fixed urllib3 2.6.3. diff --git a/pkg/patches/pip-urllib3/_version.py.patch b/pkg/patches/pip-urllib3/_version.py.patch deleted file mode 100644 index 6eca20d59475..000000000000 --- a/pkg/patches/pip-urllib3/_version.py.patch +++ /dev/null @@ -1,31 +0,0 @@ ---- a/pip/_vendor/urllib3/_version.py -+++ b/pip/_vendor/urllib3/_version.py -@@ -1,2 +1,26 @@ --# This file is protected via CODEOWNERS --__version__ = "1.26.20" -+# This file is a Salt-maintained security patch of pip's vendored urllib3. -+# -+# The underlying code is urllib3 1.26.20 (the version vendored by pip 25.2) -+# with the following CVE fixes backported from upstream urllib3 2.6.3: -+# -+# CVE-2025-66418 (GHSA-gm62-xv2j-4w53): Unbounded Content-Encoding -+# decompression chain -- MultiDecoder now enforces a 5-link limit. -+# Upstream fix: urllib3 2.6.0 (commit 24d7b67). -+# -+# CVE-2026-21441 (GHSA-38jv-5279-wg99): drain_conn unnecessarily -+# decompressed the full body of HTTP redirect responses, creating a -+# decompression-bomb vector. Fixed by adding _has_decoded_content -+# tracking and only decoding in drain_conn when decoding was already -+# in progress. -+# Upstream fix: urllib3 2.6.3 (commit 8864ac4). -+# -+# CVE-2025-66471 (GHSA-2xpw-w6gg-jr37): Decompression bomb in the -+# streaming API via max_length parameter. NOT backported -- requires a -+# full 2.x streaming infrastructure refactor. Ubuntu did not backport -+# this to 1.26.x either. pip maintainers confirmed pip is not -+# affected because all pip network calls use decode_content=False. -+# -+# The version string "2.6.3" reflects the highest upstream release from -+# which fixes have been backported. The underlying API remains urllib3 -+# 1.26.x -- this is NOT a port to urllib3 2.x. -+__version__ = "2.6.3" diff --git a/pkg/patches/pip-urllib3/response.py.patch b/pkg/patches/pip-urllib3/response.py.patch deleted file mode 100644 index 4bd47c69c053..000000000000 --- a/pkg/patches/pip-urllib3/response.py.patch +++ /dev/null @@ -1,64 +0,0 @@ ---- a/pip/_vendor/urllib3/response.py -+++ b/pip/_vendor/urllib3/response.py -@@ -129,8 +129,18 @@ - they were applied. - """ - -+ # Maximum allowed number of chained HTTP encodings in the -+ # Content-Encoding header. CVE-2025-66418 (GHSA-gm62-xv2j-4w53). -+ max_decode_links = 5 -+ - def __init__(self, modes): -- self._decoders = [_get_decoder(m.strip()) for m in modes.split(",")] -+ encodings = [m.strip() for m in modes.split(",")] -+ if len(encodings) > self.max_decode_links: -+ raise DecodeError( -+ "Too many content encodings in the chain: " -+ "%d > %d" % (len(encodings), self.max_decode_links) -+ ) -+ self._decoders = [_get_decoder(e) for e in encodings] - - def flush(self): - return self._decoders[0].flush() -@@ -222,6 +232,9 @@ - self.reason = reason - self.strict = strict - self.decode_content = decode_content -+ # CVE-2026-21441: tracks whether content decoding has been -+ # initiated so drain_conn can skip decompression on redirects. -+ self._has_decoded_content = False - self.retries = retries - self.enforce_content_length = enforce_content_length - self.auto_close = auto_close -@@ -286,7 +299,11 @@ - Unread data in the HTTPResponse connection blocks the connection from being released back to the pool. - """ - try: -- self.read() -+ self.read( -+ # CVE-2026-21441: Do not spend resources decoding the -+ # content unless decoding has already been initiated. -+ decode_content=self._has_decoded_content, -+ ) - except (HTTPError, SocketError, BaseSSLError, HTTPException): - pass - -@@ -394,11 +411,18 @@ - Decode the data passed in and potentially flush the decoder. - """ - if not decode_content: -+ # CVE-2026-21441: guard against toggling after decoding started. -+ if self._has_decoded_content: -+ raise RuntimeError( -+ "Calling read(decode_content=False) is not supported after " -+ "read(decode_content=True) was called." -+ ) - return data - - try: - if self._decoder: - data = self._decoder.decompress(data) -+ self._has_decoded_content = True - except self.DECODER_ERROR_CLASSES as e: - content_encoding = self.headers.get("content-encoding", "").lower() - raise DecodeError( diff --git a/tests/pytests/pkg/integration/test_pip_urllib3_patch.py b/tests/pytests/pkg/integration/test_pip_urllib3_patch.py deleted file mode 100644 index 13563abe6740..000000000000 --- a/tests/pytests/pkg/integration/test_pip_urllib3_patch.py +++ /dev/null @@ -1,91 +0,0 @@ -import pathlib -import re -import subprocess -import zipfile - -import pytest - -PATCHED_URLLIB3_VERSION = "2.6.3" - - -@pytest.fixture(autouse=True) -def skip_on_prev_version(install_salt): - """ - Skip urllib3 patch tests when running against the previous (downgraded) - Salt version, which does not contain the CVE backports. - """ - if install_salt.use_prev_version: - pytest.skip("urllib3 CVE patch is not present in the previous Salt version") - - -def _site_packages(install_salt) -> pathlib.Path: - """Return the site-packages directory for the installed Salt Python.""" - ret = subprocess.run( - install_salt.binary_paths["python"] - + [ - "-c", - "import pip, pathlib; print(pathlib.Path(pip.__file__).parent.parent)", - ], - capture_output=True, - text=True, - check=False, - ) - assert ret.returncode == 0, ret.stderr - return pathlib.Path(ret.stdout.strip()) - - -def test_pip_vendored_urllib3_version(install_salt): - """ - Verify that pip's vendored urllib3 in the installed Salt package - reports the security-patched version string. - """ - ret = subprocess.run( - install_salt.binary_paths["python"] - + [ - "-c", - "import pip._vendor.urllib3; print(pip._vendor.urllib3.__version__)", - ], - capture_output=True, - text=True, - check=False, - ) - assert ret.returncode == 0, ret.stderr - version = ret.stdout.strip() - assert ( - version == PATCHED_URLLIB3_VERSION - ), f"pip's vendored urllib3 is {version!r}; expected {PATCHED_URLLIB3_VERSION!r}" - - -def test_virtualenv_embedded_pip_wheel_urllib3_version(install_salt): - """ - Verify that the pip wheel bundled inside virtualenv's seed/wheels/embed - directory also contains the security-patched urllib3. New virtualenvs - seeded from this wheel will inherit the CVE fixes. - """ - site_packages = _site_packages(install_salt) - embed_dir = site_packages / "virtualenv" / "seed" / "wheels" / "embed" - - if not embed_dir.is_dir(): - pytest.skip(f"virtualenv embed directory not found: {embed_dir}") - - pip_wheels = sorted(embed_dir.glob("pip-*.whl")) - if not pip_wheels: - pytest.skip(f"No pip wheel found in {embed_dir}") - - pip_wheel = pip_wheels[-1] - with zipfile.ZipFile(pip_wheel) as zf: - try: - with zf.open("pip/_vendor/urllib3/_version.py") as f: - content = f.read().decode("utf-8") - except KeyError: - pytest.fail( - f"pip/_vendor/urllib3/_version.py not found inside {pip_wheel.name}" - ) - - match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) - assert match, f"Could not parse __version__ from {pip_wheel.name}" - version = match.group(1) - assert version == PATCHED_URLLIB3_VERSION, ( - f"Embedded pip wheel {pip_wheel.name} contains urllib3 {version!r}; " - f"expected {PATCHED_URLLIB3_VERSION!r}" - ) diff --git a/tools/pkg/build.py b/tools/pkg/build.py index 236f0e0fdcba..99f0eed070fa 100644 --- a/tools/pkg/build.py +++ b/tools/pkg/build.py @@ -5,10 +5,7 @@ # pylint: disable=resource-leakage,broad-except from __future__ import annotations -import base64 -import csv import hashlib -import io import json import logging import os @@ -27,177 +24,43 @@ log = logging.getLogger(__name__) -# Cached path to the patched pip wheel built by _build_patched_pip_wheel. +# Cached path to the pip wheel downloaded by _download_pip_wheel. # None until first call; reused across all build steps in the same process. -_PATCHED_PIP_WHEEL: pathlib.Path | None = None +_DOWNLOADED_PIP_WHEEL: pathlib.Path | None = None -def _apply_unified_diff(original_text: str, patch_text: str) -> str: +def _download_pip_wheel(ctx: Context) -> pathlib.Path: """ - Apply a unified diff patch to *original_text* and return the result. + Download pip==26.1.2 into a temporary directory and return the path to + the wheel. The result is cached for the lifetime of the current process + so subsequent calls are free. - This is a minimal pure-Python applier sufficient for the well-formed, - non-fuzzy patches stored in pkg/patches/pip-urllib3/. It handles the - standard unified diff hunk format produced by difflib.unified_diff and - GNU diff, including the '\\' (no newline at end of file) marker. + pip 26.1.2 vendors urllib3 2.6.3, which already contains upstream fixes + for CVE-2025-66418 and CVE-2026-21441 -- no patching is needed. """ - orig_lines = original_text.splitlines(True) - result: list[str] = [] - orig_idx = 0 - - patch_lines = patch_text.splitlines(True) - i = 0 - - # Skip the file-header lines (--- / +++) before the first hunk. - while i < len(patch_lines) and not patch_lines[i].startswith("@@"): - i += 1 - - while i < len(patch_lines): - line = patch_lines[i] - if line.startswith("@@"): - m = re.match(r"^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@", line) - if not m: - i += 1 - continue - orig_start = int(m.group(1)) - 1 # convert 1-based → 0-based - - # Copy unchanged original lines that precede this hunk. - result.extend(orig_lines[orig_idx:orig_start]) - orig_idx = orig_start - i += 1 - - # Process hunk body lines. - while i < len(patch_lines): - hunk_line = patch_lines[i] - if hunk_line.startswith("@@"): - break # next hunk starts - if hunk_line.startswith("+"): - result.append(hunk_line[1:]) - elif hunk_line.startswith("-"): - orig_idx += 1 - elif hunk_line.startswith(" "): - result.append(orig_lines[orig_idx]) - orig_idx += 1 - # "\\" → "No newline at end of file" marker; skip. - i += 1 - else: - i += 1 - - # Copy any original lines that follow the last hunk. - result.extend(orig_lines[orig_idx:]) - return "".join(result) - - -def _patch_pip_wheel_urllib3(wheel_path: pathlib.Path) -> None: - """ - Rewrite *wheel_path* in-place so that the urllib3 vendored inside pip - contains the Salt security backports defined in pkg/patches/pip-urllib3/. - - Patches applied (unified diff format): - response.py.patch — CVE-2025-66418, CVE-2026-21441 - _version.py.patch — version bumped to "2.6.3" - - Each patch is applied to the file as extracted from the wheel, so the - original sources do not need to be stored in the repository. The wheel's - RECORD file is updated with correct sha256 hashes and sizes for the two - patched files so that the installed dist-info stays valid. - """ - patches_dir = tools.utils.REPO_ROOT / "pkg" / "patches" / "pip-urllib3" - patch_map = { - "pip/_vendor/urllib3/response.py": ( - patches_dir / "response.py.patch" - ).read_text(encoding="utf-8"), - "pip/_vendor/urllib3/_version.py": ( - patches_dir / "_version.py.patch" - ).read_text(encoding="utf-8"), - } - - def _record_hash(content: bytes) -> str: - digest = hashlib.sha256(content).digest() - return "sha256=" + base64.urlsafe_b64encode(digest).decode().rstrip("=") - - tmp_path = wheel_path.with_suffix(".tmp.whl") - try: - with zipfile.ZipFile(wheel_path, "r") as zin: - with zipfile.ZipFile( - tmp_path, "w", compression=zipfile.ZIP_DEFLATED - ) as zout: - record_name: str | None = None - record_rows: list[list[str]] = [] - patched: dict[str, bytes] = {} - - for item in zin.infolist(): - if item.filename.endswith(".dist-info/RECORD"): - record_name = item.filename - raw = zin.read(item.filename).decode("utf-8") - record_rows = list(csv.reader(raw.splitlines())) - continue # written last after we know the new hashes - if item.filename in patch_map: - original = zin.read(item.filename).decode("utf-8") - patched_text = _apply_unified_diff( - original, patch_map[item.filename] - ) - patched_bytes = patched_text.encode("utf-8") - patched[item.filename] = patched_bytes - zout.writestr(item, patched_bytes) - else: - zout.writestr(item, zin.read(item.filename)) - - # Update RECORD rows for patched files and write it back. - if record_name: - new_rows = [] - for row in record_rows: - if len(row) >= 1 and row[0] in patched: - content = patched[row[0]] - new_rows.append( - [row[0], _record_hash(content), str(len(content))] - ) - else: - new_rows.append(row) - buf = io.StringIO() - csv.writer(buf).writerows(new_rows) - zout.writestr(record_name, buf.getvalue()) - - tmp_path.replace(wheel_path) - except Exception: - tmp_path.unlink(missing_ok=True) - raise - - -def _build_patched_pip_wheel(ctx: Context) -> pathlib.Path: - """ - Download pip==25.2 into a temporary directory, patch its vendored urllib3, - and return the path to the patched wheel. The result is cached for the - lifetime of the current process so subsequent calls are free. - """ - global _PATCHED_PIP_WHEEL - if _PATCHED_PIP_WHEEL is not None: - return _PATCHED_PIP_WHEEL - - tmpdir = pathlib.Path(tempfile.mkdtemp(prefix="salt-pip-patch-")) - ctx.info("Downloading pip==25.2 for urllib3 security patching ...") - # Drop PIP_CONSTRAINT for this single call: the constraints file - # pins pip to a newer version (e.g. 26.0.1) but the urllib3 patches - # in pkg/patches/pip-urllib3/ are written against pip 25.2's - # vendored urllib3 1.26.20 and would not apply to whatever urllib3 - # the newer pip vendors. Leaving PIP_CONSTRAINT set causes - # ResolutionImpossible. + global _DOWNLOADED_PIP_WHEEL + if _DOWNLOADED_PIP_WHEEL is not None: + return _DOWNLOADED_PIP_WHEEL + + tmpdir = pathlib.Path(tempfile.mkdtemp(prefix="salt-pip-download-")) + ctx.info("Downloading pip==26.1.2 ...") + # Drop PIP_CONSTRAINT for this single call: requirements/constraints.txt + # pins pip to an older version for the dev/lint tooling venvs, which + # would conflict with explicitly requesting pip==26.1.2 here. download_env = {k: v for k, v in os.environ.items() if k != "PIP_CONSTRAINT"} ctx.run( sys.executable, "-m", "pip", "download", - "pip==25.2", + "pip==26.1.2", "--no-deps", "--dest", str(tmpdir), env=download_env, ) wheel = next(tmpdir.glob("pip-*.whl")) - ctx.info(f"Patching urllib3 CVEs inside {wheel.name} ...") - _patch_pip_wheel_urllib3(wheel) - _PATCHED_PIP_WHEEL = wheel + _DOWNLOADED_PIP_WHEEL = wheel return wheel @@ -456,19 +319,19 @@ def macos( ctx.info("Installing salt into the relenv python") ctx.run("./install_salt.sh") - # Patch pip's vendored urllib3 in the standalone macOS build. - # install_salt.sh uses the relenv pip but does not upgrade it, so we - # install the security-patched pip wheel and replace the copy that - # virtualenv embeds so that new environments also get the fixed pip. + # Upgrade pip in the standalone macOS build. install_salt.sh uses the + # relenv pip but does not upgrade it, so install the pinned version + # and replace the copy that virtualenv embeds so that new + # environments also seed from it. build_env = checkout / "pkg" / "macos" / "build" / "opt" / "salt" python_bin = build_env / "bin" / "python3" - patched_pip = _build_patched_pip_wheel(ctx) - ctx.run(str(python_bin), "-m", "pip", "install", str(patched_pip)) + pip_wheel = _download_pip_wheel(ctx) + ctx.run(str(python_bin), "-m", "pip", "install", str(pip_wheel)) for old_pip in (build_env / "lib").glob( "python*/site-packages/virtualenv/seed/wheels/embed/pip-*.whl" ): old_pip.unlink() - shutil.copy(str(patched_pip), str(old_pip.parent / patched_pip.name)) + shutil.copy(str(pip_wheel), str(old_pip.parent / pip_wheel.name)) if sign: ctx.info("Signing binaries") @@ -821,19 +684,14 @@ def onedir_dependencies( "wheel", env=env, ) - # Install pip from the security-patched wheel instead of pulling from PyPI, - # so that pip's vendored urllib3 never contains the vulnerable version. - # --force-reinstall is required because relenv ships with pip pre-installed - # at the same version (25.2), so without it pip would skip the install as - # "already satisfied" and leave the unpatched copy in site-packages. - # PIP_CONSTRAINT is dropped for this single call because the constraints - # file pins pip to a newer version (e.g. 26.0.1) for the requirements - # install below, but here we are intentionally installing the older - # patched 25.2 wheel. Leaving PIP_CONSTRAINT set produces a - # ResolutionImpossible between "user requested pip 25.2" and the - # constraint. - patched_pip = _build_patched_pip_wheel(ctx) - patched_env = {k: v for k, v in env.items() if k != "PIP_CONSTRAINT"} + # Install the pinned pip version instead of leaving relenv's bundled + # copy in place. --force-reinstall is required because relenv ships + # with pip pre-installed, so without it pip would skip the install as + # "already satisfied". PIP_CONSTRAINT is dropped for this single call + # because requirements/constraints.txt pins pip to an older version for + # the dev/lint tooling, which would conflict with the newer pip + # explicitly requested here. + pip_env = {k: v for k, v in env.items() if k != "PIP_CONSTRAINT"} ctx.run( str(python_bin), "-m", @@ -841,8 +699,8 @@ def onedir_dependencies( "install", "--force-reinstall", "--no-deps", - str(patched_pip), - env=patched_env, + "pip==26.1.2", + env=pip_env, ) ctx.run( str(python_bin), @@ -1079,7 +937,7 @@ def errfn(fn, path, err): tools.utils.REPO_ROOT / "requirements" / "constraints.txt" ) # Download setuptools and wheel normally; pip is handled separately below - # so that the security-patched wheel is used instead of the PyPI version. + # so that the pinned version is used instead of whatever PyPI resolves. ctx.run( str(python_executable), "-m", @@ -1090,10 +948,10 @@ def errfn(fn, path, err): "--dest", str(embed_dir), ) - # Copy the security-patched pip wheel into the embed directory so that - # virtualenv seeds new environments with pip that has the urllib3 fixes. - patched_pip = _build_patched_pip_wheel(ctx) - shutil.copy(str(patched_pip), str(embed_dir / patched_pip.name)) + # Copy the pinned pip wheel into the embed directory so that virtualenv + # seeds new environments with it. + pip_wheel = _download_pip_wheel(ctx) + shutil.copy(str(pip_wheel), str(embed_dir / pip_wheel.name)) # Update __init__.py with the new versions From cc2b7571d82c2968b9c04c4e137c3b217bb9aef4 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Thu, 23 Jul 2026 15:30:42 -0700 Subject: [PATCH 132/469] Defer OpenTelemetry imports in salt.utils.tracing and salt.utils.metrics (#69856) Both modules unconditionally imported the OTel SDK at module load, even though ``tracing.enabled`` and ``metrics.enabled`` default to false. Every salt daemon entry point transitively imports both modules (via salt.master, salt.minion, salt.channel.*, salt.utils.event, salt.netapi.rest_cherrypy.app), so a ~15-process salt-master container was paying ~15 MB per subsystem per process -- ~450 MB total -- for functionality nobody was using. This showed up as a +300 MB baseline / +430 MB peak container RSS shift versus 3006.x on the nightly stress rig, and each ~300 MB salt engine child inherited the same overhead. Move the OTel imports into ``_load_otel()`` helpers invoked only after ``is_enabled()`` returns True, gated on ``_cached_opts`` (which is populated by ``configure()`` but requires the "enabled" key to be true for probes to fire). Public API is preserved; ``SpanKind`` remains a stub always and is translated to the real enum inside ``start_span()``. --- changelog/69855.fixed.md | 1 + salt/utils/metrics.py | 157 +++++++++----- salt/utils/tracing.py | 254 ++++++++++++++--------- tests/pytests/unit/utils/test_metrics.py | 104 +++++++++- tests/pytests/unit/utils/test_tracing.py | 167 ++++++++++++++- 5 files changed, 532 insertions(+), 151 deletions(-) create mode 100644 changelog/69855.fixed.md diff --git a/changelog/69855.fixed.md b/changelog/69855.fixed.md new file mode 100644 index 000000000000..2dd539dbf20c --- /dev/null +++ b/changelog/69855.fixed.md @@ -0,0 +1 @@ +Deferred OpenTelemetry imports in `salt.utils.tracing` and `salt.utils.metrics` so daemons no longer pay the ~15 MB per-process OTel import cost when `tracing.enabled` / `metrics.enabled` are false (the default). On a stress-tested salt-master container (~15 Python processes) this reclaims ~225 MB per subsystem — restoring the pre-3008.x baseline. Public API is unchanged; the imports happen on first `configure(...)` / `start_span(...)` / `counter(...)` call once the enabled flag is set. diff --git a/salt/utils/metrics.py b/salt/utils/metrics.py index 2835c57abc13..b6306aa598f5 100644 --- a/salt/utils/metrics.py +++ b/salt/utils/metrics.py @@ -49,41 +49,76 @@ import logging import os import threading +from types import SimpleNamespace log = logging.getLogger(__name__) _INSTRUMENTATION_NAME = "salt" -# OpenTelemetry is optional. It is not shipped in the salt-ssh thin -# tarball, may be absent from older installed onedirs that the upgrade / -# downgrade tests still exercise, and may be intentionally uninstalled -# by operators who want a minimal footprint. When opentelemetry is -# missing, every public function in this module short-circuits to a -# no-op, exactly as if ``opts['metrics']['enabled']`` were false. -try: - from opentelemetry import metrics as otel_metrics - from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( - OTLPMetricExporter as _OTLPMetricExporterHTTP, - ) - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.metrics.export import ( - ConsoleMetricExporter, - PeriodicExportingMetricReader, - ) - from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation, View - from opentelemetry.sdk.resources import Resource - - _OTEL_AVAILABLE = True -except ImportError: # pragma: no cover - exercised when opentelemetry is absent - _OTEL_AVAILABLE = False - otel_metrics = None # type: ignore[assignment] - _OTLPMetricExporterHTTP = None # type: ignore[assignment] - MeterProvider = None # type: ignore[assignment] - PeriodicExportingMetricReader = None # type: ignore[assignment] - ConsoleMetricExporter = None # type: ignore[assignment] - ExplicitBucketHistogramAggregation = None # type: ignore[assignment] - View = None # type: ignore[assignment] - Resource = None # type: ignore[assignment] +# Deferred OpenTelemetry state. ``None`` means "we have not yet tried +# to import"; ``True`` / ``False`` are set by :func:`_load_otel` on +# first use. ``_otel`` is a ``SimpleNamespace`` of the symbols we need +# from ``opentelemetry`` once the probe succeeds. +# +# Prior to this deferral the ``opentelemetry`` package was imported at +# module load, which cost ~15 MB per Python process. Every salt daemon +# entry point transitively imports ``salt.utils.metrics`` (via +# ``salt.master`` / ``salt.minion``), so a ~15-process salt-master +# container was paying ~225 MB up front for a subsystem that defaults +# to disabled. Deferring keeps that memory reserved for actual salt +# state on the vast majority of deployments where metrics are off. +_OTEL_AVAILABLE = None +_otel = None +_otel_load_lock = threading.Lock() + + +def _load_otel(): + """ + Attempt to import opentelemetry on first use. Returns ``True`` if + available. + + Only called from paths where metrics have already been confirmed + enabled, so daemons with ``metrics.enabled = false`` (the default) + never pay the per-process import cost. Idempotent; the second call + short-circuits on the memoised flag. + """ + global _OTEL_AVAILABLE, _otel # pylint: disable=global-statement + if _OTEL_AVAILABLE is not None: + return _OTEL_AVAILABLE + with _otel_load_lock: + if _OTEL_AVAILABLE is not None: + return _OTEL_AVAILABLE + try: + # pylint: disable=import-outside-toplevel + from opentelemetry import metrics as otel_metrics + from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( + OTLPMetricExporter as OTLPMetricExporterHTTP, + ) + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import ( + ConsoleMetricExporter, + PeriodicExportingMetricReader, + ) + from opentelemetry.sdk.metrics.view import ( + ExplicitBucketHistogramAggregation, + View, + ) + from opentelemetry.sdk.resources import Resource + except ImportError: # pragma: no cover - exercised when otel is absent + _OTEL_AVAILABLE = False + return False + _otel = SimpleNamespace( + otel_metrics=otel_metrics, + OTLPMetricExporterHTTP=OTLPMetricExporterHTTP, + MeterProvider=MeterProvider, + PeriodicExportingMetricReader=PeriodicExportingMetricReader, + ConsoleMetricExporter=ConsoleMetricExporter, + ExplicitBucketHistogramAggregation=ExplicitBucketHistogramAggregation, + View=View, + Resource=Resource, + ) + _OTEL_AVAILABLE = True + return True _lock = threading.Lock() @@ -119,10 +154,18 @@ class _NoopObservableGauge: def is_enabled(): - """Return True if metrics are configured and enabled.""" - if not _OTEL_AVAILABLE: + """ + Return True if metrics are configured, enabled, and opentelemetry + can be imported. + + Structured so the disabled path never touches opentelemetry: when + ``_cached_opts`` is unset or ``enabled`` is false (both true by + default), :func:`_load_otel` is not called and the imports stay + deferred. + """ + if not _cached_opts or not _cached_opts.get("enabled"): return False - return bool(_cached_opts and _cached_opts.get("enabled")) + return _load_otel() def configure(opts): @@ -135,20 +178,10 @@ def configure(opts): no-op that just caches the opts so subsequent calls in fork children can pick up the same setting. """ - global _cached_opts, _atexit_registered + global _cached_opts, _atexit_registered # pylint: disable=global-statement metrics_opts = (opts or {}).get("metrics") or {} _cached_opts = dict(metrics_opts) _cached_opts.setdefault("service_name", _default_service_name(opts)) - if not _OTEL_AVAILABLE: - if _cached_opts.get("enabled"): - log.warning( - "metrics.enabled is true but opentelemetry is not installed; " - "metrics remain disabled in this process." - ) - return - if not _atexit_registered: - atexit.register(shutdown) - _atexit_registered = True if not _cached_opts.get("enabled"): log.debug( "metrics.configure called but metrics.enabled is false (pid=%d, service=%s)", @@ -156,6 +189,15 @@ def configure(opts): _cached_opts.get("service_name"), ) return + if not _load_otel(): + log.warning( + "metrics.enabled is true but opentelemetry is not installed; " + "metrics remain disabled in this process." + ) + return + if not _atexit_registered: + atexit.register(shutdown) + _atexit_registered = True log.info( "Enabling OpenTelemetry metrics (pid=%d, service=%s, exporter=%s, endpoint=%s)", os.getpid(), @@ -276,12 +318,12 @@ def _build_provider(): "metrics enabled but no reader could be built; instruments " "will record into the void." ) - provider = MeterProvider( + provider = _otel.MeterProvider( resource=resource, metric_readers=readers, views=views, ) - otel_metrics.set_meter_provider(provider) + _otel.otel_metrics.set_meter_provider(provider) _provider = provider _meter = provider.get_meter(_INSTRUMENTATION_NAME) @@ -291,7 +333,7 @@ def _build_resource(opts): extra = opts.get("resource_attributes") or {} if isinstance(extra, dict): attrs.update(extra) - return Resource.create(attrs) + return _otel.Resource.create(attrs) def _build_views(opts): @@ -317,9 +359,11 @@ def _build_views(opts): ) continue views.append( - View( + _otel.View( instrument_name=instrument_name, - aggregation=ExplicitBucketHistogramAggregation(boundaries=float_bounds), + aggregation=_otel.ExplicitBucketHistogramAggregation( + boundaries=float_bounds + ), ) ) return views @@ -340,8 +384,8 @@ def _build_readers(opts): if name == "console": return [ - PeriodicExportingMetricReader( - ConsoleMetricExporter(), + _otel.PeriodicExportingMetricReader( + _otel.ConsoleMetricExporter(), export_interval_millis=int(interval_seconds * 1000), ) ] @@ -353,16 +397,17 @@ def _build_readers(opts): if headers: kwargs["headers"] = headers return [ - PeriodicExportingMetricReader( - _OTLPMetricExporterHTTP(**kwargs), + _otel.PeriodicExportingMetricReader( + _otel.OTLPMetricExporterHTTP(**kwargs), export_interval_millis=int(interval_seconds * 1000), ) ] if name == "otlp-grpc": try: + # pylint: disable=import-outside-toplevel from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( - OTLPMetricExporter as _OTLPMetricExporterGRPC, + OTLPMetricExporter as OTLPMetricExporterGRPC, ) except ImportError: log.error( @@ -377,8 +422,8 @@ def _build_readers(opts): if headers: kwargs["headers"] = headers return [ - PeriodicExportingMetricReader( - _OTLPMetricExporterGRPC(**kwargs), + _otel.PeriodicExportingMetricReader( + OTLPMetricExporterGRPC(**kwargs), export_interval_millis=int(interval_seconds * 1000), ) ] diff --git a/salt/utils/tracing.py b/salt/utils/tracing.py index 41ef59f02574..1a8970802a2e 100644 --- a/salt/utils/tracing.py +++ b/salt/utils/tracing.py @@ -7,8 +7,14 @@ When ``opts['tracing']['enabled']`` is false (the default), every public function short-circuits and ``start_span`` returns a :class:`_NoopSpan`. No -spans are created, no exporter is initialised and no background threads are -started. +spans are created, no exporter is initialised, no background threads are +started -- and, critically, ``opentelemetry`` is never imported. Every +salt daemon entry point (master, minion, salt-api, syndic) imports this +module, so eagerly importing OpenTelemetry at module load added ~15 MB +per Python process (~225 MB across a 15-process salt-master container) +even though tracing.enabled defaults to false. The imports are now +deferred to :func:`_load_otel`, which is only invoked from paths that +have already confirmed tracing is on. The carrier format on the wire is W3C TraceContext: a ``traceparent`` (and optional ``tracestate``) string injected into the appropriate dict / header @@ -42,60 +48,112 @@ import logging import os import threading +from types import SimpleNamespace log = logging.getLogger(__name__) _INSTRUMENTATION_NAME = "salt" -# OpenTelemetry is optional. It is not shipped in the salt-ssh thin -# tarball, may be absent from older installed onedirs that the upgrade / -# downgrade tests still exercise, and may be intentionally uninstalled by -# operators who want a minimal footprint. When opentelemetry is missing, -# every public function in this module short-circuits to a no-op, exactly -# as if ``opts['tracing']['enabled']`` were false. -try: - from opentelemetry import context as otel_context - from opentelemetry import trace - from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( - OTLPSpanExporter as _OTLPSpanExporterHTTP, - ) - from opentelemetry.sdk.resources import Resource - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter - from opentelemetry.sdk.trace.sampling import ( - ALWAYS_OFF, - ALWAYS_ON, - ParentBased, - TraceIdRatioBased, - ) - from opentelemetry.trace.propagation.tracecontext import ( - TraceContextTextMapPropagator, - ) +# Deferred OpenTelemetry state. ``None`` means "we have not yet tried +# to import"; ``True`` / ``False`` are set by :func:`_load_otel` on +# first use. ``_otel`` is a ``SimpleNamespace`` of the symbols we need +# from ``opentelemetry`` once the probe succeeds. +_OTEL_AVAILABLE = None +_otel = None +_otel_load_lock = threading.Lock() + + +class _SpanKindStub: + """ + Duck-typed ``trace.SpanKind`` used regardless of whether opentelemetry + is loaded. + + Callers reach for ``salt.utils.tracing.SpanKind.SERVER`` at import time + (see e.g. ``salt/minion.py``, ``salt/channel/server.py``, + ``salt/netapi/rest_cherrypy/app.py``). We can't hand them the real + ``opentelemetry.trace.SpanKind`` without importing opentelemetry + unconditionally, so we always expose the stub and translate to the + real enum inside :func:`_translate_kind` -- but only when tracing is + actually enabled. + """ + + INTERNAL = "INTERNAL" + SERVER = "SERVER" + CLIENT = "CLIENT" + PRODUCER = "PRODUCER" + CONSUMER = "CONSUMER" + - _OTEL_AVAILABLE = True - SpanKind = trace.SpanKind -except ImportError: # pragma: no cover - exercised when opentelemetry is absent - _OTEL_AVAILABLE = False - otel_context = None # type: ignore[assignment] - trace = None # type: ignore[assignment] - _OTLPSpanExporterHTTP = None # type: ignore[assignment] - Resource = None # type: ignore[assignment] - TracerProvider = None # type: ignore[assignment] - BatchSpanProcessor = None # type: ignore[assignment] - ConsoleSpanExporter = None # type: ignore[assignment] - ALWAYS_OFF = ALWAYS_ON = ParentBased = TraceIdRatioBased = None # type: ignore[assignment] - TraceContextTextMapPropagator = None # type: ignore[assignment] +SpanKind = _SpanKindStub() - class _SpanKindStub: - """Duck-typed ``trace.SpanKind`` used when opentelemetry is missing.""" - INTERNAL = "INTERNAL" - SERVER = "SERVER" - CLIENT = "CLIENT" - PRODUCER = "PRODUCER" - CONSUMER = "CONSUMER" +def _load_otel(): + """ + Attempt to import opentelemetry on first use. Returns ``True`` if + available. + + Only called from paths where tracing has already been confirmed + enabled, so daemons with ``tracing.enabled = false`` (the default) + never pay the ~15 MB per-process import cost. Idempotent; the + second call short-circuits on the memoised flag. + """ + global _OTEL_AVAILABLE, _otel # pylint: disable=global-statement + if _OTEL_AVAILABLE is not None: + return _OTEL_AVAILABLE + with _otel_load_lock: + if _OTEL_AVAILABLE is not None: + return _OTEL_AVAILABLE + try: + # pylint: disable=import-outside-toplevel + from opentelemetry import context as otel_context + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterHTTP, + ) + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, + ) + from opentelemetry.sdk.trace.sampling import ( + ALWAYS_OFF, + ALWAYS_ON, + ParentBased, + TraceIdRatioBased, + ) + from opentelemetry.trace.propagation.tracecontext import ( + TraceContextTextMapPropagator, + ) + except ImportError: # pragma: no cover - exercised when otel is absent + _OTEL_AVAILABLE = False + return False + _otel = SimpleNamespace( + otel_context=otel_context, + trace=trace, + OTLPSpanExporterHTTP=OTLPSpanExporterHTTP, + Resource=Resource, + TracerProvider=TracerProvider, + BatchSpanProcessor=BatchSpanProcessor, + ConsoleSpanExporter=ConsoleSpanExporter, + ALWAYS_OFF=ALWAYS_OFF, + ALWAYS_ON=ALWAYS_ON, + ParentBased=ParentBased, + TraceIdRatioBased=TraceIdRatioBased, + propagator=TraceContextTextMapPropagator(), + ) + _OTEL_AVAILABLE = True + return True + - SpanKind = _SpanKindStub() # type: ignore[assignment] +def _translate_kind(kind): + """Map a public :class:`_SpanKindStub` value to real ``trace.SpanKind``.""" + if kind is None: + return _otel.trace.SpanKind.INTERNAL + if isinstance(kind, str): + return getattr(_otel.trace.SpanKind, kind, _otel.trace.SpanKind.INTERNAL) + # Already a real ``trace.SpanKind`` (or duck-typed equivalent). + return kind _lock = threading.Lock() @@ -103,7 +161,6 @@ class _SpanKindStub: _provider = None _tracer = None _cached_opts = None -_propagator = TraceContextTextMapPropagator() if _OTEL_AVAILABLE else None _atexit_registered = False @@ -154,8 +211,8 @@ def end(self, end_time=None): # noqa: ARG002 return None def get_span_context(self): - if _OTEL_AVAILABLE: - return trace.INVALID_SPAN_CONTEXT + if _OTEL_AVAILABLE and _otel is not None: + return _otel.trace.INVALID_SPAN_CONTEXT return _INVALID_SPAN_CONTEXT_FALLBACK @@ -163,10 +220,18 @@ def get_span_context(self): def is_enabled(): - """Return True if tracing is configured and enabled.""" - if not _OTEL_AVAILABLE: + """ + Return True if tracing is configured, enabled, and opentelemetry can + be imported. + + Structured so the disabled path never touches opentelemetry: when + ``_cached_opts`` is unset or ``enabled`` is false (both true by + default), :func:`_load_otel` is not called and the imports stay + deferred. + """ + if not _cached_opts or not _cached_opts.get("enabled"): return False - return bool(_cached_opts and _cached_opts.get("enabled")) + return _load_otel() def configure(opts): @@ -181,20 +246,10 @@ def configure(opts): this is a cheap no-op that just caches the opts so that subsequent calls in fork children can pick up the same setting. """ - global _cached_opts, _atexit_registered + global _cached_opts, _atexit_registered # pylint: disable=global-statement tracing_opts = (opts or {}).get("tracing") or {} _cached_opts = dict(tracing_opts) _cached_opts.setdefault("service_name", _default_service_name(opts)) - if not _OTEL_AVAILABLE: - if _cached_opts.get("enabled"): - log.warning( - "tracing.enabled is true but opentelemetry is not installed; " - "tracing remains disabled in this process." - ) - return - if not _atexit_registered: - atexit.register(shutdown) - _atexit_registered = True if not _cached_opts.get("enabled"): log.debug( "tracing.configure called but tracing.enabled is false (pid=%d, service=%s)", @@ -202,6 +257,15 @@ def configure(opts): _cached_opts.get("service_name"), ) return + if not _load_otel(): + log.warning( + "tracing.enabled is true but opentelemetry is not installed; " + "tracing remains disabled in this process." + ) + return + if not _atexit_registered: + atexit.register(shutdown) + _atexit_registered = True log.info( "Enabling OpenTelemetry tracing (pid=%d, service=%s, exporter=%s, endpoint=%s)", os.getpid(), @@ -214,7 +278,7 @@ def configure(opts): def shutdown(): """Flush and tear down the active provider.""" - global _provider, _tracer, _last_pid + global _provider, _tracer, _last_pid # pylint: disable=global-statement with _lock: provider = _provider _provider = None @@ -240,11 +304,12 @@ def start_span(name, *, kind=None, attributes=None, links=None, context=None): _ensure_tracer() if _tracer is None: return _NOOP_SPAN + real_kind = _translate_kind(kind) if context is not None: - return _start_with_context(name, context, kind, attributes, links) + return _start_with_context(name, context, real_kind, attributes, links) return _tracer.start_as_current_span( name, - kind=kind or trace.SpanKind.INTERNAL, + kind=real_kind, attributes=attributes, links=links, ) @@ -252,31 +317,31 @@ def start_span(name, *, kind=None, attributes=None, links=None, context=None): @contextlib.contextmanager def _start_with_context(name, ctx, kind, attributes, links): - token = otel_context.attach(ctx) + token = _otel.otel_context.attach(ctx) try: with _tracer.start_as_current_span( name, - kind=kind or trace.SpanKind.INTERNAL, + kind=kind, attributes=attributes, links=links, ) as span: yield span finally: - otel_context.detach(token) + _otel.otel_context.detach(token) def current_span(): """Return the currently active span, or a :class:`_NoopSpan`.""" if not is_enabled(): return _NOOP_SPAN - return trace.get_current_span() + return _otel.trace.get_current_span() def set_attribute(key, value): """Set an attribute on the current span (no-op when disabled).""" if not is_enabled(): return - span = trace.get_current_span() + span = _otel.trace.get_current_span() if span is not None and span.is_recording(): span.set_attribute(key, value) @@ -285,7 +350,7 @@ def record_exception(exc): """Record an exception on the current span (no-op when disabled).""" if not is_enabled(): return - span = trace.get_current_span() + span = _otel.trace.get_current_span() if span is not None and span.is_recording(): span.record_exception(exc) @@ -300,12 +365,12 @@ def inject(carrier): not installed — this is a no-op so the on-the-wire payload is not bloated with empty headers. """ - if not is_enabled() or _propagator is None: + if not is_enabled(): return - span = trace.get_current_span() + span = _otel.trace.get_current_span() if span is None or not span.is_recording(): return - _propagator.inject(carrier) + _otel.propagator.inject(carrier) def extract(carrier): @@ -316,10 +381,10 @@ def extract(carrier): :func:`start_span` as ``context=...``, or ``None`` when no context was found, tracing is disabled, or opentelemetry is not installed. """ - if not is_enabled() or not carrier or _propagator is None: + if not is_enabled() or not carrier: return None - ctx = _propagator.extract(carrier) - if ctx is otel_context.Context(): + ctx = _otel.propagator.extract(carrier) + if ctx is _otel.otel_context.Context(): return None return ctx @@ -334,19 +399,21 @@ def _ensure_tracer(): return if _cached_opts is None or not _cached_opts.get("enabled"): return + if not _load_otel(): + return _build_provider() _last_pid = pid def _build_provider(): - global _provider, _tracer + global _provider, _tracer # pylint: disable=global-statement opts = _cached_opts or {} resource = _build_resource(opts) sampler = _build_sampler(opts) - provider = TracerProvider(resource=resource, sampler=sampler) + provider = _otel.TracerProvider(resource=resource, sampler=sampler) exporter = _build_exporter(opts) if exporter is not None: - provider.add_span_processor(BatchSpanProcessor(exporter)) + provider.add_span_processor(_otel.BatchSpanProcessor(exporter)) _provider = provider _tracer = provider.get_tracer(_INSTRUMENTATION_NAME) @@ -356,29 +423,29 @@ def _build_resource(opts): extra = opts.get("resource_attributes") or {} if isinstance(extra, dict): attrs.update(extra) - return Resource.create(attrs) + return _otel.Resource.create(attrs) def _build_sampler(opts): name = (opts.get("sampler") or "parent_based").lower() arg = opts.get("sampler_arg", 1.0) if name == "always_on": - return ALWAYS_ON + return _otel.ALWAYS_ON if name == "always_off": - return ALWAYS_OFF + return _otel.ALWAYS_OFF if name == "trace_id_ratio": - return TraceIdRatioBased(float(arg)) + return _otel.TraceIdRatioBased(float(arg)) if name == "parent_based": try: ratio = float(arg) except (TypeError, ValueError): ratio = 1.0 - root = ALWAYS_ON if ratio >= 1.0 else TraceIdRatioBased(ratio) - return ParentBased(root=root) + root = _otel.ALWAYS_ON if ratio >= 1.0 else _otel.TraceIdRatioBased(ratio) + return _otel.ParentBased(root=root) log.warning( "Unknown tracing sampler %r; defaulting to parent_based+always_on", name ) - return ParentBased(root=ALWAYS_ON) + return _otel.ParentBased(root=_otel.ALWAYS_ON) def _build_exporter(opts): @@ -388,21 +455,22 @@ def _build_exporter(opts): insecure = opts.get("insecure", True) try: if name == "console": - return ConsoleSpanExporter() + return _otel.ConsoleSpanExporter() if name == "otlp-http": kwargs = {} if endpoint: kwargs["endpoint"] = endpoint if headers: kwargs["headers"] = headers - return _OTLPSpanExporterHTTP(**kwargs) + return _otel.OTLPSpanExporterHTTP(**kwargs) if name == "otlp-grpc": # The gRPC exporter pulls in grpcio which has no wheel for some # interpreter / platform combinations. Import lazily so the # default HTTP path works even when grpc isn't installed. try: + # pylint: disable=import-outside-toplevel from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( - OTLPSpanExporter as _OTLPSpanExporterGRPC, + OTLPSpanExporter as OTLPSpanExporterGRPC, ) except ImportError: log.error( @@ -415,7 +483,7 @@ def _build_exporter(opts): kwargs["endpoint"] = endpoint if headers: kwargs["headers"] = headers - return _OTLPSpanExporterGRPC(**kwargs) + return OTLPSpanExporterGRPC(**kwargs) except Exception: # pylint: disable=broad-except log.exception("Failed to build tracing exporter %r", name) return None diff --git a/tests/pytests/unit/utils/test_metrics.py b/tests/pytests/unit/utils/test_metrics.py index d2807f256218..bb8013eafbde 100644 --- a/tests/pytests/unit/utils/test_metrics.py +++ b/tests/pytests/unit/utils/test_metrics.py @@ -18,6 +18,9 @@ def _reset_metrics_state(monkeypatch): """Reset module-level state between tests so they are isolated.""" metrics.shutdown() monkeypatch.setattr(metrics, "_cached_opts", None) + # Force _load_otel() to re-probe on next call. + monkeypatch.setattr(metrics, "_OTEL_AVAILABLE", None) + monkeypatch.setattr(metrics, "_otel", None) yield metrics.shutdown() @@ -258,8 +261,12 @@ def find_spec(self, name, path=None, target=None): import salt.utils.metrics as m - assert m._OTEL_AVAILABLE is False, 'expected otel to look absent' + # _OTEL_AVAILABLE is now a tri-state; None until first probe. + # After a configure() with enabled=True the probe fires (via + # _load_otel) and finds the blocker; the flag settles to False. + assert m._OTEL_AVAILABLE is None m.configure({'metrics': {'enabled': True}, '__role': 'master'}) + assert m._OTEL_AVAILABLE is False, 'expected otel to look absent' assert m.is_enabled() is False, 'enabled must stay false without otel' c = m.counter('foo') assert c is m._NOOP_COUNTER @@ -297,3 +304,98 @@ def test_configure_idempotent(in_memory_reader): ) # Configure does not rebuild when PID + opts are still valid. assert metrics._provider is first + + +def test_import_does_not_load_opentelemetry(): + """ + Regression test for the OTel eager-import baseline shift. + + Importing ``salt.utils.metrics`` (which happens transitively via + ``salt.master`` / ``salt.minion`` / ``salt.engines`` / any daemon + entry point) must not cause ``opentelemetry`` to end up in + ``sys.modules``. Prior to the fix, the module unconditionally + imported the OTel SDK at module top, adding ~15 MB per Python + process for a subsystem that defaults to disabled. + + Runs in a fresh subprocess so no earlier test that flipped metrics + on can pollute the assertion. + """ + import subprocess + import sys + import textwrap + + script = textwrap.dedent( + """ + import sys + + assert not any(k.startswith('opentelemetry') for k in sys.modules), ( + 'baseline interpreter already has opentelemetry loaded' + ) + + import salt.utils.metrics # noqa: F401 + + leaked = sorted(k for k in sys.modules if k.startswith('opentelemetry')) + assert not leaked, ( + 'salt.utils.metrics import pulled in opentelemetry: ' + + repr(leaked) + ) + + # Disabled-path configure() stays quiet as well. + salt.utils.metrics.configure({'metrics': {'enabled': False}}) + salt.utils.metrics.counter('x').add(1) + salt.utils.metrics.histogram('h').record(1) + leaked = sorted(k for k in sys.modules if k.startswith('opentelemetry')) + assert not leaked, ( + 'disabled metrics still pulled in opentelemetry: ' + repr(leaked) + ) + print('OK') + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, ( + f"subprocess failed (rc={result.returncode}):\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + assert "OK" in result.stdout + + +def test_enabling_metrics_loads_opentelemetry_lazily(): + """The mirror: ``configure({..., enabled: True})`` triggers the import.""" + import subprocess + import sys + import textwrap + + script = textwrap.dedent( + """ + import sys + import salt.utils.metrics as m + + assert not any(k.startswith('opentelemetry') for k in sys.modules) + m.configure({'metrics': {'enabled': True, 'exporter': 'console'}, + '__role': 'master'}) + assert m.is_enabled() is True + assert any(k.startswith('opentelemetry') for k in sys.modules), \ + 'enabling metrics should have imported opentelemetry' + c = m.counter('probe') + c.add(1) + print('OK') + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, ( + f"subprocess failed (rc={result.returncode}):\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + assert "OK" in result.stdout diff --git a/tests/pytests/unit/utils/test_tracing.py b/tests/pytests/unit/utils/test_tracing.py index 48baaaad8ec8..bef724b8dd88 100644 --- a/tests/pytests/unit/utils/test_tracing.py +++ b/tests/pytests/unit/utils/test_tracing.py @@ -15,6 +15,12 @@ def _reset_tracing_state(monkeypatch): """Reset module-level state between tests so they are isolated.""" tracing.shutdown() monkeypatch.setattr(tracing, "_cached_opts", None) + # Force _load_otel() to re-probe on next call so tests that flip + # tracing on don't rely on a stale _OTEL_AVAILABLE value from a + # previous test. Import-once caching in sys.modules keeps re-probes + # cheap. + monkeypatch.setattr(tracing, "_OTEL_AVAILABLE", None) + monkeypatch.setattr(tracing, "_otel", None) yield tracing.shutdown() @@ -266,9 +272,13 @@ def find_spec(self, name, path=None, target=None): del sys.modules[cached] import salt.utils.tracing as t - assert t._OTEL_AVAILABLE is False, 'expected otel to look absent' + # _OTEL_AVAILABLE is now a tri-state; None until first probe. + # After configure(enabled=True) the probe fires (via _load_otel) + # and finds the blocker; the flag settles to False. + assert t._OTEL_AVAILABLE is None assert t.SpanKind.SERVER == 'SERVER' t.configure({'tracing': {'enabled': True}, '__role': 'master'}) + assert t._OTEL_AVAILABLE is False, 'expected otel to look absent' assert t.is_enabled() is False, 'enabled must stay false without otel' with t.start_span('foo', kind=t.SpanKind.SERVER, attributes={'a': 'b'}) as s: assert s is t._NOOP_SPAN @@ -296,6 +306,161 @@ def find_spec(self, name, path=None, target=None): assert "OK" in result.stdout +def test_import_does_not_load_opentelemetry(): + """ + Regression test for the OTel eager-import baseline shift. + + Importing ``salt.utils.tracing`` (as every daemon entry point does + transitively via ``salt.master`` / ``salt.minion`` / + ``salt.channel.*`` / ``salt.netapi.rest_cherrypy.app``) must not + cause ``opentelemetry`` to end up in ``sys.modules``. Prior to the + fix, the module unconditionally imported the OTel SDK at module top, + adding ~15 MB per Python process (~225 MB across a 15-process + salt-master container) even though ``tracing.enabled`` defaults to + false. + + Runs in a fresh subprocess so no earlier test that flipped tracing + on can pollute the assertion. + """ + import subprocess + import sys + import textwrap + + script = textwrap.dedent( + """ + import sys + + # Sanity: nothing in the baseline interpreter has pulled in otel. + assert not any(k.startswith('opentelemetry') for k in sys.modules), ( + 'baseline interpreter already has opentelemetry loaded, ' + 'test cannot distinguish tracing-triggered imports' + ) + + import salt.utils.tracing # noqa: F401 + + leaked = sorted(k for k in sys.modules if k.startswith('opentelemetry')) + assert not leaked, ( + 'salt.utils.tracing import pulled in opentelemetry: ' + repr(leaked) + ) + + # Also assert the disabled-path stays quiet. + salt.utils.tracing.configure({'tracing': {'enabled': False}}) + with salt.utils.tracing.start_span('x'): + pass + leaked = sorted(k for k in sys.modules if k.startswith('opentelemetry')) + assert not leaked, ( + 'disabled tracing still pulled in opentelemetry: ' + repr(leaked) + ) + print('OK') + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, ( + f"subprocess failed (rc={result.returncode}):\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + assert "OK" in result.stdout + + +def test_enabling_tracing_loads_opentelemetry_lazily(): + """ + The mirror of :func:`test_import_does_not_load_opentelemetry`: when + ``tracing.enabled`` is true, ``configure()`` must trigger the OTel + import (otherwise the tracer stays null and no spans are emitted). + """ + import subprocess + import sys + import textwrap + + script = textwrap.dedent( + """ + import sys + import salt.utils.tracing as t + + assert not any(k.startswith('opentelemetry') for k in sys.modules) + t.configure({'tracing': {'enabled': True, 'exporter': 'console', + 'sampler': 'always_on'}}) + assert t.is_enabled() is True + assert any(k.startswith('opentelemetry') for k in sys.modules), \ + 'enabling tracing should have imported opentelemetry' + with t.start_span('probe') as span: + assert span is not t._NOOP_SPAN + print('OK') + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, ( + f"subprocess failed (rc={result.returncode}):\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + assert "OK" in result.stdout + + +def test_master_and_minion_imports_do_not_load_opentelemetry(): + """ + End-to-end guard for the whole daemon import chain. + + ``salt.utils.tracing`` is imported transitively by ``salt.master``, + ``salt.minion``, ``salt.channel.client``, ``salt.channel.server``, + ``salt.utils.event`` and ``salt.netapi.rest_cherrypy.app``. If any + module in that chain ever adds an eager top-level ``opentelemetry`` + import, this test catches it -- without needing to reproduce a full + daemon startup. + + Runs in a subprocess so the parent test-runner's opentelemetry + presence (pulled in by other tests) does not mask the failure. + """ + import subprocess + import sys + import textwrap + + script = textwrap.dedent( + """ + import sys + + # The whole daemon-import chain. If any of these modules pulls + # in opentelemetry at import time, we want to know. + import salt.utils.tracing # noqa: F401 + import salt.utils.event # noqa: F401 + import salt.channel.client # noqa: F401 + import salt.channel.server # noqa: F401 + import salt.master # noqa: F401 + import salt.minion # noqa: F401 + + leaked = sorted(k for k in sys.modules if k.startswith('opentelemetry')) + assert not leaked, ( + 'importing salt master/minion chain pulled in opentelemetry: ' + + repr(leaked) + ) + print('OK') + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=90, + ) + assert result.returncode == 0, ( + f"subprocess failed (rc={result.returncode}):\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + assert "OK" in result.stdout + + def test_configure_idempotent(in_memory_exporter): tracing.configure( {"tracing": {"enabled": True, "exporter": "console", "sampler": "always_on"}} From b4987fd01302af213f5da198ab3fba2d9481a31a Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Thu, 23 Jul 2026 15:31:14 -0700 Subject: [PATCH 133/469] [3008.x] Fix EventPublisher unix-socket leak (PubServer close callback + SaltEvent ResourceWarning) + port missing 3006.x fixes (#69857) (#69858) * Fix grains.append leaking defaultdict into persisted grain state (#69648) grains.append walked up the delimited-key hierarchy calling `get(key, _infinitedict(), delimiter)`. When the parent path did not yet exist, `get()` returned that fresh `collections.defaultdict`, which was then mutated in-place and persisted through `setval`. The next `grains.get` on any sibling path under the same parent traversed through the defaultdict; `salt.utils.data.traverse_dict_and_list` relies on `KeyError` from `ptr[each]` to signal a missing key, but a `defaultdict(_infinitedict)` silently auto-inserts an empty child instead. Callers such as `grains.list_present` therefore received an empty defaultdict where they expected `[]`, rejected it as "not a valid list", and failed with "Failed append value X to grain Y". Use a plain `{}` as the default. Auto-nesting is never needed there: the loop writes one level at a time via `.update({rest: grains})`, and every parent along the path is written in its own iteration. Fixes #64017 (cherry picked from commit 6b0e94a0592064a4df4a8f9d21f57f12bec99e29) * Fix AsyncAuth AttributeError on _creds race with creds_map (#69666) AsyncAuth.__singleton_init__ only assigned self._creds when the key was already in the class-wide creds_map. When it was not, __init__ fell through to self.authenticate() and left _creds unset. If a sibling AsyncAuth for the same key (same pki_dir + id + master_uri + key-mtime tuple) completed its sign_in between our construction and our _authenticate reaching the `if key not in AsyncAuth.creds_map:` check, the coroutine took the else branch and dereferenced self._creds["aes"], raising AttributeError: 'AsyncAuth' object has no attribute '_creds' That crash aborted the authenticate coroutine mid-flight, so _authenticate_future never resolved, the minion's connection to the master silently died, and running jobs continued to publish results into the void until manual restart. It reproduced most often on multi-master failover minions where several AsyncAuth instances race for the same key. Initialize self._creds = None in __singleton_init__ (matching the sibling SAuth.__init__) and treat `self._creds is None` as the first-authentication case in _authenticate, so the else branch is only entered when we have prior creds to compare against. Fixes #67947 (cherry picked from commit 28ce2e6ec46572f9179c0bb0723426dda1c4f3fe) * Fix HighState/State leaking fileclient on init failure (#69637) Backport of cb098940aad. When HighState.__init__ or State.__init__ raised after allocating their fileclient (e.g. BaseHighState.__init__ failing during master_opts(), or _gather_pillar() failing during pillar compilation), the caller never received the instance and therefore never called .destroy(). The fileclient's ZeroMQ RequestClient was finalized during garbage collection with _closing = False, tripping the ``TransportWarning: Unclosed transport!`` warning that PR #65559 added. Wrap both constructors' post-allocation bodies in try/except that destroys the freshly-allocated fileclient before re-raising. Also close the temporary Pillar object built by State._gather_pillar() in a try/finally so its channel doesn't rely on __del__ ordering at shutdown. Manual port -- salt/state.py had diverged on 3008.x. * Prune PubServer subscribers on stream close (#69857) Port 3006.x's IPCMessagePublisher.handle_connection ``set_close_callback`` back into 3008.x's replacement salt.transport.tcp.PubServer. Without this, subscribers that close their end of the stream (rest_cherrypy request handlers timing out, salt CLI clients dying between reads, short-lived MasterEvent instances from engines / reactor callbacks) sit in ``PubServer.clients`` from the moment their peer disconnects until either the reader loop's ``read_bytes`` finally unblocks or ``publish_payload`` throws ``StreamClosedError`` on the next write -- neither of which fires for a passive subscriber. Observed on a live 3008.2 salt-master container: ``EventPublisher`` at 151.7 GB RSS with 7584 open FDs (7572 sockets: 3793 on master_event_pull.ipc, 3791 on master_event_pub.ipc) after 24 h uptime. Each accumulated stream holds a Tornado ``IOStream`` whose ``_read_buffer`` / ``_write_buffer`` bytearrays are sized to ``ipc_write_buffer`` -- multiply by thousands and you get GB-scale anon RSS. The callback is installed alongside ``self.clients.add(client)`` in both the direct ``handle_stream`` path and the async ``_validate_ssl_and_add_client`` path. On close it removes the presence callback and discards the client from the set; the reader task exits at its next iteration via ``StreamClosedError``, and Python then reclaims the ``Subscriber`` / ``IOStream`` chain. * Warn on unclosed SaltEvent at GC (#69857) Commit 0c3f53d9172 ("Remove __del__ methods from leak fixes") took the position that explicit resource cleanup is preferred over __del__ finalizers. That's the right long-term direction, but the removal broke every caller -- in-tree and out-of-tree -- that predated the switch to context managers. A common pattern that worked on 3006.x prior to that commit and silently leaks now: salt.utils.event.get_master_event(opts, sock_dir).fire_event( data, tag, ) get_master_event() returns a fresh MasterEvent, .fire_event() opens a connection to ``master_event_pull.ipc``, and the reference is dropped -- but nothing closes the socket. Under sustained fire-and-forget churn (sseape's jobcompletion engine has two such patterns; operator-authored engines / reactors written before the switch have their own) this drove the ``EventPublisher`` leak tracked in #69857. Add a ``__del__`` that emits a ``ResourceWarning`` (does NOT close the sockets -- keeping the "explicit cleanup" contract intact). Callers can silence via ``warnings.filterwarnings`` if they truly want fire-and-forget semantics; the warning surfaces via pytest's ``-W error::ResourceWarning`` and sentry/log aggregators so offending sites can be tracked down and fixed at the source rather than silently leaking fds and RSS. * Tests for PubServer close-callback and SaltEvent __del__ warning Plus a fixup to the AsyncAuth creds-race test cherry-picked from 28ce2e6ec46 -- 3008.x's ``salt.crypt.gen_keys`` returns ``(priv, pub)`` strings for the caller to write, rather than writing them itself (the 3006.x-era signature). Adjust the test to match and add the ``keys.cache_driver`` opt that ``LoadAuth.__init__`` now requires. * Add changelog entry for #69857 * Stress workflow: optional install_opentelemetry input Lets a dispatched stress run uninstall opentelemetry from the salt-master container before the stress starts, so the workflow can measure the baseline without OTel loaded. Scheduled runs default to install_opentelemetry=true (matches the shipped requirements). * Await payload handler inline in TCPPuller.handle_stream (#69857) The reader loop used to fire the payload handler via ``self.io_loop.create_task(...)`` and immediately loop back to read the next framed message. Under sustained publish load (~5000 events/sec on the stress rig) tasks accumulated in the io_loop faster than they could complete: 909,120 pending tasks on the EventPublisher after ~5 min drove RSS to 10 GB. The 3006.x equivalent path (``IPCMessagePublisher._write``) solved the same accumulation via commit ``d4e2e075aa3`` by switching from ``@gen.coroutine`` to ``future.add_done_callback``. On 3008.x's asyncio-native transport the natural equivalent is to apply backpressure at the reader: await the handler inline. If publishing is slow because a subscriber's write buffer is full, we stop reading; the kernel's pull-socket buffer absorbs a bounded burst and the peer eventually blocks on write. Post-fix on the same stress load: - EP RSS: 10 GB (climbing) -> 50 MB (flat) - pending asyncio tasks: 909,120 -> 16-20 - EP fd count: unchanged (already stable from the PubServer close-callback fix earlier in this branch) Exceptions raised by the handler are caught and logged so a single bad event can't kill the reader loop -- matches the pre-await behavior where ``create_task`` swallowed failures into the fire-and-forget task. --- .github/workflows/nightly-stress-test.yml | 37 ++++ changelog/64017.fixed.md | 1 + changelog/67947.fixed.md | 1 + changelog/69857.fixed.md | 1 + salt/crypt.py | 13 +- salt/modules/grains.py | 9 +- salt/state.py | 151 +++++++++++----- salt/transport/tcp.py | 66 ++++++- salt/utils/event.py | 47 +++++ tests/pytests/unit/states/test_grains.py | 41 +++++ tests/pytests/unit/test_crypt.py | 84 +++++++++ tests/pytests/unit/transport/test_tcp.py | 174 +++++++++++++++++++ tests/pytests/unit/utils/event/test_event.py | 80 +++++++++ 13 files changed, 657 insertions(+), 48 deletions(-) create mode 100644 changelog/64017.fixed.md create mode 100644 changelog/67947.fixed.md create mode 100644 changelog/69857.fixed.md diff --git a/.github/workflows/nightly-stress-test.yml b/.github/workflows/nightly-stress-test.yml index c2dce9415675..035cdbf7a006 100644 --- a/.github/workflows/nightly-stress-test.yml +++ b/.github/workflows/nightly-stress-test.yml @@ -9,6 +9,18 @@ on: description: 'Duration of the stress test (e.g., 30m, 1h)' required: true default: '30m' + install_opentelemetry: + description: >- + Install opentelemetry in the salt-master image before the + stress starts. 'true' (the default) matches the shipped + requirements/base.txt. Set 'false' to reproduce the + pre-3008.x baseline without opentelemetry loaded. + required: false + default: 'true' + type: choice + options: + - 'true' + - 'false' jobs: stress-test: @@ -48,6 +60,31 @@ jobs: docker compose up -d sleep 30 # Wait for initialization + - name: Toggle opentelemetry install + env: + INSTALL_OTEL: ${{ github.event.inputs.install_opentelemetry || 'true' }} + run: | + if [ "$INSTALL_OTEL" = "false" ]; then + echo "Removing opentelemetry from the salt-master container" + docker exec salt-master pip uninstall -y --quiet \ + opentelemetry-api \ + opentelemetry-sdk \ + opentelemetry-exporter-otlp-proto-http \ + opentelemetry-exporter-otlp-proto-common \ + opentelemetry-exporter-prometheus \ + opentelemetry-proto \ + opentelemetry-semantic-conventions \ + prometheus-client 2>&1 | tail -5 || true + if docker exec salt-master python3 -c "import opentelemetry" 2>/dev/null; then + echo "opentelemetry is still importable after uninstall" >&2 + exit 1 + fi + docker restart salt-master + sleep 20 + else + echo "Leaving opentelemetry installed (default behaviour)" + fi + - name: Verify Connections # The salt CLI returns exit 0 even when the master returns an # error string (the legacy ``'str' object has no attribute diff --git a/changelog/64017.fixed.md b/changelog/64017.fixed.md new file mode 100644 index 000000000000..168175b7157c --- /dev/null +++ b/changelog/64017.fixed.md @@ -0,0 +1 @@ +Fixed `grains.append` (and by extension `grains.list_present`) leaking a `collections.defaultdict` into persisted grain state, which caused sibling `list_present` calls under a shared nested path to fail with "not a valid list". diff --git a/changelog/67947.fixed.md b/changelog/67947.fixed.md new file mode 100644 index 000000000000..86e96410467e --- /dev/null +++ b/changelog/67947.fixed.md @@ -0,0 +1 @@ +Fixed a race in the minion's `AsyncAuth._authenticate` that raised `AttributeError: 'AsyncAuth' object has no attribute '_creds'` and silently severed master communication when a sibling `AsyncAuth` populated `creds_map` between construction and the coroutine's `key not in creds_map` check. diff --git a/changelog/69857.fixed.md b/changelog/69857.fixed.md new file mode 100644 index 000000000000..30ec774a161b --- /dev/null +++ b/changelog/69857.fixed.md @@ -0,0 +1 @@ +Fixed unbounded socket accumulation in the master's `EventPublisher` process (observed at 7500+ open sockets / 150 GB anon RSS after 24 h uptime on 3008.2). The 3008.x `PubServer` now registers a stream close callback so subscribers are pruned from `PubServer.clients` the instant the peer disconnects (mirroring 3006.x's `IPCMessagePublisher.handle_connection`). In addition, `SaltEvent.__del__` now emits a `ResourceWarning` when the event bus is garbage-collected without an explicit `destroy()` / `with` context, so callers that inadvertently leak `MasterEvent` / `SaltEvent` instances (e.g. inline `salt.utils.event.get_master_event(opts, sock_dir).fire_event(...)`) surface loudly rather than silently accumulating `master_event_pull.ipc` / `master_event_pub.ipc` sockets. `__del__` deliberately does not close the sockets — the explicit-cleanup contract added by commit `0c3f53d9172` stays in place. diff --git a/salt/crypt.py b/salt/crypt.py index db900a164fd8..c87251a7f59a 100644 --- a/salt/crypt.py +++ b/salt/crypt.py @@ -972,6 +972,14 @@ def __singleton_init__(self, opts, io_loop=None): self.pub_path = os.path.join(self.opts["pki_dir"], "minion.pub") self.rsa_path = os.path.join(self.opts["pki_dir"], "minion.pem") self._private_key = None + # Initialize ``_creds`` so ``_authenticate`` can safely check it even + # when a sibling ``AsyncAuth`` populates ``creds_map`` between our + # construction and the ``key not in AsyncAuth.creds_map`` check in + # the coroutine. Without this pre-assignment the else-branch below + # falls through to ``self.authenticate()`` and ``_authenticate`` + # later raises ``AttributeError`` on ``self._creds["aes"]`` (see + # issue #67947). + self._creds = None if self.opts["__role"] == "syndic": self.mpub = "syndic_master.pub" else: @@ -1165,7 +1173,10 @@ async def _authenticate(self): else: key = self.__key(self.opts) new_aes, changed_aes, changed_session = False, False, False - if key not in AsyncAuth.creds_map: + # ``self._creds is None`` covers the first-authentication case + # even when a sibling ``AsyncAuth`` for the same key raced us + # into ``creds_map``. See issue #67947. + if key not in AsyncAuth.creds_map or self._creds is None: new_aes = True log.debug("%s Got new master aes key.", self) else: diff --git a/salt/modules/grains.py b/salt/modules/grains.py index d623c5c10475..8a3da331e7e9 100644 --- a/salt/modules/grains.py +++ b/salt/modules/grains.py @@ -374,7 +374,14 @@ def append(key, val, convert=False, delimiter=DEFAULT_TARGET_DELIM): while delimiter in key: key, rest = key.rsplit(delimiter, 1) - _grain = get(key, _infinitedict(), delimiter) + # NOTE: default must be a plain dict, not `_infinitedict()`. A + # `collections.defaultdict` returned here (when `key` does not yet + # exist) is later persisted via `setval` and, on subsequent lookups + # through `salt.utils.data.traverse_dict_and_list`, auto-materializes + # empty children instead of raising `KeyError`. That silent-insert + # made sibling nested `grains.append`/`grains.list_present` calls + # fail with "not a valid list". See #64017. + _grain = get(key, {}, delimiter) if isinstance(_grain, dict): _grain.update({rest: grains}) grains = _grain diff --git a/salt/state.py b/salt/state.py index 8178cb623bf7..666d130a0939 100644 --- a/salt/state.py +++ b/salt/state.py @@ -879,36 +879,45 @@ def __init__( else: self.file_client = salt.fileclient.get_file_client(self.opts) self.preserve_file_client = False - self.proxy = proxy - self._pillar_override = pillar_override - if pillar_enc is not None: - try: - pillar_enc = pillar_enc.lower() - except AttributeError: - pillar_enc = str(pillar_enc).lower() - self._pillar_enc = pillar_enc - log.debug("Gathering pillar data for state run") - if initial_pillar and not self._pillar_override: - self.opts["pillar"] = initial_pillar - else: - # Compile pillar data - self.opts["pillar"] = self._gather_pillar() - # Reapply overrides on top of compiled pillar - if self._pillar_override: - self.opts["pillar"] = salt.utils.dictupdate.merge( - self.opts["pillar"], - self._pillar_override, - self.opts.get("pillar_source_merging_strategy", "smart"), - self.opts.get("renderer", "yaml"), - self.opts.get("pillar_merge_lists", False), - ) - log.debug("Finished gathering pillar data for state run") - if context is None: - self.state_con = {} - else: - self.state_con = context - self.state_con["fileclient"] = self.file_client - self.load_modules() + # If any of the calls below raise, destroy the file client we just + # allocated so its ZeroMQ ``RequestClient`` isn't finalized with + # ``_closing = False`` and trip ``TransportWarning: Unclosed + # transport!`` during interpreter shutdown (issue #69637). + try: + self.proxy = proxy + self._pillar_override = pillar_override + if pillar_enc is not None: + try: + pillar_enc = pillar_enc.lower() + except AttributeError: + pillar_enc = str(pillar_enc).lower() + self._pillar_enc = pillar_enc + log.debug("Gathering pillar data for state run") + if initial_pillar and not self._pillar_override: + self.opts["pillar"] = initial_pillar + else: + # Compile pillar data + self.opts["pillar"] = self._gather_pillar() + # Reapply overrides on top of compiled pillar + if self._pillar_override: + self.opts["pillar"] = salt.utils.dictupdate.merge( + self.opts["pillar"], + self._pillar_override, + self.opts.get("pillar_source_merging_strategy", "smart"), + self.opts.get("renderer", "yaml"), + self.opts.get("pillar_merge_lists", False), + ) + log.debug("Finished gathering pillar data for state run") + if context is None: + self.state_con = {} + else: + self.state_con = context + self.state_con["fileclient"] = self.file_client + self.load_modules() + except Exception: + if not self.preserve_file_client: + self._destroy_fileclient_on_init_failure() + raise self.mod_init = set() self.pre = {} self.__run_num = 0 @@ -928,6 +937,31 @@ def __init__( # Fix for Issue #30971: Track processed SLS files to handle empty SLS files self._processed_sls_files = set() + def _destroy_fileclient_on_init_failure(self): + """ + Best-effort teardown for ``self.file_client`` when the constructor + is unwinding due to an exception (issue #69637). + + ``RemoteClient`` exposes ``destroy()``; ``FSChan`` / older + fileclients expose ``close()``. Swallow errors -- the caller + re-raises the original exception. + """ + try: + file_client = self.file_client + except AttributeError: + return + try: + teardown = getattr(file_client, "destroy", None) + if teardown is None: + teardown = getattr(file_client, "close", None) + if teardown is not None: + teardown() + except Exception: # pylint: disable=broad-except + log.debug( + "Error while destroying State file client after failed init", + exc_info=True, + ) + def _match_global_state_conditions(self, full, state, name): """ Return ``None`` if global state conditions are met. Otherwise, pass a @@ -1008,8 +1042,19 @@ def _gather_pillar(self): pillar_override=self._pillar_override, pillarenv=self.opts.get("pillarenv"), ) - compiled = pillar.compile_pillar() - return compiled + try: + return pillar.compile_pillar() + finally: + # Explicitly release the pillar's channel/transport. Relying + # on ``__del__`` for cleanup during interpreter shutdown can + # trip ``Unclosed transport!`` warnings (#69637) because the + # transport may be finalized before the pillar or its channel. + destroy = getattr(pillar, "destroy", None) + if destroy is not None: + try: + destroy() + except Exception: # pylint: disable=broad-except + log.debug("Error while destroying pillar", exc_info=True) def _mod_init(self, low): """ @@ -4908,19 +4953,35 @@ def __init__( else: self.client = salt.fileclient.get_file_client(self.opts) self.preserve_client = False - BaseHighState.__init__(self, opts) - self.state = State( - self.opts, - pillar_override, - jid, - pillar_enc, - proxy=proxy, - context=context, - mocked=mocked, - loader=loader, - initial_pillar=initial_pillar, - file_client=self.client, - ) + # If any of the calls below raise, destroy the file client we just + # allocated so its transport doesn't get finalized without close() + # (issue #69637 -- ``Unclosed transport!`` TransportWarning during + # interpreter shutdown). + try: + BaseHighState.__init__(self, opts) + self.state = State( + self.opts, + pillar_override, + jid, + pillar_enc, + proxy=proxy, + context=context, + mocked=mocked, + loader=loader, + initial_pillar=initial_pillar, + file_client=self.client, + ) + except Exception: + if not self.preserve_client: + try: + self.client.destroy() + except Exception: # pylint: disable=broad-except + log.debug( + "Error while destroying HighState file client " + "after failed init", + exc_info=True, + ) + raise self.matchers = salt.loader.matchers(self.opts) self.proxy = proxy diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index b8a49ae27553..d4286aa5ddd4 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -1334,6 +1334,36 @@ async def _stream_read( ) continue + def _discard_on_close(self, client): + """ + Return a Tornado ``set_close_callback``-compatible zero-arg thunk + that discards ``client`` from ``self.clients`` the instant the + underlying stream closes. + + Without this, event-bus subscribers (which passively read and + never write) sit in ``self.clients`` from the moment their peer + goes away until either ``_stream_read``'s awaiting ``read_bytes`` + finally unblocks or ``publish_payload`` throws ``StreamClosedError`` + on the next write attempt to that stream. Neither event fires + promptly for the common case of a subscriber that connects, + subscribes, and then closes without exchanging further bytes -- + so the client + its Tornado ``IOStream`` + the stream's + ``_read_buffer`` / ``_write_buffer`` bytearrays stay pinned + indefinitely. Under sustained subscribe / disconnect churn (e.g. + rest_cherrypy request handlers, salt CLI invocations, engines + that create-and-drop ``MasterEvent`` instances) this drove a + 7500-socket / 150 GB RSS accumulation on a 3008.2 + ``EventPublisher`` process observed over 24 h uptime. This + matches the ``discard_after_closed`` callback the 3006.x + ``IPCMessagePublisher`` installed. + """ + + def _cb(): + self.remove_presence_callback(client) + self.clients.discard(client) + + return _cb + def handle_stream(self, stream, address): cert = None try: @@ -1356,6 +1386,7 @@ def handle_stream(self, stream, address): return client = Subscriber(stream, address) self.clients.add(client) + stream.set_close_callback(self._discard_on_close(client)) self.io_loop.create_task(self._stream_read(client)) async def _validate_ssl_and_add_client(self, stream, address): @@ -1380,6 +1411,7 @@ async def _validate_ssl_and_add_client(self, stream, address): # Successfully got cert - add client client = Subscriber(stream, address) self.clients.add(client) + stream.set_close_callback(self._discard_on_close(client)) self.io_loop.create_task(self._stream_read(client)) return except AttributeError as exc: @@ -1551,7 +1583,39 @@ async def handle_stream(self, stream): payload = await stream.read_bytes(length) framed_msg = salt.utils.msgpack.unpackb(payload, raw=False) body = framed_msg["body"] - self.io_loop.create_task(self.payload_handler(body)) + # Await the payload handler inline instead of firing it + # as a background task. ``create_task`` here made the + # reader loop return immediately, so under sustained + # publish load (~5000 events/sec on the stress rig) + # tasks accumulated in the io_loop faster than they + # could complete: 909,120 pending tasks on the + # EventPublisher after ~5 min drove RSS to 10 GB (each + # Python task frame plus the retained event payload is + # ~11 kB). The 3006.x equivalent path + # (``IPCMessagePublisher._write`` reworked by commit + # ``d4e2e075aa3``) solved the same accumulation by + # switching from ``@gen.coroutine`` to a plain function + # with ``future.add_done_callback``; on 3008.x's + # asyncio-native transport the natural equivalent is to + # apply backpressure at the reader. If + # ``payload_handler`` is slow because a subscriber's + # write buffer is full, we stop reading; the kernel's + # pull-socket buffer absorbs a bounded burst and the + # peer eventually blocks on write -- which is exactly + # the natural backpressure we want. + try: + await self.payload_handler(body) + except Exception as exc: # pylint: disable=broad-except + # A misbehaving handler must not break the whole + # reader loop; a single bad event is dropped and the + # loop continues. Matches the pre-await behavior, + # where ``create_task`` swallowed the failure into a + # fire-and-forget task. + log.error( + "Exception in payload handler while reading IPC stream: %s", + exc, + exc_info=True, + ) except tornado.iostream.StreamClosedError: if self.path: log.trace("Client disconnected from IPC %s", self.path) diff --git a/salt/utils/event.py b/salt/utils/event.py index 92d929a22f46..46dd4bfffd7a 100644 --- a/salt/utils/event.py +++ b/salt/utils/event.py @@ -57,6 +57,7 @@ import logging import os import time +import warnings import weakref from collections.abc import Iterable, MutableMapping @@ -273,6 +274,52 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self.destroy() + # pylint: disable=W1701 + def __del__(self): + # Deliberately does NOT close the sockets -- Python's ``__del__`` + # runs during GC (may be arbitrarily delayed, may skip on reference + # cycles) and during interpreter shutdown (when the world is + # already tearing down and closing sockets can raise from a + # partially-freed C extension). Instead we emit a ``ResourceWarning`` + # so callers that missed the ``with`` / ``destroy()`` contract + # surface loudly in tests / sentry / log aggregators and can be + # fixed at the source. + # + # Silently GC-closing the sockets (which is what ``__del__`` used + # to do prior to commit 0c3f53d9172, "Remove __del__ methods from + # leak fixes") worked well enough on 3006.x that most callers came + # to rely on it -- including out-of-tree consumers like sseape's + # engines that do inline ``get_master_event(...).fire_event(...)``. + # When the ``__del__`` cascade was removed, those callers silently + # started leaking one ``master_event_pull.ipc`` (and, for + # ``listen=True``, one ``master_event_pub.ipc``) socket per + # fire-and-forget instance. ``ResourceWarning`` makes that + # visible without re-introducing the "silent GC-time cleanup" + # trap. + try: + unclosed = ( + getattr(self, "subscriber", None) is not None + or getattr(self, "pusher", None) is not None + ) + except Exception: # pylint: disable=broad-except + return + if not unclosed: + return + try: + warnings.warn( + f"unclosed {type(self).__name__} {self!r}; call " + f"``destroy()`` or use as a context manager", + ResourceWarning, + source=self, + ) + except Exception: # pylint: disable=broad-except + # ``warnings.warn`` can raise during interpreter shutdown + # when the ``warnings`` module has already been torn down. + # A finalizer must not propagate exceptions. + pass + + # pylint: enable=W1701 + def __init__( self, node, diff --git a/tests/pytests/unit/states/test_grains.py b/tests/pytests/unit/states/test_grains.py index 3f9de4dcad35..b262ed8e2520 100644 --- a/tests/pytests/unit/states/test_grains.py +++ b/tests/pytests/unit/states/test_grains.py @@ -827,6 +827,47 @@ def test_list_present_unknown_failure(): assert_grain_file_content("a: aval\nfoo:\n- bar\n") +def test_list_present_multiple_nested_siblings_64017(): + """ + Regression test for #64017. + + Successive ``grains.list_present`` calls that create nested keys sharing + a common parent path should all succeed. Previously the first call left a + ``collections.defaultdict`` (from ``_infinitedict``) in ``__grains__``, + which auto-materialized empty children when the second call traversed + the shared parent -- so ``grains.append`` was handed an empty + ``defaultdict`` instead of ``[]`` and rejected it as "not a valid list". + """ + with set_grains({}): + ret = grains.list_present(name="core-services:monitored", value="basic") + assert ret["result"] is True, ret["comment"] + + ret = grains.list_present(name="core-services:mon-config:rules", value="rules1") + assert ret["result"] is True, ret["comment"] + + ret = grains.list_present( + name="core-services:mon-config:store-servers", value="1.1.1.1" + ) + assert ret["result"] is True, ret["comment"] + + ret = grains.list_present(name="core-services:mon-config:rules", value="rules2") + assert ret["result"] is True, ret["comment"] + + assert grains.__grains__ == { + "core-services": { + "monitored": ["basic"], + "mon-config": { + "rules": ["rules1", "rules2"], + "store-servers": ["1.1.1.1"], + }, + }, + } + # The persisted grain state must contain only plain dicts, not + # defaultdicts that would leak the same bug forward. + assert type(grains.__grains__["core-services"]) is dict + assert type(grains.__grains__["core-services"]["mon-config"]) is dict + + # 'list_absent' function tests: 6 diff --git a/tests/pytests/unit/test_crypt.py b/tests/pytests/unit/test_crypt.py index fe8675add0f7..691f8970491d 100644 --- a/tests/pytests/unit/test_crypt.py +++ b/tests/pytests/unit/test_crypt.py @@ -561,3 +561,87 @@ def mock_sign_in(*args, **kwargs): exc_info.value ) assert "Attempt to authenticate with the salt master failed" in str(exc_info.value) + + +async def test_authenticate_missing_creds_attribute_67947(minion_root, io_loop, caplog): + """ + Regression test for https://github.com/saltstack/salt/issues/67947 + + ``AsyncAuth.__singleton_init__`` only assigned ``self._creds`` when the + minion's ``creds_map`` already contained the key for this auth instance. + In the not-in-cache branch it fell through to ``self.authenticate()`` and + left ``_creds`` unset. + + ``_authenticate`` then runs on the io_loop and checks ``if key not in + AsyncAuth.creds_map:`` after the round-trip to the master. If a *sibling* + ``AsyncAuth`` instance for the same key (same pki_dir + id + master_uri + + key-mtime tuple) completed its own sign_in between our construction and + our ``_authenticate`` running, ``creds_map`` now contains the key and the + check goes into the ``else`` branch that dereferences ``self._creds``. + That raises ``AttributeError: 'AsyncAuth' object has no attribute + '_creds'`` on the reporter's Windows minion, aborts the authenticate + coroutine, and silently disconnects the minion until manual restart. + + The fix initializes ``self._creds = None`` in the constructor (matching + the sibling ``SAuth`` class) and updates the else-branch to treat + ``self._creds is None`` as the first-time case rather than the + key-changed case. + """ + pki_dir = minion_root / "etc" / "salt" / "pki" + opts = { + "id": "minion", + "__role": "minion", + "pki_dir": str(pki_dir), + "master_uri": "tcp://127.0.0.1:4505", + "keysize": 4096, + "acceptance_wait_time": 0, + "acceptance_wait_time_max": 0, + "keys.cache_driver": "localfs_key", + } + priv, pub = crypt.gen_keys(opts["keysize"]) + keypath = pki_dir / "minion" + keypath.with_suffix(".pem").write_text(priv) + keypath.with_suffix(".pub").write_text(pub) + credskey = ( + opts["pki_dir"], + opts["id"], + opts["master_uri"], + str(os.path.getmtime(os.path.join(opts["pki_dir"], "minion.pem"))), + ) + + # Make sure any leftover mapping from prior tests in this session does not + # mask the bug: the constructor's short-circuit branch would otherwise set + # ``_creds`` for us. + crypt.AsyncAuth.creds_map.pop(credskey, None) + + auth = crypt.AsyncAuth(opts, io_loop) + + aes = crypt.Crypticle.generate_key_string() + session = crypt.Crypticle.generate_key_string() + + async def mock_sign_in(*args, **kwargs): + # Simulate a sibling ``AsyncAuth`` for the same key winning the race + # and populating ``creds_map`` after our constructor ran but before + # our ``_authenticate`` reaches the ``key not in creds_map`` check. + crypt.AsyncAuth.creds_map[credskey] = { + "aes": aes, + "session": session, + } + return {"enc": "pub", "aes": aes, "session": session} + + auth.sign_in = mock_sign_in + + try: + with caplog.at_level(logging.DEBUG): + await auth.authenticate() + finally: + crypt.AsyncAuth.creds_map.pop(credskey, None) + + # Before the fix, ``_authenticate`` raised ``AttributeError: 'AsyncAuth' + # object has no attribute '_creds'`` from the else branch that compared + # ``self._creds["aes"]`` against the freshly signed-in creds. After the + # fix, the constructor initializes ``_creds`` to ``None`` and the else + # branch treats that as the first-authentication case. + assert isinstance(auth._creds, dict) + assert auth._creds["aes"] == aes + assert auth._creds["session"] == session diff --git a/tests/pytests/unit/transport/test_tcp.py b/tests/pytests/unit/transport/test_tcp.py index ae3a2154f90e..df29240b5537 100644 --- a/tests/pytests/unit/transport/test_tcp.py +++ b/tests/pytests/unit/transport/test_tcp.py @@ -1042,6 +1042,49 @@ def close(self): assert server._closing is True +def test_pub_server_discard_on_close_prunes_subscribers(master_opts, io_loop): + """ + A subscriber whose stream closes must be pruned from + ``PubServer.clients`` immediately -- not when the reader loop's + next ``read_bytes`` returns or when ``publish_payload`` throws on + the next write. Without this, passive subscribers (which never + write anything) accumulate in the set from the moment their peer + disconnects, and the ``Subscriber`` / ``IOStream`` / + ``_read_buffer`` / ``_write_buffer`` graph stays pinned in memory. + """ + server = salt.transport.tcp.PubServer(master_opts, io_loop=io_loop) + + removed_from_presence = [] + + def _remove_presence(client): + removed_from_presence.append(client) + + server.remove_presence_callback = _remove_presence + + class DummyClient: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + a = DummyClient() + b = DummyClient() + server.clients = {a, b} + + # Simulate the underlying IOStream's on-close firing the callback we + # registered from handle_stream via ``stream.set_close_callback``. + server._discard_on_close(a)() + + assert a not in server.clients + assert b in server.clients + assert removed_from_presence == [a] + + # Second call is a no-op (idempotent on a stale registration). + server._discard_on_close(a)() + assert b in server.clients + + # --------------------------------------------------------------------------- # MessageClient synchronous close. # @@ -1143,3 +1186,134 @@ async def _should_not_be_called(*args, **kwargs): assert client._closed is True assert client._closing is False assert client._stream is None + + +# --------------------------------------------------------------------------- +# TCPPuller.handle_stream backpressure. +# +# ``handle_stream`` used to fire the payload handler via +# ``self.io_loop.create_task`` and immediately loop back to read the next +# framed message. Under sustained publish load (~5000 events/sec on the +# stress rig) tasks accumulated in the io_loop faster than they could +# complete: 909,120 pending tasks / 10 GB RSS on the EventPublisher +# process after ~5 min. The 3006.x equivalent path +# (``IPCMessagePublisher._write``) solved the same accumulation by +# switching from ``@gen.coroutine`` to ``future.add_done_callback``; the +# 3008.x fix is simpler -- await the handler inline so the reader +# throttles when publishes back up, giving the pull-side kernel socket +# and the peer's ``fire_event`` writes natural TCP backpressure. +# --------------------------------------------------------------------------- + + +async def test_tcp_puller_handle_stream_awaits_payload_handler(master_opts): + """ + The reader loop must await the payload handler inline so no more than + one payload is in-flight per pull connection at a time. Regression + guard: if this reverts to ``create_task(...)`` fire-and-forget, tasks + accumulate under load and drive the EventPublisher OOM observed in + #69857. + """ + import asyncio + import struct + + handler_started = asyncio.Event() + handler_release = asyncio.Event() + handled = [] + + async def slow_handler(body): + handler_started.set() + # Block until the test lets us finish. If handle_stream had + # fire-and-forget'd us, it would already be reading the next + # message; if it awaits, it's parked on this future. + await handler_release.wait() + handled.append(body) + + puller = salt.transport.tcp.TCPPuller(payload_handler=slow_handler) + + # Build two framed messages so we can prove only one runs at a time. + def _frame(body): + payload = salt.utils.msgpack.packb({"body": body}, use_bin_type=True) + return struct.pack(">I", len(payload)) + payload + + class FakeStream: + def __init__(self, chunks): + self._buf = b"".join(chunks) + self._closed = False + + async def read_bytes(self, n): + if len(self._buf) < n: + # No more data; simulate close. + self._closed = True + raise tornado.iostream.StreamClosedError() + chunk, self._buf = self._buf[:n], self._buf[n:] + return chunk + + def closed(self): + return self._closed + + stream = FakeStream([_frame("first"), _frame("second")]) + + reader_task = asyncio.get_event_loop().create_task(puller.handle_stream(stream)) + + # Handler for message 1 starts and blocks. If handle_stream + # fire-and-forget'd, it would already be reading message 2 -- and + # since our second frame is queued, it would either have called + # slow_handler a second time (started once already) or already tried + # to schedule the second task. The single-handler-active + # invariant is the whole point of the fix. + await asyncio.wait_for(handler_started.wait(), timeout=2) + await asyncio.sleep(0.05) + assert handled == [], "reader should be parked on the first handler" + + # Release; handler 1 completes, handler 2 starts and completes, then + # the stream returns EOF and handle_stream exits. + handler_release.set() + await asyncio.wait_for(reader_task, timeout=5) + + assert handled == ["first", "second"] + + +async def test_tcp_puller_handle_stream_survives_handler_exception(master_opts): + """ + A misbehaving payload handler must not break the reader loop; a + single bad event is logged and dropped, subsequent events are still + delivered. + """ + import asyncio + import struct + + handled = [] + + async def handler(body): + if body == "boom": + raise RuntimeError("simulated handler failure") + handled.append(body) + + puller = salt.transport.tcp.TCPPuller(payload_handler=handler) + + def _frame(body): + payload = salt.utils.msgpack.packb({"body": body}, use_bin_type=True) + return struct.pack(">I", len(payload)) + payload + + class FakeStream: + def __init__(self, chunks): + self._buf = b"".join(chunks) + self._closed = False + + async def read_bytes(self, n): + if len(self._buf) < n: + self._closed = True + raise tornado.iostream.StreamClosedError() + chunk, self._buf = self._buf[:n], self._buf[n:] + return chunk + + def closed(self): + return self._closed + + stream = FakeStream([_frame("ok1"), _frame("boom"), _frame("ok2")]) + + await asyncio.wait_for(puller.handle_stream(stream), timeout=5) + + # The "boom" was dropped by the except-log-and-continue guard; the + # other two got through. + assert handled == ["ok1", "ok2"] diff --git a/tests/pytests/unit/utils/event/test_event.py b/tests/pytests/unit/utils/event/test_event.py index e7e48dc30f4a..1812f4ec4ac4 100644 --- a/tests/pytests/unit/utils/event/test_event.py +++ b/tests/pytests/unit/utils/event/test_event.py @@ -418,3 +418,83 @@ def test_event_fire_ret_load(): ) assert mock_log_error.mock_calls[0].args[1] == "minion_id.example.org" assert mock_log_error.mock_calls[0].args[2] == "".join(test_traceback) + + +# --------------------------------------------------------------------------- +# ResourceWarning on unclosed SaltEvent at GC. +# +# Commit 0c3f53d9172 removed the ``__del__`` cascade that used to close +# an unreachable SaltEvent's pub/pull sockets during garbage collection. +# The replacement contract is "call destroy() or use as a context +# manager". A caller that misses that contract now silently leaks its +# ``master_event_pull.ipc`` / ``master_event_pub.ipc`` socket -- there is +# no error, no warning, RSS just climbs. ``__del__`` now emits a +# ``ResourceWarning`` (still no auto-close -- the contract stays intact) +# so callers surface loudly instead of leaking silently. +# --------------------------------------------------------------------------- + + +def test_saltevent_del_warns_when_unclosed(minion_opts): + import gc + import warnings + + ev = salt.utils.event.SaltEvent("minion", opts=minion_opts, listen=False) + # Stand in the pusher slot so ``__del__``'s "unclosed" check sees state. + ev.pusher = object() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + del ev + gc.collect() + resource_warnings = [w for w in caught if issubclass(w.category, ResourceWarning)] + assert resource_warnings, ( + "SaltEvent GC without destroy() must emit a ResourceWarning; " + f"got: {[(w.category.__name__, str(w.message)) for w in caught]}" + ) + msg = str(resource_warnings[0].message) + assert "SaltEvent" in msg or "MasterEvent" in msg + assert "destroy" in msg or "context manager" in msg + + +def test_saltevent_del_silent_when_closed(minion_opts): + """ + A SaltEvent that was properly torn down (or was never connected) + must not emit a ResourceWarning at GC. Otherwise every well-behaved + caller would fire spurious warnings on every event bus use. + """ + import gc + import warnings + + ev = salt.utils.event.SaltEvent("minion", opts=minion_opts, listen=False) + assert ev.subscriber is None + assert ev.pusher is None + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + del ev + gc.collect() + resource_warnings = [w for w in caught if issubclass(w.category, ResourceWarning)] + assert not resource_warnings, ( + "SaltEvent with no open sockets must not warn at GC; got: " + f"{[str(w.message) for w in resource_warnings]}" + ) + + +def test_saltevent_del_does_not_close_sockets(minion_opts): + """ + The intentional contract: ``__del__`` warns but does NOT close. + Silent GC-time close was the previous behaviour and was removed for + good reasons (see 0c3f53d9172). Re-introducing an auto-close would + revert that decision. The warning is the whole point. + """ + import gc + import warnings + + ev = salt.utils.event.SaltEvent("minion", opts=minion_opts, listen=False) + sentinel = type("SentinelPusher", (), {"closed": False})() + ev.pusher = sentinel + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ResourceWarning) + del ev + gc.collect() + # If ``__del__`` had auto-closed, the sentinel would have been + # cleared / mutated; it must remain untouched. + assert sentinel.closed is False From 524269c32304f62da1df4dc2ef190e6bb23a33ff Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Thu, 23 Jul 2026 15:32:04 -0700 Subject: [PATCH 134/469] Fix Nonce verification error under concurrent request/response race (#69753) (#69805) Scheduled highstate on 3006.27 minions intermittently failed with ``SaltClientError: Nonce verification error`` raised from ``Crypticle.loads``. Two independent races contribute: (a) ``salt/channel/client.py`` -- ``_do_transfer`` looked up ``self.auth.session_crypticle`` twice, once for ``dumps`` on the send path and once for ``loads`` on the receive path. A concurrent re-auth could swap the reference between the two, causing the reply to be decrypted with a different key or session than the request was encrypted with. Pin the ``session_crypticle`` reference at send time and pass it through ``_package_load`` so both halves of the transfer use the same object. Also wrap ``_crypted_transfer`` and ``crypted_transfer_decode_dictentry`` in a per-channel ``tornado.locks.Lock`` so concurrent coroutines driving the same channel cannot interleave their (send, decrypt-reply) windows. (b) ``salt/transport/zeromq.py`` -- the minion/syndic daemon branch of ``_init_socket`` built the ZMQ IDENTITY as ``salt-req/{role}/{minion_id}/{slot}`` where ``slot`` came from a process-lifetime ``itertools.count()``. That counter's state is inherited across ``fork()``, so two concurrent scheduled-job forks each draw the same slot value on their first ``next()`` call. Combined with the master's ``ROUTER_HANDOVER=1``, siblings claimed the same routing-id and in-flight replies queued for one child were re-routed to the sibling, decrypting cleanly (same session key) but failing the nonce check. Add ``os.getpid()`` to the identity so forked children are disambiguated by pid. Separately, widen the CLI branch's identity slot from 8-bit ``pid % 256`` to a per-process 24-bit ``secrets.randbits(24)`` cached at import time so bursty ``salt-call`` invocations from the same shell do not birthday-collide on the master's ROUTER. (c) ``salt/crypt.py`` -- backport the diagnostic message from master that includes the received and expected nonces in the ``SaltClientError`` string, so future field reports of this class can distinguish a true crossed reply (two uuid4 hex strings) from a replay or a zero-nonce default. Live end-to-end validation on v3006.27 master + v3006.27 minion under the same driven load (three 3-second schedules + 8-way concurrent ``salt-call state.highstate`` + 6-way ``saltutil.refresh_pillar`` / ``mine.update`` / ``cp.list_master`` / ``state.show_top`` waves): baseline 9 ``Nonce verification error`` events in 240 s dropped to 0 events in 434 s across 7 master AES rotations and 128 scheduled highstate jobs. Fixes #69753 --- changelog/69753.fixed.md | 1 + salt/channel/client.py | 73 +++++-- salt/crypt.py | 2 +- salt/transport/zeromq.py | 31 ++- tests/pytests/unit/channel/test_client.py | 202 ++++++++++++++++++++ tests/pytests/unit/transport/test_zeromq.py | 63 ++++++ 6 files changed, 348 insertions(+), 24 deletions(-) create mode 100644 changelog/69753.fixed.md diff --git a/changelog/69753.fixed.md b/changelog/69753.fixed.md new file mode 100644 index 000000000000..0a47f9e97b23 --- /dev/null +++ b/changelog/69753.fixed.md @@ -0,0 +1 @@ +Fix ``Nonce verification error`` on scheduled highstate under concurrency (crossed responses between forked minion siblings colliding on ZMQ ROUTER identity, and mid-flight session_crypticle re-resolve). diff --git a/salt/channel/client.py b/salt/channel/client.py index f7d68dbf5172..b7ffe406ff41 100644 --- a/salt/channel/client.py +++ b/salt/channel/client.py @@ -13,6 +13,7 @@ import salt.exceptions import salt.ext.tornado.gen import salt.ext.tornado.ioloop +import salt.ext.tornado.locks import salt.payload import salt.serializers.msgpack import salt.transport.frame @@ -141,6 +142,17 @@ def __init__( self._closing = False self.timeout = timeout self.tries = tries + # Serialize concurrent send()/decode_dictentry() calls on this + # channel so that the AES nonce embedded in the encrypted reply is + # matched with the request that produced it. The underlying + # transport (AsyncReqMessageClient) already queues sends FIFO, but + # the channel-layer crypt uses ``self.auth.session_crypticle`` which + # can be swapped mid-flight by a concurrent re-auth, and the master + # encrypts each reply with a session key drawn from a rotating + # cache. Holding this lock across the send + decrypt window makes + # the request/reply pair atomic w.r.t. any other coroutine on the + # same io_loop. See issue #69753. + self._req_lock = salt.ext.tornado.locks.Lock() @property def crypt(self): @@ -152,7 +164,7 @@ def crypt(self): def ttype(self): return self.transport.ttype - def _package_load(self, load, nonce=None): + def _package_load(self, load, nonce=None, session_crypticle=None): """ Prepare the load to be sent over the wire. @@ -160,6 +172,11 @@ def _package_load(self, load, nonce=None): before encrypting it using our aes session key. Then wrap the encrypted load with some meta data. For 'clear' encryption, no extra feilds are added to the load. The unencyrpted load is wrapped with meta data. + + ``session_crypticle`` may be provided to pin a specific Crypticle + reference (needed by ``_crypted_transfer`` so the same key is used + for both dumps and loads across a coroutine yield point). See + issue #69753. """ if self.crypt == "aes": if nonce is None: @@ -191,7 +208,9 @@ def _package_load(self, load, nonce=None): type(load), ) - load = self.auth.session_crypticle.dumps(load) + if session_crypticle is None: + session_crypticle = self.auth.session_crypticle + load = session_crypticle.dumps(load) ret = { "enc": self.crypt, @@ -238,21 +257,25 @@ def crypted_transfer_decode_dictentry( if not self.auth.authenticated: yield self.auth.authenticate() - nonce = uuid.uuid4().hex - ret = yield self._send_with_retry( - self._package_load(load, nonce), - tries, - timeout, - ) - key = self.auth.get_keys() - if "key" not in ret: - # Reauth in the case our key is deleted on the master side. - yield self.auth.authenticate() + # Serialize concurrent transfers on this channel to keep each + # (send, decrypt-reply) pair atomic w.r.t. any other coroutine + # driving this same channel. See issue #69753. + with (yield self._req_lock.acquire()): + nonce = uuid.uuid4().hex ret = yield self._send_with_retry( self._package_load(load, nonce), tries, timeout, ) + key = self.auth.get_keys() + if "key" not in ret: + # Reauth in the case our key is deleted on the master side. + yield self.auth.authenticate() + ret = yield self._send_with_retry( + self._package_load(load, nonce), + tries, + timeout, + ) if not isinstance(ret, dict) or "key" not in ret: # The master is still not returning a usable session key. This # happens when a clustered master defers requests with a @@ -314,10 +337,14 @@ def _crypted_transfer(self, load, timeout, raw=False): @salt.ext.tornado.gen.coroutine def _do_transfer(): + # Pin the session_crypticle reference so a concurrent re-auth + # cannot swap the key between the ``dumps`` on the send path + # and the ``loads`` on the receive path. See issue #69753. + session_crypticle = self.auth.session_crypticle # Yield control to the caller. When send() completes, resume by populating data with the Future.result nonce = uuid.uuid4().hex data = yield self.transport.send( - self._package_load(load, nonce), + self._package_load(load, nonce, session_crypticle=session_crypticle), timeout=timeout, ) # we may not have always data @@ -325,7 +352,7 @@ def _do_transfer(): # communication, we do not subscribe to return events, we just # upload the results to the master if data: - data = self.auth.session_crypticle.loads(data, raw, nonce=nonce) + data = session_crypticle.loads(data, raw, nonce=nonce) if not raw or self.ttype == "tcp": # XXX Why is this needed for tcp data = salt.transport.frame.decode_embedded_strs(data) @@ -334,13 +361,17 @@ def _do_transfer(): if not self.auth.authenticated: # Return control back to the caller, resume when authentication succeeds yield self.auth.authenticate() - try: - # We did not get data back the first time. Retry. - ret = yield _do_transfer() - except salt.crypt.AuthenticationError: - # If auth error, return control back to the caller, continue when authentication succeeds - yield self.auth.authenticate() - ret = yield _do_transfer() + # Serialize concurrent transfers on this channel to keep each + # (send, decrypt-reply) pair atomic w.r.t. any other coroutine + # driving this same channel. See issue #69753. + with (yield self._req_lock.acquire()): + try: + # We did not get data back the first time. Retry. + ret = yield _do_transfer() + except salt.crypt.AuthenticationError: + # If auth error, return control back to the caller, continue when authentication succeeds + yield self.auth.authenticate() + ret = yield _do_transfer() raise salt.ext.tornado.gen.Return(ret) @salt.ext.tornado.gen.coroutine diff --git a/salt/crypt.py b/salt/crypt.py index bbdd8bf8bb09..28e8c55e8c87 100644 --- a/salt/crypt.py +++ b/salt/crypt.py @@ -1775,7 +1775,7 @@ def loads(self, data, raw=False, nonce=None): ret_nonce = data[:32].decode() data = data[32:] if ret_nonce != nonce: - raise SaltClientError("Nonce verification error") + raise SaltClientError(f"Nonce verification error {ret_nonce} {nonce}") payload = salt.payload.loads(data, raw=raw) if isinstance(payload, dict): if "serial" in payload: diff --git a/salt/transport/zeromq.py b/salt/transport/zeromq.py index 1c05ecfc2cb3..a01687548433 100644 --- a/salt/transport/zeromq.py +++ b/salt/transport/zeromq.py @@ -8,6 +8,7 @@ import itertools import logging import os +import secrets import signal import socket import sys @@ -60,6 +61,23 @@ # leaking one per reconnect. _REQ_IDENTITY_SLOT = itertools.count() +# Per-process 24-bit random slot used to disambiguate concurrent salt CLI +# processes claiming the same host/uid/role IDENTITY on the master's ROUTER. +# ``os.getpid() % 256`` -- previously used here -- collides with probability +# ~50% at ~19 concurrent CLIs (birthday bound) and often much sooner in +# practice because the Linux kernel allocates PIDs sequentially: any burst +# of ``salt-call`` from the same shell yields adjacent PIDs whose low byte +# differs but collides again after 256 spawns. Combined with the master's +# ``ROUTER_HANDOVER=1``, a colliding IDENTITY causes in-flight replies +# queued for one CLI to be re-routed to the sibling, decrypting cleanly +# (same session key) but failing the nonce check -- issue #69753. +# 24 bits (~1 in 16.7M collision probability per pair) is more than enough +# to bound the collision odds across any realistic concurrent CLI load +# while preserving the peer-table-bounding benefit of a stable identity +# for the lifetime of the process. Computed once at import time so it is +# stable across ZMQ-level reconnects within the process. +_CLI_IDENTITY_SLOT = secrets.randbits(24) + def _get_master_uri(master_ip, master_port, source_ip=None, source_port=None): """ @@ -691,7 +709,7 @@ def _init_socket(self): role=role, host=socket.gethostname(), uid=uid, - slot=os.getpid() % 256, + slot=_CLI_IDENTITY_SLOT, ) self.socket.setsockopt(zmq.IDENTITY, identity.encode("utf-8")) elif _role in ("minion", "syndic") and _minion_id: @@ -706,9 +724,18 @@ def _init_socket(self): # never reclaims routing-id table entries. On daemon restart # slots replay in construction order and overwrite the prior # master-side entries cleanly. - identity = "salt-req/{role}/{minion_id}/{slot}".format( + # + # Include ``os.getpid()`` so forked minion children (scheduled + # jobs, published-command handlers) each have a distinct + # IDENTITY. Without the pid, two concurrent children inherit + # the parent's ``_REQ_IDENTITY_SLOT`` state and both draw the + # same slot value after fork -- with ``ROUTER_HANDOVER=1`` on + # the master, in-flight replies queued for one child get re- + # routed to the sibling and fail nonce verification (#69753). + identity = "salt-req/{role}/{minion_id}/{pid}/{slot}".format( role=_role, minion_id=_minion_id, + pid=os.getpid(), slot=next(_REQ_IDENTITY_SLOT), ) self.socket.setsockopt(zmq.IDENTITY, identity.encode("utf-8")) diff --git a/tests/pytests/unit/channel/test_client.py b/tests/pytests/unit/channel/test_client.py index b276e73a8385..a45174b80ad3 100644 --- a/tests/pytests/unit/channel/test_client.py +++ b/tests/pytests/unit/channel/test_client.py @@ -50,3 +50,205 @@ def test_async_pub_channel_key_overwritten_by_bad_data(minion_opts, tmp_path): ) with pytest.raises(salt.exceptions.SaltClientError): salt.channel.client.AsyncPubChannel.factory(minion_opts, crypt="aes") + + +class _StubTransport: + """Minimal transport stub whose ``send`` returns pre-canned encrypted replies.""" + + ttype = "zeromq" + + def __init__(self, replies): + # ``replies`` is a list of bytes payloads returned in order. + self._replies = list(replies) + self.sent = [] + + @salt.ext.tornado.gen.coroutine + def send(self, payload, timeout=None): # pylint: disable=unused-argument + self.sent.append(payload) + raise salt.ext.tornado.gen.Return(self._replies.pop(0)) + + +class _StubAuth: + """Auth stub that owns a ``session_crypticle`` we can rotate mid-test.""" + + def __init__(self, opts, session_crypticle): + self.opts = opts + self.session_crypticle = session_crypticle + self.authenticated = True + self.mpub = "master.pub" + + def gen_token(self, clear_tok): # pragma: no cover - unused + return b"" + + @salt.ext.tornado.gen.coroutine + def authenticate(self): # pragma: no cover - unused + raise salt.ext.tornado.gen.Return(None) + + +def _make_channel(minion_opts, tmp_path, transport, auth): + import salt.ext.tornado.ioloop + + minion_opts["pki_dir"] = str(tmp_path) + minion_opts["id"] = "minion" + minion_opts["master_uri"] = "tcp://127.0.0.1:4506" + minion_opts.setdefault("minion_sign_messages", False) + return salt.channel.client.AsyncReqChannel( + minion_opts, transport, auth, timeout=1, tries=1 + ) + + +def test_do_transfer_reauth_mid_flight_uses_same_crypticle(minion_opts, tmp_path): + """ + Regression for issue #69753: if ``self.auth.session_crypticle`` is + swapped between the ``dumps`` on the send path and the ``loads`` on the + receive path of ``_do_transfer``, the fixed code must still decrypt + with the crypticle that produced the outbound nonce. + """ + import salt.ext.tornado.gen + import salt.ext.tornado.ioloop + + old_key = salt.crypt.Crypticle.generate_key_string() + new_key = salt.crypt.Crypticle.generate_key_string() + + # Master encrypts its reply with the *old* crypticle (the one that + # was in place when the request went out). + master_old = salt.crypt.Crypticle(minion_opts, old_key) + # This is the reply the master would send, keyed to the request's + # nonce (fake, but we can intercept it below). + nonce_holder = {} + + class _CapturingTransport(_StubTransport): + @salt.ext.tornado.gen.coroutine + def send(self, payload, timeout=None): + self.sent.append(payload) + # Extract the actual nonce the channel used by decrypting the + # outbound load with the *old* key (which must have been used + # to encrypt it). + outer = ( + salt.payload.loads(payload) if isinstance(payload, bytes) else payload + ) + enc_load = outer["load"] + decrypted = master_old.loads(enc_load) + nonce_holder["nonce"] = decrypted["nonce"] + reply = master_old.dumps({"result": "ok"}, nonce=decrypted["nonce"]) + raise salt.ext.tornado.gen.Return(reply) + + minion_old = salt.crypt.Crypticle(minion_opts, old_key) + minion_new = salt.crypt.Crypticle(minion_opts, new_key) + auth = _StubAuth(minion_opts, minion_old) + transport = _CapturingTransport([]) + + channel = _make_channel(minion_opts, tmp_path, transport, auth) + + io_loop = salt.ext.tornado.ioloop.IOLoop() + + @salt.ext.tornado.gen.coroutine + def _drive(): + # Simulate a concurrent re-auth: swap in a *new* session_crypticle + # after the send path pinned the reference but before the reply + # is decrypted. With the fix, _do_transfer must use the pinned + # (old) crypticle for both dumps and loads; without it, loads + # would use the new one and raise AuthenticationError (HMAC). + original_transport_send = transport.send + + @salt.ext.tornado.gen.coroutine + def _rotate_and_send(payload, timeout=None): + reply = yield original_transport_send(payload, timeout=timeout) + # Rotate the auth mid-flight. + auth.session_crypticle = minion_new + raise salt.ext.tornado.gen.Return(reply) + + transport.send = _rotate_and_send + result = yield channel._crypted_transfer({"cmd": "test"}, timeout=1) + raise salt.ext.tornado.gen.Return(result) + + try: + result = io_loop.run_sync(_drive) + finally: + io_loop.close(all_fds=True) + + assert result == {"result": "ok"} + assert nonce_holder["nonce"] # sanity: request had a nonce + + +def test_do_transfer_serialized_by_lock(minion_opts, tmp_path): + """ + Regression for issue #69753: two concurrent ``_crypted_transfer`` + calls on the same channel must not overlap. We assert the second + call's send does not begin until the first call's reply has been + decrypted. + """ + import salt.ext.tornado.gen + import salt.ext.tornado.ioloop + + key = salt.crypt.Crypticle.generate_key_string() + master = salt.crypt.Crypticle(minion_opts, key) + minion = salt.crypt.Crypticle(minion_opts, key) + + events = [] + gate = salt.ext.tornado.concurrent.Future() + + class _OrderingTransport(_StubTransport): + def __init__(self): + super().__init__([]) + self.call = 0 + + @salt.ext.tornado.gen.coroutine + def send(self, payload, timeout=None): + self.call += 1 + events.append(f"send-start-{self.call}") + outer = ( + salt.payload.loads(payload) if isinstance(payload, bytes) else payload + ) + enc_load = outer["load"] + decrypted = master.loads(enc_load) + nonce = decrypted["nonce"] + reply = master.dumps({"n": self.call}, nonce=nonce) + if self.call == 1: + # Suspend the first send until the second send starts + # (would-be race) -- with the lock, the second send + # cannot begin, so this future is completed by the test + # driver after a small delay via io_loop.call_later. + yield gate + events.append(f"send-end-{self.call}") + raise salt.ext.tornado.gen.Return(reply) + + auth = _StubAuth(minion_opts, minion) + transport = _OrderingTransport() + channel = _make_channel(minion_opts, tmp_path, transport, auth) + + io_loop = salt.ext.tornado.ioloop.IOLoop() + + @salt.ext.tornado.gen.coroutine + def _drive(): + # Fire both transfers "concurrently". Under the lock, transfer2 + # must wait for transfer1 to fully finish (including decrypt) + # before its send even begins. + fut1 = channel._crypted_transfer({"cmd": "one"}, timeout=5) + fut2 = channel._crypted_transfer({"cmd": "two"}, timeout=5) + + def _release(): + if not gate.done(): + gate.set_result(None) + + io_loop.call_later(0.05, _release) + r1 = yield fut1 + r2 = yield fut2 + raise salt.ext.tornado.gen.Return((r1, r2)) + + try: + r1, r2 = io_loop.run_sync(_drive) + finally: + io_loop.close(all_fds=True) + + # With the lock, ordering must be: send-start-1, send-end-1, + # send-start-2, send-end-2. If the lock is missing, we'd see + # send-start-1, send-start-2, send-end-1, send-end-2. + assert events == [ + "send-start-1", + "send-end-1", + "send-start-2", + "send-end-2", + ], events + assert r1 == {"n": 1} + assert r2 == {"n": 2} diff --git a/tests/pytests/unit/transport/test_zeromq.py b/tests/pytests/unit/transport/test_zeromq.py index e9c8d59fb592..73afcfee541c 100644 --- a/tests/pytests/unit/transport/test_zeromq.py +++ b/tests/pytests/unit/transport/test_zeromq.py @@ -13,6 +13,7 @@ import msgpack import pytest +import zmq import zmq.eventloop.future import salt.channel.client @@ -2337,3 +2338,65 @@ def test_req_server_auth_garbage_enc_algo(pki_dir, minion_opts, master_opts, cap server.event.destroy() except ValueError: pass + + +def test_cli_identity_slot_is_wide_enough_to_avoid_pid_collisions(): + """ + Regression test for #69753. + + The CLI-mode ZMQ IDENTITY slot must be wide enough that two concurrent + ``salt-call`` processes do not claim the same routing-id on the master's + ROUTER (``ROUTER_HANDOVER=1``). Previously the slot was + ``os.getpid() % 256`` -- 8 bits -- which collides trivially under bursty + CLI load (adjacent PIDs mod 256 wrap after 256 spawns, and the birthday + bound gives ~50% collision odds at ~19 concurrent CLIs). + + The slot must: + + * be stable across ZMQ-level reconnects within one process (so libzmq's + peer-table entry is reused instead of leaked), i.e. cached at import + time rather than recomputed per socket, and + * be at least 24 bits wide so a realistic concurrent CLI fleet does not + hit the birthday bound. + """ + slot = salt.transport.zeromq._CLI_IDENTITY_SLOT + assert isinstance(slot, int) + assert 0 <= slot < 2**24 + # Import-time cached: two accesses return the same value. + assert slot == salt.transport.zeromq._CLI_IDENTITY_SLOT + + +def test_minion_daemon_identity_includes_pid_to_disambiguate_forks(minion_opts): + """ + Regression test for #69753. + + The minion / syndic daemon branch of ``_init_socket`` uses a + process-lifetime ``itertools.count`` counter to hand each + ``AsyncReqMessageClient`` a distinct slot for its ZMQ IDENTITY. When + the minion daemon forks a child (scheduled job, published-command + handler) the child inherits the counter's current state -- so two + concurrent forked children calling ``next(_REQ_IDENTITY_SLOT)`` for the + first time BOTH get the same slot value. Combined with + ``ROUTER_HANDOVER=1`` on the master's ROUTER, in-flight replies for + one child are re-routed to the sibling and fail nonce verification. + + Fix: the daemon-branch IDENTITY must include ``os.getpid()`` so forked + children are disambiguated by pid even when they draw the same slot + number. + """ + opts = dict(minion_opts) + opts["__role"] = "minion" + opts["id"] = "test-minion" + client = salt.transport.zeromq.AsyncReqMessageClient(opts, "tcp://127.0.0.1:4506") + try: + client.connect() + ident = client.socket.getsockopt(zmq.IDENTITY).decode("utf-8") + # Format: salt-req/minion/// + parts = ident.split("/") + assert parts[0] == "salt-req" + assert parts[1] == "minion" + assert parts[2] == "test-minion" + assert parts[3] == str(os.getpid()) + assert parts[4].isdigit() + finally: + client.close() From e1e03c0452b721240e8689499cacf8d85cb30975 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 24 Jul 2026 17:13:43 -0400 Subject: [PATCH 135/469] Exit non-zero from batch mode when no minions match (#69749) In batch mode the salt CLI computed its exit code by iterating batch.run(), which yields nothing when the target matches no minions (Batch.run returns early on an empty minion list). retcode stayed 0 and the CLI exited 0, while the non-batch path exits 2 ("No return received") for the same case (#57357). Mirror the non-batch behavior: when no minions matched, print "No return received" and exit 2. Fixes #57357 --- changelog/57357.fixed.md | 1 + salt/cli/salt.py | 6 +++ tests/pytests/unit/cli/test_salt.py | 66 +++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 changelog/57357.fixed.md create mode 100644 tests/pytests/unit/cli/test_salt.py diff --git a/changelog/57357.fixed.md b/changelog/57357.fixed.md new file mode 100644 index 000000000000..4da722138887 --- /dev/null +++ b/changelog/57357.fixed.md @@ -0,0 +1 @@ +Fixed the ``salt`` CLI exiting 0 in batch mode when the target matched no minions; it now exits 2 ("No return received"), matching the non-batch behavior. diff --git a/salt/cli/salt.py b/salt/cli/salt.py index 9c28e5de3d62..29bd6a29aadb 100644 --- a/salt/cli/salt.py +++ b/salt/cli/salt.py @@ -292,6 +292,12 @@ def _run_batch(self): if job_retcode > retcode: # Exit with the highest retcode we find retcode = job_retcode + if not batch.minions: + # No minions matched the target. Mirror the non-batch CLI, + # which prints "No return received" and exits 2 rather than + # silently exiting 0 (#57357). + sys.stderr.write("ERROR: No return received\n") + sys.exit(2) sys.exit(retcode) def _print_errors_summary(self, errors): diff --git a/tests/pytests/unit/cli/test_salt.py b/tests/pytests/unit/cli/test_salt.py new file mode 100644 index 000000000000..8df2a2124b81 --- /dev/null +++ b/tests/pytests/unit/cli/test_salt.py @@ -0,0 +1,66 @@ +""" +Unit tests for the salt CLI (salt.cli.salt.SaltCMD). +""" + +import pytest + +from salt.cli.salt import SaltCMD +from tests.support.mock import MagicMock, patch + + +def _fake_saltcmd(): + """ + A stand-in SaltCMD self with just the attributes _run_batch's non-static + branch touches, so the method can be exercised without full CLI parsing. + """ + fake = MagicMock() + fake.config = {} + fake.options.eauth = "" + fake.options.static = False + fake.options.batch = "100%" + return fake + + +def _run_batch_exit_code(fake_batch): + fake = _fake_saltcmd() + with patch("salt.cli.batch.Batch", return_value=fake_batch): + with pytest.raises(SystemExit) as exc: + SaltCMD._run_batch(fake) + return exc.value.code + + +def test_run_batch_no_minions_exits_nonzero(): + """ + Regression test for #57357. + + When a batch run matches zero minions, ``batch.run()`` yields nothing. The + CLI must exit non-zero -- matching the non-batch path, which prints + "No return received" and exits 2 -- instead of silently exiting 0. Pins the + bug: before the fix the empty loop leaves ``retcode=0`` and the CLI exits 0. + """ + fake_batch = MagicMock() + fake_batch.run.return_value = iter([]) + fake_batch.minions = [] + assert _run_batch_exit_code(fake_batch) == 2 + + +def test_run_batch_matched_minions_uses_highest_job_retcode(): + """ + Inverse of #57357: when minions match, the exit code is the highest job + retcode seen, and the no-return path is not taken. + """ + fake_batch = MagicMock() + fake_batch.run.return_value = iter([({"m1": {}}, 0), ({"m2": {}}, 3)]) + fake_batch.minions = ["m1", "m2"] + assert _run_batch_exit_code(fake_batch) == 3 + + +def test_run_batch_matched_minions_success_exits_zero(): + """ + A successful batch that matched minions still exits 0 -- the fix must not + regress the normal path. + """ + fake_batch = MagicMock() + fake_batch.run.return_value = iter([({"m1": {}}, 0)]) + fake_batch.minions = ["m1"] + assert _run_batch_exit_code(fake_batch) == 0 From 9287b8d3ead3a891070e04e8fe55c126bfee8f35 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 26 Jul 2026 04:58:27 -0700 Subject: [PATCH 136/469] Remove tests for community-extension modules purged in 3007.x test_dockercompose.py and test_keystone_role_grant.py came forward from 3006.x, but their source modules were purged from 3007.x by dc526dc2b17 'Initial purge of community extensions'. pylint E0611 fails on the now-broken imports. --- .../unit/modules/test_dockercompose.py | 363 ------------------ .../unit/states/test_keystone_role_grant.py | 151 -------- 2 files changed, 514 deletions(-) delete mode 100644 tests/pytests/unit/modules/test_dockercompose.py delete mode 100644 tests/pytests/unit/states/test_keystone_role_grant.py diff --git a/tests/pytests/unit/modules/test_dockercompose.py b/tests/pytests/unit/modules/test_dockercompose.py deleted file mode 100644 index 6620fc6125de..000000000000 --- a/tests/pytests/unit/modules/test_dockercompose.py +++ /dev/null @@ -1,363 +0,0 @@ -""" -Unit tests for salt.modules.dockercompose - -Tests cover the file-management functions that do not require a running -Docker daemon, verifying the YAML read/write/parse logic and the service -definition helpers. The python_on_whales / legacy-compose import paths are -controlled via patched module-level booleans so the tests run without either -library installed. - -The ``__load_project_from_file_path`` private helper is mocked throughout -because it is the only code path that actually needs a Docker daemon or the -python_on_whales library. -""" - -import os -import textwrap - -import pytest - -import salt.modules.dockercompose as dockercompose -from tests.support.mock import MagicMock, patch - -# --------------------------------------------------------------------------- -# helpers -# --------------------------------------------------------------------------- - -SIMPLE_COMPOSE = textwrap.dedent( - """\ - version: '3' - services: - web: - image: nginx:latest - db: - image: postgres:14 - """ -) - -# Sentinel object returned by mocked __load_project_from_file_path. -# Any non-dict value satisfies the ``isinstance(project, dict)`` guard -# used throughout the module. -FAKE_PROJECT = MagicMock(name="fake_docker_project") - -# Full dotted path to the private helper that touches the Docker daemon. -_LOAD_PROJECT_PATH = ( - "salt.modules.dockercompose._DockerCompose__load_project_from_file_path" -) -# The helper is a module-level function accessed via the dunder-mangled name -# inside the module; we need the actual attribute name as seen from outside. -_LOAD_PROJECT_ATTR = "salt.modules.dockercompose.__load_project_from_file_path" - - -def _patch_project(return_value=FAKE_PROJECT): - """Return a context-manager that replaces __load_project_from_file_path.""" - # The function is a plain module-level function (not a class method), so - # patch it by its public module path. - return patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=return_value, - create=True, - ) - - -@pytest.fixture -def configure_loader_modules(): - return {dockercompose: {}} - - -# --------------------------------------------------------------------------- -# __virtual__ tests -# --------------------------------------------------------------------------- - - -def test_virtual_loads_with_python_on_whales(): - with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", True): - result = dockercompose.__virtual__() - assert result == "dockercompose" - - -def test_virtual_loads_with_legacy_compose(): - compose_mock = MagicMock() - compose_mock.__version__ = "1.29.0" - with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", False): - with patch.object(dockercompose, "HAS_DOCKERCOMPOSE", True): - with patch.object(dockercompose, "compose", compose_mock, create=True): - result = dockercompose.__virtual__() - assert result == "dockercompose" - - -def test_virtual_fails_without_either_library(): - with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", False): - with patch.object(dockercompose, "HAS_DOCKERCOMPOSE", False): - result = dockercompose.__virtual__() - assert result is not True - assert isinstance(result, tuple) - assert result[0] is False - - -# --------------------------------------------------------------------------- -# _use_python_on_whales opt-in gate tests -# --------------------------------------------------------------------------- - - -def test_use_python_on_whales_defaults_to_false(): - """Default behaviour: config flag unset → legacy backend, even if library present.""" - salt_dunder = {"config.get": MagicMock(return_value=False)} - with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", True): - with patch.dict(dockercompose.__salt__, salt_dunder, clear=True): - assert dockercompose._use_python_on_whales() is False - salt_dunder["config.get"].assert_called_once_with( - "dockercompose:use_python_on_whales", False - ) - - -def test_use_python_on_whales_opt_in_true(): - """Flag set + library installed → v2 backend selected.""" - salt_dunder = {"config.get": MagicMock(return_value=True)} - with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", True): - with patch.dict(dockercompose.__salt__, salt_dunder, clear=True): - assert dockercompose._use_python_on_whales() is True - - -def test_use_python_on_whales_flag_set_but_library_missing_falls_back(caplog): - """Flag set but python_on_whales missing → warn and fall back to legacy.""" - import logging - - salt_dunder = {"config.get": MagicMock(return_value=True)} - with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", False): - with patch.dict(dockercompose.__salt__, salt_dunder, clear=True): - with caplog.at_level(logging.WARNING, logger="salt.modules.dockercompose"): - assert dockercompose._use_python_on_whales() is False - assert any( - "python_on_whales" in rec.message and "falling back" in rec.message - for rec in caplog.records - ) - - -def test_use_python_on_whales_library_present_flag_unset(): - """python_on_whales installed but flag unset → legacy backend (opt-in only).""" - salt_dunder = {"config.get": MagicMock(return_value=False)} - with patch.object(dockercompose, "HAS_PYTHON_ON_WHALES", True): - with patch.dict(dockercompose.__salt__, salt_dunder, clear=True): - assert dockercompose._use_python_on_whales() is False - - -# --------------------------------------------------------------------------- -# create() tests -# --------------------------------------------------------------------------- - - -def test_create_with_valid_content(tmp_path): - """create() writes the compose file and reports success.""" - dest = str(tmp_path) - with patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=FAKE_PROJECT, - create=True, - ): - result = dockercompose.create(dest, SIMPLE_COMPOSE) - assert result["status"] is True - assert "Successfully created" in result["message"] - written = os.path.join(dest, "docker-compose.yml") - assert os.path.isfile(written) - - -def test_create_with_empty_content(): - """create() returns a failure when no content is supplied.""" - result = dockercompose.create("/some/path", "") - assert result["status"] is False - assert "valid docker-compose file" in result["message"] - - -# --------------------------------------------------------------------------- -# get() tests -# --------------------------------------------------------------------------- - - -def test_get_returns_file_contents(tmp_path): - """get() returns the raw compose YAML when the file exists and is valid.""" - compose_file = tmp_path / "docker-compose.yml" - compose_file.write_text(SIMPLE_COMPOSE) - - with patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=FAKE_PROJECT, - create=True, - ): - result = dockercompose.get(str(tmp_path)) - - assert result["status"] is True - assert "docker-compose.yml" in result["return"] - - -def test_get_returns_failure_for_missing_path(tmp_path): - """get() returns a failure when the path has no compose file.""" - result = dockercompose.get(str(tmp_path / "nonexistent")) - assert result["status"] is False - - -# --------------------------------------------------------------------------- -# service_create() tests -# --------------------------------------------------------------------------- - - -def test_service_create_adds_new_service(tmp_path): - """service_create() adds a new service definition to the compose file.""" - compose_file = tmp_path / "docker-compose.yml" - compose_file.write_text(SIMPLE_COMPOSE) - definition = "image: redis:7\nports:\n - '6379:6379'\n" - - with patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=FAKE_PROJECT, - create=True, - ): - result = dockercompose.service_create(str(tmp_path), "cache", definition) - - assert result["status"] is True - assert "cache" in result["message"] - content = compose_file.read_text() - assert "cache" in content - assert "redis" in content - - -def test_service_create_rejects_duplicate(tmp_path): - """service_create() fails when the service already exists.""" - compose_file = tmp_path / "docker-compose.yml" - compose_file.write_text(SIMPLE_COMPOSE) - - with patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=FAKE_PROJECT, - create=True, - ): - result = dockercompose.service_create( - str(tmp_path), "web", "image: nginx:alpine" - ) - - assert result["status"] is False - assert "already exists" in result["message"] - - -# --------------------------------------------------------------------------- -# service_upsert() tests -# --------------------------------------------------------------------------- - - -def test_service_upsert_adds_service(tmp_path): - """service_upsert() adds a service that does not yet exist.""" - compose_file = tmp_path / "docker-compose.yml" - compose_file.write_text(SIMPLE_COMPOSE) - - with patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=FAKE_PROJECT, - create=True, - ): - result = dockercompose.service_upsert( - str(tmp_path), "queue", "image: rabbitmq:3" - ) - - assert result["status"] is True - content = compose_file.read_text() - assert "queue" in content - - -# --------------------------------------------------------------------------- -# service_remove() tests -# --------------------------------------------------------------------------- - - -def test_service_remove_deletes_existing_service(tmp_path): - """service_remove() removes an existing service from the compose file.""" - compose_file = tmp_path / "docker-compose.yml" - compose_file.write_text(SIMPLE_COMPOSE) - - with patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=FAKE_PROJECT, - create=True, - ): - result = dockercompose.service_remove(str(tmp_path), "db") - - assert result["status"] is True - content = compose_file.read_text() - assert "db:" not in content - assert "web:" in content - - -def test_service_remove_rejects_missing_service(tmp_path): - """service_remove() fails gracefully when the service does not exist.""" - compose_file = tmp_path / "docker-compose.yml" - compose_file.write_text(SIMPLE_COMPOSE) - - with patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=FAKE_PROJECT, - create=True, - ): - result = dockercompose.service_remove(str(tmp_path), "nonexistent") - - assert result["status"] is False - assert "did not exists" in result["message"] - - -# --------------------------------------------------------------------------- -# service_set_tag() tests -# --------------------------------------------------------------------------- - - -def test_service_set_tag_updates_image_tag(tmp_path): - """service_set_tag() replaces the image tag for the named service.""" - compose_file = tmp_path / "docker-compose.yml" - compose_file.write_text(SIMPLE_COMPOSE) - - with patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=FAKE_PROJECT, - create=True, - ): - result = dockercompose.service_set_tag(str(tmp_path), "web", "1.25") - - assert result["status"] is True - content = compose_file.read_text() - assert "nginx:1.25" in content - - -def test_service_set_tag_fails_for_missing_service(tmp_path): - """service_set_tag() returns failure when the service is not found.""" - compose_file = tmp_path / "docker-compose.yml" - compose_file.write_text(SIMPLE_COMPOSE) - - with patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=FAKE_PROJECT, - create=True, - ): - result = dockercompose.service_set_tag(str(tmp_path), "ghost", "1.0") - - assert result["status"] is False - - -def test_service_set_tag_fails_for_service_without_image(tmp_path): - """service_set_tag() returns failure when the service has no 'image' key.""" - compose_content = textwrap.dedent( - """\ - version: '3' - services: - builder: - build: . - """ - ) - compose_file = tmp_path / "docker-compose.yml" - compose_file.write_text(compose_content) - - with patch( - "salt.modules.dockercompose.__load_project_from_file_path", - return_value=FAKE_PROJECT, - create=True, - ): - result = dockercompose.service_set_tag(str(tmp_path), "builder", "2.0") - - assert result["status"] is False - assert "image" in result["message"] diff --git a/tests/pytests/unit/states/test_keystone_role_grant.py b/tests/pytests/unit/states/test_keystone_role_grant.py deleted file mode 100644 index a9d555afc84e..000000000000 --- a/tests/pytests/unit/states/test_keystone_role_grant.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -Test cases for salt.states.keystone_role_grant -""" - -import pytest - -import salt.states.keystone_role_grant as keystone_role_grant -from tests.support.mock import MagicMock, patch - - -@pytest.fixture -def configure_loader_modules(): - return {keystone_role_grant: {}} - - -def _base_salt_dunder(**overrides): - role = MagicMock() - role.id = "role-id" - salt_dunder = { - "keystoneng.setup_clouds": MagicMock(), - "keystoneng.role_get": MagicMock(return_value=role), - "keystoneng.role_grant": MagicMock(), - "keystoneng.role_revoke": MagicMock(), - } - salt_dunder.update(overrides) - return salt_dunder - - -def test_present_test_mode_does_not_grant(): - """ - In test=True mode present() must not call role_grant and must - report result=None with predicted changes. - """ - salt_dunder = _base_salt_dunder() - salt_dunder["keystoneng.role_assignment_list"] = MagicMock(return_value=[]) - - with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( - keystone_role_grant.__opts__, {"test": True} - ): - ret = keystone_role_grant.present("myrole") - - assert salt_dunder["keystoneng.role_grant"].call_count == 0 - assert ret["result"] is None - assert ret["changes"] == {"role": "role-id"} - assert ret["comment"] == "Role assignment would be granted" - - -def test_absent_test_mode_does_not_revoke(): - """ - In test=True mode absent() must not call role_revoke and must - report result=None with predicted changes. - """ - salt_dunder = _base_salt_dunder() - salt_dunder["keystoneng.role_assignment_list"] = MagicMock( - return_value=["existing-grant"] - ) - - with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( - keystone_role_grant.__opts__, {"test": True} - ): - ret = keystone_role_grant.absent("myrole") - - assert salt_dunder["keystoneng.role_revoke"].call_count == 0 - assert ret["result"] is None - assert ret["changes"] == {"role": "role-id"} - assert ret["comment"] == "Role assignment would be revoked" - - -def test_present_real_mode_still_grants_52220(): - """ - Guards against overcorrection: with test=False (the state compiler's - default __opts__["test"] value on a real run) present() must still - call role_grant exactly as before the test-mode fix. - """ - salt_dunder = _base_salt_dunder() - salt_dunder["keystoneng.role_assignment_list"] = MagicMock(return_value=[]) - - with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( - keystone_role_grant.__opts__, {"test": False} - ): - ret = keystone_role_grant.present("myrole") - - assert salt_dunder["keystoneng.role_grant"].call_count == 1 - assert ret["result"] is True - assert ret["changes"] == {"role": "role-id"} - assert ret["comment"] == "Granted role assignment" - - -def test_absent_real_mode_still_revokes_52220(): - """ - Guards against overcorrection: with test=False absent() must still - call role_revoke exactly as before the test-mode fix. - """ - salt_dunder = _base_salt_dunder() - salt_dunder["keystoneng.role_assignment_list"] = MagicMock( - return_value=["existing-grant"] - ) - - with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( - keystone_role_grant.__opts__, {"test": False} - ): - ret = keystone_role_grant.absent("myrole") - - assert salt_dunder["keystoneng.role_revoke"].call_count == 1 - assert ret["result"] is True - assert ret["changes"] == {"role": "role-id"} - assert ret["comment"] == "Revoked role assignment" - - -def test_present_test_mode_no_changes_when_grant_exists_52220(): - """ - Guards against overcorrection: in test=True mode, when the role - assignment already exists, present() must keep reporting result=True - with no changes rather than a phantom pending change. - """ - salt_dunder = _base_salt_dunder() - salt_dunder["keystoneng.role_assignment_list"] = MagicMock( - return_value=["existing-grant"] - ) - - # test=True is the decisive flag; the no-grants branch must not run - with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( - keystone_role_grant.__opts__, {"test": True} - ): - ret = keystone_role_grant.present("myrole") - - assert salt_dunder["keystoneng.role_grant"].call_count == 0 - assert ret["result"] is True - assert ret["changes"] == {} - assert ret["comment"] == "" - - -def test_absent_test_mode_no_changes_when_no_grant_52220(): - """ - Guards against overcorrection: in test=True mode, when no role - assignment exists, absent() must keep reporting result=True with no - changes rather than a phantom pending change. - """ - salt_dunder = _base_salt_dunder() - salt_dunder["keystoneng.role_assignment_list"] = MagicMock(return_value=[]) - - # test=True is the decisive flag; the grants-exist branch must not run - with patch.dict(keystone_role_grant.__salt__, salt_dunder), patch.dict( - keystone_role_grant.__opts__, {"test": True} - ): - ret = keystone_role_grant.absent("myrole") - - assert salt_dunder["keystoneng.role_revoke"].call_count == 0 - assert ret["result"] is True - assert ret["changes"] == {} - assert ret["comment"] == "" From a9aa0bd6b318dc195e644c1aac58ad2f46108d27 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 26 Jul 2026 08:00:36 -0400 Subject: [PATCH 137/469] Silence spurious ERROR log from zypperpkg search on exit code 104 (#69758) zypper returns exit code 104 when a search matches nothing. The search-style calls in zypperpkg (search, _get_visible_patterns, _get_patches, and Wildcard._get_available_versions) run through cmd.run_all, whose default success_retcodes is [0], so the 104 was logged as a command failure even though the search itself completed normally and correctly reported that nothing was found. Add an ignore_not_found option to the _Zypper wrapper that whitelists 104 via success_retcodes for those search calls, so the exit code is treated as success and no ERROR is emitted. The found case (0) and all genuine error codes are left untouched. Fixes #58551 --- changelog/58551.fixed.md | 1 + salt/modules/zypperpkg.py | 25 +++++-- tests/unit/modules/test_zypperpkg.py | 106 ++++++++++++++++++++++++--- 3 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 changelog/58551.fixed.md diff --git a/changelog/58551.fixed.md b/changelog/58551.fixed.md new file mode 100644 index 000000000000..3529ab1b5d24 --- /dev/null +++ b/changelog/58551.fixed.md @@ -0,0 +1 @@ +Stopped zypperpkg search functions from logging a spurious ERROR when zypper exits with code 104 (nothing found); the 104 exit code is now whitelisted for search-style calls. diff --git a/salt/modules/zypperpkg.py b/salt/modules/zypperpkg.py index dd580963ec8f..c4720a7f4f32 100644 --- a/salt/modules/zypperpkg.py +++ b/salt/modules/zypperpkg.py @@ -102,6 +102,7 @@ class _Zypper: } LOCK_EXIT_CODE = 7 + NOT_FOUND_EXIT_CODE = 104 XML_DIRECTIVES = ["-x", "--xmlout"] # ZYPPER_LOCK is not affected by --root ZYPPER_LOCK = "/var/run/zypp.pid" @@ -133,6 +134,7 @@ def _reset(self): self.__no_raise = False self.__refresh = False self.__ignore_repo_failure = False + self.__ignore_not_found = False self.__systemd_scope = False self.__root = None @@ -152,6 +154,9 @@ def __call__(self, *args, **kwargs): # Ignore exit code for 106 (repo is not available) if "no_repo_failure" in kwargs: self.__ignore_repo_failure = kwargs["no_repo_failure"] + # Ignore exit code for 104 (package not found) + if "ignore_not_found" in kwargs: + self.__ignore_not_found = kwargs["ignore_not_found"] if "systemd_scope" in kwargs: self.__systemd_scope = kwargs["systemd_scope"] if "root" in kwargs: @@ -332,6 +337,10 @@ def __call(self, *args, **kwargs): if self.__root: self.__cmd.extend(["--root", self.__root]) + # Do not consider 104 (nothing found) as a retcode error + if self.__ignore_not_found: + kwargs["success_retcodes"] = [_Zypper.NOT_FOUND_EXIT_CODE] + self.__cmd.extend(args) kwargs["output_loglevel"] = "trace" kwargs["python_shell"] = False @@ -476,9 +485,11 @@ def _get_available_versions(self): Get available versions of the package. :return: """ - solvables = self.zypper.nolock.xml.call( - "se", "-xv", self.name - ).getElementsByTagName("solvable") + solvables = ( + self.zypper(ignore_not_found=True) + .nolock.xml.call("se", "-xv", self.name) + .getElementsByTagName("solvable") + ) if not solvables: raise CommandExecutionError(f"No packages found matching '{self.name}'") @@ -2535,7 +2546,9 @@ def owner(*paths, **kwargs): def _get_visible_patterns(root=None): """Get all available patterns in the repo that are visible.""" patterns = {} - search_patterns = __zypper__(root=root).nolock.xml.call("se", "-t", "pattern") + search_patterns = __zypper__(root=root, ignore_not_found=True).nolock.xml.call( + "se", "-t", "pattern" + ) for element in search_patterns.getElementsByTagName("solvable"): installed = element.getAttribute("status") == "installed" patterns[element.getAttribute("name")] = { @@ -2732,7 +2745,7 @@ def search(criteria, refresh=False, **kwargs): cmd.append(criteria) solvables = ( - __zypper__(root=root) + __zypper__(root=root, ignore_not_found=True) .nolock.noraise.xml.call(*cmd) .getElementsByTagName("solvable") ) @@ -2984,7 +2997,7 @@ def _get_patches(installed_only=False, root=None): """ patches = {} for element in ( - __zypper__(root=root) + __zypper__(root=root, ignore_not_found=True) .nolock.xml.call("se", "-t", "patch") .getElementsByTagName("solvable") ): diff --git a/tests/unit/modules/test_zypperpkg.py b/tests/unit/modules/test_zypperpkg.py index ec096214eeaf..b9b152a65ae8 100644 --- a/tests/unit/modules/test_zypperpkg.py +++ b/tests/unit/modules/test_zypperpkg.py @@ -27,7 +27,10 @@ def __getattr__(self, item): def __call__(self, *args, **kwargs): # If the call is for a configuration modifier, we return self - if any(i in kwargs for i in ("no_repo_failure", "systemd_scope", "root")): + if any( + i in kwargs + for i in ("no_repo_failure", "ignore_not_found", "systemd_scope", "root") + ): return self return MagicMock(return_value=self.__return_value)() @@ -1662,7 +1665,9 @@ def test_wildcard_to_query_match_all(self): """ _zpr = MagicMock() - _zpr.nolock.xml.call = MagicMock(return_value=minidom.parseString(xmldoc)) + # _get_available_versions now calls self.zypper(ignore_not_found=True) + # before .nolock.xml.call, so configure the return_value chain. + _zpr.return_value.nolock.xml.call.return_value = minidom.parseString(xmldoc) wcard = zypper.Wildcard(_zpr) wcard.name, wcard.version = "libzypp", "*" assert wcard._get_scope_versions(wcard._get_available_versions()) == [ @@ -1685,7 +1690,9 @@ def test_wildcard_to_query_multiple_asterisk(self): """ _zpr = MagicMock() - _zpr.nolock.xml.call = MagicMock(return_value=minidom.parseString(xmldoc)) + # _get_available_versions now calls self.zypper(ignore_not_found=True) + # before .nolock.xml.call, so configure the return_value chain. + _zpr.return_value.nolock.xml.call.return_value = minidom.parseString(xmldoc) wcard = zypper.Wildcard(_zpr) wcard.name, wcard.version = "libzypp", "16.2.*-2*" assert wcard._get_scope_versions(wcard._get_available_versions()) == [ @@ -1707,7 +1714,9 @@ def test_wildcard_to_query_exact_match_at_end(self): """ _zpr = MagicMock() - _zpr.nolock.xml.call = MagicMock(return_value=minidom.parseString(xmldoc)) + # _get_available_versions now calls self.zypper(ignore_not_found=True) + # before .nolock.xml.call, so configure the return_value chain. + _zpr.return_value.nolock.xml.call.return_value = minidom.parseString(xmldoc) wcard = zypper.Wildcard(_zpr) wcard.name, wcard.version = "libzypp", "16.2.5*" assert wcard._get_scope_versions(wcard._get_available_versions()) == [ @@ -1728,7 +1737,9 @@ def test_wildcard_to_query_exact_match_at_beginning(self): """ _zpr = MagicMock() - _zpr.nolock.xml.call = MagicMock(return_value=minidom.parseString(xmldoc)) + # _get_available_versions now calls self.zypper(ignore_not_found=True) + # before .nolock.xml.call, so configure the return_value chain. + _zpr.return_value.nolock.xml.call.return_value = minidom.parseString(xmldoc) wcard = zypper.Wildcard(_zpr) wcard.name, wcard.version = "libzypp", "*.1" assert wcard._get_scope_versions(wcard._get_available_versions()) == [ @@ -1750,7 +1761,9 @@ def test_wildcard_to_query_usage(self): """ _zpr = MagicMock() - _zpr.nolock.xml.call = MagicMock(return_value=minidom.parseString(xmldoc)) + # _get_available_versions now calls self.zypper(ignore_not_found=True) + # before .nolock.xml.call, so configure the return_value chain. + _zpr.return_value.nolock.xml.call.return_value = minidom.parseString(xmldoc) assert zypper.Wildcard(_zpr)("libzypp", "16.2.4*") == "16.2.4-19.5" assert zypper.Wildcard(_zpr)("libzypp", "16.2*") == "16.2.5-25.1" assert zypper.Wildcard(_zpr)("libzypp", "*6-*") == "17.2.6-27.9.1" @@ -1770,7 +1783,9 @@ def test_wildcard_to_query_noversion(self): """ _zpr = MagicMock() - _zpr.nolock.xml.call = MagicMock(return_value=minidom.parseString(xmldoc)) + # _get_available_versions now calls self.zypper(ignore_not_found=True) + # before .nolock.xml.call, so configure the return_value chain. + _zpr.return_value.nolock.xml.call.return_value = minidom.parseString(xmldoc) assert zypper.Wildcard(_zpr)("libzypp", None) is None def test_wildcard_to_query_typecheck(self): @@ -1787,7 +1802,9 @@ def test_wildcard_to_query_typecheck(self): """ _zpr = MagicMock() - _zpr.nolock.xml.call = MagicMock(return_value=minidom.parseString(xmldoc)) + # _get_available_versions now calls self.zypper(ignore_not_found=True) + # before .nolock.xml.call, so configure the return_value chain. + _zpr.return_value.nolock.xml.call.return_value = minidom.parseString(xmldoc) assert isinstance(zypper.Wildcard(_zpr)("libzypp", "*.1"), str) def test_wildcard_to_query_condition_preservation(self): @@ -1804,7 +1821,9 @@ def test_wildcard_to_query_condition_preservation(self): """ _zpr = MagicMock() - _zpr.nolock.xml.call = MagicMock(return_value=minidom.parseString(xmldoc)) + # _get_available_versions now calls self.zypper(ignore_not_found=True) + # before .nolock.xml.call, so configure the return_value chain. + _zpr.return_value.nolock.xml.call.return_value = minidom.parseString(xmldoc) for op in zypper.Wildcard.Z_OP: assert zypper.Wildcard(_zpr)("libzypp", f"{op}*.1") == f"{op}17.2.6-27.9.1" @@ -1827,7 +1846,9 @@ def test_wildcard_to_query_unsupported_operators(self): """ _zpr = MagicMock() - _zpr.nolock.xml.call = MagicMock(return_value=minidom.parseString(xmldoc)) + # _get_available_versions now calls self.zypper(ignore_not_found=True) + # before .nolock.xml.call, so configure the return_value chain. + _zpr.return_value.nolock.xml.call.return_value = minidom.parseString(xmldoc) with self.assertRaises(CommandExecutionError): for op in [">>", "==", "<<", "+"]: zypper.Wildcard(_zpr)("libzypp", f"{op}*.1") @@ -1953,3 +1974,68 @@ def test_rpm_lock_not_acquirable(self): self.assertFalse(zypper.__zypper__._is_rpm_lock()) self.assertEqual(lockf_mock.call_count, 2) zypper.__zypper__._reset() + + def test_search(self): + """ + search() must request ignore_not_found so that zypper's 104 + (nothing found) exit code is whitelisted and not logged as an error. + """ + xml_mock = MagicMock(return_value=[]) + zypp_mock = MagicMock(return_value=xml_mock) + with patch("salt.modules.zypperpkg.__zypper__", zypp_mock): + zypper.search("emacs") + zypp_mock.assert_called_with(root=None, ignore_not_found=True) + xml_mock.nolock.noraise.xml.call.assert_called_with("search", "emacs") + + def test_search_not_found_58551(self): + """ + Regression test for issue #58551. + + When a search matches nothing, zypper exits with code 104. search() + passes ignore_not_found=True, so _Zypper whitelists 104 through + cmd.run_all's success_retcodes and no spurious ERROR is logged. The + empty result still raises CommandExecutionError as before. + """ + ret = { + "stdout": "", + "stderr": None, + "retcode": 104, + } + run_all_mock = MagicMock(return_value=ret) + with patch.dict(zypper.__salt__, {"cmd.run_all": run_all_mock}): + self.assertRaises(CommandExecutionError, zypper.search, "vim") + # ignore_not_found=True -> success_retcodes=[104] on the cmd.run_all call + run_all_mock.assert_called_with( + [ + "zypper", + "--non-interactive", + "--xmlout", + "--no-refresh", + "search", + "vim", + ], + success_retcodes=[104], + output_loglevel="trace", + python_shell=False, + env={"ZYPP_READONLY_HACK": "1"}, + ) + + def test_search_without_ignore_not_found_still_errors(self): + """ + Inverse must-not-regress guard for issue #58551. + + A zypper call that does NOT request ignore_not_found must not + whitelist retcode 104: cmd.run_all is invoked without a + success_retcodes kwarg, so a genuine non-zero retcode still surfaces + as an error. This passes with and without the fix, ensuring the 104 + whitelist never leaks into unrelated zypper calls. + """ + ret = { + "stdout": "", + "stderr": None, + "retcode": 0, + } + run_all_mock = MagicMock(return_value=ret) + with patch.dict(zypper.__salt__, {"cmd.run_all": run_all_mock}): + zypper._Zypper().xml.call("se", "-t", "pattern") + assert "success_retcodes" not in run_all_mock.call_args.kwargs From c4f3e705eded237254d42513e94cc971b3aa3e3b Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 26 Jul 2026 15:36:47 -0700 Subject: [PATCH 138/469] Fix tests broken by merge-forward from 3006.x test_linux_shadow: skip the 'crypto' variant of test_gen_password when the stdlib crypt module is unavailable (removed in Python 3.11+). The tests were previously masked by a module-level importorskip('spwd') which the merge removed; that guard is no longer valid because salt.modules.linux_shadow was decoupled from spwd in #69651. conftest (rest_tornado): add 'keys.cache_driver' to the app_mock opts so saltnado tests that construct CkMinions (which now instantiates salt.key.get_key on 3007.x+) don't hit KeyError. --- tests/pytests/unit/modules/test_linux_shadow.py | 3 +++ tests/pytests/unit/netapi/rest_tornado/conftest.py | 1 + 2 files changed, 4 insertions(+) diff --git a/tests/pytests/unit/modules/test_linux_shadow.py b/tests/pytests/unit/modules/test_linux_shadow.py index 8ba2bb462052..609aced4f24e 100644 --- a/tests/pytests/unit/modules/test_linux_shadow.py +++ b/tests/pytests/unit/modules/test_linux_shadow.py @@ -6,6 +6,7 @@ import pytest +import salt.utils.pycrypto from tests.support.mock import DEFAULT, MagicMock, mock_open, patch pytestmark = [ @@ -54,6 +55,8 @@ def password(request): @pytest.fixture(params=["crypto", "passlib"]) def library(request): + if request.param == "crypto" and not salt.utils.pycrypto.HAS_CRYPT: + pytest.skip("Native crypt module not available on this Python") with patch("salt.utils.pycrypto.HAS_CRYPT", request.param == "crypto"), patch( "salt.utils.pycrypto.HAS_PASSLIB", request.param == "passlib" ): diff --git a/tests/pytests/unit/netapi/rest_tornado/conftest.py b/tests/pytests/unit/netapi/rest_tornado/conftest.py index 31a7dc8bcc70..804de1cf1d0c 100644 --- a/tests/pytests/unit/netapi/rest_tornado/conftest.py +++ b/tests/pytests/unit/netapi/rest_tornado/conftest.py @@ -38,6 +38,7 @@ def app_mock(): "extension_modules": "/tmp/testing/moduuuuules", "order_masters": False, "gather_job_timeout": 10.001, + "keys.cache_driver": "localfs_key", } return mock From 84adb86716b6d951dc749640e6a1ad8f54bb20e3 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 26 Jul 2026 16:07:32 -0700 Subject: [PATCH 139/469] Fix tests failing after merge-forward from 3006.x - test_master.py: convert async publish tests to async def + await (ClearFuncs.publish is async on 3007.x+, was sync on 3006.x) - test_crypt.py::test_authenticate_missing_creds_attribute_67947: adopt the 3007.x gen_keys(keysize) signature + write PEMs to disk, and add keys.cache_driver to opts - test_jinja_custom_filters.py::test_regex_search/match_no_group: update expected value to match 3007.x's intentional filter change (ff28cd05b5be, ungrouped matches return (match_str,) not ()) - test_client.py::test_do_transfer_serialized_by_lock: construct the Future() inside the coroutine (unbundled tornado requires a running loop) - test_cloud.py::test_vm_config_merger_with_overrides: wrap the overrides payload as {name: {...}} to match Cloud.vm_config's per-VM keying on 3007.x - salt/cluster/consensus/raft/scheduler.py: nudge colliding timeouts by 1e-9 s to prevent dict-key collision when two nodes' random follower_timeouts happen to land on the same millisecond (fixes pre-existing flake exposed by test_cluster_log_entry) --- salt/cluster/consensus/raft/scheduler.py | 13 +++++++++++++ tests/pytests/unit/channel/test_client.py | 9 +++++++-- tests/pytests/unit/cloud/test_cloud.py | 8 +++++--- tests/pytests/unit/test_crypt.py | 6 +++++- tests/pytests/unit/test_master.py | 8 ++++---- .../unit/utils/jinja/test_jinja_custom_filters.py | 12 ++++++------ 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/salt/cluster/consensus/raft/scheduler.py b/salt/cluster/consensus/raft/scheduler.py index 2cd96ad91878..6e0de3e08ec5 100644 --- a/salt/cluster/consensus/raft/scheduler.py +++ b/salt/cluster/consensus/raft/scheduler.py @@ -53,6 +53,11 @@ def __init__(self): def schedule(self, timeout, callback): t = time.monotonic() + timeout + # Avoid clobbering an existing timeout scheduled for the exact same + # instant (millisecond-granularity randoms collide easily under the + # manual-clock tests). Nudge forward by a tiny epsilon until unique. + while t in self.timeouts: + t += 1e-9 self.timeouts[t] = callback return TimeoutHandle(self, t, callback) @@ -71,6 +76,12 @@ def __init__(self): def schedule(self, timeout, callback): t = self.time + timeout + # Same collision avoidance as the base scheduler; the manual clock + # doesn't advance between successive schedule() calls, so identical + # (self.time, timeout) pairs would otherwise silently overwrite one + # another and drop callbacks (or duplicate them into the wrong slot). + while t in self.timeouts: + t += 1e-9 self.timeouts[t] = callback return TimeoutHandle(self, t, callback) @@ -145,6 +156,8 @@ def stop(self): def schedule(self, timeout, callback): with self._lock: t = time.monotonic() + timeout + while t in self.timeouts: + t += 1e-9 self.timeouts[t] = callback return TimeoutHandle(self, t, callback) diff --git a/tests/pytests/unit/channel/test_client.py b/tests/pytests/unit/channel/test_client.py index a7b2dc10d308..18292f8c6f06 100644 --- a/tests/pytests/unit/channel/test_client.py +++ b/tests/pytests/unit/channel/test_client.py @@ -211,7 +211,10 @@ def test_do_transfer_serialized_by_lock(minion_opts, tmp_path): minion = salt.crypt.Crypticle(minion_opts, key) events = [] - gate = tornado.concurrent.Future() + # ``gate`` must be created inside the running io_loop; modern tornado's + # ``Future`` binds to the currently-running asyncio event loop, which + # only exists once ``run_sync`` has installed one. + gate_holder = {} class _OrderingTransport(_StubTransport): def __init__(self): @@ -234,7 +237,7 @@ def send(self, payload, timeout=None): # (would-be race) -- with the lock, the second send # cannot begin, so this future is completed by the test # driver after a small delay via io_loop.call_later. - yield gate + yield gate_holder["gate"] events.append(f"send-end-{self.call}") raise tornado.gen.Return(reply) @@ -249,10 +252,12 @@ def _drive(): # Fire both transfers "concurrently". Under the lock, transfer2 # must wait for transfer1 to fully finish (including decrypt) # before its send even begins. + gate_holder["gate"] = tornado.concurrent.Future() fut1 = channel._crypted_transfer({"cmd": "one"}, timeout=5) fut2 = channel._crypted_transfer({"cmd": "two"}, timeout=5) def _release(): + gate = gate_holder["gate"] if not gate.done(): gate.set_result(None) diff --git a/tests/pytests/unit/cloud/test_cloud.py b/tests/pytests/unit/cloud/test_cloud.py index 617948c2ae57..7c654dd7d7ce 100644 --- a/tests/pytests/unit/cloud/test_cloud.py +++ b/tests/pytests/unit/cloud/test_cloud.py @@ -222,9 +222,11 @@ def test_vm_config_merger_with_overrides(): }, } overrides = { - "devices": { - "network": { - "Network adapter 1": {"ip": "192.168.0.10"}, + "test_vm": { + "devices": { + "network": { + "Network adapter 1": {"ip": "192.168.0.10"}, + }, }, }, } diff --git a/tests/pytests/unit/test_crypt.py b/tests/pytests/unit/test_crypt.py index bcac67aaec5b..691f8970491d 100644 --- a/tests/pytests/unit/test_crypt.py +++ b/tests/pytests/unit/test_crypt.py @@ -596,8 +596,12 @@ async def test_authenticate_missing_creds_attribute_67947(minion_root, io_loop, "keysize": 4096, "acceptance_wait_time": 0, "acceptance_wait_time_max": 0, + "keys.cache_driver": "localfs_key", } - crypt.gen_keys(pki_dir, "minion", opts["keysize"]) + priv, pub = crypt.gen_keys(opts["keysize"]) + keypath = pki_dir / "minion" + keypath.with_suffix(".pem").write_text(priv) + keypath.with_suffix(".pub").write_text(pub) credskey = ( opts["pki_dir"], opts["id"], diff --git a/tests/pytests/unit/test_master.py b/tests/pytests/unit/test_master.py index 98df1758e783..b2f9f92e0875 100644 --- a/tests/pytests/unit/test_master.py +++ b/tests/pytests/unit/test_master.py @@ -2150,7 +2150,7 @@ def publish_clear_funcs(master_opts): clear_funcs.destroy() -def test_publish_prep_jid_returns_error_dict(publish_clear_funcs): +async def test_publish_prep_jid_returns_error_dict(publish_clear_funcs): """ Regression test for #66457. @@ -2198,7 +2198,7 @@ def test_publish_prep_jid_returns_error_dict(publish_clear_funcs): # Before #66457 was fixed, ``publish`` would pass ``prep_jid_error`` # (a dict) through as the jid and then raise ``TypeError`` inside # ``fire_event`` while converting it to bytes. - result = publish_clear_funcs.publish(load) + result = await publish_clear_funcs.publish(load) assert result == prep_jid_error, ( "publish() must return the error dict from _prep_jid unchanged when" @@ -2206,7 +2206,7 @@ def test_publish_prep_jid_returns_error_dict(publish_clear_funcs): ) -def test_publish_prep_jid_returns_none(publish_clear_funcs): +async def test_publish_prep_jid_returns_none(publish_clear_funcs): """ Companion to :func:`test_publish_prep_jid_returns_error_dict`: verify the pre-existing ``jid is None`` path still returns the generic error load. @@ -2239,7 +2239,7 @@ def test_publish_prep_jid_returns_none(publish_clear_funcs): "_prep_jid", MagicMock(return_value=None), ): - result = publish_clear_funcs.publish(load) + result = await publish_clear_funcs.publish(load) assert result == {"error": "Master failed to assign jid"} diff --git a/tests/pytests/unit/utils/jinja/test_jinja_custom_filters.py b/tests/pytests/unit/utils/jinja/test_jinja_custom_filters.py index bf8fbe5a807c..391f1f6a014a 100644 --- a/tests/pytests/unit/utils/jinja/test_jinja_custom_filters.py +++ b/tests/pytests/unit/utils/jinja/test_jinja_custom_filters.py @@ -435,9 +435,9 @@ def test_regex_search_no_match_returns_none(): def test_regex_search_no_group(): - """The filter returns ``match.groups()``, so a successful match with no - capture groups yields an empty (falsy) tuple, not the matched text.""" - assert jinja.regex_search("abcd", "bc") == () + """A successful match with no capture groups yields a 1-tuple of the + whole match (see commit ff28cd05b5be).""" + assert jinja.regex_search("abcd", "bc") == ("bc",) def test_regex_search_groups_ignorecase(): @@ -457,9 +457,9 @@ def test_regex_match_no_match_returns_none(): def test_regex_match_no_group(): - """Like regex_search, a match with no capture groups returns an empty - (falsy) tuple because the filter returns ``match.groups()``.""" - assert jinja.regex_match("abcd", "ab") == () + """Like regex_search, a match with no capture groups returns a 1-tuple of + the whole match (see commit ff28cd05b5be).""" + assert jinja.regex_match("abcd", "ab") == ("ab",) def test_regex_match_groups_ignorecase(): From 6d8e9ce5c193145b04eeb672b808c4718d06c405 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 27 Jul 2026 14:36:31 -0700 Subject: [PATCH 140/469] Add missing opts to tests migrated from 3006.x - rest_tornado/conftest.py app_mock: add __role=master (salt.key.get_key now instantiated on 3007.x+ via CkMinions rejects empty __role with ValueError). - test_documented_providers.py base_opts: add gitfs_proxy and gitfs_depth to satisfy PER_REMOTE_OVERRIDES on 3007.x (init_remotes hits failhard when either is missing from global opts). --- .../functional/fileserver/gitfs/test_documented_providers.py | 2 ++ tests/pytests/unit/netapi/rest_tornado/conftest.py | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/pytests/functional/fileserver/gitfs/test_documented_providers.py b/tests/pytests/functional/fileserver/gitfs/test_documented_providers.py index 0619c6adb8d5..cd80064552f1 100644 --- a/tests/pytests/functional/fileserver/gitfs/test_documented_providers.py +++ b/tests/pytests/functional/fileserver/gitfs/test_documented_providers.py @@ -99,6 +99,8 @@ def base_opts(tmp_path): "gitfs_disable_saltenv_mapping": False, "gitfs_ref_types": ["branch", "tag"], "gitfs_update_interval": 60, + "gitfs_proxy": "", + "gitfs_depth": 1, "__role": "master", "fileserver_events": False, "transport": "zeromq", diff --git a/tests/pytests/unit/netapi/rest_tornado/conftest.py b/tests/pytests/unit/netapi/rest_tornado/conftest.py index 804de1cf1d0c..b1226dbcacb7 100644 --- a/tests/pytests/unit/netapi/rest_tornado/conftest.py +++ b/tests/pytests/unit/netapi/rest_tornado/conftest.py @@ -39,6 +39,7 @@ def app_mock(): "order_masters": False, "gather_job_timeout": 10.001, "keys.cache_driver": "localfs_key", + "__role": "master", } return mock From e06e89132526941c7503a6f773e4c6e9e750c03d Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 27 Jul 2026 19:46:19 -0700 Subject: [PATCH 141/469] Drop changelog entries for code purged in 3007.x Removes changelog entries whose fixes target files that no longer exist after the initial purge of community extensions (dc526dc2b17) and the removal of the vendored tornado tree: Vendored tornado (salt/ext/tornado/ purged): - 69845: CVE-2026-49853 SimpleAsyncHTTPClient redirect header stripping - 69848: CVE-2026-49855 _GzipMessageDelegate gzip bomb protection Extension purge (files no longer in tree): - 52220: keystone_role_grant state - 54122: grafana4_datasource state - 55143: DigitalOcean cloud driver - 60408: s3fs fileserver docs - 61082: poudriere module - 63051: dockercompose module (python_on_whales backend) - 65233: metadata grain - 68961: vault functional test container - 69529: s3fs fileserver race --- changelog/52220.fixed.md | 1 - changelog/54122.fixed.md | 1 - changelog/55143.fixed.md | 1 - changelog/60408.fixed.md | 1 - changelog/61082.fixed.md | 1 - changelog/63051.added.md | 1 - changelog/65233.fixed.md | 1 - changelog/68961.fixed.md | 4 ---- changelog/69529.fixed.md | 1 - changelog/69845.fixed.md | 4 ---- changelog/69848.fixed.md | 4 ---- 11 files changed, 20 deletions(-) delete mode 100644 changelog/52220.fixed.md delete mode 100644 changelog/54122.fixed.md delete mode 100644 changelog/55143.fixed.md delete mode 100644 changelog/60408.fixed.md delete mode 100644 changelog/61082.fixed.md delete mode 100644 changelog/63051.added.md delete mode 100644 changelog/65233.fixed.md delete mode 100644 changelog/68961.fixed.md delete mode 100644 changelog/69529.fixed.md delete mode 100644 changelog/69845.fixed.md delete mode 100644 changelog/69848.fixed.md diff --git a/changelog/52220.fixed.md b/changelog/52220.fixed.md deleted file mode 100644 index c5892932d8b7..000000000000 --- a/changelog/52220.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed keystone_role_grant.present and keystone_role_grant.absent to honour test=True so role assignments are no longer granted or revoked in test mode diff --git a/changelog/54122.fixed.md b/changelog/54122.fixed.md deleted file mode 100644 index 3a884763dd9a..000000000000 --- a/changelog/54122.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed grafana4_datasource.present reporting a spurious update under test=True for an unchanged existing data source diff --git a/changelog/55143.fixed.md b/changelog/55143.fixed.md deleted file mode 100644 index 71d9b5bae580..000000000000 --- a/changelog/55143.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed the DigitalOcean cloud driver so destroy_dns_records paginates through every page of DNS records instead of only the first page, and dropped a Python 2 ``.decode()`` call that crashed record matching on Python 3. diff --git a/changelog/60408.fixed.md b/changelog/60408.fixed.md deleted file mode 100644 index 8b7527016873..000000000000 --- a/changelog/60408.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Documented the ``s3.location``, ``s3.service_url``, ``s3.https_enable``, ``s3.path_style``, and ``s3.verify_ssl`` master config options in the ``s3fs`` fileserver module docstring. The new "Regional endpoints" section explains why s3fs may fail with ``No AWSAccessKey was presented`` or a SigV4 region-mismatch error against buckets outside ``us-east-1`` and what setting to use to fix it. A test in ``tests/pytests/unit/fileserver/test_s3fs_documented_options.py`` pins the option names to the loader so the docs cannot silently drift. diff --git a/changelog/61082.fixed.md b/changelog/61082.fixed.md deleted file mode 100644 index 6e4ba67d56e5..000000000000 --- a/changelog/61082.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed poudriere jail functions failing on purely numeric jail names by coercing the name to a string in is_jail diff --git a/changelog/63051.added.md b/changelog/63051.added.md deleted file mode 100644 index aee1868ee1a9..000000000000 --- a/changelog/63051.added.md +++ /dev/null @@ -1 +0,0 @@ -Added optional `python_on_whales` backend for the `dockercompose` module. Enable it by setting `dockercompose: {use_python_on_whales: True}` in the minion config. The legacy `compose` library remains the default on 3006.x/3007.x/3008.x; the default flips to `python_on_whales` in 3009. diff --git a/changelog/65233.fixed.md b/changelog/65233.fixed.md deleted file mode 100644 index 5aed6d865122..000000000000 --- a/changelog/65233.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed the ``metadata`` grain module to send an ``X-aws-ec2-metadata-token`` header when the EC2 Instance Metadata Service requires IMDSv2, preventing silent grain-load failures on AMIs that enforce token-based metadata access. diff --git a/changelog/68961.fixed.md b/changelog/68961.fixed.md deleted file mode 100644 index ada44823313b..000000000000 --- a/changelog/68961.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -Fixed Docker 409 "name already in use" errors when creating the vault -functional test container by using a unique random container name via -``random_string("vault-")``, preventing conflicts from stale containers -left by interrupted runs or CI runner reuse. diff --git a/changelog/69529.fixed.md b/changelog/69529.fixed.md deleted file mode 100644 index 7260539f56e8..000000000000 --- a/changelog/69529.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed a race condition in the s3fs fileserver where two concurrent cache refreshes could raise an unhandled `FileNotFoundError` from `_write_buckets_cache_file` when the second call reached `os.remove` after the first had already removed the stale cache file. The removal is now tolerant of the file being missing, so overlapping refreshes no longer propagate the error onto the event bus or hang the master. diff --git a/changelog/69845.fixed.md b/changelog/69845.fixed.md deleted file mode 100644 index 4c4b1b4299c0..000000000000 --- a/changelog/69845.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -Patch the vendored tornado ``SimpleAsyncHTTPClient`` for CVE-2026-49853: the -``Authorization`` and ``Cookie`` headers, along with ``auth_username`` and -``auth_password``, are no longer forwarded to a different origin when -following an HTTP redirect. diff --git a/changelog/69848.fixed.md b/changelog/69848.fixed.md deleted file mode 100644 index 6fb6304e6c03..000000000000 --- a/changelog/69848.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -Patch the vendored tornado ``_GzipMessageDelegate`` for CVE-2026-49855: the -cumulative size of decompressed gzip response bodies is now checked against -``max_body_size``, preventing a malicious server from exhausting client -memory with a small, highly-compressed response (a "gzip bomb"). From a819992cb9a5b74f3adcc2194c3580b0bfac9394 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 27 Jul 2026 22:46:44 -0400 Subject: [PATCH 142/469] Support dnf5 group list/info in pkg.group_list/group_info (backport of #67975) (#69813) * Support dnf5 group list/info in pkg.group_list and pkg.group_info (#67975) Backport of the dnf5 group support from #67975 to 3006.x, grafted onto the current group functions so the #60276 multi-word member-group @-fallback is preserved. dnf5 changed the "group list" and "group info" output formats, which the yum/dnf parser did not understand, so the group functions (and pkg.group_installed) returned empty or wrong data on Fedora 41+ and RHEL/AlmaLinux 10. - group_list: parse the dnf5 "group list --hidden" table by tokenizing each row (ID plus the trailing yes/no Installed column) instead of a regex, so a group name that contains or ends in the word "yes"/"no" is not mistaken for the status column, and a row with trailing whitespace is not dropped. - group_info: parse the dnf5 "group info" format, where each package section carries its first member inline after the colon. That inline member is pulled into a dedicated variable rather than mutating the loop's line, blank members are skipped, and section lines are fully stripped on both ends. dnf5 environment and language groups live under a separate "environment" subcommand and are out of scope here, so those keys stay empty on dnf5. Validated end to end against live dnf5 5.4.2.1 on Fedora 44: group_list parses all 157 groups and group_info parses every package with none dropped. Co-authored-by: Greg Oster * Drop the spurious empty optional package from the group_info test The empty-member guard added in this PR correctly stops a blank line in the dnf/yum "groupinfo" output from being recorded as an empty-string package, so the existing test_group_info expectation no longer includes the leading "" in the gnome-desktop optional list. --------- Co-authored-by: Greg Oster --- changelog/67975.fixed.md | 1 + salt/modules/yumpkg.py | 96 +++++++++++++++++--- tests/pytests/unit/modules/test_yumpkg.py | 104 +++++++++++++++++++++- 3 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 changelog/67975.fixed.md diff --git a/changelog/67975.fixed.md b/changelog/67975.fixed.md new file mode 100644 index 000000000000..434e4ebd213e --- /dev/null +++ b/changelog/67975.fixed.md @@ -0,0 +1 @@ +Fixed ``pkg.group_list`` and ``pkg.group_info`` on dnf5 systems (Fedora 41+, RHEL/AlmaLinux 10). dnf5 changed the ``group list``/``group info`` output format, which the yum/dnf parser did not understand, so the group functions (and ``pkg.group_installed``) returned empty or incorrect data. The group name column is now tokenized so a name containing the word "yes" or "no" is no longer mistaken for the installed column. diff --git a/salt/modules/yumpkg.py b/salt/modules/yumpkg.py index f1694e907163..90758bce9728 100644 --- a/salt/modules/yumpkg.py +++ b/salt/modules/yumpkg.py @@ -2634,6 +2634,37 @@ def group_list(): "available language groups:": "available languages", } + if _yum() == "dnf5": + # dnf5 lists environment groups and language groups under separate + # subcommands ("dnf5 environment list"), not under "group list", so the + # "installed/available environments" and "available languages" keys + # stay empty on dnf5 (the dnf/yum path below still fills them). + out = __salt__["cmd.run_stdout"]( + [_yum(), "group", "list", "--hidden"], + output_loglevel="trace", + python_shell=False, + ) + for line in salt.utils.itertools.split(out, "\n"): + # dnf5 'group list' is a whitespace-aligned table: + # ID Name (may contain spaces) Installed (yes|no) + # Tokenize the row rather than matching the name with a regex, so a + # group name that happens to contain the word "yes" or "no" cannot + # be mistaken for the trailing Installed column. The header row and + # any blank/administrative lines have no yes/no last column and are + # skipped here. + parts = line.split() + if len(parts) < 3: + continue + installed = parts[-1].lower() + if installed not in ("yes", "no"): + continue + group_id = parts[0] + if installed == "yes": + ret["installed"].append(group_id) + else: + ret["available"].append(group_id) + return ret + out = __salt__["cmd.run_stdout"]( [_yum(), "grouplist", "hidden"], output_loglevel="trace", python_shell=False ) @@ -2737,7 +2768,10 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): } ) - cmd = [_yum(), "--quiet"] + options + ["groupinfo", name] + if _yum() == "dnf5": + cmd = [_yum(), "--quiet"] + options + ["group", "info", name] + else: + cmd = [_yum(), "--quiet"] + options + ["groupinfo", name] out = __salt__["cmd.run_stdout"](cmd, output_loglevel="trace", python_shell=False) g_info = {} @@ -2752,9 +2786,17 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): ret["type"] = "environment group" elif "group" in g_info: ret["type"] = "package group" + elif "name" in g_info: + # dnf5 'group info' labels a package group with "Name"/"Id" rather than + # the "Group"/"Group-Id" that dnf uses. + ret["type"] = "package group" - ret["group"] = g_info.get("environment group") or g_info.get("group") - ret["id"] = g_info.get("environment-id") or g_info.get("group-id") + ret["group"] = ( + g_info.get("environment group") or g_info.get("group") or g_info.get("name") + ) + ret["id"] = ( + g_info.get("environment-id") or g_info.get("group-id") or g_info.get("id") + ) if not ret["group"] and not ret["id"]: raise CommandExecutionError(f"Group '{name}' not found") @@ -2765,7 +2807,14 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): for pkgtype in pkgtypes: target_found = False for line in salt.utils.itertools.split(out, "\n"): - line = line.strip().lstrip(string.punctuation) + line = line.strip().lstrip(string.punctuation).strip() + # ``member`` is the group member (a package or, for environment + # groups, a subgroup) this line contributes. For an ordinary member + # line it is the line itself; a dnf5 section header (below) carries + # its section's first member inline and overrides it. + member = line + # dnf (yum): the section header sits on its own line, e.g. + # "Mandatory Packages:", with members on the lines that follow. match = re.match( pkgtypes_capturegroup + r" (?:groups|packages):\s*$", line.lower() ) @@ -2778,16 +2827,38 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): # We've reached the targeted section target_found = True continue + # dnf5: the section header carries this section's first member + # inline, e.g. "Mandatory packages : gettext". + match_dnf5 = re.match( + pkgtypes_capturegroup + r" (?:groups|packages)\s*:\s*(.*?)$", + line.lower(), + ) + if match_dnf5: + if target_found: + # We've reached a new section, break from loop + break + if match_dnf5.group(1) != pkgtype: + continue + # We've reached the targeted section + target_found = True + # Pull the inline first member into its own variable (keeping + # the original case) rather than overwriting ``line``: a header + # with trailing spaces or localized text could otherwise leave + # an empty string and silently drop the member. + first_member = re.match(r"^[^:]+:\s*(.+)$", line) + if first_member is None: + continue + member = first_member.group(1).strip() if target_found: if expand and ret["type"] == "environment group": - if not line or line in completed_groups: + if not member or member in completed_groups: continue log.trace( 'Adding group "%s" to completed list: %s', - line, + member, completed_groups, ) - completed_groups.append(line) + completed_groups.append(member) # The @ prefix disambiguates single-token group ids (e.g. # gnome-desktop) that would otherwise match multiple # groups, but dnf cannot resolve "@" + a multi-word group @@ -2797,17 +2868,20 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): # name so multi-word member groups still expand (#60276). try: expanded = group_info( - "@" + line, expand=True, ignore_groups=completed_groups + "@" + member, expand=True, ignore_groups=completed_groups ) except CommandExecutionError: expanded = group_info( - line, expand=True, ignore_groups=completed_groups + member, expand=True, ignore_groups=completed_groups ) # Don't shadow the pkgtype variable from the outer loop for p_type in pkgtypes: ret[p_type].update(set(expanded[p_type])) - else: - ret[pkgtype].add(line) + elif member: + # Skip blank lines that fall inside the section (e.g. a + # trailing/blank line before the next header) rather than + # recording an empty package name. + ret[pkgtype].add(member) for pkgtype in pkgtypes: ret[pkgtype] = sorted(ret[pkgtype]) diff --git a/tests/pytests/unit/modules/test_yumpkg.py b/tests/pytests/unit/modules/test_yumpkg.py index fe8f4faf8ed1..89d0e6e2b895 100644 --- a/tests/pytests/unit/modules/test_yumpkg.py +++ b/tests/pytests/unit/modules/test_yumpkg.py @@ -2944,7 +2944,6 @@ def test_group_info(): "yelp", ], "optional": [ - "", "alacarte", "dconf-editor", "dvgrab", @@ -3580,3 +3579,106 @@ def fake_parse(*args, **kwargs): yumpkg.install("fnord", version=new) call = cmd_mock.mock_calls[0][1][0] assert call == expected_cmd + + +def test_67975_dnf5_group_info(): + """ + Test yumpkg.group_info parsing of the dnf5 'group info' format, where each + package section carries its first member inline after the colon and the + rest on continuation lines. + """ + cmd_out = """\ +Id : libreoffice +Name : LibreOffice +Description : LibreOffice Productivity Suite +Installed : yes +Order : +Langonly : +Uservisible : yes +Repositories : @System +Mandatory packages : libreoffice-calc + : libreoffice-emailmerge + : libreoffice-graphicfilter + : libreoffice-impress + : libreoffice-writer +Optional packages : libreoffice-base + : libreoffice-draw + : libreoffice-math + : libreoffice-pyuno""" + expected = { + "mandatory": [ + "libreoffice-calc", + "libreoffice-emailmerge", + "libreoffice-graphicfilter", + "libreoffice-impress", + "libreoffice-writer", + ], + "optional": [ + "libreoffice-base", + "libreoffice-draw", + "libreoffice-math", + "libreoffice-pyuno", + ], + "default": [], + "conditional": [], + "type": "package group", + "group": "LibreOffice", + "id": "libreoffice", + "description": "LibreOffice Productivity Suite", + } + with patch.object(yumpkg, "_yum", MagicMock(return_value="dnf5")), patch.dict( + yumpkg.__salt__, {"cmd.run_stdout": MagicMock(return_value=cmd_out)} + ): + assert yumpkg.group_info("libreoffice") == expected + + +def test_67975_dnf5_group_list(): + """ + Test yumpkg.group_list parsing of the dnf5 'group list' table. The group + name column is tokenized rather than matched with a regex, so a name that + contains or ends with the word "yes"/"no" (``Just (testing) yes``) is not + mistaken for the trailing Installed column, and a row with trailing + whitespace (``last``) is still classified rather than dropped. + """ + cmd_out = ( + "ID Name Installed\n" + "foo Foo package no\n" + "bar Bar package no\n" + "brackets Just (testing) yes yes\n" + "cleaners Mop and bucket yes\n" + "last But not least no \n" + ) + expected = { + "installed": ["brackets", "cleaners"], + "available": ["foo", "bar", "last"], + "installed environments": [], + "available environments": [], + "available languages": {}, + } + with patch.object(yumpkg, "_yum", MagicMock(return_value="dnf5")), patch.dict( + yumpkg.__salt__, {"cmd.run_stdout": MagicMock(return_value=cmd_out)} + ): + assert yumpkg.group_list() == expected + + +def test_dnf5_group_info_skips_blank_member_lines(): + """ + A blank line inside a package section (e.g. between sections) must not be + recorded as an empty package name. + """ + cmd_out = ( + "Id : development-tools\n" + "Name : Development Tools\n" + "Installed : no\n" + "Mandatory packages : gettext\n" + "\n" + "Optional packages : cmake\n" + ) + with patch.object(yumpkg, "_yum", MagicMock(return_value="dnf5")), patch.dict( + yumpkg.__salt__, {"cmd.run_stdout": MagicMock(return_value=cmd_out)} + ): + info = yumpkg.group_info("Development Tools") + assert info["mandatory"] == ["gettext"] + assert info["optional"] == ["cmake"] + assert "" not in info["mandatory"] + assert "" not in info["optional"] From f22b3a4561fb585c83e8e425cb3501cdd13ec0fd Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 27 Jul 2026 19:47:25 -0700 Subject: [PATCH 143/469] Support relenv option in salt-ssh roster entries (#69886) Fixes #69885 Add a `relenv: True` roster key so individual salt-ssh targets can opt into the relenv (Salt+Python bundled) deployment without forcing every host reached by a wildcard match to download the ~200MB onedir tarball. The roster key is additive to (and cannot silently downgrade) the existing global `--relenv` CLI flag or Saltfile setting. Previously users had to enable relenv globally to unblock a single legacy-Python host, ballooning the on-disk footprint on every other target and generating repeated RELENV cache warnings. --- changelog/69885.added.md | 5 + salt/client/ssh/__init__.py | 7 + salt/config/schemas/ssh.py | 9 + .../integration/ssh/test_relenv_roster.py | 195 ++++++++++++++++++ tests/pytests/unit/client/ssh/test_single.py | 56 +++++ tests/pytests/unit/config/schemas/test_ssh.py | 26 +++ 6 files changed, 298 insertions(+) create mode 100644 changelog/69885.added.md create mode 100644 tests/pytests/integration/ssh/test_relenv_roster.py diff --git a/changelog/69885.added.md b/changelog/69885.added.md new file mode 100644 index 000000000000..4fa38c410031 --- /dev/null +++ b/changelog/69885.added.md @@ -0,0 +1,5 @@ +Support a per-host ``relenv: True`` entry in the salt-ssh roster so that +individual targets can use the relenv (Salt+Python bundled) deployment +without forcing every host reached by a wildcard match to download the +onedir tarball. Equivalent to the ``--relenv`` CLI flag but scoped to a +single roster entry. diff --git a/salt/client/ssh/__init__.py b/salt/client/ssh/__init__.py index 71bc99acb131..7886675679a8 100644 --- a/salt/client/ssh/__init__.py +++ b/salt/client/ssh/__init__.py @@ -1145,6 +1145,7 @@ def __init__( mods=None, fsclient=None, thin=None, + relenv=False, mine=False, minion_opts=None, identities_only=False, @@ -1170,6 +1171,12 @@ def __init__( self.wipe = False else: self.wipe = bool(self.opts.get("ssh_wipe")) + # Allow the roster to enable relenv per-host, mirroring the --relenv + # CLI flag. This is additive only: an explicit CLI/global True must + # not be silently downgraded by a roster that omits the key. + # See #69885. + if relenv: + self.opts["relenv"] = True if kwargs.get("thin_dir"): self.thin_dir = kwargs["thin_dir"] elif self.winrm: diff --git a/salt/config/schemas/ssh.py b/salt/config/schemas/ssh.py index 2123768935c5..7a1e5f61322e 100644 --- a/salt/config/schemas/ssh.py +++ b/salt/config/schemas/ssh.py @@ -85,6 +85,15 @@ class RosterEntryConfig(Schema): "components. Defaults to /tmp/salt-." ), ) + relenv = BooleanItem( + title="Relenv", + description=( + "Deploy and use a relenv (Salt+Python bundled) environment on " + "the SSH target, equivalent to the --relenv CLI flag but scoped " + "to this roster entry." + ), + default=False, + ) minion_opts = DictItem( title="Minion Options", description="Dictionary of minion options", diff --git a/tests/pytests/integration/ssh/test_relenv_roster.py b/tests/pytests/integration/ssh/test_relenv_roster.py new file mode 100644 index 000000000000..2ae7f9098296 --- /dev/null +++ b/tests/pytests/integration/ssh/test_relenv_roster.py @@ -0,0 +1,195 @@ +""" +Integration tests for per-host ``relenv:`` roster support in salt-ssh. + +Regression coverage for https://github.com/saltstack/salt/issues/69885 + +Prior to the fix, setting ``relenv: True`` on an individual roster entry was +silently ignored -- only the global ``--relenv`` CLI flag (or Saltfile +setting) actually toggled the relenv deployment path. That forced operators +of mixed fleets to either enable relenv globally (shipping the ~200MB onedir +tarball to every host reached by a wildcard target) or forgo relenv entirely +for hosts that legitimately needed it. + +These tests exercise the observable end-to-end behavior of ``Single``: + + * The rendered ``thin_dir`` for a roster entry with ``relenv: True`` ends + in ``_salt_relenv`` (the suffix ``Single.__init__`` applies when + ``opts['relenv']`` is truthy). + * The rendered ``thin_dir`` for a roster entry without ``relenv`` (and + without global ``--relenv``) has no such suffix. + * When a roster contains both kinds of entries and salt-ssh targets them + via wildcard, each host gets its own deployment path -- the relenv host + gets the relenv thin_dir, the plain host keeps the classic thin_dir. + This is the mixed-fleet behavior the bug prevented. + +Cases that require a fully deployed relenv onedir (cases 1 and 3) reuse the +session-scoped ``relenv_tarball_cached`` fixture from +``tests/pytests/integration/ssh/conftest.py`` and skip when the tarball is +not available locally, mirroring the pattern in ``test_deploy_relenv.py``. +Case 2 does not need the tarball and always runs on supported platforms. +""" + +import shutil + +import pytest + +import salt.utils.files +import salt.utils.yaml + +pytestmark = [ + pytest.mark.slow_test, + pytest.mark.skip_on_windows(reason="salt-ssh not available on Windows"), +] + + +@pytest.fixture(autouse=True) +def _cleanup_thin_dirs(salt_ssh_cli): + """ + Best-effort cleanup of the on-disk thin directories the test creates. + + We do not fail the test on cleanup errors -- the goal is only to keep + ``/var/tmp/.__salt*`` from accumulating across runs. + """ + try: + yield + finally: + # Query whichever thin_dir the default roster produced; individual + # tests may have created additional per-host thin_dirs but this + # covers the shared baseline. + try: + ret = salt_ssh_cli.run("config.get", "thin_dir") + if ret.returncode == 0 and ret.data: + shutil.rmtree(ret.data, ignore_errors=True) + except Exception: # pylint: disable=broad-exception-caught + pass + + +def _write_roster(tmp_path, name, entries): + """ + Serialize a roster ``dict`` to a temp file under ``tmp_path`` and return + its path. Kept local to this module to avoid coupling to unrelated + fixtures. + """ + roster_file = tmp_path / name + with salt.utils.files.fopen(str(roster_file), "w") as wfh: + salt.utils.yaml.safe_dump(entries, wfh) + return roster_file + + +def _base_entry(salt_ssh_roster_file): + """ + Read the shared roster and return the ``localhost`` entry -- we reuse its + port/user/known_hosts wiring so the new roster files talk to the same + session sshd. + """ + with salt.utils.files.fopen(salt_ssh_roster_file) as rfh: + data = salt.utils.yaml.safe_load(rfh) + return data["localhost"] + + +def test_roster_relenv_true_uses_relenv_thin_dir( + salt_ssh_cli, salt_ssh_roster_file, tmp_path, relenv_tarball_cached +): + """ + Case 1: a roster entry with ``relenv: True`` deploys via the relenv path. + + Observable: the target's ``thin_dir`` ends with ``_salt_relenv``. Before + the fix, the roster key was dropped and ``thin_dir`` ended in plain + ``_salt``. + """ + if relenv_tarball_cached is None: + pytest.skip("Relenv tarball not available") + entry = _base_entry(salt_ssh_roster_file) + entry_relenv = dict(entry) + entry_relenv["relenv"] = True + roster = {"localhost": entry_relenv} + roster_file = _write_roster(tmp_path, "roster-relenv-true", roster) + + ret = salt_ssh_cli.run(f"--roster-file={roster_file}", "config.get", "thin_dir") + assert ret.returncode == 0 + assert ret.data + assert ret.data.endswith( + "_salt_relenv" + ), f"expected relenv thin_dir suffix, got {ret.data!r}" + + +def test_roster_relenv_absent_uses_classic_thin_dir( + salt_ssh_cli, salt_ssh_roster_file, tmp_path +): + """ + Case 2: no ``relenv`` in the roster and no ``--relenv`` flag -- classic + thin deployment. + + Observable: ``thin_dir`` ends with plain ``_salt`` (no ``_relenv`` + suffix). Guards against a regression where roster-relenv might leak into + hosts that never asked for it. + """ + entry = _base_entry(salt_ssh_roster_file) + # Ensure no accidental relenv key survives. + entry_plain = {k: v for k, v in entry.items() if k != "relenv"} + roster = {"localhost": entry_plain} + roster_file = _write_roster(tmp_path, "roster-relenv-absent", roster) + + ret = salt_ssh_cli.run(f"--roster-file={roster_file}", "config.get", "thin_dir") + assert ret.returncode == 0 + assert ret.data + assert ret.data.endswith( + "_salt" + ), f"expected classic thin_dir suffix, got {ret.data!r}" + assert not ret.data.endswith("_salt_relenv") + + +def test_roster_relenv_mixed_fleet( + salt_ssh_cli, salt_ssh_roster_file, tmp_path, relenv_tarball_cached +): + """ + Case 3: mixed roster + wildcard target -- only the entry with + ``relenv: True`` gets the relenv deployment; the plain entry keeps the + classic thin deployment. + + This is the scenario the bug fix exists for: prior to the fix, a + ``salt-ssh '*' test.ping`` against a roster where only some hosts had + ``relenv: True`` would treat every host as classic thin (silently + ignoring the roster key), forcing operators to opt in globally. + """ + if relenv_tarball_cached is None: + pytest.skip("Relenv tarball not available") + + entry = _base_entry(salt_ssh_roster_file) + entry_plain = {k: v for k, v in entry.items() if k != "relenv"} + entry_relenv = dict(entry_plain) + entry_relenv["relenv"] = True + + roster = { + "host-thin": entry_plain, + "host-relenv": entry_relenv, + } + roster_file = _write_roster(tmp_path, "roster-relenv-mixed", roster) + + ret = salt_ssh_cli.run( + f"--roster-file={roster_file}", + "config.get", + "thin_dir", + minion_tgt="*", + ) + assert ret.returncode == 0 + assert isinstance( + ret.data, dict + ), f"expected per-host dict, got {type(ret.data).__name__}: {ret.data!r}" + assert set(ret.data.keys()) == {"host-thin", "host-relenv"}, ret.data + + thin_host_dir = ret.data["host-thin"] + relenv_host_dir = ret.data["host-relenv"] + + assert thin_host_dir.endswith("_salt") + assert not thin_host_dir.endswith( + "_salt_relenv" + ), f"plain roster entry unexpectedly got relenv thin_dir: {thin_host_dir!r}" + assert relenv_host_dir.endswith( + "_salt_relenv" + ), f"relenv roster entry did not get relenv thin_dir: {relenv_host_dir!r}" + + # Belt-and-suspenders cleanup for the extra per-host thin dirs the + # mixed-target run created. + for path in (thin_host_dir, relenv_host_dir): + shutil.rmtree(path, ignore_errors=True) diff --git a/tests/pytests/unit/client/ssh/test_single.py b/tests/pytests/unit/client/ssh/test_single.py index 126146fb3bd3..24b6c7f6f71f 100644 --- a/tests/pytests/unit/client/ssh/test_single.py +++ b/tests/pytests/unit/client/ssh/test_single.py @@ -857,6 +857,62 @@ def test_cmd_run_not_set_path(opts, target): assert re.search('SET_PATH=""', ret) +def test_single_relenv_from_roster_enables_relenv(opts, target): + """ + Regression test for #69885: ``relenv: True`` in a roster entry must enable + the relenv deployment code path for that host, mirroring the ``--relenv`` + CLI flag. Previously the roster key was silently dropped, forcing users to + enable relenv globally (via CLI or Saltfile), which shipped the ~200MB + onedir to every target. + """ + opts["ssh_wipe"] = True + # CLI/global default is thin (relenv not set). + opts.pop("relenv", None) + target["relenv"] = True + + single = ssh.Single( + opts, + opts["argv"], + "localhost", + mods={}, + fsclient=None, + thin=salt.utils.thin.thin_path(opts["cachedir"]), + mine=False, + **target, + ) + + # The roster-level relenv=True must be honored via opts so the downstream + # thin_dir suffixing, shim selection, and tarball resolution all take the + # relenv branch. + assert single.opts.get("relenv") is True + assert single.thin_dir.endswith("_salt_relenv") + + +def test_single_relenv_absent_from_roster_defaults_thin(opts, target): + """ + Companion to #69885 regression test: omitting ``relenv`` from a roster + entry must NOT force relenv on that host, so wildcard salt-ssh calls keep + using the lightweight thin deployment for hosts that don't need relenv. + """ + opts["ssh_wipe"] = True + opts.pop("relenv", None) + target.pop("relenv", None) + + single = ssh.Single( + opts, + opts["argv"], + "localhost", + mods={}, + fsclient=None, + thin=salt.utils.thin.thin_path(opts["cachedir"]), + mine=False, + **target, + ) + + assert not single.opts.get("relenv") + assert not single.thin_dir.endswith("_salt_relenv") + + @pytest.mark.skip_on_windows(reason="SSH_PY_SHIM not set on windows") @pytest.mark.slow_test def test_cmd_block_python_version_error(opts, target): diff --git a/tests/pytests/unit/config/schemas/test_ssh.py b/tests/pytests/unit/config/schemas/test_ssh.py index 602b693c9fe5..2513f78e1d0a 100644 --- a/tests/pytests/unit/config/schemas/test_ssh.py +++ b/tests/pytests/unit/config/schemas/test_ssh.py @@ -98,6 +98,16 @@ def test_config(): ), "title": "Thin Directory", }, + "relenv": { + "default": False, + "type": "boolean", + "description": ( + "Deploy and use a relenv (Salt+Python bundled) environment" + " on the SSH target, equivalent to the --relenv CLI flag" + " but scoped to this roster entry." + ), + "title": "Relenv", + }, # The actuall representation of the minion options would make this HUGE! "minion_opts": ssh_schemas.DictItem( title="Minion Options", @@ -117,6 +127,7 @@ def test_config(): "sudo", "timeout", "thin_dir", + "relenv", "minion_opts", ], "additionalProperties": False, @@ -204,6 +215,21 @@ def test_config_validate(): except jsonschema.exceptions.ValidationError as exc: pytest.fail(f"ValidationError raised: {exc}") + try: + # Regression for #69885 - roster may set relenv per host + jsonschema.validate( + { + "host": "127.1.0.1", + "user": "root", + "passwd": "foo", + "relenv": True, + }, + ssh_schemas.RosterEntryConfig.serialize(), + format_checker=jsonschema.FormatChecker(), + ) + except jsonschema.exceptions.ValidationError as exc: + pytest.fail(f"ValidationError raised: {exc}") + with pytest.raises(jsonschema.exceptions.ValidationError) as excinfo: jsonschema.validate( {"host": "127.1.0.1", "user": "", "passwd": "foo"}, From a7436d2ce22870910e9c796510603b35c0556eb2 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 27 Jul 2026 19:47:41 -0700 Subject: [PATCH 144/469] Release workflow fails when multiple draft releases exist for the target version (#69875) Adds a new `tools ci check-draft-releases ` command that queries the GitHub Releases API and exits non-zero if more than one draft release is found for the given tag, listing the duplicates. Calls it as an early step in the release.yml prepare-workflow job. Fixes #69861 --- .github/workflows/release.yml | 6 +++ changelog/69861.fixed.md | 1 + tools/ci.py | 80 +++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 changelog/69861.fixed.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dbc254bfbbe..a8d19d2af0ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,6 +91,12 @@ jobs: salt-version: "${{ inputs.salt-version }}" validate-version: true + - name: Check For Duplicate Draft Releases + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tools ci check-draft-releases ${{ steps.setup-salt-version.outputs.salt-version }} + - name: Get Salt Releases id: get-salt-releases env: diff --git a/changelog/69861.fixed.md b/changelog/69861.fixed.md new file mode 100644 index 000000000000..2fecf648aac2 --- /dev/null +++ b/changelog/69861.fixed.md @@ -0,0 +1 @@ +The release workflow now fails immediately with a clear error message if more than one draft release exists for the target version, preventing silent publication of the wrong artifact set. diff --git a/tools/ci.py b/tools/ci.py index 534cab7c544a..5c002721bd6b 100644 --- a/tools/ci.py +++ b/tools/ci.py @@ -167,6 +167,86 @@ def _build_matrix(os_kind, linux_arm_runner): return _matrix +@ci.command( + name="check-draft-releases", + arguments={ + "salt_version": { + "help": "The salt version to check for duplicate draft releases.", + "metavar": "SALT_VERSION", + }, + "repository": { + "help": "The repository to query for releases, e.g. saltstack/salt", + }, + }, +) +def check_draft_releases( + ctx: Context, salt_version: str, repository: str = "saltstack/salt" +): + """ + Fail if more than one draft release exists for the given salt version. + + A duplicate draft release is almost always a human error during release + prep. Proceeding silently risks publishing the wrong artifact set. + """ + tag = f"v{salt_version}" if not salt_version.startswith("v") else salt_version + ctx.info( + f"Checking for duplicate draft releases tagged {tag!r} in {repository!r} ..." + ) + + with ctx.web as web: + headers = { + "Accept": "application/vnd.github+json", + } + github_token = tools.utils.gh.get_github_token(ctx) + if github_token is not None: + headers["Authorization"] = f"Bearer {github_token}" + web.headers.update(headers) + + page = 1 + draft_releases = [] + while True: + ret = web.get( + f"https://api.github.com/repos/{repository}/releases", + params={"per_page": 100, "page": page}, + ) + if ret.status_code != 200: + ctx.error(f"Failed to get releases for {repository!r}: {ret.reason}") + ctx.exit(1) + releases = ret.json() + if not releases: + break + for release in releases: + if release.get("draft", False) and release.get("tag_name") == tag: + draft_releases.append(release) + if len(releases) < 100: + break + page += 1 + + if len(draft_releases) > 1: + ctx.error( + f"Found {len(draft_releases)} draft releases for {tag!r}. " + "There must be exactly one. Please delete the duplicate(s) before " + "re-running the release workflow. Duplicates found:" + ) + for rel in draft_releases: + ctx.error( + f" id={rel['id']} name={rel['name']!r} " f"url={rel['html_url']}" + ) + ctx.exit(1) + + if len(draft_releases) == 0: + ctx.warn( + f"No draft release found for {tag!r}. " + "The release workflow expects a draft release to exist at this point." + ) + else: + ctx.info( + f"Found exactly one draft release for {tag!r}: " + f"id={draft_releases[0]['id']} name={draft_releases[0]['name']!r}" + ) + ctx.exit(0) + + @ci.command( name="get-releases", arguments={ From 3e780bd6d2d5de0f2127ec0a6f286580ee3031da Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 27 Jul 2026 19:48:03 -0700 Subject: [PATCH 145/469] Skip PyPI upload for patch releases with a -N version suffix (#69874) Versions like 3008.1-1 are RPM packaging revisions; the Python package is unchanged and already on PyPI. Uploading them creates a spurious post-release entry (3008.1.post1) that is confusing and wrong. Fixes #69862 --- .github/workflows/release.yml | 2 +- changelog/69862.fixed.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog/69862.fixed.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8d19d2af0ca..d6f3d7331fa7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -203,7 +203,7 @@ jobs: publish-pypi: name: Publish to PyPi - if: ${{ always() && ! failure() && ! cancelled() && github.event.repository.fork != true }} + if: ${{ always() && ! failure() && ! cancelled() && github.event.repository.fork != true && !contains(inputs.salt-version, '-') }} needs: - prepare-workflow - release diff --git a/changelog/69862.fixed.md b/changelog/69862.fixed.md new file mode 100644 index 000000000000..11440ae85da3 --- /dev/null +++ b/changelog/69862.fixed.md @@ -0,0 +1 @@ +Skip the PyPI upload step for patch releases (versions containing a ``-N`` suffix, e.g. ``3008.1-1``) since those are RPM-specific packaging revisions and the base Python package is already on PyPI. From 9292147e1bf5b22a92eac33eb3130162bc09deb1 Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 27 Jul 2026 19:48:16 -0700 Subject: [PATCH 146/469] Fix publish-draft running when PyPI upload fails (#69873) Replace `always()` with `!failure()` in the publish-draft job condition so the draft release is not published if the PyPI upload step fails or is cancelled. Fixes #69863 --- .github/workflows/release.yml | 2 +- changelog/69863.fixed.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog/69863.fixed.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6f3d7331fa7..1c747e434feb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,7 +253,7 @@ jobs: publish-draft: name: Publish Relase v${{ needs.prepare-workflow.outputs.salt-version }} - if: ${{ !cancelled() && always() }} + if: ${{ !cancelled() && !failure() }} runs-on: ubuntu-22.04 needs: - check-requirements diff --git a/changelog/69863.fixed.md b/changelog/69863.fixed.md new file mode 100644 index 000000000000..5ddb2d6b2ec8 --- /dev/null +++ b/changelog/69863.fixed.md @@ -0,0 +1 @@ +The release workflow no longer publishes the draft GitHub release when the PyPI upload step fails. From 4b04c3eae604809168020dcac1b12c82387e259a Mon Sep 17 00:00:00 2001 From: Shane Lee Date: Mon, 27 Jul 2026 20:48:30 -0600 Subject: [PATCH 147/469] Update bootstrap script to v2026.07.23 (#69864) --- salt/cloud/deploy/bootstrap-salt.sh | 89 ++++++++++++++++++----------- 1 file changed, 56 insertions(+), 33 deletions(-) diff --git a/salt/cloud/deploy/bootstrap-salt.sh b/salt/cloud/deploy/bootstrap-salt.sh index 0f1b2735a152..df4666a131ab 100644 --- a/salt/cloud/deploy/bootstrap-salt.sh +++ b/salt/cloud/deploy/bootstrap-salt.sh @@ -26,7 +26,7 @@ #====================================================================================================================== set -o nounset # Treat unset variables as an error -__ScriptVersion="2026.07.10" +__ScriptVersion="2026.07.23" __ScriptName="bootstrap-salt.sh" __ScriptFullName="$0" @@ -617,6 +617,33 @@ ONEDIR_REV="latest" _ONEDIR_REV="latest" YUM_REPO_FILE="/etc/yum.repos.d/salt.repo" +#--- FUNCTION ------------------------------------------------------------------------------------------------------- +# NAME: __validate_salt_version_arg +# DESCRIPTION: True (status 0) if $1 is a valid Salt version argument: +# latest, a bare 4-digit major version, or MAJOR.MINOR[.MICRO] +# with at most one of an rcN prerelease suffix or a -N +# package-release suffix (e.g. 3006, 3008.1, 3008.0rc1, 3008.1-1). +#---------------------------------------------------------------------------------------------------------------------- +__validate_salt_version_arg() { + echo "$1" | grep -qE '^(latest|[0-9]{4}(\.[0-9]+(\.[0-9]+)*(rc[0-9]+|-[0-9]+)?)?)$' +} + +#--- FUNCTION ------------------------------------------------------------------------------------------------------- +# NAME: __salt_version_string +# DESCRIPTION: Render a validated Salt version string verbatim for use in +# an RPM package name, an APT pin, a .repo section name, an +# onedir/macOS tarball URL, or a GitHub release tag. A -N +# package-release suffix (e.g. 3008.1-1) is a real, published +# repackage of the same version across every artifact type +# (RPM, APT, onedir, macOS, GitHub releases), so it must be +# preserved verbatim everywhere rather than stripped or +# rejected; rcN prerelease suffixes never contain a hyphen so +# they pass through unchanged too. +#---------------------------------------------------------------------------------------------------------------------- +__salt_version_string() { + echo "$1" +} + # check if systemd is functional __check_services_systemd_functional @@ -664,13 +691,7 @@ elif [ "$ITYPE" = "stable" ]; then _ONEDIR_REV="latest" ITYPE="onedir" else - if [ "$(echo "$1" | grep -E '^(latest|[0-9]{4})$')" != "" ]; then - STABLE_REV="$1" - ONEDIR_REV="$1" - _ONEDIR_REV="$1" - ITYPE="onedir" - shift - elif [ "$(echo "$1" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then + if __validate_salt_version_arg "$1"; then STABLE_REV="$1" ONEDIR_REV="$1" _ONEDIR_REV="$1" @@ -687,11 +708,7 @@ elif [ "$ITYPE" = "onedir" ]; then ONEDIR_REV="latest" STABLE_REV="latest" else - if [ "$(echo "$1" | grep -E '^(latest|[0-9]{4})$')" != "" ]; then - ONEDIR_REV="$1" - STABLE_REV="$1" - shift - elif [ "$(echo "$1" | grep -E '^([3-9][0-9]{3}(\.[0-9]*)?)')" != "" ]; then + if __validate_salt_version_arg "$1"; then ONEDIR_REV="$1" STABLE_REV="$1" shift @@ -3067,7 +3084,7 @@ __install_saltstack_ubuntu_onedir_repository() { echo "Pin: version $ONEDIR_REV.*" >> /etc/apt/preferences.d/salt-pin-1001 echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/salt-pin-1001 elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then - ONEDIR_REV_DOT=$(echo "$ONEDIR_REV" | sed 's/-/\./') + ONEDIR_REV_DOT=$(__salt_version_string "$ONEDIR_REV") echo "Package: salt-*" > /etc/apt/preferences.d/salt-pin-1001 echo "Pin: version $ONEDIR_REV_DOT" >> /etc/apt/preferences.d/salt-pin-1001 echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/salt-pin-1001 @@ -3519,7 +3536,7 @@ __install_saltstack_debian_repository() { echo "Pin: version $STABLE_REV.*" >> /etc/apt/preferences.d/salt-pin-1001 echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/salt-pin-1001 elif [ "$(echo "$STABLE_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then - STABLE_REV_DOT=$(echo "$STABLE_REV" | sed 's/-/\./') + STABLE_REV_DOT=$(__salt_version_string "$STABLE_REV") MINOR_VER_STRG="-$STABLE_REV_DOT" echo "Package: salt-*" > /etc/apt/preferences.d/salt-pin-1001 echo "Pin: version $STABLE_REV_DOT" >> /etc/apt/preferences.d/salt-pin-1001 @@ -3565,7 +3582,7 @@ __install_saltstack_debian_onedir_repository() { echo "Pin: version $ONEDIR_REV.*" >> /etc/apt/preferences.d/salt-pin-1001 echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/salt-pin-1001 elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then - ONEDIR_REV_DOT=$(echo "$ONEDIR_REV" | sed 's/-/\./') + ONEDIR_REV_DOT=$(__salt_version_string "$ONEDIR_REV") echo "Package: salt-*" > /etc/apt/preferences.d/salt-pin-1001 echo "Pin: version $ONEDIR_REV_DOT" >> /etc/apt/preferences.d/salt-pin-1001 echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/salt-pin-1001 @@ -3915,7 +3932,7 @@ __install_saltstack_fedora_onedir_repository() { fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version - ONEDIR_REV_DOT=$(echo "$ONEDIR_REV" | sed 's/-/\./') + ONEDIR_REV_DOT=$(__salt_version_string "$ONEDIR_REV") echo "[salt-repo-${ONEDIR_REV_DOT}-lts]" > "${YUM_REPO_FILE}" # shellcheck disable=SC2129 echo "name=Salt Repo for Salt v${ONEDIR_REV_DOT} LTS" >> "${YUM_REPO_FILE}" @@ -4152,7 +4169,7 @@ install_fedora_onedir() { MINOR_VER_STRG="" elif [ "$(echo "$STABLE_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # Minor version Salt, need to add specific minor version - STABLE_REV_DOT=$(echo "$STABLE_REV" | sed 's/-/\./') + STABLE_REV_DOT=$(__salt_version_string "$STABLE_REV") MINOR_VER_STRG="-$STABLE_REV_DOT" else MINOR_VER_STRG="" @@ -4245,7 +4262,7 @@ __install_saltstack_rhel_onedir_repository() { fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version - ONEDIR_REV_DOT=$(echo "$ONEDIR_REV" | sed 's/-/\./') + ONEDIR_REV_DOT=$(__salt_version_string "$ONEDIR_REV") echo "[salt-repo-${ONEDIR_REV_DOT}-lts]" > "${YUM_REPO_FILE}" # shellcheck disable=SC2129 echo "name=Salt Repo for Salt v${ONEDIR_REV_DOT} LTS" >> "${YUM_REPO_FILE}" @@ -4317,7 +4334,7 @@ install_centos_stable() { MINOR_VER_STRG="" elif [ "$(echo "$STABLE_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # Minor version Salt, need to add specific minor version - STABLE_REV_DOT=$(echo "$STABLE_REV" | sed 's/-/\./') + STABLE_REV_DOT=$(__salt_version_string "$STABLE_REV") MINOR_VER_STRG="-$STABLE_REV_DOT" else MINOR_VER_STRG="" @@ -4537,7 +4554,7 @@ install_centos_onedir() { MINOR_VER_STRG="" elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # Minor version Salt, need to add specific minor version - ONEDIR_REV_DOT=$(echo "$ONEDIR_REV" | sed 's/-/\./') + ONEDIR_REV_DOT=$(__salt_version_string "$ONEDIR_REV") MINOR_VER_STRG="-$ONEDIR_REV_DOT" else MINOR_VER_STRG="" @@ -5697,7 +5714,7 @@ install_amazon_linux_ami_2_deps() { fi elif [ "$(echo "$STABLE_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version - STABLE_REV_DOT=$(echo "$STABLE_REV" | sed 's/-/\./') + STABLE_REV_DOT=$(__salt_version_string "$STABLE_REV") echo "[salt-repo-${STABLE_REV_DOT}-lts]" > "${YUM_REPO_FILE}" echo "name=Salt Repo for Salt v${STABLE_REV_DOT} LTS" >> "${YUM_REPO_FILE}" echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" @@ -5798,7 +5815,7 @@ install_amazon_linux_ami_2_onedir_deps() { fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version - ONEDIR_REV_DOT=$(echo "$ONEDIR_REV" | sed 's/-/\./') + ONEDIR_REV_DOT=$(__salt_version_string "$ONEDIR_REV") echo "[salt-repo-${ONEDIR_REV_DOT}-lts]" > "${YUM_REPO_FILE}" echo "name=Salt Repo for Salt v${ONEDIR_REV_DOT} LTS" >> "${YUM_REPO_FILE}" echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" @@ -5991,7 +6008,7 @@ install_amazon_linux_ami_2023_onedir_deps() { fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version - ONEDIR_REV_DOT=$(echo "$ONEDIR_REV" | sed 's/-/\./') + ONEDIR_REV_DOT=$(__salt_version_string "$ONEDIR_REV") echo "[salt-repo-${ONEDIR_REV_DOT}-lts]" > "${YUM_REPO_FILE}" echo "name=Salt Repo for Salt v${ONEDIR_REV_DOT} LTS" >> "${YUM_REPO_FILE}" echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" @@ -6217,10 +6234,12 @@ install_arch_linux_onedir() { # Resolve "latest" to actual version if [ "$version" = "latest" ]; then version=$(wget -qO- https://api.github.com/repos/saltstack/salt/releases/latest \ - | grep -Eo '"tag_name": *"v[0-9.]+"' \ + | grep -Eo '"tag_name": *"v[0-9.]+(-[0-9]+)?"' \ | sed 's/"tag_name": *"v//;s/"//') || return 1 fi + version=$(__salt_version_string "$version") + tarball="salt-${version}-onedir-linux-${arch}.tar.xz" url="https://github.com/saltstack/salt/releases/download/v${version}/${tarball}" extractdir="/tmp/salt-${version}-onedir-linux-${arch}" @@ -6439,11 +6458,15 @@ EOF #--- FUNCTION ------------------------------------------------------------------------------------------------------- # NAME: __salt_onedir_filter_ga_version_dirs -# DESCRIPTION: From stdin: keep only GA CalVer-style directory names (digits and dots; -# prerelease dirs like 3008.0rc1 are excluded). +# DESCRIPTION: From stdin: keep only GA CalVer-style directory names (digits +# and dots, with an optional -N package-release suffix, e.g. +# 3008.1 or 3008.1-1). Prerelease dirs like 3008.0rc1 are +# excluded; sort -V already orders 3008.1-1 after 3008.1, so +# "latest"/major-only resolution naturally prefers a -N +# repackage over the bare version it replaces. #---------------------------------------------------------------------------------------------------------------------- __salt_onedir_filter_ga_version_dirs() { - grep -E '^[0-9]+\.[0-9]+(\.[0-9]+)*$' + grep -E '^[0-9]+\.[0-9]+(\.[0-9]+)*(-[0-9]+)?$' } #--- FUNCTION ------------------------------------------------------------------------------------------------------- @@ -6544,7 +6567,7 @@ __install_saltstack_vmware_photon_os_onedir_repository() { fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version - ONEDIR_REV_DOT=$(echo "$ONEDIR_REV" | sed 's/-/\./') + ONEDIR_REV_DOT=$(__salt_version_string "$ONEDIR_REV") echo "[salt-repo-${ONEDIR_REV_DOT}-lts]" > "${YUM_REPO_FILE}" echo "name=Salt Repo for Salt v${ONEDIR_REV_DOT} LTS" >> "${YUM_REPO_FILE}" echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${YUM_REPO_FILE}" @@ -6837,7 +6860,7 @@ install_vmware_photon_os_onedir() { MINOR_VER_STRG="-$_GENERIC_PKG_VERSION" elif [ "$(echo "$STABLE_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # Minor version Salt, need to add specific minor version - STABLE_REV_DOT=$(echo "$STABLE_REV" | sed 's/-/\./') + STABLE_REV_DOT=$(__salt_version_string "$STABLE_REV") MINOR_VER_STRG="-$STABLE_REV_DOT" else # default to latest version Salt, config and repo already setup @@ -6945,7 +6968,7 @@ __check_and_refresh_suse_pkg_repo() { fi elif [ "$(echo "$ONEDIR_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # using minor version - ONEDIR_REV_DOT=$(echo "$ONEDIR_REV" | sed 's/-/\./') + ONEDIR_REV_DOT=$(__salt_version_string "$ONEDIR_REV") echo "[salt-repo-${ONEDIR_REV_DOT}-lts]" > "${ZYPPER_REPO_FILE}" echo "name=Salt Repo for Salt v${ONEDIR_REV_DOT} LTS" >> "${ZYPPER_REPO_FILE}" echo "baseurl=https://${_REPO_URL}/saltproject-rpm/" >> "${ZYPPER_REPO_FILE}" @@ -7109,7 +7132,7 @@ install_opensuse_stable() { MINOR_VER_STRG="" elif [ "$(echo "$STABLE_REV" | grep -E '^([3-9][0-5]{2}[6-9](\.[0-9]*)?)')" != "" ]; then # Minor version Salt, need to add specific minor version - STABLE_REV_DOT=$(echo "$STABLE_REV" | sed 's/-/\./') + STABLE_REV_DOT=$(__salt_version_string "$STABLE_REV") MINOR_VER_STRG="-$STABLE_REV_DOT" else MINOR_VER_STRG="" @@ -7978,7 +8001,7 @@ __macosx_get_packagesite_onedir() { # need to get latest for major version __macosx_get_packagesite_onedir_latest "$_ONEDIR_REV" || return 1 elif [ "$(echo "$_ONEDIR_REV" | grep -E '^([3-9][0-9]{3}(\.[0-9]*)?)')" != "" ]; then - _PKG_VERSION=$_ONEDIR_REV + _PKG_VERSION=$(__salt_version_string "$_ONEDIR_REV") else # default to getting latest __macosx_get_packagesite_onedir_latest || return 1 From 5be0b0eefd06a4938771488bb0d8ae2a515d1abb Mon Sep 17 00:00:00 2001 From: Daniel Wozniak Date: Mon, 27 Jul 2026 20:03:06 -0700 Subject: [PATCH 148/469] Merge 3008.1-1 into 3008.x (#69865) * Add patch-release support to packaging toolchain Introduce first-class support for patch releases (e.g. 3008.1-1) across the full packaging stack: - salt/version.py: add 'patch' field to SaltStackVersion with word-boundary lookahead to disambiguate patch suffix from git-describe commit count; parse() uses groupdict() to keep patch keyword-only and avoid positional arg breakage - tools/changelog.py: _get_salt_version() reads _version.txt directly when present; update_rpm() splits post-release into Version/Release fields - tools/pkg/__init__.py: guard --release stamp against post-releases - tools/pkg/build.py: pass -b to debuild for patch releases to avoid dpkg-source native+hyphen incompatibility - pkg/windows/msi/build_pkg.ps1: extend version regex and produce 4-part InternalVersion (major1.major2.minor.patch) - .github/workflows/templates/layout.yml.jinja: add patch-branch glob '[0-9][0-9][0-9][0-9].[0-9]*-[0-9]*' to on-push triggers; regenerate ci.yml - tests/pytests/unit/test_version.py: add parsing and ordering tests - tests/pytests/pkg/integration/test_version.py: add RPM NVR ordering test * Add NNNN.N-patch branch glob to CI on-push trigger * Fix staging/release workflows to handle patch release versions alongside RCs - tools/ci.py: get_release_changelog_target() now matches patch branches (e.g. refs/heads/3008.1-patch) via regex fallback when the standard release_branches substring check doesn't match - staging.yml.jinja + staging.yml: document 3008.1-1 as valid input form - release.yml: document patch release version format and RPM Release: N sort ordering in the salt-version input description * Rename normalized sdist back to hyphenated form for patch releases setuptools normalizes "3008.1-1" to "3008.1.post1" per PEP 440, producing salt-3008.1.post1.tar.gz. After recompression, rename it to salt-3008.1-1.tar.gz so that CI artifact names and the release workflow's download paths stay consistent with the version string from salt/_version.txt. * Fix _default_artifact_version to preserve patch release suffix The old code called .split("-")[0] which stripped the patch release suffix (-1) from every artifact type, so pep440_public_equal("3008.1-1", "3008.1") always returned False and all 63 package tests failed. For RPM-family artifacts the release digit lives in regex group 2 (-N.el, -N.fc, -N.am); reconstruct "version-release" when release > 0. For non-RPM artifacts (DEB, macOS pkg, MSI, exe) the patch digit may already be in group 1 ("3008.1-1" or "3008.1-1-macos"); preserve a purely-numeric first hyphen segment and strip platform suffixes. * Disable salt-repo-3008-lts when installing 3006.x prev_version Newer salt.repo files downloaded from the install guide enable both salt-repo-3006-lts and salt-repo-3008-lts by default. The 3006.x branch in install_previous() disabled salt-repo-3007-sts but not the 3008 LTS repo, so an unversioned `yum install salt` would pull 3008.2 (the latest in the 3008 LTS channel) instead of staying on 3006.x. For patch releases (e.g. 3008.1-1), this caused test_salt_upgrade to fail: start_version=3008.2 > artifact_version=3008.1.post1. Don't call _check_retcode here because older salt.repo files without the [salt-repo-3008-lts] stanza would return non-zero from config-manager. * Fix package fixture to match patch release RPM filenames The rpm_re pattern hardcoded release=0 (-0.) which never matches a patch release RPM like salt-3008.1-1.x86_64.rpm (release=1). The fallback filename builder also always appended -0. Both paths now handle any release number; the fallback derives the correct NVR from packaging.version for post-releases. * Disable salt-repo-3008-lts when installing 3007.x prev_version The downloaded salt.repo now enables salt-repo-3008-lts by default (3008 is current LTS). When install_previous() enabled salt-repo-3007-sts to install a 3007.x base version, the 3008-lts repo remained active and yum resolved the unversioned `salt` install to 3008.2 instead of 3007.x, causing upgrade tests to assert 3008.2 <= 3008.1.post1. Mirrors the same fix already applied in the 3006.x else-branch. * Defer OpenTelemetry imports in salt.utils.tracing and salt.utils.metrics Both modules unconditionally imported the OTel SDK at module load, even though tracing.enabled and metrics.enabled default to false. Every salt daemon entry point transitively imports both modules, so a ~15-process salt-master container was paying ~15 MB per subsystem per process (~450 MB total) for functionality nobody was using. Move the OTel imports into _load_otel() helpers invoked only after is_enabled() returns True. Public API is preserved. Backport of 73bca444ec2 from PR #69856. * Fix HighState/State leaking fileclient on init failure (#69637) When HighState.__init__ or State.__init__ raised after allocating their fileclient (e.g. BaseHighState.__init__ failing during master_opts(), or _gather_pillar() failing during pillar compilation), the caller never received the instance and therefore never called .destroy(). The fileclient's ZeroMQ RequestClient was finalized during garbage collection with _closing = False, tripping the ``TransportWarning: Unclosed transport!`` warning that PR #65559 added. Wrap both constructors' post-allocation bodies in try/except that destroys the freshly-allocated fileclient before re-raising. Also close the temporary Pillar object built by State._gather_pillar() in a try/finally so its channel doesn't rely on __del__ ordering at shutdown. Backport of cb098940aa from PR #69675. * Fix three TCP-transport EventPublisher memory leaks (#69847, #69857) Three orthogonal leaks in the salt-master EventPublisher / salt-api TCP transport that together drove multi-GB steady-state growth under sustained load: 1. MessageClient.close() polled ``send_future_map`` at 1s intervals via ``check_close`` and only actually tore down the transport when that map drained. One orphaned in-flight future (e.g. a coroutine cancelled by cherrypy mid-request) kept the map non-empty forever, so the whole MessageClient graph -- Unpacker, IOStream, LazyLoaders reachable via self -- stayed alive. Under salt-api load memray showed ~18 MessageClient objects/s leaking. Close synchronously instead: cancel pending futures with SaltReqTimeoutError, tear the tcp client and stream down before returning, and guard connect() against clobbering _closing/_closed after close() has run. Backport of fb6f95ac283. 2. PubServer had no way to notice a subscriber disconnect promptly -- ``_stream_read`` only observes the close on the next ``read_bytes`` return, but event-bus subscribers never write, so the read is a permanently-pending await. Result: subscribers accumulate in ``self.clients`` from the moment the peer half-closes until ``publish_payload`` throws on the next write to that stream, and the Tornado IOStream's ``_read_buffer`` / ``_write_buffer`` bytearrays stay pinned. Observed on a live 3008.2 master: 7500+ leaked subscriber sockets / 150 GB RSS in 24 h. Register a ``stream.set_close_callback`` alongside ``clients.add(client)`` so ``clients.discard(client)`` fires the instant the peer goes away (mirrors 3006.x ``IPCMessagePublisher``'s ``discard_after_closed``). 3. TCPPuller.handle_stream fired each payload via ``self.io_loop.create_task(self.payload_handler(body))`` and immediately looped back to read the next framed message. Under sustained publish load (~5000 events/sec) tasks accumulated in the io_loop faster than they could complete: 909,120 pending tasks on the EventPublisher after ~5 min drove RSS to 10 GB (each task frame plus retained event payload is ~11 kB). The 3006.x equivalent (``IPCMessagePublisher._write``, commit d4e2e075aa3) solved the same accumulation by switching from ``@gen.coroutine`` to a plain function with ``future.add_done_callback``. On 3008.1's asyncio-native transport the natural equivalent is to apply backpressure at the reader: await the handler inline. A misbehaving handler is caught and logged so a single bad event can't kill the reader loop. Combined, these keep EP RSS flat and pending-task count in the low tens under identical stress load. * Add nightly stress-test workflow + monitoring infra Copies tests/monitoring/ and .github/workflows/nightly-stress-test.yml verbatim from origin/3008.x head so 3008.1-patch can dispatch and schedule the same stress harness we use to validate memory profile changes on 3008.x. Contents (all newly added on this branch, no existing 3008.1-patch files touched): - .github/workflows/nightly-stress-test.yml -- runs at 02:00 UTC and on workflow_dispatch (with configurable ``duration``) - tests/monitoring/Dockerfile.salt + docker-compose.yml + master.conf + minion.conf -- 3-minion salt-master + salt-api + prometheus + grafana + cadvisor test rig - tests/monitoring/stress_test.sh + stress_api.sh -- driver scripts - tests/monitoring/analyze_stats.py + render_panels.py -- post-run Prometheus queries, slope analysis, and PNG dashboard rendering - tests/monitoring/srv/salt/ -- test states, event flooders, and observability sidecar for the master container - tests/monitoring/grafana/provisioning/ -- dashboards + datasource All copied at origin/3008.x tip; no local modifications. * Add tests/monitoring to test_module_names EXCLUDED_DIRS The tests/monitoring directory contains helper scripts (analyze_stats.py, render_panels.py, srv/salt/flood_events.py, etc.) that aren't pytest modules -- they're driver / observer scripts for the stress-test harness -- and don't follow the ``test_*.py`` naming convention. test_module_names.py::BadTestModuleNamesTestCase flags them and the ``unit / zeromq`` job under the Stage Release workflow fails. Mirror the same EXCLUDED_DIRS entry origin/3008.x has for the same directory. * Release v3008.1-1 --------- Co-authored-by: Salt Project Packaging --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 8 +- .github/workflows/staging.yml | 3 +- .github/workflows/templates/layout.yml.jinja | 2 + .github/workflows/templates/staging.yml.jinja | 3 +- CHANGELOG.md | 2 + doc/topics/releases/3008.1.md | 96 +------------------ pkg/debian/changelog | 10 ++ pkg/rpm/salt.spec | 5 + pkg/windows/msi/build_pkg.ps1 | 8 +- salt/version.py | 31 +++++- .../pytests/pkg/integration/test_pkg_meta.py | 11 ++- tests/pytests/pkg/integration/test_version.py | 35 +++++++ tests/pytests/unit/test_version.py | 29 ++++++ tests/support/pkg.py | 38 ++++++-- tools/changelog.py | 26 ++++- tools/ci.py | 19 +++- tools/pkg/__init__.py | 24 ++++- tools/pkg/build.py | 10 +- 19 files changed, 239 insertions(+), 123 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dca4587f6f1..e58721b678c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ on: - 3006.x - 3007.x - 3008.x + - '[0-9][0-9][0-9][0-9].[0-9]*-[0-9]*' + - '[0-9][0-9][0-9][0-9].[0-9]*-patch' - master pull_request: types: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c747e434feb..ae26b25d8e29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,11 +12,15 @@ on: DO NOT prefix the version with a "v" (use 3006.0, not v3006.0). For prereleases use the PEP 440 form WITHOUT a hyphen (use 3008.0rc1, not 3008.0-rc1). + For patch releases use the post-release form with a hyphen and number + (use 3008.1-1 for the first patch of 3008.1). The Python sdist/wheel and the GitHub tag/release will use this - string verbatim (e.g. "salt-3008.0rc1.tar.gz" / "v3008.0rc1"). + string verbatim (e.g. "salt-3008.0rc1.tar.gz" / "v3008.0rc1", + "salt-3008.1-1.tar.gz" / "v3008.1-1"). The RPM "Version:" and the Debian changelog stanza substitute "rc" for "~rc" so prereleases sort before the GA version - (e.g. "3008.0~rc1" < "3008.0"). + (e.g. "3008.0~rc1" < "3008.0"). Patch releases set RPM Release: N + so they sort after the base (e.g. "3008.1-1" > "3008.1-0"). skip-salt-pkg-download-test-suite: type: boolean default: false diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 646e5cb5fe5a..f6fb8de551bd 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -13,7 +13,8 @@ on: required: true description: > The Salt version to set prior to building packages and staging the release. - Good: 3006.0, 3008.0rc1. Bad: v3006.0, 3008.0-rc1, 3008.0~rc1. + Good: 3006.0, 3008.0rc1, 3008.1-1. Bad: v3006.0, 3008.0-rc1, 3008.0~rc1. + For patch releases use the post-release form: 3008.1-1 (first patch of 3008.1). sign-windows-packages: type: boolean default: false diff --git a/.github/workflows/templates/layout.yml.jinja b/.github/workflows/templates/layout.yml.jinja index 9e025ebc6104..2fd017321d54 100644 --- a/.github/workflows/templates/layout.yml.jinja +++ b/.github/workflows/templates/layout.yml.jinja @@ -23,6 +23,8 @@ on: - 3006.x - 3007.x - 3008.x + - '[0-9][0-9][0-9][0-9].[0-9]*-[0-9]*' + - '[0-9][0-9][0-9][0-9].[0-9]*-patch' - master pull_request: types: diff --git a/.github/workflows/templates/staging.yml.jinja b/.github/workflows/templates/staging.yml.jinja index 611807c96e05..5c14248c5d7f 100644 --- a/.github/workflows/templates/staging.yml.jinja +++ b/.github/workflows/templates/staging.yml.jinja @@ -24,7 +24,8 @@ on: required: true description: > The Salt version to set prior to building packages and staging the release. - Good: 3006.0, 3008.0rc1. Bad: v3006.0, 3008.0-rc1, 3008.0~rc1. + Good: 3006.0, 3008.0rc1, 3008.1-1. Bad: v3006.0, 3008.0-rc1, 3008.0~rc1. + For patch releases use the post-release form: 3008.1-1 (first patch of 3008.1). sign-windows-packages: type: boolean default: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 563c7dfc78af..b7616906c4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Versions are `MAJOR.PATCH`. ### Changed - Upgrade the bundled onedir Python from 3.10.20 to 3.11.15 on the 3006.x branch. Python 3.10 reaches end of security support in October 2026, while Salt 3006.x must ship security fixes through July 2027. Users upgrading from a previous 3006.x package will need to reinstall any Salt extensions installed via `salt-pip` because the onedir `extras-3.10` directory is replaced by `extras-3.11`. [#69526](https://github.com/saltstack/salt/issues/69526) +## 3008.1-1 (2026-07-23) ### Fixed @@ -109,6 +110,7 @@ Versions are `MAJOR.PATCH`. - added conditional X functionality to linux_acl [#62852](https://github.com/saltstack/salt/issues/62852) - Added ``unmask`` parameter to ``pillar.ls``, ``pillar.raw``, ``pillar.ext``, ``pillar.keys``, and ``pillar.obfuscate`` for API consistency with ``pillar.get`` / ``pillar.items`` / ``pillar.item`` / ``pillar.data``. Default masking behavior is unchanged. [#69453](https://github.com/saltstack/salt/issues/69453) - Documented the ``gitcli`` GitFS provider (added in 3008.0) which shells out to the system ``git`` binary, auto-detected after ``pygit2`` and ``gitpython`` and used as a silent fallback when neither Python library is installed. Documented the ``cluster_isolated_filesystem`` master option (added in 3008.0) which lets master clusters run without a shared filesystem; keys, denied keys, ``file_roots`` and ``pillar_roots`` are sync'd in-band over the cluster transport, with ``keys.cache_driver: mmap_key`` as the recommended companion. [#69494](https://github.com/saltstack/salt/issues/69494) +- Deferred OpenTelemetry imports in `salt.utils.tracing` and `salt.utils.metrics` so daemons no longer pay the ~15 MB per-process OTel import cost when `tracing.enabled` / `metrics.enabled` are false (the default). On a stress-tested salt-master container (~15 Python processes) this reclaims ~225 MB per subsystem — restoring the pre-3008.x baseline. [#69855](https://github.com/saltstack/salt/issues/69855) ## 3008.1 (2026-06-11) diff --git a/doc/topics/releases/3008.1.md b/doc/topics/releases/3008.1.md index f79b03a59049..4e93b33bf7c8 100644 --- a/doc/topics/releases/3008.1.md +++ b/doc/topics/releases/3008.1.md @@ -18,100 +18,6 @@ This is auto generated. --> ## Changelog -### Changed - -- Changed `salt.returners.redis_return` to enumerate the Redis keyspace [#69037](https://github.com/saltstack/salt/issues/69037) -- with `SCAN` instead of the blocking `KEYS pattern` command in both [#69037](https://github.com/saltstack/salt/issues/69037) -- `get_jids` and `clean_old_jobs`. `KEYS` walks the entire keyspace [#69037](https://github.com/saltstack/salt/issues/69037) -- synchronously and stalls the Redis server for the duration; on a [#69037](https://github.com/saltstack/salt/issues/69037) -- master with hundreds of thousands of jobs this can block all clients [#69037](https://github.com/saltstack/salt/issues/69037) -- of that Redis instance for seconds. `SCAN` is incremental and [#69037](https://github.com/saltstack/salt/issues/69037) -- non-blocking. Order of returned keys is no longer guaranteed (the [#69037](https://github.com/saltstack/salt/issues/69037) -- returner does not rely on order); operators with custom scripts that [#69037](https://github.com/saltstack/salt/issues/69037) -- read `ret:*` or `load:*` directly may see them in a different order. [#69037](https://github.com/saltstack/salt/issues/69037) - - ### Fixed -- Fixed ``win_pkg`` functions ignoring the ``saltenv`` setting in minion configuration. All public functions (``refresh_db``, ``genrepo``, ``install``, ``remove``, ``list_pkgs``, ``latest_version``, ``upgrade_available``, ``list_upgrades``, ``list_available``, ``version``, ``get_repo_data``, ``get_package_info``) now fall back to ``__opts__["saltenv"]`` when ``saltenv`` is not passed explicitly, instead of always defaulting to ``base``. [#38551](https://github.com/saltstack/salt/issues/38551) -- Added ``encoding`` parameter to ``file.replace`` execution module and state to support UTF-16, UTF-32, and other multi-byte encoded files that would otherwise be incorrectly treated as binary. [#52793](https://github.com/saltstack/salt/issues/52793) -- Improved documentation for the `runas` and `password` parameters in `cmd.run`, `cmd.script`, and all `salt.modules.cmdmod` execution functions on Windows. The docs now accurately describe when a password is required: only when the salt-minion is **not** running as SYSTEM or as an elevated Administrator. Removed the inaccurate claim that the target user account must be in the Administrators group. Also changed `cmd.script` to log a warning instead of hard-failing when `runas` is used without a password on Windows, since a password is not always required. [#57951](https://github.com/saltstack/salt/issues/57951) -- Fixed `SSL: DECRYPTION_FAILED_OR_BAD_RECORD_MAC` errors in the VMware cloud driver by reconnecting when a cached vCenter service instance is found to be stale or corrupted (for example when inherited across a fork by salt-cloud's parallel provider queries). [#61983](https://github.com/saltstack/salt/issues/61983) -- Fixed event signature verification failing under ``minion_sign_messages``. The minion was signing the return load before ``salt.channel.client.AsyncReqChannel._package_load`` attached transport metadata (``nonce``, ``ts``, ``tok``, ``id``), so the bytes the master re-serialized to verify did not match what was signed and every signed return was dropped. Signing is now performed inside ``_package_load`` after the metadata is attached, against the same bytes the master verifies. [#68181](https://github.com/saltstack/salt/issues/68181) -- Fixed two distinct bugs in the `salt.engines.redis_sentinel` engine that [#69031](https://github.com/saltstack/salt/issues/69031) -- together prevented it from being usable. `start()` no longer raises [#69031](https://github.com/saltstack/salt/issues/69031) -- `AttributeError: 'dict_values' object has no attribute 'pop'` on Python 3 [#69031](https://github.com/saltstack/salt/issues/69031) -- (the dict.values() result is now wrapped in `list(...)`). `Listener` and [#69031](https://github.com/saltstack/salt/issues/69031) -- `start()` now accept an optional `password` argument and forward it to [#69031](https://github.com/saltstack/salt/issues/69031) -- the redis client, allowing the engine to authenticate against a Sentinel [#69031](https://github.com/saltstack/salt/issues/69031) -- that requires AUTH; the default of `None` keeps existing configurations [#69031](https://github.com/saltstack/salt/issues/69031) -- working unchanged. [#69031](https://github.com/saltstack/salt/issues/69031) -- Fixed `salt.returners.redis_return` silently ignoring the documented [#69032](https://github.com/saltstack/salt/issues/69032) -- `redis.password` configuration option. The returner now reads [#69032](https://github.com/saltstack/salt/issues/69032) -- `redis.password` from config (in both regular and proxy modes) and [#69032](https://github.com/saltstack/salt/issues/69032) -- forwards it to both the single-server `redis.StrictRedis` and the [#69032](https://github.com/saltstack/salt/issues/69032) -- `StrictRedisCluster` constructors. Operators with auth-protected Redis [#69032](https://github.com/saltstack/salt/issues/69032) -- no longer lose every job return to a hidden `NOAUTH Authentication [#69032](https://github.com/saltstack/salt/issues/69032) -- required` failure; deployments without a password are unaffected. [#69032](https://github.com/saltstack/salt/issues/69032) -- Fixed three closely-related bugs in `salt.cache.redis_cache` that [#69033](https://github.com/saltstack/salt/issues/69033) -- together broke hierarchical-bank semantics: [#69033](https://github.com/saltstack/salt/issues/69033) -- `_build_bank_hier` now registers each child bank name in both the [#69033](https://github.com/saltstack/salt/issues/69033) -- parent's `$BANK_` set (consumed by `flush()` tree traversal) and the [#69033](https://github.com/saltstack/salt/issues/69033) -- parent's `$BANKEYS_` set (consumed by `list_()`); `_get_banks_to_remove` [#69033](https://github.com/saltstack/salt/issues/69033) -- now decodes the bytes returned by `smembers` and skips the `"."` [#69033](https://github.com/saltstack/salt/issues/69033) -- placeholder, so recursive `flush()` of a parent bank actually descends [#69033](https://github.com/saltstack/salt/issues/69033) -- into sub-banks instead of corrupting the path; and `flush(bank)` of a [#69033](https://github.com/saltstack/salt/issues/69033) -- sub-bank now removes the flushed bank's own reference from its [#69033](https://github.com/saltstack/salt/issues/69033) -- parent's index sets so `list_(parent)` no longer reports it as [#69033](https://github.com/saltstack/salt/issues/69033) -- present. Together these fixes restore `cache.list("minions")`, [#69033](https://github.com/saltstack/salt/issues/69033) -- `salt-run manage.present` and `salt-run manage.up` for masters [#69033](https://github.com/saltstack/salt/issues/69033) -- configured with `cache: redis`. [#69033](https://github.com/saltstack/salt/issues/69033) -- Fixed `salt.tokens.rediscluster` being unable to retrieve any eauth [#69035](https://github.com/saltstack/salt/issues/69035) -- token. The cluster client was created with `decode_responses=True`, [#69035](https://github.com/saltstack/salt/issues/69035) -- which caused `redis_client.get()` to return `str` and broke [#69035](https://github.com/saltstack/salt/issues/69035) -- `salt.payload.loads` (msgpack rejects `str`); it also caused [#69035](https://github.com/saltstack/salt/issues/69035) -- `redis_client.keys()` to return `str` and broke [#69035](https://github.com/saltstack/salt/issues/69035) -- `[k.decode("utf8") for k in ...]` (`str` has no `.decode`). Both [#69035](https://github.com/saltstack/salt/issues/69035) -- errors were swallowed by broad `except Exception` handlers, so eauth [#69035](https://github.com/saltstack/salt/issues/69035) -- appeared to silently reject every token. `decode_responses=True` is [#69035](https://github.com/saltstack/salt/issues/69035) -- removed; values now round-trip as bytes through msgpack as the rest [#69035](https://github.com/saltstack/salt/issues/69035) -- of the module already expected. [#69035](https://github.com/saltstack/salt/issues/69035) -- Fixed `salt.returners.redis_return` leaking `:` last-jid [#69038](https://github.com/saltstack/salt/issues/69038) -- pointer keys indefinitely. The pointer was written with `pipeline.set` [#69038](https://github.com/saltstack/salt/issues/69038) -- and no `ex=` TTL, so any (minion, fun) pair that stopped running stuck [#69038](https://github.com/saltstack/salt/issues/69038) -- in Redis forever -- O(minions × distinct funcs) keys accumulating over [#69038](https://github.com/saltstack/salt/issues/69038) -- the lifetime of the master. The pointer now expires on the same TTL [#69038](https://github.com/saltstack/salt/issues/69038) -- as the rest of the returner data (`keep_jobs_seconds`). Operators with [#69038](https://github.com/saltstack/salt/issues/69038) -- external scripts reading these keys directly may observe them [#69038](https://github.com/saltstack/salt/issues/69038) -- expiring; the documentation never promised they would not. [#69038](https://github.com/saltstack/salt/issues/69038) -- Fixed `salt.returners.redis_return.get_fun` always returning an [#69039](https://github.com/saltstack/salt/issues/69039) -- empty dict. The function read return data from a `:` [#69039](https://github.com/saltstack/salt/issues/69039) -- key that no other code in the module ever wrote -- a leftover from [#69039](https://github.com/saltstack/salt/issues/69039) -- an older storage schema. It now reads from the canonical [#69039](https://github.com/saltstack/salt/issues/69039) -- `ret:` hash via `HGET ret: `, matching the [#69039](https://github.com/saltstack/salt/issues/69039) -- storage layout that `returner` actually produces and the read [#69039](https://github.com/saltstack/salt/issues/69039) -- pattern that `get_jid` already uses. [#69039](https://github.com/saltstack/salt/issues/69039) -- ``cmd.run`` and friends no longer include the ``env`` and ``stdin`` arguments in the ``CommandExecutionError`` raised when the underlying subprocess fails to start (typically ``ENOENT`` / binary not found). Both fields routinely carry credentials passed in by the caller (``env={"DB_PASSWORD": "..."}``, password piped via ``stdin``), and the error message ends up in master/minion logs and in event-bus return data visible to the API caller. [#69075](https://github.com/saltstack/salt/issues/69075) -- * Relenv 0.22.14 [#69129](https://github.com/saltstack/salt/issues/69129) -- - Update python 3.14 to 3.14.6 [#69129](https://github.com/saltstack/salt/issues/69129) -- - Update sqlite to 3.53.2.0 [#69129](https://github.com/saltstack/salt/issues/69129) -- - Update openssl to 3.5.7 [#69129](https://github.com/saltstack/salt/issues/69129) -- Fix pillar masking leaking ``**********`` into rendered pillar and state values. ``MaskedDict`` / ``MaskedList`` ``__repr__`` / ``__str__`` now consult the ``salt.utils.secret.mask_pillar`` ContextVar, so ``{{ pillar['list_or_dict_value'] }}`` interpolations on the minion return plain values inside a render bracket. Hoist the ``mask_pillar=False`` bracket from ``render_pillar`` to ``compile_pillar`` so ``ext_pillar`` handlers and the rest of the master-side pillar build also run unmasked. [#69160](https://github.com/saltstack/salt/issues/69160) -- Fixed Windows MSI self-upgrade via ``pkg.install`` failing with error 1603. The old product's ``DeleteConfig_DECAC`` custom action was unconditionally deleting ``ROOTDIR\var`` during ``RemoveExistingProducts``, destroying the MSI that ``pkg.install`` had cached to ``ROOTDIR\var\cache`` before launching the upgrade. Users who had ``REMOVE_CONFIG=1`` persisted in the registry (from checking "On uninstall" at install time) hit a worse variant where the entire ``ROOTDIR`` was deleted. The fix checks ``UPGRADINGPRODUCTCODE`` — set by Windows Installer whenever an uninstall is triggered by a major upgrade — and skips all ``ROOTDIR`` deletion during upgrades, matching the behaviour of the NSIS installer which has always preserved ``ROOTDIR`` during upgrades. [#69219](https://github.com/saltstack/salt/issues/69219) -- Fixed `TypeError: string indices must be integers` in the minion when the master returns a bare string error response (e.g. `"bad load"`, `"Some exception handling minion payload"`) for a pillar request. The minion now raises a clean `AuthenticationError` instead of crashing, allowing the caller to retry or fail gracefully. [#69228](https://github.com/saltstack/salt/issues/69228) -- pkg.list_patches in yumpkg.py parses tdnf output on Photon OS [#69229](https://github.com/saltstack/salt/issues/69229) -- Restore Python dependencies in the PyPI sdist by including ``requirements/*.in`` and ``requirements/**/*.lock`` in ``MANIFEST.in``. After the requirements ``.txt`` → ``.in`` rename, the sdist no longer shipped the files that ``setup.py`` reads to populate ``install_requires``, so ``pip install salt`` produced an installation with no dependencies. [#69244](https://github.com/saltstack/salt/issues/69244) -- Fix `salt-cloud` failing to start with `AttributeError: module 'salt' has no attribute 'minion'` by importing `salt.minion` in `salt.cloud`. [#69281](https://github.com/saltstack/salt/issues/69281) -- Ensure multiple masters have their own job/state queues [#69308](https://github.com/saltstack/salt/issues/69308) -- Fixed minion state queue replacing the master-assigned JID on queued state runs, so returns now come back tagged with the JID the master actually published. [#69386](https://github.com/saltstack/salt/issues/69386) -- Made the salt user's home directory and the relenv ``extras-`` directory configurable in the Linux packaging. The DEB preinst scripts now source ``/etc/default/salt-setup`` (and ``/etc/sysconfig/salt-minion-setup`` for cross-distro parity with RPM) before applying the ``SALT_HOME``/``SALT_USER``/``SALT_GROUP``/``SALT_NAME`` defaults, mirroring the long-standing RPM behavior. A new ``SALT_EXTRAS_DIR`` override is honored by both stacks so the extras tree can be relocated outside ``/opt/saltstack/salt`` and its ownership is correctly restored on upgrade. [#69402](https://github.com/saltstack/salt/issues/69402) - - -### Added - -- Added ``dsc_resource`` execution module and state module for invoking individual [#43718](https://github.com/saltstack/salt/issues/43718) -- PowerShell DSC resources directly via ``Invoke-DscResource``, without compiling [#43718](https://github.com/saltstack/salt/issues/43718) -- a MOF file or involving the Local Configuration Manager. The [#43718](https://github.com/saltstack/salt/issues/43718) -- ``dsc_resource.managed`` state provides idiomatic Salt state management for any [#43718](https://github.com/saltstack/salt/issues/43718) -- installed DSC resource module. [#43718](https://github.com/saltstack/salt/issues/43718) -- fix etcdv3 module authentification when using etcd3-py lib [#69202](https://github.com/saltstack/salt/issues/69202) +- Deferred OpenTelemetry imports in `salt.utils.tracing` and `salt.utils.metrics` so daemons no longer pay the ~15 MB per-process OTel import cost when `tracing.enabled` / `metrics.enabled` are false (the default). On a stress-tested salt-master container (~15 Python processes) this reclaims ~225 MB per subsystem — restoring the pre-3008.x baseline. [#69855](https://github.com/saltstack/salt/issues/69855) diff --git a/pkg/debian/changelog b/pkg/debian/changelog index 6db2e2f828a5..2d58a99df305 100644 --- a/pkg/debian/changelog +++ b/pkg/debian/changelog @@ -101,6 +101,16 @@ salt (3008.2) stable; urgency=medium -- Salt Project Packaging Wed, 01 Jul 2026 14:44:25 +0000 +salt (3008.1-1) stable; urgency=medium + + + # Fixed + + * Deferred OpenTelemetry imports in `salt.utils.tracing` and `salt.utils.metrics` so daemons no longer pay the ~15 MB per-process OTel import cost when `tracing.enabled` / `metrics.enabled` are false (the default). On a stress-tested salt-master container (~15 Python processes) this reclaims ~225 MB per subsystem — restoring the pre-3008.x baseline. [#69855](https://github.com/saltstack/salt/issues/69855) + + + -- Salt Project Packaging Thu, 23 Jul 2026 19:20:00 +0000 + salt (3008.1) stable; urgency=medium diff --git a/pkg/rpm/salt.spec b/pkg/rpm/salt.spec index 5e4c59e5f7cd..4705bbe25e06 100644 --- a/pkg/rpm/salt.spec +++ b/pkg/rpm/salt.spec @@ -1395,6 +1395,11 @@ fi - Migrate Salt documentation to the PyData Sphinx theme. This update modernizes the documentation UI, improves navigation with a persistent sidebar tree, and fixes issues with embedded video playback. [#69185](https://github.com/saltstack/salt/issues/69185) - fix etcdv3 module authentification when using etcd3-py lib [#69202](https://github.com/saltstack/salt/issues/69202) - Added ``lgpo_reg.get_rsop_value`` to query the Resultant Set of Policy (RSoP) for a registry key/value and detect whether it is managed by a Domain Group Policy Object. The ``lgpo_reg`` module functions ``set_value``, ``disable_value``, and ``delete_value`` now log a warning when a Domain GPO is detected for the target value. The ``lgpo_reg`` state functions ``value_present``, ``value_disabled``, and ``value_absent`` append the same warning to the state comment so it is visible in state output. [#69205](https://github.com/saltstack/salt/issues/69205) +* Thu Jul 23 2026 Salt Project Packaging - 3008.1-1 + +# Fixed + +- Deferred OpenTelemetry imports in `salt.utils.tracing` and `salt.utils.metrics` so daemons no longer pay the ~15 MB per-process OTel import cost when `tracing.enabled` / `metrics.enabled` are false (the default). On a stress-tested salt-master container (~15 Python processes) this reclaims ~225 MB per subsystem — restoring the pre-3008.x baseline. [#69855](https://github.com/saltstack/salt/issues/69855) * Thu Jun 11 2026 Salt Project Packaging - 3008.1 diff --git a/pkg/windows/msi/build_pkg.ps1 b/pkg/windows/msi/build_pkg.ps1 index eaa612c0666c..9de1422adc8e 100644 --- a/pkg/windows/msi/build_pkg.ps1 +++ b/pkg/windows/msi/build_pkg.ps1 @@ -174,17 +174,19 @@ $RUNTIMES | ForEach-Object { #------------------------------------------------------------------------------- Write-Host "Getting internal version: " -NoNewline -[regex]$tagRE = '(?:[^\d]+)?(?[\d]{1,4})(?:\.(?[\d]{1,2}))?(?:\.(?[\d]{0,2}))?' +[regex]$tagRE = '(?:[^\d]+)?(?[\d]{1,4})(?:\.(?[\d]{1,2}))?(?:\.(?[\d]{0,2}))?(?:-(?[\d]{1,2}))?' $tagREM = $tagRE.Match($Version) $major = $tagREM.groups["major"].ToString() $minor = $tagREM.groups["minor"] $bugfix = $tagREM.groups["bugfix"] -if ([string]::IsNullOrEmpty($minor)) {$minor = 0} +$patch = $tagREM.groups["patch"] +if ([string]::IsNullOrEmpty($minor)) {$minor = 0} if ([string]::IsNullOrEmpty($bugfix)) {$bugfix = 0} +if ([string]::IsNullOrEmpty($patch)) {$patch = 0} # Assumption: major is a number $major1 = $major.substring(0, 2) $major2 = $major.substring(2) -$INTERNAL_VERSION = "$major1.$major2.$minor" +$INTERNAL_VERSION = "$major1.$major2.$minor.$patch" Write-Result $INTERNAL_VERSION -ForegroundColor Green #------------------------------------------------------------------------------- diff --git a/salt/version.py b/salt/version.py index 15d10ee5ae48..db7374b13e50 100644 --- a/salt/version.py +++ b/salt/version.py @@ -252,6 +252,7 @@ class SaltStackVersion: "minor", "bugfix", "mbugfix", + "patch", "pre_type", "pre_num", "noc", @@ -265,6 +266,7 @@ class SaltStackVersion: r"(?:\.(?P[\d]{1,2}))?" r"(?:\.(?P[\d]{0,2}))?" r"(?:\.(?P[\d]{0,2}))?" + r"(?:-(?P[\d]{1,2})\b(?!-g?[a-f0-9]))?" r"(?:(?Prc|a|b|alpha|beta|nb)(?P[\d]+))?" r"(?:(?:.*)(?:\+|-)(?P(?:0na|[\d]+|n/a))(?:-|\.)" + git_sha_regex + r")?" ) @@ -287,6 +289,8 @@ def __init__( pre_num=None, noc=0, sha=None, + *, + patch=None, ): if isinstance(major, str): major = int(major) @@ -313,6 +317,11 @@ def __init__( elif isinstance(mbugfix, str): mbugfix = int(mbugfix) + if patch is None: + patch = 0 + elif isinstance(patch, str): + patch = int(patch) if patch else 0 + if pre_type is None: pre_type = "" if pre_num is None: @@ -331,6 +340,7 @@ def __init__( self.minor = minor self.bugfix = bugfix self.mbugfix = mbugfix + self.patch = patch self.pre_type = pre_type self.pre_num = pre_num if self.new_version(major): @@ -365,7 +375,18 @@ def parse(cls, version_string): match = cls.git_describe_regex.match(vstr) if not match: raise ValueError(f"Unable to parse version string: '{version_string}'") - return cls(*match.groups()) + g = match.groupdict() + return cls( + g["major"], + g["minor"], + g["bugfix"], + g["mbugfix"], + g["pre_type"], + g["pre_num"], + g["noc"], + g["sha"], + patch=g["patch"], + ) @classmethod def from_name(cls, name): @@ -462,6 +483,8 @@ def string(self): version_string = f"{self.major}.{self.minor}.{self.bugfix}" if self.mbugfix: version_string += f".{self.mbugfix}" + if self.patch: + version_string += f"-{self.patch}" if self.pre_type: version_string += f"{self.pre_type}{self.pre_num}" if self.noc is not None and self.sha: @@ -537,6 +560,8 @@ def __compare__(self, other, method): # The other side has pre-release information, we don't noc_info[pre_type] = "zzzzz" + if tuple(noc_info) == tuple(other_noc_info): + return method(self.patch or 0, other.patch or 0) return method(tuple(noc_info), tuple(other_noc_info)) def __lt__(self, other): @@ -663,8 +688,8 @@ def __discover_version(saltstack_version): saltstack_version.minor, saltstack_version.bugfix, saltstack_version.mbugfix, - saltstack_version.pre_type, - saltstack_version.pre_num, + pre_type=saltstack_version.pre_type, + pre_num=saltstack_version.pre_num, noc=parsed.noc, sha=parsed.sha, ) diff --git a/tests/pytests/pkg/integration/test_pkg_meta.py b/tests/pytests/pkg/integration/test_pkg_meta.py index 01b5107178c3..883aa067c0de 100644 --- a/tests/pytests/pkg/integration/test_pkg_meta.py +++ b/tests/pytests/pkg/integration/test_pkg_meta.py @@ -82,14 +82,21 @@ def package(install_salt, artifact_version, pkg_arch): match the real file from ``install_salt.pkgs`` instead of string-building. """ rpm_re = re.compile( - rf"^salt-\d.*-0\.{re.escape(pkg_arch)}\.rpm$", + rf"^salt-\d.*-\d+\.{re.escape(pkg_arch)}\.rpm$", re.IGNORECASE, ) for pkg_path in install_salt.pkgs: path = pathlib.Path(pkg_path) if rpm_re.match(path.name): return path - name = f"salt-{artifact_version}-0.{pkg_arch}.rpm" + import packaging.version as _pv + + _parsed = _pv.parse(artifact_version) + if _parsed.post is not None: + _base = ".".join(str(p) for p in _parsed.release) + name = f"salt-{_base}-{_parsed.post}.{pkg_arch}.rpm" + else: + name = f"salt-{artifact_version}-0.{pkg_arch}.rpm" return ARTIFACTS_DIR / name diff --git a/tests/pytests/pkg/integration/test_version.py b/tests/pytests/pkg/integration/test_version.py index 9370c9c92dc9..5eb059f56588 100644 --- a/tests/pytests/pkg/integration/test_version.py +++ b/tests/pytests/pkg/integration/test_version.py @@ -223,3 +223,38 @@ def test_compare_pkg_versions_redhat_rc(version, install_salt): comp_pkg = pkg.split("~")[0] ret = install_salt.proc.run("rpmdev-vercmp", pkg, comp_pkg) ret.stdout.matcher.fnmatch_lines([f"{pkg} < {comp_pkg}"]) + + +@pytest.mark.skip_unless_on_linux +@pytest.mark.skip_if_binaries_missing("rpmdev-vercmp") +def test_compare_pkg_versions_redhat_patch(version, install_salt): + """ + Test that patch releases (Release: N) sort above the base (Release: 0). + For example, salt-3008.1-1.x86_64.rpm must be greater than salt-3008.1-0.x86_64.rpm. + """ + if install_salt.distro_id not in ( + "almalinux", + "rocky", + "centos", + "redhat", + "amzn", + "fedora", + "photon", + ): + pytest.skip("Only tests rpm packages") + + pkg = [x for x in install_salt.pkgs if "rpm" in x] + if not pkg: + pytest.skip("Not testing rpm packages") + import packaging.version + + parsed = packaging.version.parse(version) + if parsed.post is None: + pytest.skip("Not a patch release") + pkg_name = pkg[0].split("/")[-1] + assert ( + f"-{parsed.post}." in pkg_name + ), f"Expected Release={parsed.post} in package name {pkg_name!r}" + base_pkg = pkg_name.replace(f"-{parsed.post}.", "-0.", 1) + ret = install_salt.proc.run("rpmdev-vercmp", pkg_name, base_pkg) + ret.stdout.matcher.fnmatch_lines([f"{pkg_name} > {base_pkg}"]) diff --git a/tests/pytests/unit/test_version.py b/tests/pytests/unit/test_version.py index 918792a62afe..535629b6517a 100644 --- a/tests/pytests/unit/test_version.py +++ b/tests/pytests/unit/test_version.py @@ -599,3 +599,32 @@ def test_parsed_version_name(version_str, expected_str, expected_name): assert ver.name == expected_name else: assert ver.name is None + + +@pytest.mark.parametrize( + "version_string,patch,version_str,codename", + [ + ("v3008.1-1", 1, "3008.1-1", "Argon"), + ("v3008.1-2", 2, "3008.1-2", "Argon"), + ("3008.1-1", 1, "3008.1-1", "Argon"), + ], +) +def test_patch_version_parsing(version_string, patch, version_str, codename): + v = SaltStackVersion.parse(version_string) + assert v.patch == patch + assert v.string == version_str + assert v.name == codename + + +@pytest.mark.parametrize( + "higher,lower", + [ + ("v3008.1-2", "v3008.1-1"), + ("v3008.1-1", "v3008.1"), + ("v3008.2", "v3008.1-99"), + ("v3008.1", "v3008.1rc1"), + ], +) +def test_patch_version_ordering(higher, lower): + assert SaltStackVersion.parse(higher) > SaltStackVersion.parse(lower) + assert SaltStackVersion.parse(lower) < SaltStackVersion.parse(higher) diff --git a/tests/support/pkg.py b/tests/support/pkg.py index 570803c9fa72..d9bf965dc503 100644 --- a/tests/support/pkg.py +++ b/tests/support/pkg.py @@ -393,13 +393,27 @@ def _default_artifact_version(self): version = "" artifacts = list(ARTIFACTS_DIR.glob("**/*.*")) for artifact in artifacts: - version = re.search( + m = re.search( r"([0-9].*)(\-[0-9].fc|\-[0-9].el|\+ds|\_all|\_any|\_amd64|\_arm64|\-[0-9].am|(\-[0-9]-[a-z]*-[a-z]*[0-9_]*.|\-[0-9]*.*)(exe|msi|pkg|rpm|deb))", artifact.name, ) - if version: - version = version.groups()[0].replace("_", "-").replace("~", "") - version = version.split("-")[0] + if m: + version = m.groups()[0].replace("_", "-").replace("~", "") + # For RPM-family artifacts the release segment (-N.el, -N.fc, -N.am) + # ends up in group 2, not group 1. Reconstruct "version-release" for + # patch releases (release > 0) so "3008.1-1" is not collapsed to "3008.1". + rpm_rel_m = re.match(r"^-(\d+)\.", m.group(2) or "") + if rpm_rel_m and int(rpm_rel_m.group(1)) > 0: + version = f"{version}-{rpm_rel_m.group(1)}" + else: + # For non-RPM artifacts the patch suffix (-1) may already be + # in group 1 (e.g. "3008.1-1" or "3008.1-1-macos"). Preserve a + # purely-numeric first hyphen segment; strip platform suffixes. + parts = version.split("-") + if len(parts) >= 2 and parts[1].isdigit(): + version = f"{parts[0]}-{parts[1]}" + else: + version = parts[0] break if not version: pytest.fail( @@ -1201,6 +1215,14 @@ def install_previous(self, downgrade=False): "salt-repo-3007-sts", ) self._check_retcode(ret) + # Newer salt.repo files also enable salt-repo-3008-lts; disable it + # so unversioned `yum install salt` stays on the 3007.x STS channel. + self.proc.run( + self.pkg_mngr, + "config-manager", + "--disable", + "salt-repo-3008-lts", + ) elif major_ver >= 3008: # Default ``salt.repo`` enables v3006 LTS only; that stanza excludes # ``*3008*``. Published 3008.x RPMs (including pre-releases) are only @@ -1220,22 +1242,22 @@ def install_previous(self, downgrade=False): "salt-repo-3007-sts", ) self._check_retcode(ret) + # Newer salt.repo files also enable salt-repo-3008-lts; enable it only + # if installing 3008.x, otherwise disable to stay on the correct channel. if "3008" in self.prev_version: - ret = self.proc.run( + self.proc.run( self.pkg_mngr, "config-manager", "--enable", "salt-repo-3008-lts", ) - self._check_retcode(ret) else: - ret = self.proc.run( + self.proc.run( self.pkg_mngr, "config-manager", "--disable", "salt-repo-3008-lts", ) - self._check_retcode(ret) ret = self.proc.run(self.pkg_mngr, "clean", "expire-cache") self._check_retcode(ret) # Unversioned ``yum downgrade`` only moves one step among *all* repo diff --git a/tools/changelog.py b/tools/changelog.py index 2435a19d0a2b..d9f634c4a683 100644 --- a/tools/changelog.py +++ b/tools/changelog.py @@ -68,9 +68,11 @@ def _get_pkg_changelog_contents(ctx: Context, version: Version): def _get_salt_version(ctx, next_release=False): - args = [] - if next_release: - args.append("--next-release") + if not next_release: + version_file = REPO_ROOT / "salt" / "_version.txt" + if version_file.exists(): + return Version(version_file.read_text(encoding="utf-8").strip()) + args = ["--next-release"] if next_release else [] ret = ctx.run("python3", "salt/version.py", *args, capture=True, check=False) if ret.returncode: ctx.error(ret.stderr.decode()) @@ -95,18 +97,32 @@ def _get_salt_version(ctx, next_release=False): }, ) def update_rpm(ctx: Context, salt_version: Version, draft: bool = False): + import re as _re + if salt_version is None: salt_version = _get_salt_version(ctx) changes = _get_pkg_changelog_contents(ctx, salt_version) - str_salt_version = str(salt_version).replace("rc", "~rc") + + if salt_version.post is not None: + rpm_version = ".".join(str(p) for p in salt_version.release) + rpm_release = str(salt_version.post) + str_salt_version = f"{rpm_version}-{rpm_release}" + else: + rpm_version = str(salt_version).replace("rc", "~rc") + rpm_release = "0" + str_salt_version = rpm_version + ctx.info(f"Salt version is {str_salt_version}") orig = ctx.run( "sed", - f"s/Version: .*/Version: {str_salt_version}/g", + f"s/Version: .*/Version: {rpm_version}/g", "pkg/rpm/salt.spec", capture=True, check=True, ).stdout.decode() + orig = _re.sub( + r"^Release:.*$", f"Release: {rpm_release}", orig, count=1, flags=_re.MULTILINE + ) dt = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) date = dt.strftime("%a %b %d %Y") header = f"* {date} Salt Project Packaging - {str_salt_version}\n" diff --git a/tools/ci.py b/tools/ci.py index 5c002721bd6b..9f370801d2aa 100644 --- a/tools/ci.py +++ b/tools/ci.py @@ -11,6 +11,7 @@ import pathlib import pprint import random +import re import shutil import sys import time @@ -313,6 +314,11 @@ def get_release_changelog_target(ctx: Context, event_name: str): ) release_branches = shared_context["release_branches"] + # Patch release branches look like "3008.1-1" or "3008.1-patch". The + # major prefix (e.g. "3008") is enough to associate them with the correct + # release family; extract it once for the else-branch below. + _patch_branch_re = re.compile(r"refs/heads/(\d{4})\.\d") + release_changelog_target = "next-major-release" if event_name == "pull_request": if gh_event["pull_request"]["base"]["ref"] in release_branches: @@ -322,10 +328,21 @@ def get_release_changelog_target(ctx: Context, event_name: str): if branch_name in release_branches: release_changelog_target = "next-minor-release" else: + ref = gh_event.get("ref", "") for branch_name in release_branches: - if branch_name in gh_event["ref"]: + if branch_name in ref: release_changelog_target = "next-minor-release" break + else: + # Patch release branches (e.g. refs/heads/3008.1-1) share the + # major version with a release branch but differ in the minor part. + m = _patch_branch_re.match(ref) + if m: + major = m.group(1) + for branch_name in release_branches: + if branch_name.startswith(major + "."): + release_changelog_target = "next-minor-release" + break with open(github_output, "a", encoding="utf-8") as wfh: wfh.write(f"release-changelog-target={release_changelog_target}\n") ctx.exit(0) diff --git a/tools/pkg/__init__.py b/tools/pkg/__init__.py index b66a6a786572..ce24bc987507 100644 --- a/tools/pkg/__init__.py +++ b/tools/pkg/__init__.py @@ -181,7 +181,11 @@ def set_salt_version( ctx.info(f"Successfuly wrote {salt_version!r} to 'salt/_version.txt'") version_instance = tools.utils.Version(salt_version) - if release and not version_instance.is_prerelease: + if ( + release + and not version_instance.is_prerelease + and not version_instance.is_postrelease + ): with open( tools.utils.REPO_ROOT / "salt" / "version.py", "r+", encoding="utf-8" ) as rwfh: @@ -421,6 +425,24 @@ def source_tarball(ctx: Context): for pkg in tools.utils.REPO_ROOT.joinpath("dist").iterdir() ] ctx.run("sha256sum", *packages) + # setuptools normalizes "3008.1-1" → "3008.1.post1" per PEP 440. + # Rename back to the hyphenated form so artifact names stay consistent. + version_file = tools.utils.REPO_ROOT / "salt" / "_version.txt" + if version_file.exists(): + import packaging.version as _pv + + raw = version_file.read_text(encoding="utf-8").strip() + parsed = _pv.parse(raw) + if parsed.post is not None: + dist_dir = tools.utils.REPO_ROOT / "dist" + pep440_name = f"salt-{parsed!s}.tar.gz" + hyphen_name = f"salt-{raw}.tar.gz" + src = dist_dir / pep440_name + dst = dist_dir / hyphen_name + if src.exists() and not dst.exists(): + ctx.info(f"Renaming {pep440_name} → {hyphen_name}") + src.rename(dst) + ctx.run("python3", "-m", "twine", "check", "dist/*", check=True) diff --git a/tools/pkg/build.py b/tools/pkg/build.py index 303d1a32101e..3a68ca812c73 100644 --- a/tools/pkg/build.py +++ b/tools/pkg/build.py @@ -155,7 +155,15 @@ def debian( ) ctx.run("ln", "-sf", "pkg/debian/", ".") - ctx.run("debuild", *env_args, "-uc", "-us", env=env) + debuild_flags = ["-uc", "-us"] + try: + import packaging.version as _pv + + if _pv.parse(os.environ.get("SALT_VERSION", "")).post is not None: + debuild_flags.insert(0, "-b") + except Exception: + pass + ctx.run("debuild", *env_args, *debuild_flags, env=env) ctx.info("Done") From 18974328d70b6cbd9b8b7201504d15b444720523 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 28 Jul 2026 15:56:50 +0200 Subject: [PATCH 149/469] Add test for issue #69893 --- .../pytests/functional/states/test_x509_v2.py | 97 +++++++++ tests/pytests/functional/utils/test_x509.py | 189 ++++++++++++++++++ 2 files changed, 286 insertions(+) diff --git a/tests/pytests/functional/states/test_x509_v2.py b/tests/pytests/functional/states/test_x509_v2.py index 2c96146018b4..a1c2ef55af29 100644 --- a/tests/pytests/functional/states/test_x509_v2.py +++ b/tests/pytests/functional/states/test_x509_v2.py @@ -4,6 +4,13 @@ import pytest +from tests.pytests.functional.utils.test_x509 import ( # pylint: disable=unused-import + ca_A, + ca_AI, + ca_B, + ca_BI, +) + try: import cryptography import cryptography.x509 as cx509 @@ -180,6 +187,67 @@ def ca_key_enc(): -----END ENCRYPTED PRIVATE KEY-----""" +@pytest.fixture +def ca_sub(): + return """\ +-----BEGIN CERTIFICATE----- +MIIDejCCAmKgAwIBAgIUYJfOr7sQ4QiGVO8/OOe+Jc9RL7cwDQYJKoZIhvcNAQEL +BQAwKzELMAkGA1UEBhMCVVMxDTALBgNVBAMMBFRlc3QxDTALBgNVBAoMBFNhbHQw +HhcNMjYwNzI4MDgwNjU1WhcNMzIxMTEyMTQwNDMzWjAuMQswCQYDVQQGEwJVUzEN +MAsGA1UECgwEU2FsdDEQMA4GA1UEAwwHVGVzdFN1YjCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAM62B1iql/J2d9V642X/UQWmVHzkntnW4ydZa98YHz5a +VQ6/Vawo2vHPxJhOvtBpok+rNG3Yj4VNbE7wD0yWI/FZl9SXe3QnqGRnVwBR1EWc +l/iVKvnknxtub/M8FxE+wjpje2F7p0crujz95y//jzEZqeTVRbTEMQCalUaCkQYR +7B4FL4CdsbeZlAxQ+T0DRMU1JG53aYjV5PrEaWP6Ss026jiLlJq3b8+A6ePwKA/S +JYh2VWAUyVusp7fusR+iI35m1D+JkdFrGuJqL3C/WlXZ/Ps97FRNhK/C0xVZ62v6 +sJSwrklvwVRdGjvWC7kHCIkFVopg5j2F5kyhBUZSRBsCAwEAAaOBkjCBjzASBgNV +HRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBRcWpT3rRGE0cgoYJjDD8pk0lVFKTBa +BgNVHSMEUzBRgBRc8vH0Uykjnu5AWpKjtCou9aaLZ6EvpC0wKzELMAkGA1UEBhMC +VVMxDTALBgNVBAMMBFRlc3QxDTALBgNVBAoMBFNhbHSCCG36YKj9FRj4MA0GCSqG +SIb3DQEBCwUAA4IBAQCprzp7z/NVZOXtZwW97LcJJLr9ukYb/rKLT+atTY2dFST+ +5TpMa1f89WoDPNcvSCJPeOXO9am9h43M9D47FE9X9q7HPO1OjW2ZP6ucPGJ8j3hR +VSxpDkc/g5jbWtPdx9RyUEsO/a34l+JPWgWXI+jz/PjE0ltqN6qhV71Q0DzDcw3D +JJ2QvEWCO1tti5L0crXOFCkEDnAXJqF603CVSvmymkcgGxT9kuaufbL0BWAV8pc8 +SDDbeH+eBxbZ/1rSfBGvW3mDDkL/wlz1LUC2u5n/w6xGxBr/ojONukiKIRwA3abB +8mWSEFTt7DfS0Uh6shcjzRM/dKEtox1qrtLZMr9O +-----END CERTIFICATE----- +""" + + +@pytest.fixture +def ca_sub_key(): + return """\ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDOtgdYqpfydnfV +euNl/1EFplR85J7Z1uMnWWvfGB8+WlUOv1WsKNrxz8SYTr7QaaJPqzRt2I+FTWxO +8A9MliPxWZfUl3t0J6hkZ1cAUdRFnJf4lSr55J8bbm/zPBcRPsI6Y3the6dHK7o8 +/ecv/48xGank1UW0xDEAmpVGgpEGEeweBS+AnbG3mZQMUPk9A0TFNSRud2mI1eT6 +xGlj+krNNuo4i5Sat2/PgOnj8CgP0iWIdlVgFMlbrKe37rEfoiN+ZtQ/iZHRaxri +ai9wv1pV2fz7PexUTYSvwtMVWetr+rCUsK5Jb8FUXRo71gu5BwiJBVaKYOY9heZM +oQVGUkQbAgMBAAECggEAMAFuK1VS/Ggu5FEpxmJI+rrqHCcsDQMutdC6kJEVkHGC +F26wAs9qKYZK7eQ7xEMEAuSLxIbqrdaRNLPjmbG0nzRjYmfbr9oV7VtihRx7476+ +PGjIFkjV+pTnQuHNqZ+dk9nOqZECBDFPiyKcMjVzl7+SCSbOjXCSwMUlrb5c17+e +eaLpEYRP0CgQhBPMzo8D2JUqERjHMwJtNbKN7vz6tSfSCL9kL4m7NkZcDPpK6A8E +bQb4ueCtecwUpCxBtSCyE3o9U1I4xIKePWiszSr7c430PUSXhAPjcDEY5N+AqfrB +abxPRg4Fo0KH+ZWPJ35FQF7v+hrTpGK2KmWt6Swf4QKBgQD8k39eOx/zl0pT+TPT +srM31eF9aITg0hZp1XDXJ/n7J7N+/OzgxYQQV3+UO9ksss34lCRViqNU4lLxhBIc +YcfXNk+yxMCoGhY5PJhPPPbkRmYWWU1RQ3s5V3tAdjDOK5kpUMZQxGHO6YW/RAJ/ +FiJmbRZcSHlsW2CkLVJzPAtWzwKBgQDRg14k5Kb2f7bq1CJyZsq6hhWCLt99JTG3 +9vl9Wga90KoeUonUe0hRaDw7kCp+uK+7Lu5lRw4pJW0dVqFX1HxSREAGANBhKMab +AN4mnQMvpJawJiGBSYxmUp565/NmKHBoTuugbGiCx0ftbzjtgGv85H3fzIVaRmuX +FAGqFBDQ9QKBgQDfyYQ5pqNJvguSWaPM93GJkEzJQ9kwJZTMUtw3FmmMWYHVix4K +jZbUr+IPIfPrgcWzcPa8gCj1Zc5dxUoSsaRSEAIPf/q/NtXoAsNkubx7R9DeDmPO +E79TcCp5U/8sPT7od3QvTcDnhssFS6n2llMGc7MzMte65T+8V5fNGC9nywKBgB6T ++sCNsqSVXUAGuARUZlA005zNdIbST+BWpnEaG5PGiZ2lVEJzv8lJ2kijMOCP2e4K +2nZjmXh94t/+TcwA0ig7l9CIe+FCT0I+LS4bimSAtBF/bzJsZpZkhobPpaGKU2WV +5yPhzpsPtLq9meRn8trVCl4Ifon/byJ8pAWLqiylAoGBAJct0hhpTIN+cUtY47cw +++E1tei8lyde61dV8ZHpIVhYoJ91qMAxQmIBXTJ1cE0HQ36hb5a4Np6VUVsjMovF +zk/kH99dgMUX7X8JI61EUlfM4FucgsUmleiIvJLy+/rn8RD24p9J9BDseKHkZ0C2 +JIjZyneR8x3D+yy95o7WS+Qo +-----END PRIVATE KEY----- +""" + + @pytest.fixture def rsa_privkey(): return """\ @@ -1053,6 +1121,35 @@ def test_certificate_managed_existing_chain(x509, cert_args): _assert_not_changed(ret) +@pytest.mark.parametrize("encoding", ("pem", "pkcs7_pem", "pkcs7_der", "pkcs12")) +def test_certificate_managed_multi_chain( + x509, + cert_args, + ca_cert, + ca_sub, + ca_sub_key, + rsa_privkey, + encoding, + ca_A, + ca_B, + ca_AI, + ca_BI, +): + """ + Ensure absence of order in pkcs7 certificates is accounted for. Also test the rest + with multiple appended certificates. + """ + cert_args["private_key"] = rsa_privkey + cert_args["encoding"] = encoding + cert_args["signing_cert"] = ca_sub + cert_args["signing_private_key"] = ca_sub_key + cert_args["append_certs"] = [ca_sub, ca_cert, ca_AI, ca_A, ca_BI, ca_B] + ret = x509.certificate_managed(**cert_args) + assert ret.result is True + ret = x509.certificate_managed(**cert_args) + _assert_not_changed(ret) + + @pytest.mark.usefixtures("existing_cert") @pytest.mark.parametrize( "existing_cert", diff --git a/tests/pytests/functional/utils/test_x509.py b/tests/pytests/functional/utils/test_x509.py index 1f9c8007901e..c9b33586363b 100644 --- a/tests/pytests/functional/utils/test_x509.py +++ b/tests/pytests/functional/utils/test_x509.py @@ -1,8 +1,10 @@ +import contextlib from base64 import b64decode from textwrap import dedent import pytest +import salt.modules.x509_v2 import salt.utils.x509 as x509 cx509 = pytest.importorskip("cryptography.x509") @@ -588,3 +590,190 @@ def test_load_cert_broken_pkcs7_pem(cert_pkcs7_pem): x509.CertDeserializationError, match="Could not load PEM-encoded PKCS.*" ): x509.load_cert(data) + + +@pytest.fixture +def ca_C(): + """ + self-signed root, did not issue ``leaf`` + """ + return """\ +-----BEGIN CERTIFICATE----- +MIIDODCCAiCgAwIBAgIIbfpgqP0VGPgwDQYJKoZIhvcNAQELBQAwKzELMAkGA1UE +BhMCVVMxDTALBgNVBAMMBFRlc3QxDTALBgNVBAoMBFNhbHQwHhcNMjIxMTE1MTQw +NDMzWhcNMzIxMTEyMTQwNDMzWjArMQswCQYDVQQGEwJVUzENMAsGA1UEAwwEVGVz +dDENMAsGA1UECgwEU2FsdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AOGTScvrjcEt6vsJcG9RUp6fKaDNDWZnJET0omanK9ZwaoGpJPp8UDYe/8ADeI7N +10wdyB4oDM9gRDjInBtdQO/PsrmKZF6LzqVFgLMxu2up+PHMi9z6B2P4esIAzMu9 +PYxc9zH4HzLImHqscVD2HCabsjp9X134Af7hVY5NN/W/4qTP7uOM20wSG2TPI6+B +tA9VyPbEPMPRzXzrqc45rVYe6kb2bT84GE93Vcu/e5JZ/k2AKD8Hoa2cxLPsTLq5 +igl+D+k+dfUtiABiKPvVQiYBsD1fyHDn2m7B6pCgvrGqHjsoAKufgFnXy6PJRg7n +vQfaxSiusM5s+VS+fjlvgwsCAwEAAaNgMF4wDwYDVR0TBAgwBgEB/wIBATALBgNV +HQ8EBAMCAQYwHQYDVR0OBBYEFFzy8fRTKSOe7kBakqO0Ki71potnMB8GA1UdIwQY +MBaAFFzy8fRTKSOe7kBakqO0Ki71potnMA0GCSqGSIb3DQEBCwUAA4IBAQBZS4MP +fXYPoGZ66seM+0eikScZHirbRe8vHxHkujnTBUjQITKm86WeQgeBCD2pobgBGZtt +5YFozM4cERqY7/1BdemUxFvPmMFFznt0TM5w+DfGWVK8un6SYwHnmBbnkWgX4Srm +GsL0HHWxVXkGnFGFk6Sbo3vnN7CpkpQTWFqeQQ5rHOw91pt7KnNZwc6I3ZjrCUHJ ++UmKKrga16a4Q+8FBpYdphQU609npo/0zuaE6FyiJYlW3tG+mlbbNgzY/+eUaxt2 +9Bp9mtA+Hkox551Mfpq45Oi+ehwMt0xjZCjuFCM78oiUdHCGO+EmcT7ogiYALiOF +LN1w5sybsYwIw6QN +-----END CERTIFICATE----- +""" + + +@pytest.fixture +def ca_CI(): + """ + signed by ca_C + """ + return """\ +-----BEGIN CERTIFICATE----- +MIIDejCCAmKgAwIBAgIUYJfOr7sQ4QiGVO8/OOe+Jc9RL7cwDQYJKoZIhvcNAQEL +BQAwKzELMAkGA1UEBhMCVVMxDTALBgNVBAMMBFRlc3QxDTALBgNVBAoMBFNhbHQw +HhcNMjYwNzI4MDgwNjU1WhcNMzIxMTEyMTQwNDMzWjAuMQswCQYDVQQGEwJVUzEN +MAsGA1UECgwEU2FsdDEQMA4GA1UEAwwHVGVzdFN1YjCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAM62B1iql/J2d9V642X/UQWmVHzkntnW4ydZa98YHz5a +VQ6/Vawo2vHPxJhOvtBpok+rNG3Yj4VNbE7wD0yWI/FZl9SXe3QnqGRnVwBR1EWc +l/iVKvnknxtub/M8FxE+wjpje2F7p0crujz95y//jzEZqeTVRbTEMQCalUaCkQYR +7B4FL4CdsbeZlAxQ+T0DRMU1JG53aYjV5PrEaWP6Ss026jiLlJq3b8+A6ePwKA/S +JYh2VWAUyVusp7fusR+iI35m1D+JkdFrGuJqL3C/WlXZ/Ps97FRNhK/C0xVZ62v6 +sJSwrklvwVRdGjvWC7kHCIkFVopg5j2F5kyhBUZSRBsCAwEAAaOBkjCBjzASBgNV +HRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBRcWpT3rRGE0cgoYJjDD8pk0lVFKTBa +BgNVHSMEUzBRgBRc8vH0Uykjnu5AWpKjtCou9aaLZ6EvpC0wKzELMAkGA1UEBhMC +VVMxDTALBgNVBAMMBFRlc3QxDTALBgNVBAoMBFNhbHSCCG36YKj9FRj4MA0GCSqG +SIb3DQEBCwUAA4IBAQCprzp7z/NVZOXtZwW97LcJJLr9ukYb/rKLT+atTY2dFST+ +5TpMa1f89WoDPNcvSCJPeOXO9am9h43M9D47FE9X9q7HPO1OjW2ZP6ucPGJ8j3hR +VSxpDkc/g5jbWtPdx9RyUEsO/a34l+JPWgWXI+jz/PjE0ltqN6qhV71Q0DzDcw3D +JJ2QvEWCO1tti5L0crXOFCkEDnAXJqF603CVSvmymkcgGxT9kuaufbL0BWAV8pc8 +SDDbeH+eBxbZ/1rSfBGvW3mDDkL/wlz1LUC2u5n/w6xGxBr/ojONukiKIRwA3abB +8mWSEFTt7DfS0Uh6shcjzRM/dKEtox1qrtLZMr9O +-----END CERTIFICATE----- +""" + + +@pytest.fixture +def leaf(): + """ + Certificate issued by cross-signed intermediate + """ + return """\ +-----BEGIN CERTIFICATE----- +MIIBhDCCASqgAwIBAgIUUfho58FH0YibFO3PwI+GCCliPzswCgYIKoZIzj0EAwIw +FzEVMBMGA1UEAwwMSW50ZXJtZWRpYXRlMB4XDTI2MDYyODExNTE0MVoXDTI3MDcy +ODExNTE0MVowGzEZMBcGA1UEAwwQbGVhZi5leGFtcGxlLmNvbTBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABNlc5BexY+JmrhtMSp3y3KAF5X6ujudYihTh4/8tS4pZ +oB2HexRABBagDIuJDdktbYIH2Ryq60v7QnU+sHQO1JKjUDBOMAwGA1UdEwEB/wQC +MAAwHQYDVR0OBBYEFO7VPMpOeUsAA8G1BtTmSl/iwVEHMB8GA1UdIwQYMBaAFKZL +UKNDNkPUg8p5Z2Qm2ls3vnFyMAoGCCqGSM49BAMCA0gAMEUCIEECTpi6f06fNN1J +QUCEtf6lwCvapwyrvUDzJ0yqxD8jAiEAgTAVhMbRB5zsyxf3wh5Bq6woCUgQ72aD +1RZ5zi26gBs= +-----END CERTIFICATE----- +""" + + +@pytest.fixture +def ca_AI(): + """ + cross-signed intermediate, signed by ca_A + """ + return """\ +-----BEGIN CERTIFICATE----- +MIIBfjCCASOgAwIBAgIUYmpkbQuDqM3dO9i139Sx7ySHr/cwCgYIKoZIzj0EAwIw +ETEPMA0GA1UEAwwGUm9vdCBBMB4XDTI2MDYyODExNTE0MVoXDTI3MDcyODExNTE0 +MVowFzEVMBMGA1UEAwwMSW50ZXJtZWRpYXRlMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAE9BftFUgrqiC09uL85Ywj+wW/u+x6RoUoUcgnsUYJoN2yIdXWGKsWoAU2 +jGfYoMcyOzxh2vANT96n1tICWUBk9aNTMFEwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUpktQo0M2Q9SDynlnZCbaWze+cXIwHwYDVR0jBBgwFoAUI5LLBbL2KL1u +R4izAau3IaAFWBIwCgYIKoZIzj0EAwIDSQAwRgIhAKWkHq8EBfUjyRrbgjzBrpfg +q7xQ68fvLWSF8Uh0iMLlAiEA+KyFHfCO6UoNL6A8z6+aMHoBoYmYLRHDSQyQz+nk +m7A= +-----END CERTIFICATE----- +""" + + +@pytest.fixture +def ca_A(): + """ + self-signed root + """ + return """\ +-----BEGIN CERTIFICATE----- +MIIBdzCCAR2gAwIBAgIUC/y/5CoGVmQZm94iCl2rvBzTQScwCgYIKoZIzj0EAwIw +ETEPMA0GA1UEAwwGUm9vdCBBMB4XDTI2MDYyODExNTE0MVoXDTI3MDcyODExNTE0 +MVowETEPMA0GA1UEAwwGUm9vdCBBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +4fc0YCVw4clsQbxr+o/N9nrHQXfB8fGjEYyOYGmLCkcGPrkDJhd0OURsWDiqxKRh +f5ZDSGZ2JXtMeMRJB3Rcj6NTMFEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU +I5LLBbL2KL1uR4izAau3IaAFWBIwHwYDVR0jBBgwFoAUI5LLBbL2KL1uR4izAau3 +IaAFWBIwCgYIKoZIzj0EAwIDSAAwRQIhANyDI1ej2lGw6boz41N77JTQY35fc30+ +gk7bkvfxIZopAiBZhQElEq8dbU73hwVrISQvM76IFNpQV5qW1Yiqlag8fA== +-----END CERTIFICATE----- +""" + + +@pytest.fixture +def ca_BI(): + """ + cross-signed intermediate, signed by ca_B + """ + return """\ +-----BEGIN CERTIFICATE----- +MIIBfTCCASOgAwIBAgIUD6smne5ycd6zLbfX63jMs+CPXOQwCgYIKoZIzj0EAwIw +ETEPMA0GA1UEAwwGUm9vdCBCMB4XDTI2MDYyODExNTE0MVoXDTI3MDcyODExNTE0 +MVowFzEVMBMGA1UEAwwMSW50ZXJtZWRpYXRlMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAE9BftFUgrqiC09uL85Ywj+wW/u+x6RoUoUcgnsUYJoN2yIdXWGKsWoAU2 +jGfYoMcyOzxh2vANT96n1tICWUBk9aNTMFEwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUpktQo0M2Q9SDynlnZCbaWze+cXIwHwYDVR0jBBgwFoAUqEVIergleZMo +G5K+dKStHGLWGdgwCgYIKoZIzj0EAwIDSAAwRQIhAIYDBNXckhpStfcJkTr/+EnA +DUSXYcEE2hW2+fGFR93RAiAylTL28+jfb3wuSsl3dWgAVCiynPdbUYmaLHn/zuhs +oQ== +-----END CERTIFICATE----- +""" + + +@pytest.fixture +def ca_B(): + """ + self-signed root + """ + return """\ +-----BEGIN CERTIFICATE----- +MIIBdzCCAR2gAwIBAgIUWK37d5PTFxJNwJp2Ah78Uv99z94wCgYIKoZIzj0EAwIw +ETEPMA0GA1UEAwwGUm9vdCBCMB4XDTI2MDYyODExNTE0MVoXDTI3MDcyODExNTE0 +MVowETEPMA0GA1UEAwwGUm9vdCBCMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +F4gYt8XU4zLjHsYycE4va8izSACMMs5A6k/KZDI+XdEV++A6Pp5hAyq/45LWqIQe +lwC/kAqcqY7Lbr3/U3XnzKNTMFEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU +qEVIergleZMoG5K+dKStHGLWGdgwHwYDVR0jBBgwFoAUqEVIergleZMoG5K+dKSt +HGLWGdgwCgYIKoZIzj0EAwIDSAAwRQIhAOSSlDW0gm5vYU9eljoeRARTbIyFvgO/ +ZOHdbEYHl5klAiBGHCe/f8phNPF/DOuKuqv81kGGmmvpGf7USwnZ+fdbjQ== +-----END CERTIFICATE----- +""" + + +@pytest.mark.parametrize("encoding", ("pkcs7_pem", "pkcs7_der")) +def test_load_cert_pkcs7_returns_correct_cert_and_chain(ca_A, ca_AI, leaf, encoding): + serialized = salt.modules.x509_v2.encode_certificate( + leaf, encoding=encoding, append_certs=[ca_A, ca_AI] + ) + cert, chain = x509.load_cert(serialized, load_chain=True) + cert_issuer = cert.issuer.rfc4514_string() + assert chain[0].subject.rfc4514_string() == cert_issuer + assert chain[1].subject.rfc4514_string() == chain[0].issuer.rfc4514_string() + + +@pytest.mark.parametrize("encoding", ("pkcs7_pem", "pkcs7_der")) +def test_load_cert_pkcs7_orders_chain_with_multiple_valid_paths( + encoding, ca_A, ca_AI, ca_B, ca_BI, leaf +): + serialized = salt.modules.x509_v2.encode_certificate( + leaf, encoding=encoding, append_certs=[ca_A, ca_AI, ca_B, ca_BI] + ) + cert, chain = x509.load_cert(serialized, load_chain=True) + assert cert.subject.rfc4514_string() == "CN=leaf.example.com" + assert len(chain) == 4 + cert_issuer = cert.issuer.rfc4514_string() + # Best case, we order the certificates by immediate certification, backwards, and account for cross-signing + assert chain[0].subject.rfc4514_string() == cert_issuer + assert chain[1].subject.rfc4514_string() == chain[0].issuer.rfc4514_string() + assert chain[1].subject.rfc4514_string() == chain[0].issuer.rfc4514_string() + orphan_subjects = {orphan.subject.rfc4514_string() for orphan in chain[2:]} + assert orphan_subjects == {"CN=Root A", "CN=Intermediate"} From 8cabca17251b53fefcf8cd64c6cf756bb2196013 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 28 Jul 2026 16:00:29 +0200 Subject: [PATCH 150/469] Account for PKCS#7 being unordered --- changelog/69893.fixed.md | 1 + salt/states/x509_v2.py | 15 ++- salt/utils/x509.py | 119 ++++++++++++++++++-- tests/pytests/functional/utils/test_x509.py | 55 +++++++++ 4 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 changelog/69893.fixed.md diff --git a/changelog/69893.fixed.md b/changelog/69893.fixed.md new file mode 100644 index 000000000000..898d3455bbd6 --- /dev/null +++ b/changelog/69893.fixed.md @@ -0,0 +1 @@ +Fixed stateful management of PKCS#7 certificates with appended chain using `x509_v2.certificate_managed`. Also fixed loading of PKCS#7-encoded certificate bundles with `salt.utils.x509.load_cert`. diff --git a/salt/states/x509_v2.py b/salt/states/x509_v2.py index 1c2dc8edb590..b7a91fcba231 100644 --- a/salt/states/x509_v2.py +++ b/salt/states/x509_v2.py @@ -187,8 +187,11 @@ import os.path from datetime import datetime, timedelta, timezone +import salt.utils.dictupdate import salt.utils.files import salt.utils.platform +import salt.utils.stringutils +import salt.utils.versions from salt.exceptions import CommandExecutionError, SaltInvocationError from salt.state import STATE_INTERNAL_KEYWORDS as _STATE_INTERNAL_KEYWORDS @@ -505,7 +508,9 @@ def certificate_managed( current_chain = current_chain or [] ca_chain = [x509util.load_cert(x) for x in append_certs] - if not _compare_ca_chain(current_chain, ca_chain): + if not _compare_ca_chain( + current_chain, ca_chain, unordered="pkcs7" in current_encoding + ): changes["additional_certs"] = True ( @@ -1747,9 +1752,13 @@ def getextname(ext): return {"added": added, "changed": changed, "removed": removed} -def _compare_ca_chain(current, new): - if not len(current) == len(new): +def _compare_ca_chain(current, new, unordered=False): + if len(current) != len(new): return False + if unordered: + return {cert.fingerprint(hashes.SHA256()) for cert in new} == { + cert.fingerprint(hashes.SHA256()) for cert in current + } for i, new_cert in enumerate(new): if new_cert.fingerprint(hashes.SHA256()) != current[i].fingerprint( hashes.SHA256() diff --git a/salt/utils/x509.py b/salt/utils/x509.py index 19c83739b294..211a7c820d05 100644 --- a/salt/utils/x509.py +++ b/salt/utils/x509.py @@ -869,6 +869,98 @@ def load_pubkey(pk, get_encoding=False): raise PubDeserializationError("Could not load DER-encoded public key.") from err +def order_certs_naively(bundle, allow_orphans=True, require_leaf=True): + """ + Deterministically order certificates in a bundle using a naive algorithm. + This is not a chain building algorithm! It just selects the longest chain + of direct certification, preferring leaves by default, and appends all + orphans ordered by their fingerprints, if orphans are allowed. + + bundle + A set of cryptography.x509.Certificate objects to order. + + allow_orphans + Do not require all certificates to build a single chain. Defaults to true. + + require_leaf + Require that a path begins with a certificate that itself has not + been used to issue another certificate in the bundle. Defaults to true. + """ + if len(bundle) < 2: + return list(bundle) + + def _directly_issued_by(subject, issuer): + if subject.issuer != issuer.subject: + return False + try: + subject.verify_directly_issued_by(issuer) + except (InvalidSignature, TypeError, ValueError): + return False + return True + + def _fp(cert): + return cert.fingerprint(hashes.SHA256()) + + ordered_bundle = tuple(sorted(bundle, key=_fp)) + issuers = { + cert: [ + candidate + for candidate in ordered_bundle + if _directly_issued_by(cert, candidate) + ] + for cert in ordered_bundle + } + if require_leaf: + # ensure we treat self-signed root certificates that have not issued another certificate in this bundle as a leaf + cert_issuers = { + issuer + for subject, candidates in issuers.items() + for issuer in candidates + if issuer != subject + } + leaves = {cert for cert in ordered_bundle if cert not in cert_issuers} + if not leaves: + # This would be unusual, but possible when e.g. two certificates signed each other + raise ValueError( + "Certificate bundle did not contain a single leaf certificate" + ) + else: + leaves = {} + + def _paths_from( + cert, + seen, + ): + candidates = [issuer for issuer in issuers[cert] if issuer not in seen] + if not candidates: + return [[cert]] + return [ + [cert, *tail] + for issuer in candidates + for tail in _paths_from(issuer, seen | {issuer}) + ] + + paths = [ + path for cert in ordered_bundle for path in _paths_from(cert, frozenset({cert})) + ] + + # Longest path first; fingerprints provide a stable tie-breaker. + selected = min( + paths, + key=lambda path: ( + -int(path[0] in leaves), + -len(path), + tuple(_fp(cert) for cert in path), + ), + ) + orphans = [cert for cert in ordered_bundle if cert not in selected] + if not allow_orphans and orphans: + raise ValueError( + "Certificate bundle did not contain a singular chain comprising all certificates" + ) + return [*selected, *orphans] + + def load_cert(cert, passphrase=None, load_chain=False, get_encoding=False): """ Return a certificate instance from @@ -910,12 +1002,13 @@ def load_cert(cert, passphrase=None, load_chain=False, get_encoding=False): ) from err else: try: - loaded = pkcs7.load_pem_pkcs7_certificates(pems[0]) + chain = order_certs_naively(pkcs7.load_pem_pkcs7_certificates(pems[0])) + loaded = chain.pop(0) # the first cert is sure to be a leaf if load_chain: - return loaded.pop(0), loaded + return loaded, chain if get_encoding: - return loaded.pop(0), "pkcs7_pem", loaded, None - return loaded.pop(0) + return loaded, "pkcs7_pem", chain, None + return loaded except ValueError as err: raise CertDeserializationError( "Could not load PEM-encoded PKCS#7 blob" @@ -952,14 +1045,20 @@ def load_cert(cert, passphrase=None, load_chain=False, get_encoding=False): # PKCS7 try: # v37+ - loaded = pkcs7.load_der_pkcs7_certificates(cert) - if load_chain: - return loaded.pop(0), loaded - if get_encoding: - return loaded.pop(0), "pkcs7_der", loaded, None - return loaded[0] + bundle = pkcs7.load_der_pkcs7_certificates(cert) except ValueError: pass + else: + try: + chain = order_certs_naively(bundle) + except ValueError as err: + raise CertDeserializationError(str(err)) from err + loaded = chain.pop(0) # the first cert is sure to be a leaf + if load_chain: + return loaded, chain + if get_encoding: + return loaded, "pkcs7_der", chain, None + return loaded # nothing worked raise CertDeserializationError( "Could not deserialize binary data, neither as DER nor PKCS#7, PKCS#12." diff --git a/tests/pytests/functional/utils/test_x509.py b/tests/pytests/functional/utils/test_x509.py index c9b33586363b..756963a2e33f 100644 --- a/tests/pytests/functional/utils/test_x509.py +++ b/tests/pytests/functional/utils/test_x509.py @@ -777,3 +777,58 @@ def test_load_cert_pkcs7_orders_chain_with_multiple_valid_paths( assert chain[1].subject.rfc4514_string() == chain[0].issuer.rfc4514_string() orphan_subjects = {orphan.subject.rfc4514_string() for orphan in chain[2:]} assert orphan_subjects == {"CN=Root A", "CN=Intermediate"} + + +@pytest.mark.parametrize( + "certs,order", + ( + (["leaf"], ["leaf"]), + (["ca_A"], ["ca_A"]), + (["leaf", "ca_AI", "ca_A"], ["leaf", "ca_AI", "ca_A"]), + (["leaf", "ca_BI", "ca_B"], ["leaf", "ca_BI", "ca_B"]), + ( + ["ca_A", "ca_BI", "ca_B", "ca_AI", "leaf"], + ["leaf", "ca_BI", "ca_B", "ca_A", "ca_AI"], + ), + (["ca_A", "ca_B", "ca_C"], ["ca_C", "ca_B", "ca_A"]), + ( + ["ca_A", "ca_B", "ca_C", "leaf", "ca_AI", "ca_BI", "ca_CI"], + ["leaf", "ca_BI", "ca_B", "ca_C", "ca_CI", "ca_A", "ca_AI"], + ), + ), +) +def test_order_certs_naively_works(certs, order, request): + bundle = [x509.load_cert(request.getfixturevalue(cert)) for cert in certs] + ordered_bundle = [x509.load_cert(request.getfixturevalue(cert)) for cert in order] + res = x509.order_certs_naively(bundle) + assert res == ordered_bundle + + +@pytest.mark.parametrize( + "certs,expected", + ( + (["leaf"], ["leaf"]), + (["ca_A"], ["ca_A"]), + (["leaf", "ca_AI", "ca_A"], ["leaf", "ca_AI", "ca_A"]), + (["leaf", "ca_BI", "ca_B"], ["leaf", "ca_BI", "ca_B"]), + (["leaf", "ca_BI", "ca_B", "ca_AI"], False), + ( + ["ca_A", "ca_BI", "ca_B", "ca_AI", "leaf"], + False, + ), + (["ca_A", "ca_B", "ca_C"], False), + ), +) +def test_order_certs_naively_no_allow_orphans(certs, expected, request): + if expected is False: + ctx = pytest.raises(ValueError, match=".*did not contain a singular chain.*") + ordered_bundle = [] + else: + ordered_bundle = [ + x509.load_cert(request.getfixturevalue(cert)) for cert in expected + ] + ctx = contextlib.nullcontext() + bundle = [x509.load_cert(request.getfixturevalue(cert)) for cert in certs] + with ctx: + res = x509.order_certs_naively(bundle, allow_orphans=False) + assert res == ordered_bundle From 859782885bef762aa0e1fb3fcadd1b85bedca7e0 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 28 Jul 2026 16:38:59 +0200 Subject: [PATCH 151/469] Add test for issue #69895 --- .../pytests/functional/states/test_x509_v2.py | 70 +++++++++++++------ 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/tests/pytests/functional/states/test_x509_v2.py b/tests/pytests/functional/states/test_x509_v2.py index a1c2ef55af29..050862cbafd4 100644 --- a/tests/pytests/functional/states/test_x509_v2.py +++ b/tests/pytests/functional/states/test_x509_v2.py @@ -39,6 +39,11 @@ ] +@pytest.fixture(params=(False, True)) +def testmode(request): + return request.param + + @pytest.fixture(scope="module") def ca_dir(tmp_path_factory): ca_dir = tmp_path_factory.mktemp("ca") @@ -1549,13 +1554,17 @@ def test_certificate_managed_backup( @pytest.mark.parametrize( - "existing_symlink,existing_cert,encoding", - [("existing_cert", {}, "pem"), ("existing_cert", {"encoding": "der"}, "der")], + "existing_symlink,existing_cert,encoding,testmode", + [ + ("existing_cert", {}, "pem", False), + ("existing_cert", {}, "pem", True), + ("existing_cert", {"encoding": "der"}, "der", False), + ], indirect=["existing_symlink", "existing_cert"], ) @pytest.mark.parametrize("follow", [True, False]) def test_certificate_managed_follow_symlinks( - x509, cert_args, existing_symlink, follow, existing_cert, encoding + x509, cert_args, existing_symlink, follow, existing_cert, encoding, testmode ): """ file.managed follow_symlinks arg needs special attention as well since @@ -1563,10 +1572,12 @@ def test_certificate_managed_follow_symlinks( """ cert_args["name"] = str(existing_symlink) cert_args["encoding"] = encoding - assert pathlib.Path(cert_args["name"]).is_symlink() + syml = pathlib.Path(cert_args["name"]) + assert syml.is_symlink() cert_args["follow_symlinks"] = follow - ret = x509.certificate_managed(**cert_args) + ret = x509.certificate_managed(**cert_args, test=testmode) assert bool(ret.changes) == (not follow) + assert syml.is_symlink() is (testmode or follow) @pytest.mark.parametrize( @@ -1952,13 +1963,17 @@ def test_crl_managed_backup(x509, crl_args, ca_key, modules, backup, encoding): @pytest.mark.parametrize( - "existing_symlink,existing_crl,encoding", - [("existing_crl", {}, "pem"), ("existing_crl", {"encoding": "der"}, "der")], + "existing_symlink,existing_crl,encoding,testmode", + [ + ("existing_crl", {}, "pem", False), + ("existing_crl", {}, "pem", True), + ("existing_crl", {"encoding": "der"}, "der", False), + ], indirect=["existing_symlink", "existing_crl"], ) @pytest.mark.parametrize("follow", [True, False]) def test_crl_managed_follow_symlinks( - x509, crl_args, existing_symlink, follow, existing_crl, encoding + x509, crl_args, existing_symlink, follow, existing_crl, encoding, testmode ): """ file.managed follow_symlinks arg needs special attention as well since @@ -1966,10 +1981,12 @@ def test_crl_managed_follow_symlinks( """ crl_args["name"] = str(existing_symlink) crl_args["encoding"] = encoding - assert pathlib.Path(crl_args["name"]).is_symlink() + syml = pathlib.Path(crl_args["name"]) + assert syml.is_symlink() crl_args["follow_symlinks"] = follow - ret = x509.crl_managed(**crl_args) + ret = x509.crl_managed(**crl_args, test=testmode) assert bool(ret.changes) == (not follow) + assert syml.is_symlink() is (testmode or follow) @pytest.mark.parametrize( @@ -2240,25 +2257,30 @@ def test_csr_managed_backup(x509, csr_args, rsa_privkey, modules, backup, encodi @pytest.mark.parametrize( - "existing_symlink,existing_csr,encoding", - [("existing_csr", {}, "pem"), ("existing_csr", {"encoding": "der"}, "der")], + "existing_symlink,existing_csr,encoding,testmode", + [ + ("existing_csr", {}, "pem", False), + ("existing_csr", {}, "pem", True), + ("existing_csr", {"encoding": "der"}, "der", False), + ], indirect=["existing_symlink", "existing_csr"], ) @pytest.mark.parametrize("follow", [True, False]) def test_csr_managed_follow_symlinks( - x509, csr_args, existing_symlink, follow, existing_csr, encoding + x509, csr_args, existing_symlink, follow, existing_csr, encoding, testmode ): """ file.managed follow_symlinks arg needs special attention as well since the checking of the existing file is performed by the x509 module """ csr_args["name"] = str(existing_symlink) - assert pathlib.Path(csr_args["name"]).is_symlink() + syml = pathlib.Path(csr_args["name"]) + assert syml.is_symlink() csr_args["follow_symlinks"] = follow csr_args["encoding"] = encoding - ret = x509.csr_managed(**csr_args) + ret = x509.csr_managed(**csr_args, test=testmode) assert bool(ret.changes) == (not follow) - assert pathlib.Path(ret.name).is_symlink() == follow + assert syml.is_symlink() is (testmode or follow) @pytest.mark.parametrize( @@ -2548,13 +2570,17 @@ def test_private_key_managed_backup(x509, pk_args, modules, backup, encoding): @pytest.mark.parametrize( - "existing_symlink,existing_pk,encoding", - [("existing_pk", {}, "pem"), ("existing_pk", {"encoding": "der"}, "der")], + "existing_symlink,existing_pk,encoding,testmode", + [ + ("existing_pk", {}, "pem", False), + ("existing_pk", {}, "pem", True), + ("existing_pk", {"encoding": "der"}, "der", False), + ], indirect=["existing_symlink", "existing_pk"], ) @pytest.mark.parametrize("follow", [True, False]) def test_private_key_managed_follow_symlinks( - x509, pk_args, existing_symlink, follow, existing_pk, encoding + x509, pk_args, existing_symlink, follow, existing_pk, encoding, testmode ): """ file.managed follow_symlinks arg needs special attention as well since @@ -2562,10 +2588,12 @@ def test_private_key_managed_follow_symlinks( """ pk_args["name"] = str(existing_symlink) pk_args["encoding"] = encoding - assert pathlib.Path(pk_args["name"]).is_symlink() + syml = pathlib.Path(pk_args["name"]) + assert syml.is_symlink() pk_args["follow_symlinks"] = follow - ret = x509.private_key_managed(**pk_args) + ret = x509.private_key_managed(**pk_args, test=testmode) assert bool(ret.changes) == (not follow) + assert syml.is_symlink() is (testmode or follow) @pytest.mark.parametrize( From f50736686159bb024dd1caed27930ffeb7b3376c Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 28 Jul 2026 16:40:11 +0200 Subject: [PATCH 152/469] Don't delete symlinks in test mode --- changelog/69895.fixed.md | 1 + salt/states/x509_v2.py | 24 ++++++++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) create mode 100644 changelog/69895.fixed.md diff --git a/changelog/69895.fixed.md b/changelog/69895.fixed.md new file mode 100644 index 000000000000..a5688a06a3ee --- /dev/null +++ b/changelog/69895.fixed.md @@ -0,0 +1 @@ +Fixed `x509_v2.certificate_managed` deleting symlinks in test mode if `follow_symlinks` was explicitly set to `false` diff --git a/salt/states/x509_v2.py b/salt/states/x509_v2.py index b7a91fcba231..31dcbd2a01ea 100644 --- a/salt/states/x509_v2.py +++ b/salt/states/x509_v2.py @@ -466,8 +466,9 @@ def certificate_managed( if file_args.get("follow_symlinks", True): real_name = os.path.realpath(name) else: - # workaround https://github.com/saltstack/salt/issues/31802 - __salt__["file.remove"](name) + if not __opts__["test"]: + # workaround https://github.com/saltstack/salt/issues/31802 + __salt__["file.remove"](name) replace = True if __salt__["file.file_exists"](real_name): @@ -871,8 +872,9 @@ def crl_managed( if file_args.get("follow_symlinks", True): real_name = os.path.realpath(name) else: - # workaround https://github.com/saltstack/salt/issues/31802 - __salt__["file.remove"](name) + if not __opts__["test"]: + # workaround https://github.com/saltstack/salt/issues/31802 + __salt__["file.remove"](name) replace = True if __salt__["file.file_exists"](real_name): @@ -1106,8 +1108,9 @@ def csr_managed( if file_args.get("follow_symlinks", True): real_name = os.path.realpath(name) else: - # workaround https://github.com/saltstack/salt/issues/31802 - __salt__["file.remove"](name) + if not __opts__["test"]: + # workaround https://github.com/saltstack/salt/issues/31802 + __salt__["file.remove"](name) replace = True if __salt__["file.file_exists"](real_name): @@ -1382,13 +1385,14 @@ def private_key_managed( if file_args.get("follow_symlinks", True): real_name = os.path.realpath(name) else: - # workaround https://github.com/saltstack/salt/issues/31802 - __salt__["file.remove"](name) + if not __opts__["test"]: + # workaround https://github.com/saltstack/salt/issues/31802 + __salt__["file.remove"](name) replace = True file_exists = __salt__["file.file_exists"](real_name) - if file_exists and not new: + if file_exists and not (new or replace): try: current, current_encoding, _ = x509util.load_privkey( real_name, passphrase=passphrase, get_encoding=True @@ -1445,7 +1449,7 @@ def private_key_managed( changes["keysize"] = check_keysize if encoding != current_encoding: changes["encoding"] = encoding - elif file_exists and new: + elif (file_exists and new) or replace: changes["replaced"] = name else: changes["created"] = name From 662f96bbeaff1e91307f8d09143b67793a61767f Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 28 Jul 2026 19:08:55 +0200 Subject: [PATCH 153/469] Minor doc fixes --- salt/modules/x509_v2.py | 5 ++--- salt/states/x509_v2.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/salt/modules/x509_v2.py b/salt/modules/x509_v2.py index ea91c79bc08c..55def98ad8d1 100644 --- a/salt/modules/x509_v2.py +++ b/salt/modules/x509_v2.py @@ -236,7 +236,7 @@ def create_certificate( .. note:: - Mind that when ``der`` encoding is in use, appending certificatees is prohibited. + Mind that when ``der`` encoding is in use, appending certificates is prohibited. copypath Create a copy of the issued certificate in PEM format in this directory. @@ -681,7 +681,7 @@ def encode_certificate( .. note:: - Mind that when ``der`` encoding is in use, appending certificatees is prohibited. + Mind that when ``der`` encoding is in use, appending certificates is prohibited. private_key For ``pkcs12``, the private key corresponding to the public key of the ``certificate`` @@ -2059,7 +2059,6 @@ def verify_signature( certificate. signing_pub_key_passphrase - If ``signing_pub_key`` is encrypted, the passphrase to decrypt it. """ cert = x509util.load_cert(certificate) diff --git a/salt/states/x509_v2.py b/salt/states/x509_v2.py index 31dcbd2a01ea..c52e9113751d 100644 --- a/salt/states/x509_v2.py +++ b/salt/states/x509_v2.py @@ -285,7 +285,7 @@ def certificate_managed( .. note:: - Mind that when ``der`` encoding is in use, appending certificatees is prohibited. + Mind that when ``der`` encoding is in use, appending certificates is prohibited. copypath Create a copy of the issued certificate in PEM format in this directory. @@ -1052,7 +1052,7 @@ def csr_managed( Ignored for ``ed25519`` and ``ed448`` key types. encoding - Specify the encoding of the resulting certificate revocation list. + Specify the encoding of the resulting certificate signing request. It can be serialized as a ``pem`` text or binary ``der`` file. Defaults to ``pem``. From 863c1d13fbec9f46abf0655fdd29c9654ec5475e Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 28 Jul 2026 19:19:39 +0200 Subject: [PATCH 154/469] Add test for issue #69896 --- tests/pytests/functional/states/test_x509_v2.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/pytests/functional/states/test_x509_v2.py b/tests/pytests/functional/states/test_x509_v2.py index 050862cbafd4..a5ada53daab6 100644 --- a/tests/pytests/functional/states/test_x509_v2.py +++ b/tests/pytests/functional/states/test_x509_v2.py @@ -1689,6 +1689,13 @@ def test_crl_managed_exts(x509, crl_args, crl_args_exts, ca_key): assert len(crl.extensions) == len(crl_args_exts) +def test_crl_managed_no_signing_cert(x509, crl_args): + crl_args.pop("signing_cert") + ret = x509.crl_managed(**crl_args) + assert ret.result is False + assert "`signing_cert`" in ret.comment + + def test_crl_managed_test_true(x509, crl_args, crl_revoked): crl_args["revoked"] = crl_revoked crl_args["test"] = True From ed5fdca2a6ef03d349844384edebd7e12bd4bef8 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 28 Jul 2026 19:21:32 +0200 Subject: [PATCH 155/469] Require `signing_cert` --- changelog/69896.fixed.md | 1 + salt/modules/x509_v2.py | 4 ++-- salt/states/x509_v2.py | 12 +++++++++--- salt/utils/x509.py | 12 +++++------- 4 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 changelog/69896.fixed.md diff --git a/changelog/69896.fixed.md b/changelog/69896.fixed.md new file mode 100644 index 000000000000..00f844c21f8c --- /dev/null +++ b/changelog/69896.fixed.md @@ -0,0 +1 @@ +Fixed traceback when `signing_cert` was not passed to `x509_v2.crl_managed` or `x509_v2.create_crl`. It has always been required. diff --git a/salt/modules/x509_v2.py b/salt/modules/x509_v2.py index 55def98ad8d1..9df431a2a50f 100644 --- a/salt/modules/x509_v2.py +++ b/salt/modules/x509_v2.py @@ -794,7 +794,7 @@ def encode_certificate( def create_crl( signing_private_key, revoked, - signing_cert=None, + signing_cert, signing_private_key_passphrase=None, include_expired=False, days_valid=None, @@ -856,7 +856,7 @@ def create_crl( The value should be a string in the same format as ``revocation_date``. signing_cert - The CA certificate to be used for signing the CRL. + The CA certificate to be used for signing the CRL. Required. signing_private_key_passphrase If ``signing_private_key`` is encrypted, the passphrase to decrypt it. diff --git a/salt/states/x509_v2.py b/salt/states/x509_v2.py index c52e9113751d..3ab4b7fb3ec7 100644 --- a/salt/states/x509_v2.py +++ b/salt/states/x509_v2.py @@ -736,6 +736,7 @@ def crl_managed( signing_cert The CA certificate to be used for signing the issued certificate. + Required. signing_private_key_passphrase If ``signing_private_key`` is encrypted, the passphrase to decrypt it. @@ -840,15 +841,20 @@ def crl_managed( "result": True, "comment": "The certificate revocation list is in the correct state", } - current = current_encoding = None + current = None changes = {} verb = "create" file_args, extra_args = _split_file_kwargs(_filter_state_internal_kwargs(kwargs)) extensions = extensions or {} - if extra_args: - raise SaltInvocationError(f"Unrecognized keyword arguments: {list(extra_args)}") try: + if extra_args: + raise SaltInvocationError( + f"Unrecognized keyword arguments: {list(extra_args)}" + ) + if not signing_cert: + raise SaltInvocationError("`signing_cert` is required") + # check file.managed changes early to avoid using unnecessary resources file_managed_test = _file_managed(name, test=True, replace=False, **file_args) diff --git a/salt/utils/x509.py b/salt/utils/x509.py index 211a7c820d05..3b9f58ce71b0 100644 --- a/salt/utils/x509.py +++ b/salt/utils/x509.py @@ -475,7 +475,7 @@ def build_csr(private_key, private_key_passphrase=None, subject=None, **kwargs): def build_crl( signing_private_key, revoked, - signing_cert=None, + signing_cert, signing_private_key_passphrase=None, include_expired=False, days_valid=100, @@ -488,24 +488,22 @@ def build_crl( Also returns signing private key. """ extensions = extensions or {} - if signing_cert: - signing_cert = load_cert(signing_cert) + signing_cert = load_cert(signing_cert) signing_private_key = load_privkey( signing_private_key, passphrase=signing_private_key_passphrase ) - if signing_cert and not is_pair(signing_cert.public_key(), signing_private_key): + if not is_pair(signing_cert.public_key(), signing_private_key): raise SaltInvocationError( "Signing private key does not match the certificate's public key" ) builder = cx509.CertificateRevocationListBuilder() - if signing_cert: - builder = builder.issuer_name(signing_cert.subject) + builder = builder.issuer_name(signing_cert.subject) builder = builder.last_update(datetime.now(tz=timezone.utc)) builder = builder.next_update( datetime.now(tz=timezone.utc) + timedelta(days=days_valid) ) for rev in revoked: - serial_number = not_after = revocation_date = None + serial_number = not_after = None if "not_after" in rev: not_after = datetime.strptime(rev["not_after"], TIME_FMT).replace( tzinfo=timezone.utc From 8feb73358280ca17031747df4d2076514e16f512 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 28 Jul 2026 22:17:26 +0200 Subject: [PATCH 156/469] Add tests for issue #69898 --- .../functional/modules/test_x509_v2.py | 3 +- .../pytests/functional/states/test_x509_v2.py | 22 ++++++++++ tests/pytests/unit/utils/test_x509.py | 41 +++++++++++++++---- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/tests/pytests/functional/modules/test_x509_v2.py b/tests/pytests/functional/modules/test_x509_v2.py index 7ff0f7fda92a..82ea650199e8 100644 --- a/tests/pytests/functional/modules/test_x509_v2.py +++ b/tests/pytests/functional/modules/test_x509_v2.py @@ -1441,7 +1441,8 @@ def test_create_private_key_pkcs12(x509, passphrase): @pytest.mark.parametrize("encoding", ["pem", "der"]) def test_create_private_key_write_to_path(x509, encoding, tmp_path): tgt = tmp_path / "pk" - x509.create_private_key(encoding=encoding, path=str(tgt)) + res = x509.create_private_key(encoding=encoding, path=str(tgt)) + assert str(tgt) in res assert tgt.exists() if encoding == "pem": assert tgt.read_text().startswith("-----BEGIN PRIVATE KEY-----") diff --git a/tests/pytests/functional/states/test_x509_v2.py b/tests/pytests/functional/states/test_x509_v2.py index a5ada53daab6..f420a0fd5c95 100644 --- a/tests/pytests/functional/states/test_x509_v2.py +++ b/tests/pytests/functional/states/test_x509_v2.py @@ -1888,6 +1888,28 @@ def test_crl_managed_existing_encoding_change_only(x509, crl_args, ca_key): assert new.extensions[0].value.crl_number == 1 +def test_crl_managed_existing_revocation_extension_added(x509, crl_args, ca_key): + crl_args["revoked"] = [{"serial_number": "01337A"}] + ret = x509.crl_managed(**crl_args) + _assert_crl_basic(ret, ca_key) + crl_args["revoked"] = [ + {"serial_number": "01337A", "extensions": {"CRLReason": "keyCompromise"}} + ] + ret = x509.crl_managed(**crl_args) + _assert_crl_basic(ret, ca_key) + assert "revocations" in ret.changes + assert len(ret.changes["revocations"]["changed"]) == 1 + + +@pytest.mark.usefixtures("existing_crl") +def test_crl_managed_existing_crlnumber_auto_added(x509, crl_args, ca_key): + crl_args["extensions"] = {"cRLNumber": "auto"} + ret = x509.crl_managed(**crl_args) + assert ret.result is True + new = _get_crl(crl_args["name"]) + assert new.extensions.get_extension_for_class(cx509.CRLNumber).value.crl_number == 1 + + @pytest.mark.skip_on_windows @pytest.mark.parametrize("mode", ["0400", "0640", "0644"]) def test_crl_managed_mode(x509, crl_args, ca_key, mode, modules): diff --git a/tests/pytests/unit/utils/test_x509.py b/tests/pytests/unit/utils/test_x509.py index 7776257137a3..5b8113a08e1e 100644 --- a/tests/pytests/unit/utils/test_x509.py +++ b/tests/pytests/unit/utils/test_x509.py @@ -518,6 +518,7 @@ def test_create_issuer_alt_name( ), ( [ + "critical", {"OCSP": "URI:http://ocsp.example.com/"}, {"OCSP": "URI:http://ocsp2.example.com/"}, ], @@ -1577,7 +1578,7 @@ def test_get_dn(inpt, expected): ), { "critical": False, - "value": ["mail:ca@example.com", "DNS:example.com", "DNS:example.io"], + "value": ["email:ca@example.com", "DNS:example.com", "DNS:example.io"], }, ), ( @@ -1594,7 +1595,7 @@ def test_get_dn(inpt, expected): ), { "critical": False, - "value": ["mail:ca@example.com", "DNS:example.com", "DNS:example.io"], + "value": ["email:ca@example.com", "DNS:example.com", "DNS:example.io"], }, ), ( @@ -1634,7 +1635,7 @@ def test_get_dn(inpt, expected): ), critical=False, ), - {"critical": False, "value": ["DNS:example.io", "mail:hello@example.io"]}, + {"critical": False, "value": ["DNS:example.io", "email:hello@example.io"]}, ), ( cx509.Extension( @@ -1763,7 +1764,7 @@ def test_get_dn(inpt, expected): "onlyAA": False, "onlyCA": False, "onlyuser": True, - "onysomereasons": ["keyCompromise"], + "onlysomereasons": ["keyCompromise"], "relativename": None, }, ), @@ -1799,7 +1800,7 @@ def test_get_dn(inpt, expected): { "explicit_text": "mytext", "notice_numbers": [1, 2, 3], - "organizataion": "myorg", + "organization": "myorg", }, ] } @@ -1838,8 +1839,8 @@ def test_get_dn(inpt, expected): ), { "critical": False, - "excluded": ["mail:.com"], - "permitted": ["IP:192.168.0.0/16", "mail:.example.com"], + "excluded": ["email:.com"], + "permitted": ["IP:192.168.0.0/16", "email:.example.com"], }, ), ( @@ -1976,3 +1977,29 @@ def dtn(tz=None): assert crl.last_update_utc == curr_time_utc except AttributeError: assert crl.last_update == curr_time_utc_naive + + +@pytest.mark.parametrize("timestr", ("not_before", "not_after")) +def test_build_crt_malformed_date_raises_salt_invocation_error(ca_key, timestr): + """ + A malformed not_before/not_after must surface as a SaltInvocationError + (caught by the state) instead of a raw ValueError from strptime. + """ + with pytest.raises( + salt.exceptions.SaltInvocationError, match=f"Invalid date.*{timestr}.*" + ): + x509.build_crt(ca_key, **{timestr: "booh"}) + + +@pytest.mark.parametrize("timestr", ("not_after", "revocation_date")) +def test_build_crl_malformed_date_raises_salt_invocation_error( + ca_cert, ca_key, timestr +): + """ + Malformed date definitions must surface as a SaltInvocationError + (caught by the state) instead of a raw ValueError from strptime. + """ + with pytest.raises( + salt.exceptions.SaltInvocationError, match=f"Invalid date.*{timestr}.*" + ): + x509.build_crl(ca_key, [{"serial_number": 1, timestr: "booh"}], ca_cert) From 2ebfcdfa68cb76b2983ae7e0434141a33479c2f7 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 28 Jul 2026 22:20:57 +0200 Subject: [PATCH 157/469] Fix tracebacks and output inconsistencies --- changelog/69898.fixed.md | 1 + salt/modules/x509_v2.py | 3 +- salt/states/x509_v2.py | 39 ++++++++------- salt/utils/x509.py | 50 +++++++++---------- .../functional/modules/test_x509_v2.py | 8 +-- 5 files changed, 51 insertions(+), 50 deletions(-) create mode 100644 changelog/69898.fixed.md diff --git a/changelog/69898.fixed.md b/changelog/69898.fixed.md new file mode 100644 index 000000000000..229f18a59492 --- /dev/null +++ b/changelog/69898.fixed.md @@ -0,0 +1 @@ +Fixed some tracebacks being thrown instead of errors being reported in `x509_v2`. Fixed a typo in the rendered output of `issuingDistributionPoint` and `certificatePolicies` extensions. Fixed rendered prefix of an `RFC822Name`. diff --git a/salt/modules/x509_v2.py b/salt/modules/x509_v2.py index 9df431a2a50f..95a38079c9fb 100644 --- a/salt/modules/x509_v2.py +++ b/salt/modules/x509_v2.py @@ -165,6 +165,7 @@ import salt.utils.dictupdate import salt.utils.files import salt.utils.stringutils +import salt.utils.versions from salt.exceptions import CommandExecutionError, SaltInvocationError log = logging.getLogger(__name__) @@ -1294,7 +1295,7 @@ def create_private_key( ) with salt.utils.files.fopen(path, "wb") as fp_: fp_.write(out) - return + return f"File written to {path}" def encode_private_key( diff --git a/salt/states/x509_v2.py b/salt/states/x509_v2.py index 3ab4b7fb3ec7..dc1547e79723 100644 --- a/salt/states/x509_v2.py +++ b/salt/states/x509_v2.py @@ -938,11 +938,15 @@ def crl_managed( if crl_auto: # put cRLNumber = auto back if it was set extensions["cRLNumber"] = "auto" - changes["extensions"]["removed"].pop( - changes["extensions"]["removed"].index("cRLNumber") - ) - if not any(changes["extensions"].values()): - changes.pop("extensions") + try: + changes["extensions"]["removed"].remove("cRLNumber") + if not any(changes["extensions"].values()): + changes.pop("extensions") + except (KeyError, ValueError): + # cRLNumber was added to an existing CRL + changes.setdefault("extensions", {}).setdefault( + "added", [] + ).append("cRLNumber") else: changes["created"] = name @@ -1656,7 +1660,6 @@ def _compare_cert(current, builder, signing_cert, serial_number, not_before, not def _compare_csr(current, builder): changes = {} - # if _getattr_safe(builder, "_subject_name") != current.subject: if not _compareattr_safe(builder, "_subject_name", current.subject): changes["subject_name"] = _getattr_safe( builder, "_subject_name" @@ -1689,31 +1692,29 @@ def _get_extension_for_oid(extensions, oid): if not current.is_signature_valid(sig_pubkey): changes["public_key"] = True - rev_changes = {"added": [], "changed": [], "removed": []} + rev_changes = {"added": set(), "changed": set(), "removed": set()} revoked = _getattr_safe(builder, "_revoked_certificates") for rev in revoked: cur = current.get_revoked_certificate_by_serial_number(rev.serial_number) if cur is None: # certificate was not revoked before - rev_changes["added"].append(x509util.dec2hex(rev.serial_number)) + rev_changes["added"].add(x509util.dec2hex(rev.serial_number)) continue for ext in rev.extensions: cur_ext = _get_extension_for_oid(cur.extensions, ext.oid) # revoked certificate's extensions have changed (added/changed) - if any( - ( - cur_ext is None, - cur_ext.critical != ext.critical, - cur_ext.value != ext.value, - ) + if ( + cur_ext is None + or cur_ext.critical != ext.critical + or cur_ext.value != ext.value ): - rev_changes["changed"].append(x509util.dec2hex(rev.serial_number)) + rev_changes["changed"].add(x509util.dec2hex(rev.serial_number)) for cur_ext in cur.extensions: if _get_extension_for_oid(rev.extensions, cur_ext.oid) is None: # an extension was removed from from the revoked certificate - rev_changes["changed"].append(x509util.dec2hex(rev.serial_number)) + rev_changes["changed"].add(x509util.dec2hex(rev.serial_number)) for rev in current: # certificate was removed from the CRL, probably because it was outdated anyways @@ -1721,10 +1722,12 @@ def _get_extension_for_oid(extensions, oid): _get_revoked_certificate_by_serial_number(revoked, rev.serial_number) is None ): - rev_changes["removed"].append(x509util.dec2hex(rev.serial_number)) + rev_changes["removed"].add(x509util.dec2hex(rev.serial_number)) if any(rev_changes.values()): - changes["revocations"] = rev_changes + changes["revocations"] = { + typ: list(sorted(val)) for typ, val in rev_changes.items() + } ext_changes = _compare_exts(current, builder) if any(ext_changes.values()): diff --git a/salt/utils/x509.py b/salt/utils/x509.py index 3b9f58ce71b0..c6bf34604a4a 100644 --- a/salt/utils/x509.py +++ b/salt/utils/x509.py @@ -390,15 +390,9 @@ def build_crt( signing_cert.subject if not self_signed else subject_name ) - not_before = ( - datetime.strptime(not_before, TIME_FMT).replace(tzinfo=timezone.utc) - if not_before - else datetime.now(tz=timezone.utc) - ) - not_after = ( - datetime.strptime(not_after, TIME_FMT).replace(tzinfo=timezone.utc) - if not_after - else datetime.now(tz=timezone.utc) + timedelta(days=days_valid) + not_before = _strptime(not_before, "not_before") or datetime.now(tz=timezone.utc) + not_after = _strptime(not_after, "not_after") or ( + datetime.now(tz=timezone.utc) + timedelta(days=days_valid) ) builder = builder.not_valid_before(not_before).not_valid_after(not_after) @@ -503,13 +497,8 @@ def build_crl( datetime.now(tz=timezone.utc) + timedelta(days=days_valid) ) for rev in revoked: - serial_number = not_after = None - if "not_after" in rev: - not_after = datetime.strptime(rev["not_after"], TIME_FMT).replace( - tzinfo=timezone.utc - ) - if "serial_number" in rev: - serial_number = rev["serial_number"] + serial_number = rev.get("serial_number") + not_after = _strptime(rev.get("not_after"), "not_after") if "certificate" in rev: rev_cert = load_cert(rev["certificate"]) serial_number = rev_cert.serial_number @@ -524,13 +513,9 @@ def build_crl( if not_after and not include_expired: if datetime.now(tz=timezone.utc) > not_after: continue - if "revocation_date" in rev: - revocation_date = datetime.strptime( - rev["revocation_date"], TIME_FMT - ).replace(tzinfo=timezone.utc) - else: - revocation_date = datetime.now(tz=timezone.utc) - + revocation_date = _strptime( + rev.get("revocation_date"), "revocation_date" + ) or datetime.now(tz=timezone.utc) revoked_cert = cx509.RevokedCertificateBuilder( serial_number=serial_number, revocation_date=revocation_date ) @@ -1471,7 +1456,7 @@ def _create_authority_info_access(val, **kwargs): elif isinstance(val, dict): val = ((k, v) for k, v in val.items() if k != "critical") elif isinstance(val, list): - val = ((k, v) for x in val for k, v in x.items() if x != "critical") + val = ((k, v) for x in val if x != "critical" for k, v in x.items()) parsed = [] for oid, general_name in val: @@ -2077,7 +2062,7 @@ def render_gn(gn): if isinstance(gn, cx509.IPAddress): return f"IP:{gn.value.exploded}" if isinstance(gn, cx509.RFC822Name): - return f"mail:{gn.value}" + return f"email:{gn.value}" if isinstance(gn, cx509.RegisteredID): return f"RID:{gn.value.dotted_string}" if isinstance(gn, cx509.UniformResourceIdentifier): @@ -2216,7 +2201,7 @@ def _render_distribution_points(ext): def _render_issuing_distribution_point(ext): return { "fullname": [render_gn(x) for x in ext.value.full_name or []], - "onysomereasons": list( + "onlysomereasons": list( sorted(x.value for x in ext.value.only_some_reasons or []) ), "relativename": ( @@ -2249,7 +2234,7 @@ def _render_certificate_policies(ext): notice_numbers = notice.notice_reference.notice_numbers qualifiers.append( { - "organizataion": organization, + "organization": organization, "notice_numbers": notice_numbers, "explicit_text": notice.explicit_text, } @@ -2306,6 +2291,17 @@ def _render_invalidity_date(ext): return {"value": ext.value.invalidity_date.strftime(TIME_FMT)} +def _strptime(val, param): + if val is None: + return val + try: + return datetime.strptime(val, TIME_FMT).replace(tzinfo=timezone.utc) + except ValueError as err: + raise SaltInvocationError( + f"Invalid date format in param `{param}`: {err}" + ) from err + + EXTENSION_RENDERERS = immutabletypes.freeze( { cx509.BasicConstraints: _render_basic_constraints, diff --git a/tests/pytests/functional/modules/test_x509_v2.py b/tests/pytests/functional/modules/test_x509_v2.py index 82ea650199e8..e51001b4b6ad 100644 --- a/tests/pytests/functional/modules/test_x509_v2.py +++ b/tests/pytests/functional/modules/test_x509_v2.py @@ -422,7 +422,7 @@ def cert_exts_read(): }, "nameConstraints": { "critical": False, - "excluded": ["mail:.com"], + "excluded": ["email:.com"], "permitted": ["IP:192.168.0.0/16"], }, "noCheck": {"critical": False, "value": True}, @@ -433,7 +433,7 @@ def cert_exts_read(): }, "subjectAltName": { "critical": False, - "value": ["DNS:sub.salt.ca", "mail:sub@salt.ca"], + "value": ["DNS:sub.salt.ca", "email:sub@salt.ca"], }, "subjectKeyIdentifier": { "critical": False, @@ -509,7 +509,7 @@ def csr_exts_read(): }, "nameConstraints": { "critical": False, - "excluded": ["mail:.com"], + "excluded": ["email:.com"], "permitted": ["IP:192.168.0.0/16"], }, "noCheck": {"critical": False, "value": True}, @@ -520,7 +520,7 @@ def csr_exts_read(): }, "subjectAltName": { "critical": False, - "value": ["DNS:sub.salt.ca", "mail:sub@salt.ca"], + "value": ["DNS:sub.salt.ca", "email:sub@salt.ca"], }, "subjectKeyIdentifier": { "critical": False, From 7765af7062151374e0cc8796b3abdb6c144f532a Mon Sep 17 00:00:00 2001 From: jeanluc Date: Wed, 29 Jul 2026 04:52:03 +0200 Subject: [PATCH 158/469] Support `otherName` in `x509_v2` --- changelog/69900.fixed.md | 1 + salt/modules/x509_v2.py | 35 ++++- salt/utils/x509.py | 105 +++++++++++++- .../functional/modules/test_x509_v2.py | 2 +- .../pytests/functional/states/test_x509_v2.py | 6 +- tests/pytests/unit/utils/test_x509.py | 128 +++++++++++++++++- 6 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 changelog/69900.fixed.md diff --git a/changelog/69900.fixed.md b/changelog/69900.fixed.md new file mode 100644 index 000000000000..7efa6c6c19e2 --- /dev/null +++ b/changelog/69900.fixed.md @@ -0,0 +1 @@ +Added support for `otherName` definitions in `x509_v2`, e.g. inside a `subjectAltNames` extension. diff --git a/salt/modules/x509_v2.py b/salt/modules/x509_v2.py index 95a38079c9fb..4b204561b20c 100644 --- a/salt/modules/x509_v2.py +++ b/salt/modules/x509_v2.py @@ -403,7 +403,7 @@ def create_certificate( ``keyid:always, issuer`` subjectAltName - There is support for all OpenSSL-defined types except ``otherName``. + There is support for all OpenSSL-defined types, but ``otherName`` support is limited. ``email:me@example.com,DNS:example.com`` or @@ -412,6 +412,39 @@ def create_certificate( - subjectAltName: - email:me@example.com # list items can be strings - dns: example.com # or single-key dicts + - ip: 1.2.3.4 + - otherName: + oid: 1.2.3.4.5.5 + value: some utf8 string + - otherName: + oid: 1.2.3.4.5.6 + value: true # this renders a BOOL:TRUE + - otherName: + oid: 1.2.3.4.5.7.7 + der: "hex:0101ff" # raw DER passthrough, hex-encoded + - otherName: + oid: 1.2.3.4.5.7.7 + der: "b64:AQH/" # raw DER passthrough, base64-encoded + - dirName: + C: US + ST: California + L: San Francisco + O: My Company + CN: mysite.com + + .. versionchanged:: 3006.28 + + ``otherName`` support was added. + + .. note:: + + Regarding ``otherName`` support: + + * OpenSSL-style strings (``otherName:1.2.3.4;UTF8:foo``) only allow ``UTF8`` type data. + * Dictionary definitions can additionally render other simple types like booleans by passing + in a value of the type. + * Arbitrary DER is supported by passing it in ``der``, with either ``hex:`` (hexadecimal encoding) + or ``b64:`` (base64 encoding) prefix. issuerAltName The syntax is the same as for ``subjectAltName``, except that the additional diff --git a/salt/utils/x509.py b/salt/utils/x509.py index c6bf34604a4a..54dd893d39ea 100644 --- a/salt/utils/x509.py +++ b/salt/utils/x509.py @@ -12,6 +12,7 @@ import cryptography from cryptography import x509 as cx509 from cryptography.exceptions import InvalidSignature +from cryptography.hazmat import asn1 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, padding, rsa from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12 @@ -1851,6 +1852,100 @@ def _deserialize_openssl_confstring(conf, multiple=False): }, critical +def _parse_other_name(value): + """ + Parse otherName definition. Accepted formats: + + OpenSSL-style string + e.g. ``1.2.3.4;UTF8:foobar``. Can only map to UTF8STRING, other ASN1 types raise an exception. + + Dictionary + ``{oid: 1.2.3.4, value: foobar}``: ``value`` is passed into the encoder, + meaning other simple types (in addition to UTF8, like BOOLEAN) are supported, even from SLS files. + In theory, more complex types can be passed in programmatically from Python. + + ``{oid: 1.2.3.4, der: "hex:deadbeef"}``: ``der`` can be an arbitrary DER blob. + It needs to be a hex/base64-encoded string with ``hex:``/``b64:`` prefix. + Raw Python bytes are passed through. + """ + if isinstance(value, str): + try: + oid_text, asn_expr = value.split(";", maxsplit=1) + except ValueError as err: + raise SaltInvocationError( + "`othername` string definition needs semicolon (;) between OID and " + "value: othername:1.2.3.4;UTF8:value" + ) from err + asn_expr = asn_expr.removeprefix( + "FORMAT:UTF8," + ) # Compatibility with OpenSSL's documented SmtpUTF8Mailbox spelling + try: + asn_typ, asn_val = asn_expr.split(":", maxsplit=1) + except ValueError as err: + raise SaltInvocationError( + "`othername` string definition needs colon (:) between value type and " + "value: othername:1.2.3.4;UTF8:value" + ) from err + if asn_typ.upper() not in {"UTF8", "UTF8STRING"}: + raise SaltInvocationError( + f"Unsupported otherName ASN.1 type {asn_typ!r}; only UTF8STRING is supported" + ) + oid = _get_oid(oid_text) + try: + encoded = asn1.encode_der(asn_val) + except ValueError as err: + raise SaltInvocationError( + f"Failed parsing OpenSSL otherName value {value!r}" + ) from err + return cx509.OtherName(oid, encoded) + + if not isinstance(value, dict): + raise SaltInvocationError( + f"Invalid otherName definition, dict or string required, got {value!r}" + ) + if "oid" not in value: + raise SaltInvocationError("Invalid otherName definition, missing `oid` key") + oid = _get_oid(value["oid"]) + + if "der" in value: + if isinstance(value["der"], bytes): + encoded = value["der"] + elif value["der"].startswith("hex:"): + try: + encoded = bytes.fromhex(value["der"].removeprefix("hex:")) + except ValueError as err: + raise SaltInvocationError( + "Failed to parse otherName `der` input as hex" + ) from err + elif value["der"].startswith("b64:"): + try: + encoded = base64.b64decode(value["der"].removeprefix("b64:")) + except ValueError as err: + raise SaltInvocationError( + "Failed to parse otherName `der` input as base64" + ) from err + else: + raise SaltInvocationError( + "Failed to parse otherName `der` input, needs `hex:` or `b64:` prefix" + ) + return cx509.OtherName(oid, encoded) + if "value" in value: + # Support basic types by passing them through + to_encode = value["value"] + if to_encode is None: + to_encode = asn1.Null() + try: + encoded = asn1.encode_der(to_encode) + except ValueError as err: + raise SaltInvocationError( + f"Failed to encode otherName value {value['value']!r} to ASN1" + ) from err + return cx509.OtherName(oid, encoded) + raise SaltInvocationError( + "Invalid otherName definition, missing `value` or `der` key" + ) + + def _parse_general_names(val): def idna_encode(val, allow_leading_dot=False, allow_wildcard=False): # A leading dot is allowed in some values (nameConstraints). @@ -1915,7 +2010,7 @@ def idna_encode(val, allow_leading_dot=False, allow_wildcard=False): "rid": cx509.general_name.RegisteredID, "ip": cx509.general_name.IPAddress, "dirname": cx509.general_name.DirectoryName, - # othername currently not implemented + "othername": _parse_other_name, } parsed = [] @@ -1953,8 +2048,6 @@ def idna_encode(val, allow_leading_dot=False, allow_wildcard=False): ) elif typ == "dns": v = idna_encode(v, allow_leading_dot=True, allow_wildcard=True) - elif typ == "othername": - raise SaltInvocationError("otherName is currently not implemented") if typ in valid_types: try: parsed.append(valid_types[typ](v)) @@ -2067,6 +2160,12 @@ def render_gn(gn): return f"RID:{gn.value.dotted_string}" if isinstance(gn, cx509.UniformResourceIdentifier): return f"URI:{gn.value}" + if isinstance(gn, cx509.OtherName): + try: + val = "UTF8:" + asn1.decode_der(str, gn.value) + except ValueError: + val = f"" + return f"otherName:{gn.type_id.dotted_string};{val}" return str(gn) diff --git a/tests/pytests/functional/modules/test_x509_v2.py b/tests/pytests/functional/modules/test_x509_v2.py index e51001b4b6ad..394b34987e02 100644 --- a/tests/pytests/functional/modules/test_x509_v2.py +++ b/tests/pytests/functional/modules/test_x509_v2.py @@ -850,7 +850,7 @@ def test_create_certificate_with_extensions(x509, ca_key, ca_cert, rsa_privkey): "authorityKeyIdentifier": "keyid:always", "issuerAltName": "DNS:salt.ca", "authorityInfoAccess": "OCSP;URI:http://ocsp.salt.ca/", - "subjectAltName": "DNS:sub.salt.ca,email:sub@salt.ca", + "subjectAltName": "DNS:sub.salt.ca,email:sub@salt.ca,otherName:1.2.3.4;UTF8:foobar", "crlDistributionPoints": "URI:http://salt.ca/myca.crl", "certificatePolicies": "1.2.4.5", "policyConstraints": "requireExplicitPolicy:3", diff --git a/tests/pytests/functional/states/test_x509_v2.py b/tests/pytests/functional/states/test_x509_v2.py index f420a0fd5c95..b0cb774ec377 100644 --- a/tests/pytests/functional/states/test_x509_v2.py +++ b/tests/pytests/functional/states/test_x509_v2.py @@ -505,7 +505,11 @@ def cert_args_exts(): "authorityKeyIdentifier": "keyid:always", "issuerAltName": "DNS:salt.ca", "authorityInfoAccess": "OCSP;URI:http://ocsp.salt.ca/", - "subjectAltName": "DNS:sub.salt.ca,email:sub@salt.ca", + "subjectAltName": [ + "DNS:sub.salt.ca", + {"email": "sub@salt.ca"}, + {"othername": {"oid": "1.2.3.4", "value": True}}, + ], "crlDistributionPoints": "URI:http://salt.ca/myca.crl", "certificatePolicies": "1.2.4.5", "policyConstraints": "requireExplicitPolicy:3", diff --git a/tests/pytests/unit/utils/test_x509.py b/tests/pytests/unit/utils/test_x509.py index 5b8113a08e1e..7023d3c1f963 100644 --- a/tests/pytests/unit/utils/test_x509.py +++ b/tests/pytests/unit/utils/test_x509.py @@ -1,3 +1,4 @@ +import base64 import ipaddress from datetime import datetime, timedelta, timezone @@ -11,6 +12,9 @@ "cryptography", reason="Needs cryptography library", minversion="37.0" ) cx509 = pytest.importorskip("cryptography.x509", reason="Needs cryptography library") +asn1 = pytest.importorskip( + "cryptography.hazmat.asn1", reason="Needs cryptography library" +) cprim = pytest.importorskip( "cryptography.hazmat.primitives", reason="Needs cryptography library" ) @@ -1272,9 +1276,101 @@ def test_create_invalidity_date(self, val, expected, critical): "Failed parsing rfc4514 dirName string", ), ( - ("otherName", "otherName:1.2.3.4;UTF8:some other identifier"), - salt.exceptions.SaltInvocationError, - "otherName is currently not implemented", + ("otherName", "1.2.3.4;UTF8:some other identifier"), + cx509.OtherName, + ( + cx509.ObjectIdentifier("1.2.3.4"), + asn1.encode_der("some other identifier"), + ), + ), + ( + ( + "otherName", + "1.3.6.1.5.5.7.8.9;FORMAT:UTF8,UTF8String:nonasciinäme.example.com", + ), + cx509.OtherName, + ( + cx509.ObjectIdentifier("1.3.6.1.5.5.7.8.9"), + asn1.encode_der("nonasciinäme.example.com"), + ), + ), + ( + ("otherName", "1.2.3.4;BOOL:TRUE"), + salt.exceptions.CommandExecutionError, + ".*only UTF8STRING is supported.*", + ), + ( + ("otherName", {"oid": "1.2.3.4", "value": "some other identifier"}), + cx509.OtherName, + ( + cx509.ObjectIdentifier("1.2.3.4"), + asn1.encode_der("some other identifier"), + ), + ), + ( + ("otherName", {"oid": "1.2.3.4", "value": True}), + cx509.OtherName, + (cx509.ObjectIdentifier("1.2.3.4"), asn1.encode_der(True)), + ), + ( + ("otherName", {"oid": "1.2.3.4", "value": None}), + cx509.OtherName, + (cx509.ObjectIdentifier("1.2.3.4"), asn1.encode_der(asn1.Null())), + ), + ( + ( + "otherName", + { + "oid": "1.2.3.4", + "der": "hex:" + asn1.encode_der("hex encoded utf8string").hex(), + }, + ), + cx509.OtherName, + ( + cx509.ObjectIdentifier("1.2.3.4"), + asn1.encode_der("hex encoded utf8string"), + ), + ), + ( + ( + "otherName", + { + "oid": "1.2.3.4", + "der": "b64:" + + base64.b64encode( + asn1.encode_der( + "base64 encoded utf8string, but arbitrary types are allowed" + ) + ).decode(), + }, + ), + cx509.OtherName, + ( + cx509.ObjectIdentifier("1.2.3.4"), + asn1.encode_der( + "base64 encoded utf8string, but arbitrary types are allowed" + ), + ), + ), + ( + ("otherName", []), + salt.exceptions.CommandExecutionError, + ".*dict or string required.*", + ), + ( + ("otherName", {}), + salt.exceptions.CommandExecutionError, + ".*missing `oid` key.*", + ), + ( + ("otherName", {"oid": "1.2.3.4"}), + salt.exceptions.CommandExecutionError, + ".*missing `value` or `der` key.*", + ), + ( + ("otherName", {"oid": "1.2.3.4", "der": "foobar"}), + salt.exceptions.CommandExecutionError, + ".*needs `hex:` or `b64:` prefix.*", ), ( ("invalidType", "L'état c'est moi!"), @@ -1288,7 +1384,10 @@ def test_parse_general_names(inpt, cls, parsed): with pytest.raises(cls, match=parsed): x509._parse_general_names([inpt]) return - expected = cls(parsed) + if inpt[0] == "otherName": + expected = cls(*parsed) + else: + expected = cls(parsed) res = x509._parse_general_names([inpt]) if inpt[0] == "dirName": assert res[0].value == expected @@ -1631,11 +1730,28 @@ def test_get_dn(inpt, expected): cx509.Extension( cx509.SubjectAlternativeName.oid, value=cx509.SubjectAlternativeName( - [cx509.DNSName("example.io"), cx509.RFC822Name("hello@example.io")] + [ + cx509.DNSName("example.io"), + cx509.RFC822Name("hello@example.io"), + cx509.OtherName( + cx509.ObjectIdentifier("1.2.3.4"), asn1.encode_der("foobar") + ), + cx509.OtherName( + cx509.ObjectIdentifier("1.2.3.4.5"), asn1.encode_der(True) + ), + ] ), critical=False, ), - {"critical": False, "value": ["DNS:example.io", "email:hello@example.io"]}, + { + "critical": False, + "value": [ + "DNS:example.io", + "email:hello@example.io", + "otherName:1.2.3.4;UTF8:foobar", + "otherName:1.2.3.4.5;", + ], + }, ), ( cx509.Extension( From fbba188687f567e4ada74a3ae8c6553267983692 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 30 Jul 2026 13:30:27 -0700 Subject: [PATCH 159/469] Cap setuptools <83 in SaltVirtualEnv default requirement setuptools 83 was recently published and cannot be installed by the pip<25.0 range that SaltVirtualEnv pins on Python 3.12+: TypeError: InstallRequirement.install() got an unexpected keyword argument 'script_executable' The pre-installed setuptools 82 in the onedir already satisfies >=68.1.0,<83, so no install fires and the SaltVirtualEnv fixture comes up cleanly. Fixes functional-1 (test_module_dirs_priority, test_new_entry_points_*, test_old_entry_points_*, test_utils_loader_does_not_load_extensions) and functional-5 (test_salt_extensions_in_versions_report). --- tests/support/helpers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/support/helpers.py b/tests/support/helpers.py index c19f55ee2735..ac5e1de7e8c4 100644 --- a/tests/support/helpers.py +++ b/tests/support/helpers.py @@ -1625,7 +1625,11 @@ def _default_setuptools_requirement(self): if sys.version_info >= (3, 12): # setuptools dropped support for Python 3.12 in versions older # than 68.1; require a version that supports Python 3.12. - return "setuptools>=68.1.0" + # Cap below 83 because installing setuptools 83 with the + # pip<25.0 range we pin above trips + # ``TypeError: InstallRequirement.install() got an unexpected + # keyword argument 'script_executable'``. + return "setuptools>=68.1.0,<83" if os.environ.get("ONEDIR_TESTRUN", "0") == "1": # https://github.com/pypa/setuptools/commit/137ab9d684075f772c322f455b0dd1f992ddcd8f return "setuptools>=65.6.3,<66" From d821652d8ccc2461167d41b8aae7becbbf7cf011 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Fri, 31 Jul 2026 13:07:57 -0700 Subject: [PATCH 160/469] Apply PIP_CONSTRAINT to the relenv[toolchain] install too _install_requirements() ran 'pip install relenv[toolchain]' before _upgrade_pip_setuptools_and_wheel() had a chance to pin pip to 25.2 via PIP_CONSTRAINT. On CI hosts where the ambient pip is 25.1, that install pulls in setuptools 83.0.0 and crashes with: TypeError: InstallRequirement.install() got an unexpected keyword argument 'script_executable' Pass the same PIP_CONSTRAINT env for the toolchain install so pip is constrained to the same 25.2 the rest of the CI Deps stage uses. Fixes 'CI Deps / Linux (arm64)' failure. --- noxfile.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index d59730e38e8b..23b3546e7e6f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -281,8 +281,18 @@ def _install_requirements( onedir=False, ): if onedir and IS_LINUX: + relenv_env = os.environ.copy() + relenv_env["PIP_CONSTRAINT"] = str( + REPO_ROOT / "requirements" / "constraints.txt" + ) session_run_always( - session, "python3", "-m", "pip", "install", "relenv[toolchain]" + session, + "python3", + "-m", + "pip", + "install", + "relenv[toolchain]", + env=relenv_env, ) if not _upgrade_pip_setuptools_and_wheel(session): From 4ea2a1ebea96d225a91156717013fb94f608ef18 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sat, 1 Aug 2026 13:38:25 -0700 Subject: [PATCH 161/469] Seed nox virtualenvs with pip 25.2 to avoid relenv wrapper crash pip 26.2 added a script_executable kwarg to InstallRequirement.install(); the wrapper the shipped relenv (0.22.14) monkey-patches over that method has fixed positional signatures and rejects the new kwarg with TypeError: InstallRequirement.install() got an unexpected keyword argument 'script_executable' The prior PIP_CONSTRAINT fix only affected what pip *installed*, not what pip *was*. Nox creates its venvs via virtualenv, which seeds whatever pip the ambient virtualenv bundles (26.2 on py3.14). Set VIRTUALENV_PIP=25.2 at noxfile import time so every nox-created venv starts with the compatible pip, and stays there until relenv PR #314 lands in a shipped relenv. --- noxfile.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/noxfile.py b/noxfile.py index 23b3546e7e6f..6e8532f3b609 100644 --- a/noxfile.py +++ b/noxfile.py @@ -22,6 +22,16 @@ import nox.command +# pip 26.2 added an ``script_executable`` keyword to +# ``InstallRequirement.install()``; the wrapper the shipped relenv (0.22.14) +# monkey-patches over that method has fixed positional signatures and rejects +# the new kwarg with: +# TypeError: InstallRequirement.install() got an unexpected keyword +# argument 'script_executable' +# Seed every nox-created virtualenv with pip 25.2 so the running pip stays +# compatible with the shipped relenv wrapper. See relenv PR #314. +os.environ.setdefault("VIRTUALENV_PIP", "25.2") + # fmt: off if __name__ == "__main__": sys.stderr.write( From dccfea2eafcbeb12cef706966d9b7f23ae9ac9e6 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sat, 1 Aug 2026 16:47:00 -0700 Subject: [PATCH 162/469] Let virtualenv download the pinned pip 25.2 seed wheel Setting VIRTUALENV_PIP=25.2 alone made virtualenv fail on hosts whose ambient virtualenv doesn't ship a pip 25.2 seed wheel: Exception: Wheel for pip for Python 3.14 is unavailable. apt install python3-pip-whl RuntimeError: seed failed due to failing to download wheels pip Debian 12 pkg tests and virtualenv_mod functional tests on Amazon Linux 2 / Rocky Linux 8 Arm64 all hit this. Add VIRTUALENV_DOWNLOAD=true so the via_app_data seeder fetches the pinned wheel from PyPI when the local seed doesn't have it. --- noxfile.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/noxfile.py b/noxfile.py index 6e8532f3b609..dd603d5dc901 100644 --- a/noxfile.py +++ b/noxfile.py @@ -30,7 +30,11 @@ # argument 'script_executable' # Seed every nox-created virtualenv with pip 25.2 so the running pip stays # compatible with the shipped relenv wrapper. See relenv PR #314. +# VIRTUALENV_DOWNLOAD=1 lets the via_app_data seeder fetch the pinned pip +# wheel from PyPI on hosts (e.g. Debian 12 pkg-test containers) whose +# ambient virtualenv doesn't ship a 25.2 seed wheel locally. os.environ.setdefault("VIRTUALENV_PIP", "25.2") +os.environ.setdefault("VIRTUALENV_DOWNLOAD", "true") # fmt: off if __name__ == "__main__": From 649ada85997d654e7d14545d2824b063af07e133 Mon Sep 17 00:00:00 2001 From: Twangboy Date: Sun, 2 Aug 2026 21:42:26 -0600 Subject: [PATCH 163/469] Update relenv to 0.22.18 --- .github/workflows/ci.yml | 6 +++--- .github/workflows/nightly.yml | 6 +++--- .github/workflows/scheduled.yml | 6 +++--- .github/workflows/staging.yml | 6 +++--- changelog/69928.fixed.md | 5 +++++ cicd/shared-gh-workflows-context.yml | 2 +- 6 files changed, 18 insertions(+), 13 deletions(-) create mode 100644 changelog/69928.fixed.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e8f0ce63229..04cd00214064 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -441,7 +441,7 @@ jobs: with: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['onedir-matrix']) }} @@ -458,7 +458,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" source: "onedir" @@ -475,7 +475,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" source: "src" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 16d95b9cf9f6..f3f85e46c135 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -505,7 +505,7 @@ jobs: with: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['onedir-matrix']) }} @@ -522,7 +522,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" source: "onedir" @@ -543,7 +543,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" source: "src" diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml index 7d141c0218d8..50624c45fa15 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/scheduled.yml @@ -490,7 +490,7 @@ jobs: with: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['onedir-matrix']) }} @@ -507,7 +507,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" source: "onedir" @@ -524,7 +524,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" source: "src" diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index fb516a29933b..8e6d1a0e024b 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -464,7 +464,7 @@ jobs: with: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['onedir-matrix']) }} @@ -482,7 +482,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" source: "onedir" @@ -504,7 +504,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.16" + relenv-version: "0.22.18" python-version: "3.11.15" ci-python-version: "3.14" source: "src" diff --git a/changelog/69928.fixed.md b/changelog/69928.fixed.md new file mode 100644 index 000000000000..68d527e8e187 --- /dev/null +++ b/changelog/69928.fixed.md @@ -0,0 +1,5 @@ +* Relenv 0.22.18 + - Fix pip 26.2 compatibility in InstallRequirement.install/install_wheel wrappers - #314 + - Fix Windows 3.10 native builds failing on find_python.bat's EOL fallback - #315 + - Preserve caller cwd in macOS shebang launcher - #311 + - Share Linux build deps via artifact, not cache - #310 diff --git a/cicd/shared-gh-workflows-context.yml b/cicd/shared-gh-workflows-context.yml index 3d1e6bf230b7..e4b584dfecf4 100644 --- a/cicd/shared-gh-workflows-context.yml +++ b/cicd/shared-gh-workflows-context.yml @@ -1,6 +1,6 @@ nox_version: "2022.8.7" python_version: "3.11.15" -relenv_version: "0.22.16" +relenv_version: "0.22.18" release_branches: - "3006.x" - "3007.x" From b8138f9925ee520bcd26a3d774e05c3ff5c5cacf Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 01:23:25 -0700 Subject: [PATCH 164/469] Revert "Let virtualenv download the pinned pip 25.2 seed wheel" This reverts commit dccfea2eafcbeb12cef706966d9b7f23ae9ac9e6. --- noxfile.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/noxfile.py b/noxfile.py index dd603d5dc901..6e8532f3b609 100644 --- a/noxfile.py +++ b/noxfile.py @@ -30,11 +30,7 @@ # argument 'script_executable' # Seed every nox-created virtualenv with pip 25.2 so the running pip stays # compatible with the shipped relenv wrapper. See relenv PR #314. -# VIRTUALENV_DOWNLOAD=1 lets the via_app_data seeder fetch the pinned pip -# wheel from PyPI on hosts (e.g. Debian 12 pkg-test containers) whose -# ambient virtualenv doesn't ship a 25.2 seed wheel locally. os.environ.setdefault("VIRTUALENV_PIP", "25.2") -os.environ.setdefault("VIRTUALENV_DOWNLOAD", "true") # fmt: off if __name__ == "__main__": From 8dfc30ee1d9989b399a71ad5240f06a437ec93ae Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 01:23:25 -0700 Subject: [PATCH 165/469] Revert "Seed nox virtualenvs with pip 25.2 to avoid relenv wrapper crash" This reverts commit 4ea2a1ebea96d225a91156717013fb94f608ef18. --- noxfile.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/noxfile.py b/noxfile.py index 6e8532f3b609..23b3546e7e6f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -22,16 +22,6 @@ import nox.command -# pip 26.2 added an ``script_executable`` keyword to -# ``InstallRequirement.install()``; the wrapper the shipped relenv (0.22.14) -# monkey-patches over that method has fixed positional signatures and rejects -# the new kwarg with: -# TypeError: InstallRequirement.install() got an unexpected keyword -# argument 'script_executable' -# Seed every nox-created virtualenv with pip 25.2 so the running pip stays -# compatible with the shipped relenv wrapper. See relenv PR #314. -os.environ.setdefault("VIRTUALENV_PIP", "25.2") - # fmt: off if __name__ == "__main__": sys.stderr.write( From c979de5bf7ffdc143ff0b91d92e350054c767969 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 01:23:25 -0700 Subject: [PATCH 166/469] Revert "Apply PIP_CONSTRAINT to the relenv[toolchain] install too" This reverts commit d821652d8ccc2461167d41b8aae7becbbf7cf011. --- noxfile.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/noxfile.py b/noxfile.py index 23b3546e7e6f..d59730e38e8b 100644 --- a/noxfile.py +++ b/noxfile.py @@ -281,18 +281,8 @@ def _install_requirements( onedir=False, ): if onedir and IS_LINUX: - relenv_env = os.environ.copy() - relenv_env["PIP_CONSTRAINT"] = str( - REPO_ROOT / "requirements" / "constraints.txt" - ) session_run_always( - session, - "python3", - "-m", - "pip", - "install", - "relenv[toolchain]", - env=relenv_env, + session, "python3", "-m", "pip", "install", "relenv[toolchain]" ) if not _upgrade_pip_setuptools_and_wheel(session): From 598cd5d85d463638f03de4d425d9d5307f6ea4c6 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 01:23:25 -0700 Subject: [PATCH 167/469] Revert "Cap setuptools <83 in SaltVirtualEnv default requirement" This reverts commit fbba188687f567e4ada74a3ae8c6553267983692. --- tests/support/helpers.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/support/helpers.py b/tests/support/helpers.py index ac5e1de7e8c4..c19f55ee2735 100644 --- a/tests/support/helpers.py +++ b/tests/support/helpers.py @@ -1625,11 +1625,7 @@ def _default_setuptools_requirement(self): if sys.version_info >= (3, 12): # setuptools dropped support for Python 3.12 in versions older # than 68.1; require a version that supports Python 3.12. - # Cap below 83 because installing setuptools 83 with the - # pip<25.0 range we pin above trips - # ``TypeError: InstallRequirement.install() got an unexpected - # keyword argument 'script_executable'``. - return "setuptools>=68.1.0,<83" + return "setuptools>=68.1.0" if os.environ.get("ONEDIR_TESTRUN", "0") == "1": # https://github.com/pypa/setuptools/commit/137ab9d684075f772c322f455b0dd1f992ddcd8f return "setuptools>=65.6.3,<66" From 469a6522a44c2b895c9e5759275928fc074d2c82 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 01:23:55 -0700 Subject: [PATCH 168/469] Update relenv to 0.22.18 Cherry-picked from 3006.x (649ada85997 / PR #69929). Relenv 0.22.18 includes the pip 26.2 InstallRequirement.install/install_wheel wrapper fix (relenv PR #314) that the four preceding revert commits were working around. --- .github/workflows/ci.yml | 6 +++--- .github/workflows/nightly.yml | 6 +++--- .github/workflows/scheduled.yml | 6 +++--- .github/workflows/staging.yml | 6 +++--- changelog/69928.fixed.md | 5 +++++ cicd/shared-gh-workflows-context.yml | 2 +- 6 files changed, 18 insertions(+), 13 deletions(-) create mode 100644 changelog/69928.fixed.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e58721b678c2..e4efd81647dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -474,7 +474,7 @@ jobs: with: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -491,7 +491,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" source: "onedir" @@ -508,7 +508,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" source: "src" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c176a4e788e2..f4d963c320ee 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -466,7 +466,7 @@ jobs: with: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -483,7 +483,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" source: "onedir" @@ -504,7 +504,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" source: "src" diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml index 83cd30775666..41f5f3ff7841 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/scheduled.yml @@ -520,7 +520,7 @@ jobs: with: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -537,7 +537,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" source: "onedir" @@ -554,7 +554,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" source: "src" diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index f6fb8de551bd..e4a3340b496f 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -495,7 +495,7 @@ jobs: with: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -513,7 +513,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" source: "onedir" @@ -535,7 +535,7 @@ jobs: with: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} - relenv-version: "0.22.14" + relenv-version: "0.22.18" python-version: "3.14.6" ci-python-version: "3.14" source: "src" diff --git a/changelog/69928.fixed.md b/changelog/69928.fixed.md new file mode 100644 index 000000000000..68d527e8e187 --- /dev/null +++ b/changelog/69928.fixed.md @@ -0,0 +1,5 @@ +* Relenv 0.22.18 + - Fix pip 26.2 compatibility in InstallRequirement.install/install_wheel wrappers - #314 + - Fix Windows 3.10 native builds failing on find_python.bat's EOL fallback - #315 + - Preserve caller cwd in macOS shebang launcher - #311 + - Share Linux build deps via artifact, not cache - #310 diff --git a/cicd/shared-gh-workflows-context.yml b/cicd/shared-gh-workflows-context.yml index 4eb2723abf25..0dda448a28a0 100644 --- a/cicd/shared-gh-workflows-context.yml +++ b/cicd/shared-gh-workflows-context.yml @@ -4,7 +4,7 @@ # Tool versions nox_version: "2022.8.7" python_version: "3.14.6" -relenv_version: "0.22.14" +relenv_version: "0.22.18" release_branches: - "3006.x" - "3007.x" From 689bb6084eb7aab5c7f4782b859e380aa06a6709 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 14 Jul 2026 14:46:00 -0700 Subject: [PATCH 169/469] Add Python 3.9 upper caps for 3.9-dropping deps and fix pip-compile hooks Two coupled fixes that keep grouped pip-updates PRs from failing on 3006.x: 1. Python 3.9 caps. Dependabot raises the shared floor of several packages to a release that no longer supports Python 3.9 (jaraco.functools 4.5.0, jaraco.context 6.1.2, msgpack 1.2.1, more-itertools 11.0.0, pycparser 3.0, pythonnet 3.1.0, virtualenv 21.5.1, xmldiff 3.0, zipp 4.1.0). With a single unmarked floor the py3.9 lock targets become unresolvable. Split each into a python_version < '3.10' branch capped at the last 3.9-compatible release plus an open py>=3.10 branch, mirroring the existing cryptography/aiohttp/urllib3 splits. (cryptography and pyopenssl already carry all-Python caps here, so they need no split.) 2. Malformed pip-compile hooks. The Py3.13 ZeroMQ hooks (linux/freebsd/ darwin/windows) and the docs hook were missing their `- id: pip-compile` line, so YAML folded them into the preceding Py3.14 blocks as duplicate keys and the Py3.14 CI locks never regenerated. Restore the missing id lines, correct the py3.14->py3.13 file globs, and regenerate the affected Py3.14 locks. --- .pre-commit-config.yaml | 13 +- requirements/base.txt | 32 +++-- requirements/static/ci/common.txt | 6 +- requirements/static/ci/py3.14/cloud.lock | 1 + requirements/static/ci/py3.14/darwin.lock | 109 +++++++++++----- requirements/static/ci/py3.14/docs.lock | 82 ++++++++---- requirements/static/ci/py3.14/freebsd.lock | 144 +++++++++++++++------ requirements/static/ci/py3.14/lint.lock | 1 + requirements/static/ci/py3.14/linux.lock | 5 + requirements/static/ci/py3.14/windows.lock | 66 ++++++---- 10 files changed, 317 insertions(+), 142 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c6feb81ed0ef..5d93e254fa0a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -747,9 +747,10 @@ repos: - -c=requirements/static/pkg/py3.14/linux.lock - -o=requirements/static/ci/py3.14/linux.lock + - id: pip-compile alias: compile-ci-linux-3.13-zmq-requirements name: Linux CI Py3.13 ZeroMQ Requirements - files: ^requirements/(constraints\.txt|(base|zeromq|pytest)\.txt|static/((ci|pkg)/(linux\.txt|common\.txt)|py3\.14/linux\.txt))$ + files: ^requirements/(constraints\.txt|(base|zeromq|pytest)\.txt|static/((ci|pkg)/(linux\.txt|common\.txt)|py3\.13/linux\.txt))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -970,9 +971,10 @@ repos: - -c=requirements/static/pkg/py3.14/freebsd.lock - -o=requirements/static/ci/py3.14/freebsd.lock + - id: pip-compile alias: compile-ci-freebsd-3.13-zmq-requirements name: FreeBSD CI Py3.13 ZeroMQ Requirements - files: ^requirements/(constraints\.txt|(base|zeromq|pytest)\.txt|static/((ci|pkg)/(freebsd|common)\.txt|py3\.14/freebsd\.txt))$ + files: ^requirements/(constraints\.txt|(base|zeromq|pytest)\.txt|static/((ci|pkg)/(freebsd|common)\.txt|py3\.13/freebsd\.txt))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1195,9 +1197,10 @@ repos: - -c=requirements/static/pkg/py3.14/darwin.lock - -o=requirements/static/ci/py3.14/darwin.lock + - id: pip-compile alias: compile-ci-darwin-3.13-zmq-requirements name: Darwin CI Py3.13 ZeroMQ Requirements - files: ^(requirements/(constraints\.txt|(base|zeromq|pytest)\.txt|static/((ci|pkg)/(darwin|common)\.txt|py3\.14/darwin\.txt)))$ + files: ^(requirements/(constraints\.txt|(base|zeromq|pytest)\.txt|static/((ci|pkg)/(darwin|common)\.txt|py3\.13/darwin\.txt)))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1418,9 +1421,10 @@ repos: - -c=requirements/static/pkg/py3.14/windows.lock - -o=requirements/static/ci/py3.14/windows.lock + - id: pip-compile alias: compile-ci-windows-3.13-zmq-requirements name: Windows CI Py3.13 ZeroMQ Requirements - files: requirements/((base|zeromq|pytest)\.txt|static/((ci|pkg)/(windows|common)\.txt|py3\.14/windows\.txt))$ + files: requirements/((base|zeromq|pytest)\.txt|static/((ci|pkg)/(windows|common)\.txt|py3\.13/windows\.txt))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1777,6 +1781,7 @@ repos: - -c=requirements/static/ci/py3.14/linux.lock - -o=requirements/static/ci/py3.14/docs.lock + - id: pip-compile alias: compile-doc-requirements name: Docs CI Py3.13 Requirements files: ^requirements/(constraints\.txt|(base|zeromq|pytest|crypto)\.txt|static/ci/(docs|common|linux)\.txt|static/pkg/linux\.txt|static/pkg/.*/linux\.txt)$ diff --git a/requirements/base.txt b/requirements/base.txt index 29531bfb3f73..678d934f0ce6 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -24,8 +24,12 @@ frozenlist>=1.5.0; python_version >= '3.11' gitpython>=3.1.50 immutables>=0.21 importlib-metadata>=8.7.0 -jaraco.functools>=4.4.0 -jaraco.context>=6.1.1 +# jaraco.functools 4.5.0 and jaraco.context 6.1.2 drop Python 3.9; keep the +# last 3.9-compatible releases there and let py>=3.10 float forward. +jaraco.functools>=4.4.0,<4.5.0; python_version < '3.10' +jaraco.functools>=4.4.0; python_version >= '3.10' +jaraco.context>=6.1.1,<6.1.2; python_version < '3.10' +jaraco.context>=6.1.1; python_version >= '3.10' jaraco.text>=4.2.0 Jinja2>=3.1.6 jmespath>=1.1.0 @@ -36,7 +40,9 @@ MarkupSafe<4.0.0 # conversion checks (macOS 15 onedir builds compile from sdist via # --no-binary=:all:). 6.6+ fixed the C source compatibility. multidict>=6.6.0 -msgpack>=1.1.2 +# msgpack 1.2.1 drops Python 3.9; keep the last 3.9-compatible release there. +msgpack>=1.1.2,<1.2.1; python_version < '3.10' +msgpack>=1.1.2; python_version >= '3.10' # Packaging 24.1+ imports annotations from __future__ which breaks # salt-ssh on target hosts with older Python versions (Amazon Linux 2 # still ships Python 3.7). 26.x additionally uses positional-only @@ -54,7 +60,9 @@ pymysql>=1.2.0; sys_platform == 'win32' pyopenssl>=26.0.0,<26.2.0 python-dateutil>=2.9.0.post0 python-gnupg>=0.5.6 -pythonnet>=3.0.5; sys_platform == 'win32' +# pythonnet 3.1.0 drops Python 3.9; keep the last 3.9-compatible release there. +pythonnet>=3.0.5,<3.1.0; sys_platform == 'win32' and python_version < '3.10' +pythonnet>=3.0.5; sys_platform == 'win32' and python_version >= '3.10' tzdata; sys_platform == 'win32' pywin32>=312; sys_platform == 'win32' pycryptodomex>=3.23.0 @@ -72,17 +80,25 @@ tornado>=6.5.5 # (CVE-2025-66418, CVE-2026-21441). urllib3>=1.26.20,<2.0.0; python_version < '3.10' urllib3>=2.7.0; python_version >= '3.10' -virtualenv>=21.4.2 +# virtualenv 21.5.1 drops Python 3.9; keep the last 3.9-compatible release there. +virtualenv>=21.4.2,<21.5.1; python_version < '3.10' +virtualenv>=21.4.2; python_version >= '3.10' # Transitive of virtualenv; some uv resolver caches pin a stale 3.25 # version that conflicts with the CI floor of 3.29.1 on Python 3.10+. filelock>=3.29.1; python_version >= '3.10' filelock>=3.19.1,<3.29.0; python_version < '3.10' wmi>=1.5.1; sys_platform == 'win32' xmltodict>=1.0.4; sys_platform == 'win32' -zipp>=3.23.1 +# zipp 4.1.0 drops Python 3.9; keep the last 3.9-compatible release there. +zipp>=3.23.1,<4.1.0; python_version < '3.10' +zipp>=3.23.1; python_version >= '3.10' apache-libcloud>=3.8.0,<3.9.1; python_version < '3.10' apache-libcloud>=3.9.1; python_version >= '3.10' idna>=3.18 -more-itertools>=10.8.0 +# more-itertools 11.0.0 drops Python 3.9; keep the last 3.9-compatible release there. +more-itertools>=10.8.0,<11.0.0; python_version < '3.10' +more-itertools>=10.8.0; python_version >= '3.10' pyasn1>=0.6.3 -pycparser>=2.23 +# pycparser 3.0 drops Python 3.9; keep the last 3.9-compatible release there. +pycparser>=2.23,<3.0; python_version < '3.10' +pycparser>=2.23; python_version >= '3.10' diff --git a/requirements/static/ci/common.txt b/requirements/static/ci/common.txt index c93548d42595..d422f3bd3212 100644 --- a/requirements/static/ci/common.txt +++ b/requirements/static/ci/common.txt @@ -59,12 +59,14 @@ toml # vcert 0.18.x adds hard pins on cryptography, pynacl, and six that # conflict with every other CI requirement; stay on 0.9.x. vcert~=0.9.0; sys_platform != 'win32' -virtualenv>=21.4.2 +virtualenv>=21.4.2,<21.5.1; python_version < '3.10' +virtualenv>=21.4.2; python_version >= '3.10' watchdog>=6.0.0 websocket-client>=1.9.0 # werkzeug is a dependency of moto werkzeug>=3.1.8 -xmldiff>=2.7.0 +xmldiff>=2.7.0,<3.0; python_version < '3.10' +xmldiff>=2.7.0; python_version >= '3.10' # Available template libraries that can be used genshi>=0.7.11 cheetah3>=3.2.6.post1 diff --git a/requirements/static/ci/py3.14/cloud.lock b/requirements/static/ci/py3.14/cloud.lock index 76c86e45a17f..ba3e12a00e6e 100644 --- a/requirements/static/ci/py3.14/cloud.lock +++ b/requirements/static/ci/py3.14/cloud.lock @@ -727,6 +727,7 @@ toml==0.10.2 # -r requirements/static/ci/common.txt tornado==6.5.7 # via + # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt transitions==0.9.3 diff --git a/requirements/static/ci/py3.14/darwin.lock b/requirements/static/ci/py3.14/darwin.lock index 496819b9a79b..497fdc123c14 100644 --- a/requirements/static/ci/py3.14/darwin.lock +++ b/requirements/static/ci/py3.14/darwin.lock @@ -4,17 +4,22 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.14/darwin.lock # aiohttp -aiohttp==3.13.5 +aiohttp==3.14.1 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt # etcd3-py + # kubernetes aiosignal==1.4.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # aiohttp -apache-libcloud==3.9.0 +annotated-doc==0.0.4 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # typer +apache-libcloud==3.9.1 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -34,26 +39,22 @@ attrs==25.4.0 # pytest-subtests # pytest-system-statistics # referencing -autocommand==2.2.2 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # jaraco-text bcrypt==5.0.0 # via # -r requirements/static/ci/common.txt # paramiko boto==2.49.0 # via -r requirements/static/ci/common.txt -boto3==1.42.33 +boto3==1.43.48 # via # -r requirements/static/ci/common.txt # moto -botocore==1.42.33 +botocore==1.43.48 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -91,11 +92,11 @@ contextvars==2.4 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt -croniter==6.0.0 +croniter==6.2.2 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt -cryptography==46.0.7 +cryptography==47.0.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -125,10 +126,12 @@ durationpy==0.10 # via kubernetes etcd3-py==0.1.6 # via -r requirements/static/ci/common.txt -filelock==3.20.3 +filelock==3.29.1 # via # -c requirements/static/pkg/py3.14/darwin.lock + # -r requirements/base.txt # -r requirements/static/ci/common.txt + # python-discovery # virtualenv flaky==3.8.1 # via -r requirements/pytest.txt @@ -138,7 +141,7 @@ frozenlist==1.8.0 # -r requirements/base.txt # aiohttp # aiosignal -genshi==0.7.10 +genshi==0.7.11 # via -r requirements/static/ci/common.txt gitdb==4.0.12 # via @@ -152,7 +155,7 @@ gitpython==3.1.50 # -r requirements/static/ci/darwin.txt hglib==2.6.2 # via -r requirements/static/ci/darwin.txt -idna==3.11 +idna==3.18 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -173,23 +176,27 @@ iniconfig==2.3.0 # via pytest invoke==2.2.1 # via paramiko +jaraco-classes==3.4.0 + # via keyring jaraco-collections==5.2.1 # via # -c requirements/static/pkg/py3.14/darwin.lock # cherrypy -jaraco-context==6.1.0 +jaraco-context==6.1.2 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt # jaraco-text + # keyring jaraco-functools==4.4.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt # cheroot # jaraco-text + # keyring # tempora -jaraco-text==4.0.0 +jaraco-text==4.2.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -199,7 +206,6 @@ jinja2==3.1.6 # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt # junos-eznc - # moto jmespath==1.1.0 # via # -c requirements/static/pkg/py3.14/darwin.lock @@ -217,9 +223,9 @@ junos-eznc==2.7.6 # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt -keyring==5.7.1 +keyring==25.7.0 # via -r requirements/static/ci/common.txt -kubernetes==35.0.0 +kubernetes==36.0.3 # via -r requirements/static/ci/common.txt looseversion==1.3.0 # via @@ -232,6 +238,10 @@ lxml==6.0.2 # xmldiff mako==1.3.10 # via -r requirements/static/ci/common.txt +markdown-it-py==4.2.0 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # rich markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.14/darwin.lock @@ -239,7 +249,11 @@ markupsafe==2.1.5 # jinja2 # mako # werkzeug -mercurial==7.1.2 +mdurl==0.1.2 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # markdown-it-py +mercurial==7.2.3 # via -r requirements/static/ci/darwin.txt mock==5.2.0 # via -r requirements/pytest.txt @@ -250,9 +264,10 @@ more-itertools==10.8.0 # -r requirements/pytest.txt # cheroot # cherrypy + # jaraco-classes # jaraco-functools # jaraco-text -moto==5.1.20 +moto==5.2.2 # via -r requirements/static/ci/common.txt msgpack==1.1.2 # via @@ -262,6 +277,7 @@ msgpack==1.1.2 multidict==6.7.0 # via # -c requirements/static/pkg/py3.14/darwin.lock + # -r requirements/base.txt # aiohttp # yarl ncclient==0.7.0 @@ -287,6 +303,7 @@ pathspec==1.0.3 platformdirs==4.5.1 # via # -c requirements/static/pkg/py3.14/darwin.lock + # python-discovery # virtualenv pluggy==1.6.0 # via pytest @@ -322,15 +339,18 @@ pycryptodomex==3.23.0 # -r requirements/static/ci/common.txt pyfakefs==6.0.0 # via -r requirements/pytest.txt -pygit2==1.19.1 +pygit2==1.19.3 # via -r requirements/static/ci/darwin.txt -pygments==2.19.2 - # via pytest +pygments==2.20.0 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # pytest + # rich pynacl==1.6.2 # via # -r requirements/static/ci/common.txt # paramiko -pyopenssl==26.0.0 +pyopenssl==26.1.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -384,19 +404,18 @@ python-dateutil==2.9.0.post0 # botocore # croniter # kubernetes - # moto # tempora # vcert +python-discovery==1.4.0 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # virtualenv python-etcd==0.4.5 # via -r requirements/static/ci/common.txt python-gnupg==0.5.6 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt -pytz==2025.2 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # croniter pyvmomi==9.0.0.0 # via -r requirements/static/ci/common.txt pyyaml==6.0.3 @@ -410,7 +429,7 @@ pyyaml==6.0.3 # responses # yamllint # yamlloader -pyzmq==25.1.2 +pyzmq==27.1.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/zeromq.txt @@ -437,11 +456,15 @@ responses==0.25.8 # via moto rfc3987==1.3.8 # via -r requirements/static/ci/common.txt +rich==15.0.0 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # typer rpds-py==0.30.0 # via # jsonschema # referencing -s3transfer==0.16.0 +s3transfer==0.19.1 # via boto3 scp==0.15.0 # via junos-eznc @@ -451,6 +474,10 @@ setproctitle==1.3.7 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt +shellingham==1.5.4 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # typer six==1.17.0 # via # -c requirements/static/pkg/py3.14/darwin.lock @@ -477,10 +504,22 @@ textfsm==2.1.0 # via -r requirements/static/ci/common.txt toml==0.10.2 # via -r requirements/static/ci/common.txt +tornado==6.5.7 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # -r requirements/base.txt transitions==0.9.3 # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt +typer==0.26.7 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # typer-slim +typer-slim==0.24.0 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # jaraco-text typing-extensions==4.14.1 # via pytest-system-statistics urllib3==2.7.0 @@ -495,7 +534,7 @@ urllib3==2.7.0 # responses vcert==0.9.1 # via -r requirements/static/ci/common.txt -virtualenv==20.36.1 +virtualenv==21.4.2 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -509,7 +548,7 @@ websocket-client==1.9.0 # kubernetes wempy==0.2.1 # via -r requirements/static/ci/common.txt -werkzeug==3.1.6 +werkzeug==3.1.8 # via # -r requirements/static/ci/common.txt # moto @@ -530,7 +569,7 @@ zc-lockfile==4.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # cherrypy -zipp==3.23.0 +zipp==4.1.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/docs.lock b/requirements/static/ci/py3.14/docs.lock index ecd9c3c650ae..f713a9a38aa0 100644 --- a/requirements/static/ci/py3.14/docs.lock +++ b/requirements/static/ci/py3.14/docs.lock @@ -6,7 +6,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/ci/py3.14/linux.lock # aiohttp -aiohttp==3.13.5 +aiohttp==3.14.1 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -16,7 +16,11 @@ aiosignal==1.4.0 # aiohttp alabaster==1.0.0 # via sphinx -apache-libcloud==3.9.0 +annotated-doc==0.0.4 + # via + # -c requirements/static/ci/py3.14/linux.lock + # typer +apache-libcloud==3.9.1 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -24,17 +28,13 @@ attrs==25.4.0 # via # -c requirements/static/ci/py3.14/linux.lock # aiohttp -autocommand==2.2.2 - # via - # -c requirements/static/ci/py3.14/linux.lock - # jaraco-text babel==2.17.0 # via # pydata-sphinx-theme # sphinx beautifulsoup4==4.14.3 # via pydata-sphinx-theme -certifi==2026.1.4 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -62,11 +62,11 @@ contextvars==2.4 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt -croniter==6.0.0 +croniter==6.2.2 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt -cryptography==46.0.7 +cryptography==47.0.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -83,9 +83,11 @@ docutils==0.22.4 # via # pydata-sphinx-theme # sphinx -filelock==3.20.3 +filelock==3.29.1 # via # -c requirements/static/ci/py3.14/linux.lock + # -r requirements/base.txt + # python-discovery # virtualenv frozenlist==1.8.0 # via @@ -101,7 +103,7 @@ gitpython==3.1.50 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt -idna==3.11 +idna==3.18 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -114,7 +116,7 @@ immutables==0.21 # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt # contextvars -importlib-metadata==8.7.1 +importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -122,7 +124,7 @@ jaraco-collections==5.2.1 # via # -c requirements/static/ci/py3.14/linux.lock # cherrypy -jaraco-context==6.1.0 +jaraco-context==6.1.2 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -134,7 +136,7 @@ jaraco-functools==4.4.0 # cheroot # jaraco-text # tempora -jaraco-text==4.0.0 +jaraco-text==4.2.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -156,10 +158,12 @@ looseversion==1.3.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt -markdown-it-py==4.0.0 +markdown-it-py==4.2.0 # via + # -c requirements/static/ci/py3.14/linux.lock # mdit-py-plugins # myst-docutils + # rich markupsafe==2.1.5 # via # -c requirements/static/ci/py3.14/linux.lock @@ -169,8 +173,10 @@ markupsafe==2.1.5 mdit-py-plugins==0.5.0 # via myst-docutils mdurl==0.1.2 - # via markdown-it-py -more-itertools==10.8.0 + # via + # -c requirements/static/ci/py3.14/linux.lock + # markdown-it-py +more-itertools==11.1.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -185,6 +191,7 @@ msgpack==1.1.2 multidict==6.7.0 # via # -c requirements/static/ci/py3.14/linux.lock + # -r requirements/base.txt # aiohttp # yarl myst-docutils==5.0.0 @@ -197,6 +204,7 @@ packaging==24.0 platformdirs==4.5.1 # via # -c requirements/static/ci/py3.14/linux.lock + # python-discovery # virtualenv portend==3.2.1 # via @@ -229,13 +237,14 @@ pydata-sphinx-theme==0.18.0 # via -r requirements/static/ci/docs.txt pyenchant==3.3.0 # via sphinxcontrib-spelling -pygments==2.19.2 +pygments==2.20.0 # via # -c requirements/static/ci/py3.14/linux.lock # accessible-pygments # pydata-sphinx-theme + # rich # sphinx -pyopenssl==26.0.0 +pyopenssl==26.1.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -245,14 +254,14 @@ python-dateutil==2.9.0.post0 # -r requirements/base.txt # croniter # tempora -python-gnupg==0.5.6 +python-discovery==1.4.0 # via # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/base.txt -pytz==2025.2 + # virtualenv +python-gnupg==0.5.6 # via # -c requirements/static/ci/py3.14/linux.lock - # croniter + # -r requirements/base.txt pyyaml==6.0.3 # via # -c requirements/static/ci/py3.14/linux.lock @@ -269,17 +278,24 @@ requests==2.33.1 # apache-libcloud # sphinx # sphinxcontrib-spelling +rich==15.0.0 + # via + # -c requirements/static/ci/py3.14/linux.lock + # typer roman-numerals==4.1.0 # via sphinx setproctitle==1.3.7 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt +shellingham==1.5.4 + # via + # -c requirements/static/ci/py3.14/linux.lock + # typer six==1.17.0 # via # -c requirements/static/ci/py3.14/linux.lock # python-dateutil - # sphinxcontrib-httpdomain smmap==5.0.2 # via # -c requirements/static/ci/py3.14/linux.lock @@ -300,7 +316,7 @@ sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx -sphinxcontrib-httpdomain==1.8.1 +sphinxcontrib-httpdomain==2.0.0 # via -r requirements/static/ci/docs.txt sphinxcontrib-jsmath==1.0.1 # via sphinx @@ -314,6 +330,18 @@ tempora==5.8.1 # via # -c requirements/static/ci/py3.14/linux.lock # portend +tornado==6.5.7 + # via + # -c requirements/static/ci/py3.14/linux.lock + # -r requirements/base.txt +typer==0.26.7 + # via + # -c requirements/static/ci/py3.14/linux.lock + # typer-slim +typer-slim==0.24.0 + # via + # -c requirements/static/ci/py3.14/linux.lock + # jaraco-text typing-extensions==4.15.0 # via # -c requirements/static/ci/py3.14/linux.lock @@ -326,7 +354,7 @@ urllib3==2.7.0 # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt # requests -virtualenv==20.36.1 +virtualenv==21.4.2 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -338,7 +366,7 @@ zc-lockfile==4.0 # via # -c requirements/static/ci/py3.14/linux.lock # cherrypy -zipp==3.23.0 +zipp==4.1.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/freebsd.lock b/requirements/static/ci/py3.14/freebsd.lock index 4fb3d8e1cc24..ae9eb0e1a47e 100644 --- a/requirements/static/ci/py3.14/freebsd.lock +++ b/requirements/static/ci/py3.14/freebsd.lock @@ -4,17 +4,22 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.14/freebsd.lock # aiohttp -aiohttp==3.13.5 +aiohttp==3.14.1 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt # etcd3-py + # kubernetes aiosignal==1.4.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock # aiohttp -apache-libcloud==3.9.0 +annotated-doc==0.0.4 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # typer +apache-libcloud==3.9.1 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -34,26 +39,23 @@ attrs==25.4.0 # pytest-subtests # pytest-system-statistics # referencing -autocommand==2.2.2 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # jaraco-text bcrypt==5.0.0 # via + # -c requirements/constraints.txt # -r requirements/static/ci/common.txt # paramiko boto==2.49.0 # via -r requirements/static/ci/common.txt -boto3==1.42.33 +boto3==1.43.48 # via # -r requirements/static/ci/common.txt # moto -botocore==1.42.33 +botocore==1.43.48 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -95,16 +97,19 @@ clr-loader==0.2.10 ; sys_platform == 'win32' clustershell==1.9.3 # via -r requirements/static/ci/common.txt colorama==0.4.6 ; sys_platform == 'win32' - # via pytest + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # pytest + # typer contextvars==2.4 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt -croniter==6.0.0 ; sys_platform != 'win32' +croniter==6.2.2 ; sys_platform != 'win32' # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt -cryptography==46.0.7 +cryptography==47.0.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -114,6 +119,7 @@ cryptography==46.0.7 # moto # paramiko # pyopenssl + # secretstorage # trustme # vcert distlib==0.4.0 @@ -136,10 +142,12 @@ durationpy==0.10 # via kubernetes etcd3-py==0.1.6 # via -r requirements/static/ci/common.txt -filelock==3.20.3 +filelock==3.29.1 # via # -c requirements/static/pkg/py3.14/freebsd.lock + # -r requirements/base.txt # -r requirements/static/ci/common.txt + # python-discovery # virtualenv flaky==3.8.1 # via -r requirements/pytest.txt @@ -149,7 +157,7 @@ frozenlist==1.8.0 # -r requirements/base.txt # aiohttp # aiosignal -genshi==0.7.10 +genshi==0.7.11 # via -r requirements/static/ci/common.txt gitdb==4.0.12 # via @@ -162,7 +170,7 @@ gitpython==3.1.50 # -r requirements/static/ci/common.txt hglib==2.6.2 # via -r requirements/static/ci/freebsd.txt -idna==3.11 +idna==3.18 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -175,7 +183,7 @@ immutables==0.21 # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # contextvars -importlib-metadata==8.7.1 +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -184,33 +192,40 @@ iniconfig==2.3.0 # via pytest invoke==2.2.1 ; sys_platform != 'win32' # via paramiko +jaraco-classes==3.4.0 + # via keyring jaraco-collections==5.2.1 # via # -c requirements/static/pkg/py3.14/freebsd.lock # cherrypy -jaraco-context==6.1.0 +jaraco-context==6.1.2 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # jaraco-text + # keyring jaraco-functools==4.4.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # cheroot # jaraco-text + # keyring # tempora -jaraco-text==4.0.0 +jaraco-text==4.2.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # jaraco-collections +jeepney==0.9.0 ; sys_platform == 'linux' + # via + # keyring + # secretstorage jinja2==3.1.6 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # junos-eznc - # moto jmespath==1.1.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -219,20 +234,24 @@ jmespath==1.1.0 # boto3 # botocore jsonschema==4.26.0 - # via -r requirements/static/ci/common.txt + # via + # -c requirements/constraints.txt + # -r requirements/static/ci/common.txt jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt junos-eznc==2.7.6 ; sys_platform != 'win32' - # via -r requirements/static/ci/common.txt + # via + # -c requirements/constraints.txt + # -r requirements/static/ci/common.txt jxmlease==1.0.3 ; sys_platform != 'win32' # via -r requirements/static/ci/common.txt kazoo==2.10.0 ; sys_platform != 'darwin' and sys_platform != 'win32' # via -r requirements/static/ci/common.txt -keyring==5.7.1 +keyring==25.7.0 # via -r requirements/static/ci/common.txt -kubernetes==35.0.0 +kubernetes==36.0.3 # via -r requirements/static/ci/common.txt libnacl==2.1.0 ; sys_platform != 'darwin' and sys_platform != 'win32' # via -r requirements/static/ci/common.txt @@ -240,7 +259,7 @@ looseversion==1.3.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt -lxml==6.0.2 +lxml==6.1.1 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -249,6 +268,12 @@ lxml==6.0.2 # xmldiff mako==1.3.10 # via -r requirements/static/ci/common.txt +markdown-it-py==4.2.0 + # via + # -c requirements/constraints.txt + # -c requirements/static/pkg/py3.14/freebsd.lock + # -r requirements/static/ci/common.txt + # rich markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -256,7 +281,11 @@ markupsafe==2.1.5 # jinja2 # mako # werkzeug -mercurial==7.1.2 +mdurl==0.1.2 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # markdown-it-py +mercurial==7.2.3 # via -r requirements/static/ci/freebsd.txt mock==5.2.0 # via -r requirements/pytest.txt @@ -267,9 +296,10 @@ more-itertools==10.8.0 # -r requirements/pytest.txt # cheroot # cherrypy + # jaraco-classes # jaraco-functools # jaraco-text -moto==5.1.20 +moto==5.2.2 # via -r requirements/static/ci/common.txt msgpack==1.1.2 # via @@ -279,6 +309,7 @@ msgpack==1.1.2 multidict==6.7.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock + # -r requirements/base.txt # aiohttp # yarl ncclient==0.7.0 ; sys_platform != 'win32' @@ -292,7 +323,7 @@ packaging==24.0 # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # pytest -paramiko==4.0.0 ; sys_platform != 'win32' +paramiko==5.0.0 ; sys_platform != 'win32' # via # -r requirements/static/ci/common.txt # junos-eznc @@ -305,6 +336,7 @@ pathspec==1.0.3 platformdirs==4.5.1 # via # -c requirements/static/pkg/py3.14/freebsd.lock + # python-discovery # virtualenv pluggy==1.6.0 # via pytest @@ -341,15 +373,18 @@ pycryptodomex==3.23.0 # -r requirements/static/ci/common.txt pyfakefs==6.0.0 # via -r requirements/pytest.txt -pygments==2.19.2 - # via pytest +pygments==2.20.0 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # pytest + # rich pyinotify==0.9.6 ; platform_system != 'openbsd' and sys_platform != 'darwin' and sys_platform != 'win32' # via -r requirements/static/ci/common.txt pymssql==2.3.11 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt -pymysql==1.1.2 ; sys_platform == 'win32' +pymysql==1.2.0 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -357,7 +392,7 @@ pynacl==1.6.2 # via # -r requirements/static/ci/common.txt # paramiko -pyopenssl==26.0.0 +pyopenssl==26.1.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -413,9 +448,12 @@ python-dateutil==2.9.0.post0 # botocore # croniter # kubernetes - # moto # tempora # vcert +python-discovery==1.4.0 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # virtualenv python-etcd==0.4.5 # via -r requirements/static/ci/common.txt python-gnupg==0.5.6 @@ -427,19 +465,17 @@ pythonnet==3.0.5 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt -pytz==2025.2 ; sys_platform != 'win32' - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # croniter pyvmomi==9.0.0.0 # via -r requirements/static/ci/common.txt -pywin32==311 ; sys_platform == 'win32' +pywin32==312 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # docker # pytest-skip-markers # wmi +pywin32-ctypes==0.2.3 ; sys_platform == 'win32' + # via keyring pyyaml==6.0.3 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -478,14 +514,20 @@ responses==0.25.8 # via moto rfc3987==1.3.8 # via -r requirements/static/ci/common.txt +rich==15.0.0 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # typer rpds-py==0.30.0 # via # jsonschema # referencing -s3transfer==0.16.0 +s3transfer==0.19.1 # via boto3 scp==0.15.0 ; sys_platform != 'win32' # via junos-eznc +secretstorage==3.5.0 ; sys_platform == 'linux' + # via keyring semantic-version==2.10.0 # via etcd3-py setproctitle==1.3.7 @@ -493,6 +535,10 @@ setproctitle==1.3.7 # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt +shellingham==1.5.4 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # typer six==1.17.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -523,12 +569,28 @@ timelib==0.3.0 # -r requirements/static/pkg/freebsd.txt toml==0.10.2 # via -r requirements/static/ci/common.txt +tornado==6.5.7 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # -r requirements/base.txt transitions==0.9.3 ; sys_platform != 'win32' # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt +typer==0.26.7 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # typer-slim +typer-slim==0.24.0 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # jaraco-text typing-extensions==4.15.0 # via pytest-system-statistics +tzdata==2026.2 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -541,7 +603,7 @@ urllib3==2.7.0 # responses vcert==0.9.1 ; sys_platform != 'win32' # via -r requirements/static/ci/common.txt -virtualenv==20.36.1 +virtualenv==21.4.2 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -555,7 +617,7 @@ websocket-client==1.9.0 # kubernetes wempy==0.2.1 # via -r requirements/static/ci/common.txt -werkzeug==3.1.6 +werkzeug==3.1.8 # via # -r requirements/static/ci/common.txt # moto @@ -583,7 +645,7 @@ zc-lockfile==4.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock # cherrypy -zipp==3.23.0 +zipp==4.1.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/lint.lock b/requirements/static/ci/py3.14/lint.lock index 1bf5fb100f44..8acb1b8acba3 100644 --- a/requirements/static/ci/py3.14/lint.lock +++ b/requirements/static/ci/py3.14/lint.lock @@ -727,6 +727,7 @@ tomlkit==0.14.0 # via pylint tornado==6.5.7 # via + # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt transitions==0.9.3 diff --git a/requirements/static/ci/py3.14/linux.lock b/requirements/static/ci/py3.14/linux.lock index 93d4af4759bb..42a7fed9f495 100644 --- a/requirements/static/ci/py3.14/linux.lock +++ b/requirements/static/ci/py3.14/linux.lock @@ -309,6 +309,7 @@ msgpack==1.1.2 multidict==6.7.0 # via # -c requirements/static/pkg/py3.14/linux.lock + # -r requirements/base.txt # aiohttp # yarl ncclient==0.7.0 @@ -565,6 +566,10 @@ textfsm==2.1.0 # via -r requirements/static/ci/common.txt toml==0.10.2 # via -r requirements/static/ci/common.txt +tornado==6.5.7 + # via + # -c requirements/static/pkg/py3.14/linux.lock + # -r requirements/base.txt transitions==0.9.3 # via junos-eznc trustme==1.2.1 diff --git a/requirements/static/ci/py3.14/windows.lock b/requirements/static/ci/py3.14/windows.lock index 964f4d307e28..dfd2b41b48fb 100644 --- a/requirements/static/ci/py3.14/windows.lock +++ b/requirements/static/ci/py3.14/windows.lock @@ -4,12 +4,13 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.14/windows.lock # aiohttp -aiohttp==3.13.5 +aiohttp==3.14.1 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt # etcd3-py + # kubernetes aiosignal==1.4.0 # via # -c requirements/static/pkg/py3.14/windows.lock @@ -18,7 +19,7 @@ annotated-doc==0.0.4 # via # -c requirements/static/pkg/py3.14/windows.lock # typer -apache-libcloud==3.9.0 +apache-libcloud==3.9.1 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -37,16 +38,16 @@ bcrypt==5.0.0 # via -r requirements/static/ci/common.txt boto==2.49.0 # via -r requirements/static/ci/common.txt -boto3==1.42.33 +boto3==1.43.48 # via # -r requirements/static/ci/common.txt # moto -botocore==1.42.33 +botocore==1.43.48 # via # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -96,7 +97,7 @@ contextvars==2.4 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt -cryptography==46.0.7 +cryptography==47.0.0 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -128,9 +129,10 @@ durationpy==0.10 # via kubernetes etcd3-py==0.1.6 # via -r requirements/static/ci/common.txt -filelock==3.25.0 +filelock==3.29.1 # via # -c requirements/static/pkg/py3.14/windows.lock + # -r requirements/base.txt # -r requirements/static/ci/common.txt # python-discovery # virtualenv @@ -142,7 +144,7 @@ frozenlist==1.8.0 # -r requirements/base.txt # aiohttp # aiosignal -genshi==0.7.10 +genshi==0.7.11 # via -r requirements/static/ci/common.txt gitdb==4.0.12 # via @@ -153,7 +155,7 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -idna==3.11 +idna==3.18 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -172,21 +174,25 @@ importlib-metadata==8.7.1 # -r requirements/base.txt iniconfig==2.3.0 # via pytest +jaraco-classes==3.4.0 + # via keyring jaraco-collections==5.2.1 # via # -c requirements/static/pkg/py3.14/windows.lock # cherrypy -jaraco-context==6.1.0 +jaraco-context==6.1.2 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt # jaraco-text + # keyring jaraco-functools==4.4.0 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt # cheroot # jaraco-text + # keyring # tempora jaraco-text==4.2.0 # via @@ -197,7 +203,6 @@ jinja2==3.1.6 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt - # moto jmespath==1.1.0 # via # -c requirements/static/pkg/py3.14/windows.lock @@ -211,15 +216,15 @@ jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -keyring==5.7.1 +keyring==25.7.0 # via -r requirements/static/ci/common.txt -kubernetes==35.0.0 +kubernetes==36.0.3 # via -r requirements/static/ci/common.txt looseversion==1.3.0 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt -lxml==6.0.2 +lxml==6.1.1 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -250,9 +255,10 @@ more-itertools==10.8.0 # -r requirements/pytest.txt # cheroot # cherrypy + # jaraco-classes # jaraco-functools # jaraco-text -moto==5.1.20 +moto==5.2.2 # via -r requirements/static/ci/common.txt msgpack==1.1.2 # via @@ -262,6 +268,7 @@ msgpack==1.1.2 multidict==6.7.1 # via # -c requirements/static/pkg/py3.14/windows.lock + # -r requirements/base.txt # aiohttp # yarl oauthlib==3.3.1 @@ -316,7 +323,7 @@ pycryptodomex==3.23.0 # -r requirements/static/ci/common.txt pyfakefs==6.0.0 # via -r requirements/pytest.txt -pygit2==1.19.1 +pygit2==1.19.3 # via -r requirements/static/ci/windows.txt pygments==2.19.2 # via @@ -327,13 +334,13 @@ pymssql==2.3.11 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt -pymysql==1.1.2 +pymysql==1.2.0 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt pynacl==1.6.2 # via -r requirements/static/ci/common.txt -pyopenssl==26.0.0 +pyopenssl==26.1.0 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -384,9 +391,8 @@ python-dateutil==2.9.0.post0 # -r requirements/base.txt # botocore # kubernetes - # moto # tempora -python-discovery==1.1.0 +python-discovery==1.4.0 # via # -c requirements/static/pkg/py3.14/windows.lock # virtualenv @@ -402,13 +408,15 @@ pythonnet==3.0.5 # -r requirements/base.txt pyvmomi==9.0.0.0 # via -r requirements/static/ci/common.txt -pywin32==311 +pywin32==312 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt # docker # pytest-skip-markers # wmi +pywin32-ctypes==0.2.3 + # via keyring pywinrm==0.5.0 # via -r requirements/static/ci/windows.txt pyyaml==6.0.3 @@ -458,7 +466,7 @@ rpds-py==0.30.0 # via # jsonschema # referencing -s3transfer==0.16.0 +s3transfer==0.19.1 # via boto3 sed==0.3.1 # via -r requirements/static/ci/windows.txt @@ -497,6 +505,10 @@ textfsm==2.1.0 # via -r requirements/static/ci/common.txt toml==0.10.2 # via -r requirements/static/ci/common.txt +tornado==6.5.6 + # via + # -c requirements/static/pkg/py3.14/windows.lock + # -r requirements/base.txt trustme==1.2.1 # via -r requirements/pytest.txt typer==0.24.1 @@ -509,6 +521,10 @@ typer-slim==0.24.0 # jaraco-text typing-extensions==4.15.0 # via pytest-system-statistics +tzdata==2026.2 + # via + # -c requirements/static/pkg/py3.14/windows.lock + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.14/windows.lock @@ -519,7 +535,7 @@ urllib3==2.7.0 # python-etcd # requests # responses -virtualenv==21.1.0 +virtualenv==21.4.2 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -533,7 +549,7 @@ websocket-client==1.9.0 # kubernetes wempy==0.2.1 # via -r requirements/static/ci/common.txt -werkzeug==3.1.6 +werkzeug==3.1.8 # via # -r requirements/static/ci/common.txt # moto @@ -560,7 +576,7 @@ zc-lockfile==4.0 # via # -c requirements/static/pkg/py3.14/windows.lock # cherrypy -zipp==3.23.0 +zipp==4.1.0 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt From 11f517b91953ce39a293f7e0bfc139f9de047c2c Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 02:52:55 -0700 Subject: [PATCH 170/469] Remove dead msgpack version check in _sanitize_msgpack_unpack_kwargs ``_sanitize_msgpack_unpack_kwargs`` runs on every ``unpackb``/``packb`` call. Its historical ``salt.utils.versions.reqs.msgpack > "0.5.2"`` guard was dead code on any supported install (3006.x pins msgpack ``>=1.1.2``, 3007.x/3008.x pin ``>=1.1.0``, and even the CentOS 7 EPEL ``python-msgpack`` was 0.5.6) but its ``Requirement.__gt__`` walk still allocated two fresh ``packaging.version.Version`` objects on every call. Under stress this fired ~4 million times per 60 s in the master's ``EventPublisher`` alone -- second-largest allocation source after msgpack.packb itself. Removing the gate takes the total transient allocation churn in that process from ~200 GB / 60 s to ~175 GB / 60 s and drops ``Version.__init__`` out of the top-5 hot allocators entirely. Existing ``test_sanitize_msgpack_unpack_kwargs`` still passes because all supported msgpack versions are ``> 0.5.2`` -- the observable output kwargs are unchanged. Added three tests to lock in the new behavior: zero Version allocations on the hot path, correct defaults, and ``setdefault`` semantics preserved. Fixes #69931 --- changelog/69931.fixed.md | 1 + salt/utils/msgpack.py | 21 ++++++--- tests/pytests/unit/utils/test_msgpack.py | 55 ++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 changelog/69931.fixed.md diff --git a/changelog/69931.fixed.md b/changelog/69931.fixed.md new file mode 100644 index 000000000000..bfd8376063d2 --- /dev/null +++ b/changelog/69931.fixed.md @@ -0,0 +1 @@ +Removed the dead ``salt.utils.versions.reqs.msgpack > "0.5.2"`` guard inside ``salt.utils.msgpack._sanitize_msgpack_unpack_kwargs``. The guard could never be false on any supported install (3006.x pins ``msgpack>=1.1.2``, 3007.x/3008.x pin ``msgpack>=1.1.0``, and even the ancient CentOS 7 EPEL ``python-msgpack`` was 0.5.6) but its per-call ``Requirement.__gt__`` walk allocated two fresh ``packaging.version.Version`` objects on every ``unpackb``/``packb``. Under stress this fired ~4 million times per 60 s in the master's ``EventPublisher`` alone, cutting the process's total transient allocation churn by more than half once eliminated. diff --git a/salt/utils/msgpack.py b/salt/utils/msgpack.py index 1b4adcef1953..f041bf4a6397 100644 --- a/salt/utils/msgpack.py +++ b/salt/utils/msgpack.py @@ -58,15 +58,22 @@ def _sanitize_msgpack_kwargs(kwargs): def _sanitize_msgpack_unpack_kwargs(kwargs): """ - Clean up msgpack keyword arguments for unpack operations, based on - the version - https://github.com/msgpack/msgpack-python/blob/master/ChangeLog.rst + Clean up msgpack keyword arguments for unpack operations. + + The historical ``salt.utils.versions.reqs.msgpack > "0.5.2"`` gate + here was dead code on any supported install: 3006.x requires + ``msgpack>=1.1.2``, 3007.x/3008.x require ``msgpack>=1.1.0``, and + even the CentOS 7 EPEL system ``python-msgpack`` was 0.5.6 (already + newer than 0.5.2 by the time EPEL 7 shipped it). The gate was never + false in practice, but its per-call ``Requirement.__gt__`` walk + allocated two fresh ``packaging.version.Version`` objects on every + ``unpackb``/``packb`` call. Under a stressed master this cost + ~4 million ``Version`` constructions per 60 s just in the + ``EventPublisher``, ~7 GB of transient allocation churn per minute. """ assert isinstance(kwargs, dict) - if salt.utils.versions.reqs.msgpack: - if salt.utils.versions.reqs.msgpack > "0.5.2": - kwargs.setdefault("raw", True) - kwargs.setdefault("strict_map_key", False) + kwargs.setdefault("raw", True) + kwargs.setdefault("strict_map_key", False) return _sanitize_msgpack_kwargs(kwargs) diff --git a/tests/pytests/unit/utils/test_msgpack.py b/tests/pytests/unit/utils/test_msgpack.py index fda1c00dc55b..424ee1067acb 100644 --- a/tests/pytests/unit/utils/test_msgpack.py +++ b/tests/pytests/unit/utils/test_msgpack.py @@ -106,6 +106,61 @@ def test_sanitize_msgpack_unpack_kwargs(version, exp_kwargs): ) +def test_sanitize_msgpack_unpack_kwargs_no_version_allocs(): + """ + ``_sanitize_msgpack_unpack_kwargs`` must not construct + ``packaging.version.Version`` on the hot path. + + The historical ``salt.utils.versions.reqs.msgpack > "0.5.2"`` guard + was dead on any supported install (see the function's docstring for + the rationale) but its ``Requirement.__gt__`` walk allocated two + fresh ``Version`` objects on every call. Regressing this back would + reintroduce ~4 million transient ``Version`` allocations per 60 s in + the master's ``EventPublisher`` under stress (issue :issue:`69931`). + """ + import packaging.version + + original_init = packaging.version.Version.__init__ + calls = {"n": 0} + + def counting_init(self, *args, **kwargs): + calls["n"] += 1 + return original_init(self, *args, **kwargs) + + packaging.version.Version.__init__ = counting_init + try: + # Warmup (in case any first-call caches allocate). + salt.utils.msgpack._sanitize_msgpack_unpack_kwargs({}) + calls["n"] = 0 + for _ in range(1000): + salt.utils.msgpack._sanitize_msgpack_unpack_kwargs({}) + assert calls["n"] == 0, ( + "sanitize allocated %d Version objects across 1000 calls " + "(expected 0)" % calls["n"] + ) + finally: + packaging.version.Version.__init__ = original_init + + +def test_sanitize_msgpack_unpack_kwargs_sets_defaults(): + """The defaults set unconditionally are the same ones the historical + ``> 0.5.2`` guarded branch set (all supported msgpack versions are + > 0.5.2, so callers observe no behavior change).""" + out = salt.utils.msgpack._sanitize_msgpack_unpack_kwargs({}) + assert out["raw"] is True + assert out["strict_map_key"] is False + + +def test_sanitize_msgpack_unpack_kwargs_respects_caller_override(): + """Caller-supplied ``raw`` / ``strict_map_key`` values win over the + defaults (``setdefault`` semantics unchanged).""" + out = salt.utils.msgpack._sanitize_msgpack_unpack_kwargs( + {"raw": False, "strict_map_key": True} + ) + assert out["raw"] is False + assert out["strict_map_key"] is True + + def test_version(): """ Verify that the version exists and returns a value in the expected format From 8db60f2833f6d6bee0a08b7af4bcb33144e04c3b Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sat, 1 Aug 2026 17:21:55 -0700 Subject: [PATCH 171/469] Memoize SaltStackVersion construction in warn_until() (#69921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warn_until() built two fresh SaltStackVersion instances per call — one for the target-version comparison and one for the running version. SaltStackVersion.__init__ transitively allocates a packaging.version. Version. Under stress the master EventPublisher was allocating ~1.4M Version objects (2.5 GB of transient allocation churn) per 90 s window; on hot deprecation-warning paths (TCPPubClient, TCPReqServer, MessageClient) this drove Python's arena high-water mark and pinned process RSS above its actual working set. Wrap both constructions in functools.lru_cache-backed helpers: _resolve_target_version_hashable — 32-slot cache keyed on the common hashable inputs (int, tuple, str); returns None for non-hashable input so the caller falls through to the inline path unchanged _resolve_current_version — 8-slot cache keyed on the version_info tuple; effectively a one-time construction for a given process After the patch a 10 000-call warn_until loop makes 0 SaltStackVersion constructions (100% reduction on the repeated-argument path). WebSocket-transport master EventPublisher RSS peak: 271 MB -> 214 MB (-57 MB / -21%) under 30 min stress. --- changelog/69921.fixed.md | 1 + salt/utils/versions.py | 71 ++++++++-- .../utils/test_versions_warn_until_cache.py | 128 ++++++++++++++++++ 3 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 changelog/69921.fixed.md create mode 100644 tests/pytests/unit/utils/test_versions_warn_until_cache.py diff --git a/changelog/69921.fixed.md b/changelog/69921.fixed.md new file mode 100644 index 000000000000..db67c1b920af --- /dev/null +++ b/changelog/69921.fixed.md @@ -0,0 +1 @@ +Memoized the ``SaltStackVersion`` construction inside ``salt.utils.versions.warn_until()`` so hot paths that fire deprecation-warning calls per event (for example the ``TCPPubClient``/``TCPReqServer``/``MessageClient`` deprecated aliases) no longer allocate two fresh ``SaltStackVersion`` (and, transitively, ``packaging.version.Version``) objects on every call. Measured on a 4h stress rig, the master's ``EventPublisher`` was allocating ~1.4M ``Version`` objects (2.5 GB of transient allocation churn) per 90 s window; after the patch, the same 10 000-call loop makes zero ``SaltStackVersion`` constructions on the repeated-argument path (100% reduction). Per-process RSS impact on the WebSocket-transport master: ``EventPublisher`` peak dropped from 271 MB to 214 MB (-57 MB / -21%). diff --git a/salt/utils/versions.py b/salt/utils/versions.py index 3975785d58c2..a20e7cc8c573 100644 --- a/salt/utils/versions.py +++ b/salt/utils/versions.py @@ -9,6 +9,7 @@ import collections import datetime +import functools import inspect import logging import numbers @@ -24,6 +25,51 @@ log = logging.getLogger(__name__) +# PERF: warn_until() is called on every hot-path event (deprecated +# transport class aliases fire it per instantiation). A memray capture +# on the master's EventPublisher under stress showed 1.4M +# ``packaging.version.Version`` allocations totaling 2.5 GB in 90 s — +# all from ``SaltStackVersion`` construction inside warn_until(). Both +# the target and the current version are effectively immutable within a +# process (same version.info the whole time, same numeric constant +# arguments at call sites like ``warn_until(3009, "...")``), so cache +# the resolved objects. +@functools.lru_cache(maxsize=32) +def _resolve_target_version_hashable(version): + """Resolve target-version input to a SaltStackVersion, cached. + + Handles the common hashable inputs (int, tuple, str) that dominate + warn_until() call sites. Non-hashable inputs (SaltVersion, + SaltStackVersion) are handled inline in warn_until() without caching. + """ + if isinstance(version, int): + return salt.version.SaltStackVersion(version) + if isinstance(version, tuple): + return salt.version.SaltStackVersion(*version) + if isinstance(version, str): + if version.lower() not in salt.version.SaltStackVersion.LNAMES: + raise RuntimeError( + "Incorrect spelling for the release name in the warn_utils " + "call. Expecting one of these release names: {}".format( + [vs.name for vs in salt.version.SaltVersionsInfo.versions()] + ) + ) + return salt.version.SaltStackVersion.from_name(version) + # Signal to caller: not a hashable case we handle here. + return None + + +@functools.lru_cache(maxsize=8) +def _resolve_current_version(version_info): + """Cache SaltStackVersion(*version_info) — the current running version. + + ``salt.version.__version_info__`` is immutable within a process, so + this is effectively a one-time construction shared across every + warn_until() call. + """ + return salt.version.SaltStackVersion(*version_info) + + class Version(packaging.version.Version): def __lt__(self, other): if isinstance(other, str): @@ -139,21 +185,13 @@ def warn_until( issued. When we're only after the salt version checks to raise a ``RuntimeError``. """ - if isinstance(version, salt.version.SaltVersion): + # PERF: fast path for the common hashable inputs (int, tuple, str) + # via a small lru_cache. Non-hashable inputs fall through to the + # per-call construction below. + if isinstance(version, (int, tuple, str)): + version = _resolve_target_version_hashable(version) + elif isinstance(version, salt.version.SaltVersion): version = salt.version.SaltStackVersion(*version.info) - elif isinstance(version, int): - version = salt.version.SaltStackVersion(version) - elif isinstance(version, tuple): - version = salt.version.SaltStackVersion(*version) - elif isinstance(version, str): - if version.lower() not in salt.version.SaltStackVersion.LNAMES: - raise RuntimeError( - "Incorrect spelling for the release name in the warn_utils " - "call. Expecting one of these release names: {}".format( - [vs.name for vs in salt.version.SaltVersionsInfo.versions()] - ) - ) - version = salt.version.SaltStackVersion.from_name(version) elif not isinstance(version, salt.version.SaltStackVersion): raise RuntimeError( "The 'version' argument should be passed as a tuple, integer, string or " @@ -168,7 +206,10 @@ def warn_until( if _version_info_ is None: _version_info_ = salt.version.__version_info__ - _version_ = salt.version.SaltStackVersion(*_version_info_) + # PERF: _version_info_ is normally immutable across the process + # lifetime, so this cache turns 300+ Version() allocations/sec + # observed under stress into a single one-time construction. + _version_ = _resolve_current_version(tuple(_version_info_)) if _version_ >= version: caller = inspect.getframeinfo(sys._getframe(stacklevel - 1)) diff --git a/tests/pytests/unit/utils/test_versions_warn_until_cache.py b/tests/pytests/unit/utils/test_versions_warn_until_cache.py new file mode 100644 index 000000000000..e71aa3f59b49 --- /dev/null +++ b/tests/pytests/unit/utils/test_versions_warn_until_cache.py @@ -0,0 +1,128 @@ +""" +Tests for the ``warn_until()`` memoized resolvers. + +The two cached helpers convert ``warn_until()``'s hashable arguments into +:class:`salt.version.SaltStackVersion` instances once per unique input, +sparing hot paths that fire the deprecation warning per event from +allocating a fresh ``SaltStackVersion`` (and, transitively, a +:class:`packaging.version.Version`) on every call. See :issue:`69921`. +""" + +import warnings + +import pytest + +import salt.utils.versions +import salt.version + + +def test_resolve_target_version_returns_same_instance_for_same_hashable_input(): + """Repeated identical inputs return the cached SaltStackVersion object.""" + resolve = salt.utils.versions._resolve_target_version_hashable + resolve.cache_clear() + try: + v1 = resolve(3009) + v2 = resolve(3009) + assert v1 is v2 + assert isinstance(v1, salt.version.SaltStackVersion) + finally: + resolve.cache_clear() + + +def test_resolve_target_version_returns_none_for_unhandled_type(): + """Non-hashable / non-supported inputs signal the miss with ``None``.""" + resolve = salt.utils.versions._resolve_target_version_hashable + resolve.cache_clear() + try: + assert resolve(object()) is None + finally: + resolve.cache_clear() + + +@pytest.mark.parametrize( + "value", + [ + 3009, + (3009, 0), + "Argon", + ], +) +def test_resolve_target_version_handles_supported_hashable_types(value): + """int, tuple, and string inputs all produce a SaltStackVersion.""" + resolve = salt.utils.versions._resolve_target_version_hashable + resolve.cache_clear() + try: + v = resolve(value) + assert isinstance(v, salt.version.SaltStackVersion) + finally: + resolve.cache_clear() + + +def test_resolve_target_version_raises_on_unknown_release_name(): + """An unknown release name still raises the original ``RuntimeError``.""" + resolve = salt.utils.versions._resolve_target_version_hashable + resolve.cache_clear() + try: + with pytest.raises(RuntimeError, match="Incorrect spelling"): + resolve("NotARelease") + finally: + resolve.cache_clear() + + +def test_resolve_current_version_returns_same_instance(): + """The current-version cache returns one shared instance per version_info tuple.""" + resolve = salt.utils.versions._resolve_current_version + resolve.cache_clear() + try: + v1 = resolve((3008, 2)) + v2 = resolve((3008, 2)) + assert v1 is v2 + assert isinstance(v1, salt.version.SaltStackVersion) + finally: + resolve.cache_clear() + + +def test_warn_until_makes_zero_saltstackversion_allocations_after_warmup(): + """After the cache is warm, warn_until() no longer constructs + SaltStackVersion objects on repeated calls with the same target.""" + original_init = salt.version.SaltStackVersion.__init__ + call_count = {"n": 0} + + def counting_init(self, *args, **kwargs): + call_count["n"] += 1 + return original_init(self, *args, **kwargs) + + salt.utils.versions._resolve_target_version_hashable.cache_clear() + salt.utils.versions._resolve_current_version.cache_clear() + + try: + salt.version.SaltStackVersion.__init__ = counting_init + # Warmup — this call is allowed to allocate. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + salt.utils.versions.warn_until(3009, "warmup") + + call_count["n"] = 0 + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + for _ in range(1000): + salt.utils.versions.warn_until(3009, "test") + assert call_count["n"] == 0 + finally: + salt.version.SaltStackVersion.__init__ = original_init + salt.utils.versions._resolve_target_version_hashable.cache_clear() + salt.utils.versions._resolve_current_version.cache_clear() + + +def test_warn_until_still_accepts_saltstackversion_target(): + """Passing a fully-constructed :class:`SaltStackVersion` bypasses the + cache (as before) and still resolves correctly.""" + # A well-in-the-future major release so warn_until doesn't fire the + # "past release" branch. + future_version = salt.version.SaltStackVersion( + salt.version.__version_info__[0] + 100 + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + # Should not raise; the fall-through path handles this input inline. + salt.utils.versions.warn_until(future_version, "future") From 8b1266935270517612107a30d5b33946351dbd37 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 16:21:18 -0700 Subject: [PATCH 172/469] Fix warn_until() TypeError on SaltVersion targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``SaltVersion`` is a ``namedtuple`` subclass, so it matches ``isinstance(version, tuple)`` and hits the ``lru_cache`` fast path added in #69921. But it defines ``__eq__`` without ``__hash__``, which makes it unhashable — ``hash(version)`` raises ``TypeError`` before the cache lookup can even start. On top of that, its tuple form is ``(name, info, released)``, not version parts, so even if it were hashable the resolver would build the wrong ``SaltStackVersion``. Route ``SaltVersion`` explicitly to its own branch before the fast path and add a regression test that guards it. Fixes CI failure in ``test_warn_until_good_version_argument[version3]``. --- salt/utils/versions.py | 24 ++++++++++++------- .../utils/test_versions_warn_until_cache.py | 17 +++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/salt/utils/versions.py b/salt/utils/versions.py index a20e7cc8c573..4f2b7b959772 100644 --- a/salt/utils/versions.py +++ b/salt/utils/versions.py @@ -38,9 +38,12 @@ def _resolve_target_version_hashable(version): """Resolve target-version input to a SaltStackVersion, cached. - Handles the common hashable inputs (int, tuple, str) that dominate - warn_until() call sites. Non-hashable inputs (SaltVersion, - SaltStackVersion) are handled inline in warn_until() without caching. + Handles the common hashable inputs (int, plain tuple, str) that + dominate warn_until() call sites. ``SaltVersion`` and + ``SaltStackVersion`` targets are handled inline in warn_until() + without caching (SaltVersion is a namedtuple with unhashable + semantics and a ``(name, info, released)`` shape, so it must be + routed away from this fast path). """ if isinstance(version, int): return salt.version.SaltStackVersion(version) @@ -185,13 +188,16 @@ def warn_until( issued. When we're only after the salt version checks to raise a ``RuntimeError``. """ - # PERF: fast path for the common hashable inputs (int, tuple, str) - # via a small lru_cache. Non-hashable inputs fall through to the - # per-call construction below. - if isinstance(version, (int, tuple, str)): - version = _resolve_target_version_hashable(version) - elif isinstance(version, salt.version.SaltVersion): + # PERF: fast path for the common hashable inputs (int, plain tuple, + # str) via a small lru_cache. ``SaltVersion`` is a namedtuple so it + # also matches ``isinstance(version, tuple)``, but it defines + # ``__eq__`` without ``__hash__`` (i.e. it is unhashable) *and* its + # tuple form is ``(name, info, released)`` rather than version parts + # — handle it explicitly before the fast path. + if isinstance(version, salt.version.SaltVersion): version = salt.version.SaltStackVersion(*version.info) + elif isinstance(version, (int, tuple, str)): + version = _resolve_target_version_hashable(version) elif not isinstance(version, salt.version.SaltStackVersion): raise RuntimeError( "The 'version' argument should be passed as a tuple, integer, string or " diff --git a/tests/pytests/unit/utils/test_versions_warn_until_cache.py b/tests/pytests/unit/utils/test_versions_warn_until_cache.py index e71aa3f59b49..2e46ab971b3b 100644 --- a/tests/pytests/unit/utils/test_versions_warn_until_cache.py +++ b/tests/pytests/unit/utils/test_versions_warn_until_cache.py @@ -126,3 +126,20 @@ def test_warn_until_still_accepts_saltstackversion_target(): warnings.simplefilter("ignore") # Should not raise; the fall-through path handles this input inline. salt.utils.versions.warn_until(future_version, "future") + + +def test_warn_until_accepts_saltversion_target(): + """Passing a :class:`salt.version.SaltVersion` (a namedtuple that is + unhashable due to a custom ``__eq__``) must be routed away from the + ``lru_cache`` fast path — otherwise ``hash(version)`` blows up with + ``TypeError: unhashable type: 'SaltVersion'`` before the function + body even runs. Regression test for the CI failure on the initial + landing of the memoize change.""" + # POTASSIUM is well in the future relative to 3008.x so warn_until + # won't fire the "past release" branch when _version_info_ is set to + # the current running version. + future_saltversion = salt.version.SaltVersionsInfo.POTASSIUM + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + # Should not raise TypeError; the SaltVersion branch handles it. + salt.utils.versions.warn_until(future_saltversion, "future") From 8d793ec3252ebef88c75a22181c7d0cc972267ae Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 16:57:35 -0700 Subject: [PATCH 173/469] Fix OptsDict.__len__ to avoid materializing full items dict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``OptsDict.__len__()`` called ``iter(self)`` just to trigger the underlying-dict resync that ``__iter__`` performs, then returned ``dict.__len__(self)``. Every ``len(opts)`` call therefore walked the entire copy-on-write parent chain, allocated a fresh temporary dict of all key/value pairs, cleared the underlying dict, and re-inserted every entry -- O(N) allocations plus 2 × O(N) dict mutations per length lookup. Compute the length directly from ``_get_all_keys()`` minus locally deleted keys (``_DELETED`` sentinels in ``_local``). Python's ``len()`` slot dispatches through this override, so the previous underlying-dict sync -- which is only useful for C-level ``dict.__iter__(self)`` consumers like ``json.dumps`` -- was never required for ``len()``. Fixes #69938 --- changelog/69938.fixed.md | 1 + salt/utils/optsdict.py | 20 ++++-- tests/pytests/unit/utils/test_optsdict.py | 86 +++++++++++++++++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 changelog/69938.fixed.md diff --git a/changelog/69938.fixed.md b/changelog/69938.fixed.md new file mode 100644 index 000000000000..8d4600ef34ba --- /dev/null +++ b/changelog/69938.fixed.md @@ -0,0 +1 @@ +Fixed ``salt.utils.optsdict.OptsDict.__len__`` to compute the key count directly instead of calling ``iter(self)``, which materialized a fresh temporary dict of every key/value in the copy-on-write chain, cleared the underlying dict, and re-inserted every entry -- all just to return ``dict.__len__(self)``. Every ``len(opts)`` call was therefore O(N) allocations plus 2 × O(N) dict mutations. The new implementation counts via ``_get_all_keys()`` minus ``_DELETED`` sentinels in ``_local`` (no value walk, no dict rebuild); Python's ``len()`` slot dispatches through the override, so the previous underlying-dict sync (a side effect of ``__iter__``) was never required for ``len()``. diff --git a/salt/utils/optsdict.py b/salt/utils/optsdict.py index fbcc1746eca1..484cd6626b95 100644 --- a/salt/utils/optsdict.py +++ b/salt/utils/optsdict.py @@ -693,11 +693,23 @@ def _get_all_keys(self): return keys def __len__(self) -> int: - """Return total number of keys.""" + """ + Return total number of keys, excluding those deleted locally. + + The previous implementation called ``iter(self)``, which walked the + entire parent chain, allocated a fresh ``items`` dict holding every + key/value pair, cleared the underlying dict, and re-inserted every + entry -- all just to return ``dict.__len__(self)``. Under + salt-api stress that produced ~1 MB of transient allocation per + call. ``len()`` never needs the underlying-dict sync that + ``__iter__`` performs (Python's ``len()`` slot dispatches through + this override, not through the underlying dict), so count via the + cheap key set and subtract locally-deleted keys. + """ with self._ensure_lock(): - # Sync underlying dict for C-level access - _ = iter(self) - return dict.__len__(self) + return len(self._get_all_keys()) - sum( + 1 for v in self._local.values() if v is _DELETED + ) def __contains__(self, key: str) -> bool: """Check if key exists in local, parent chain, or base (excluding deleted keys).""" diff --git a/tests/pytests/unit/utils/test_optsdict.py b/tests/pytests/unit/utils/test_optsdict.py index 3e8d79fb6c05..b07713e60663 100644 --- a/tests/pytests/unit/utils/test_optsdict.py +++ b/tests/pytests/unit/utils/test_optsdict.py @@ -158,6 +158,92 @@ def test_len(self): opts = OptsDict.from_dict({"a": 1, "b": 2, "c": 3}) assert len(opts) == 3 + def test_len_matches_iter_count(self): + """``len(opts) == len(list(iter(opts)))`` across every mutation state.""" + opts = OptsDict.from_dict({"a": 1, "b": 2, "c": 3}) + assert len(opts) == len(list(iter(opts))) + + # After a local set + opts["d"] = 4 + assert len(opts) == len(list(iter(opts))) + assert len(opts) == 4 + + # After a local overwrite (no length change) + opts["a"] = 10 + assert len(opts) == len(list(iter(opts))) + assert len(opts) == 4 + + # After deleting an inherited key (leaves _DELETED sentinel) + del opts["b"] + assert len(opts) == len(list(iter(opts))) + assert len(opts) == 3 + + # After deleting a purely-local key (true removal, no sentinel) + del opts["d"] + assert len(opts) == len(list(iter(opts))) + assert len(opts) == 2 + + def test_len_across_parent_chain(self): + """``__len__`` on a child correctly counts inherited + local minus deleted.""" + root = OptsDict.from_dict({"a": 1, "b": 2, "c": 3}) + child = OptsDict.from_parent(root) + # Inherits all 3 + assert len(child) == 3 + + # Add a local key on the child only + child["d"] = 4 + assert len(child) == 4 + assert len(root) == 3 # root unaffected + + # Delete an inherited key on the child (parent still sees it) + del child["a"] + assert len(child) == 3 + assert len(root) == 3 + assert set(iter(child)) == {"b", "c", "d"} + + def test_len_after_delete_of_local_only_key(self): + """Deleting a key that lives only in ``_local`` truly removes it and + does not leave a ``_DELETED`` sentinel to skew the count.""" + opts = OptsDict.from_dict({}) + opts["x"] = 1 + assert len(opts) == 1 + del opts["x"] + assert len(opts) == 0 + # And the underlying-dict sentinel would break math if leaked + assert list(iter(opts)) == [] + + def test_len_no_temporary_items_dict(self): + """Regression guard: ``len(opts)`` must not allocate a fresh dict + of all key/value pairs the way ``__iter__`` does. Track dict + construction via a hook to prove the fix stays.""" + opts = OptsDict.from_dict({f"k{i}": i for i in range(200)}) + + # Baseline: number of dicts built by a no-op reference call + original_dict_new = dict.__new__ + counts = {"n": 0} + + def counting_new(cls, *args, **kwargs): + if cls is dict: + counts["n"] += 1 + return original_dict_new(cls, *args, **kwargs) + + # We can't monkeypatch ``dict.__new__`` (builtin C type), so instead + # assert on iter-call count via a hook on ``_get_all_keys``. + original_get = opts._get_all_keys + get_call_count = {"n": 0} + + def hooked_get_all_keys(self=opts): + get_call_count["n"] += 1 + return original_get() + + opts._get_all_keys = hooked_get_all_keys + for _ in range(5): + _ = len(opts) + # __len__ must call _get_all_keys exactly once per invocation and + # nothing else -- specifically it must NOT trigger __iter__ (which + # would call _get_all_keys AND materialize items). + assert get_call_count["n"] == 5 + def test_update(self): """Test update method.""" opts = OptsDict.from_dict({"a": 1}) From 5ff8f8edf8911b0c9972f8ce005fc2f794bbf811 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 21:53:52 -0700 Subject: [PATCH 174/469] Switch nightly stress OTel toggle to metrics.enabled checkbox The workflow's ``install_opentelemetry`` input was a three-value ``choice`` (true/false string) that pip-uninstalled the OpenTelemetry packages inside the running salt-master container when set to false. That was heavier than it needed to be: ``salt.utils.metrics._load_otel`` defers the import until ``metrics.enabled`` is true in the master config, so with the config gate off the OTel package on disk has zero runtime cost (no import, no allocation, no CPU). Uninstalling the package changed nothing that a config toggle wouldn't. Replace the input with a ``type: boolean`` ``enable_metrics`` (rendered as a checkbox in the ``workflow_dispatch`` UI), defaulted to false so scheduled runs continue to reproduce the stock salt-master memory profile. When checked, append ``metrics.enabled: true`` to ``tests/monitoring/master.conf`` and trigger ``_load_otel`` post-restart to fail fast on OpenTelemetry misconfiguration instead of surfacing mid-stress. --- .github/workflows/nightly-stress-test.yml | 64 +++++++++++++---------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/.github/workflows/nightly-stress-test.yml b/.github/workflows/nightly-stress-test.yml index 035cdbf7a006..f2ea02b1f714 100644 --- a/.github/workflows/nightly-stress-test.yml +++ b/.github/workflows/nightly-stress-test.yml @@ -9,18 +9,20 @@ on: description: 'Duration of the stress test (e.g., 30m, 1h)' required: true default: '30m' - install_opentelemetry: + enable_metrics: description: >- - Install opentelemetry in the salt-master image before the - stress starts. 'true' (the default) matches the shipped - requirements/base.txt. Set 'false' to reproduce the - pre-3008.x baseline without opentelemetry loaded. + Enable OpenTelemetry metrics on the salt-master by setting + ``metrics.enabled: true`` in the master config before the + stress starts. Unchecked (default) leaves ``metrics.enabled`` + at its default (false), which matches every stock salt-master + install: the ``opentelemetry`` package is still shipped from + ``requirements/base.txt`` but is never imported thanks to the + lazy-load in ``salt.utils.metrics._load_otel``, so the package + being on disk has zero runtime cost. Check the box to measure + the memory / CPU footprint when tracing is actually turned on. required: false - default: 'true' - type: choice - options: - - 'true' - - 'false' + default: false + type: boolean jobs: stress-test: @@ -60,29 +62,37 @@ jobs: docker compose up -d sleep 30 # Wait for initialization - - name: Toggle opentelemetry install + - name: Toggle OpenTelemetry metrics + # ``metrics.enabled`` gates the entire ``salt.utils.metrics`` + # stack, including the ``_load_otel`` deferred import. Unchecked + # (default) matches every stock salt-master install: OTel is on + # disk but never imported, so no memory / CPU cost. Checked + # enables metrics -- the master imports OpenTelemetry on first + # use and starts recording. Uninstalling the pip package (the + # old behaviour) was unnecessary because the lazy loader already + # guarantees zero cost when the config gate is off. env: - INSTALL_OTEL: ${{ github.event.inputs.install_opentelemetry || 'true' }} + ENABLE_METRICS: ${{ github.event.inputs.enable_metrics || 'false' }} run: | - if [ "$INSTALL_OTEL" = "false" ]; then - echo "Removing opentelemetry from the salt-master container" - docker exec salt-master pip uninstall -y --quiet \ - opentelemetry-api \ - opentelemetry-sdk \ - opentelemetry-exporter-otlp-proto-http \ - opentelemetry-exporter-otlp-proto-common \ - opentelemetry-exporter-prometheus \ - opentelemetry-proto \ - opentelemetry-semantic-conventions \ - prometheus-client 2>&1 | tail -5 || true - if docker exec salt-master python3 -c "import opentelemetry" 2>/dev/null; then - echo "opentelemetry is still importable after uninstall" >&2 - exit 1 + cd tests/monitoring + if [ "$ENABLE_METRICS" = "true" ]; then + echo "Enabling OpenTelemetry metrics on salt-master" + # Append the metrics block only if not already present so + # re-runs are idempotent. ``printf`` (not a heredoc) keeps + # the YAML block-scalar indentation intact. + if ! grep -q '^metrics:' master.conf; then + printf '\nmetrics:\n enabled: true\n' >> master.conf fi docker restart salt-master sleep 20 + # Trigger a code path that calls ``metrics.configure`` so + # the OpenTelemetry import fires and any misconfiguration + # surfaces here rather than mid-stress. + docker exec salt-master python3 -c \ + "import salt.utils.metrics as m; m._load_otel(); assert m._OTEL_AVAILABLE, 'OTel import failed'" else - echo "Leaving opentelemetry installed (default behaviour)" + echo "Leaving metrics.enabled at default (false); OTel package is" + echo "shipped but never imported by the lazy loader." fi - name: Verify Connections From c07786f6abaea4a96becb62e9bb6455b6f7a5c96 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 22:00:30 -0700 Subject: [PATCH 175/469] Add worker_threads input to nightly stress workflow Sizes the ``worker_threads`` master config option per run so a stress sweep can trade parallelism against per-worker RSS floor. Defaults to ``'10'`` (matches the value already in ``tests/monitoring/master.conf``, so scheduled runs behave unchanged). Consolidated with the earlier ``enable_metrics`` toggle into a single ``Configure salt-master`` step that idempotently mutates ``master.conf`` and only restarts the container when at least one value actually changes -- unchanged defaults skip the restart entirely. --- .github/workflows/nightly-stress-test.yml | 55 ++++++++++++++++++----- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/.github/workflows/nightly-stress-test.yml b/.github/workflows/nightly-stress-test.yml index f2ea02b1f714..dcc544a281d8 100644 --- a/.github/workflows/nightly-stress-test.yml +++ b/.github/workflows/nightly-stress-test.yml @@ -23,6 +23,17 @@ on: required: false default: false type: boolean + worker_threads: + description: >- + Number of MWorker processes the salt-master should spawn (the + ``worker_threads`` master config option). Defaults to the + value already in ``tests/monitoring/master.conf`` (currently 10). + Adjust to sweep the memory / throughput trade-off -- more + workers = more parallelism but more per-worker RSS floor; + fewer = tighter contention but smaller container footprint. + required: false + default: '10' + type: string jobs: stress-test: @@ -62,19 +73,23 @@ jobs: docker compose up -d sleep 30 # Wait for initialization - - name: Toggle OpenTelemetry metrics - # ``metrics.enabled`` gates the entire ``salt.utils.metrics`` - # stack, including the ``_load_otel`` deferred import. Unchecked - # (default) matches every stock salt-master install: OTel is on - # disk but never imported, so no memory / CPU cost. Checked - # enables metrics -- the master imports OpenTelemetry on first - # use and starts recording. Uninstalling the pip package (the - # old behaviour) was unnecessary because the lazy loader already - # guarantees zero cost when the config gate is off. + - name: Configure salt-master + # Apply the workflow_dispatch overrides to ``master.conf`` and, + # if anything actually changed, restart salt-master so it picks + # them up. ``metrics.enabled`` gates the entire + # ``salt.utils.metrics`` stack (including the ``_load_otel`` + # deferred OpenTelemetry import); the pip package on disk has + # zero runtime cost when this gate is off, so a config toggle + # is all that is needed to measure the OTel-on vs OTel-off + # profile. ``worker_threads`` sizes the MWorker pool and lets + # a run sweep the parallelism / RSS trade-off. env: ENABLE_METRICS: ${{ github.event.inputs.enable_metrics || 'false' }} + WORKER_THREADS: ${{ github.event.inputs.worker_threads || '10' }} run: | cd tests/monitoring + need_restart=0 + if [ "$ENABLE_METRICS" = "true" ]; then echo "Enabling OpenTelemetry metrics on salt-master" # Append the metrics block only if not already present so @@ -82,17 +97,33 @@ jobs: # the YAML block-scalar indentation intact. if ! grep -q '^metrics:' master.conf; then printf '\nmetrics:\n enabled: true\n' >> master.conf + need_restart=1 fi + else + echo "Leaving metrics.enabled at default (false); OTel package is" + echo "shipped but never imported by the lazy loader." + fi + + # Update ``worker_threads`` only when it differs from the value + # already in the file, so unchanged defaults skip the restart. + current_workers=$(awk '/^worker_threads:/ {print $2}' master.conf) + if [ -n "$current_workers" ] && [ "$current_workers" != "$WORKER_THREADS" ]; then + echo "Setting worker_threads: $current_workers -> $WORKER_THREADS" + sed -i "s/^worker_threads:.*/worker_threads: $WORKER_THREADS/" master.conf + need_restart=1 + fi + + if [ "$need_restart" = "1" ]; then docker restart salt-master sleep 20 + fi + + if [ "$ENABLE_METRICS" = "true" ]; then # Trigger a code path that calls ``metrics.configure`` so # the OpenTelemetry import fires and any misconfiguration # surfaces here rather than mid-stress. docker exec salt-master python3 -c \ "import salt.utils.metrics as m; m._load_otel(); assert m._OTEL_AVAILABLE, 'OTel import failed'" - else - echo "Leaving metrics.enabled at default (false); OTel package is" - echo "shipped but never imported by the lazy loader." fi - name: Verify Connections From fb01c008a16efb6c63a5998c877d1dffff65dd68 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 22:05:55 -0700 Subject: [PATCH 176/469] Default nightly stress worker_threads input to 5 (salt master default) The salt master's own ``worker_threads`` default in ``salt/config/__init__.py`` is 5, but ``tests/monitoring/master.conf`` pinned it to 10. Default the workflow input to ``'5'`` so scheduled runs (and manual runs left at the default) measure the stock-default worker layout. The step will overwrite the file's 10 to 5, restart the master, and CI's stress comparisons will reflect what real deployments experience unless the operator explicitly bumps ``worker_threads`` back up. --- .github/workflows/nightly-stress-test.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/nightly-stress-test.yml b/.github/workflows/nightly-stress-test.yml index dcc544a281d8..67b47c55a52b 100644 --- a/.github/workflows/nightly-stress-test.yml +++ b/.github/workflows/nightly-stress-test.yml @@ -26,13 +26,17 @@ on: worker_threads: description: >- Number of MWorker processes the salt-master should spawn (the - ``worker_threads`` master config option). Defaults to the - value already in ``tests/monitoring/master.conf`` (currently 10). - Adjust to sweep the memory / throughput trade-off -- more - workers = more parallelism but more per-worker RSS floor; - fewer = tighter contention but smaller container footprint. + ``worker_threads`` master config option). Defaults to ``'5'``, + which matches the salt master's own default in + ``salt/config/__init__.py``. ``tests/monitoring/master.conf`` + currently pins it to 10; the step will overwrite that to the + input value so unchanged runs measure the stock-default + worker layout. Adjust to sweep the memory / throughput + trade-off -- more workers = more parallelism but more + per-worker RSS floor; fewer = tighter contention but smaller + container footprint. required: false - default: '10' + default: '5' type: string jobs: @@ -85,7 +89,7 @@ jobs: # a run sweep the parallelism / RSS trade-off. env: ENABLE_METRICS: ${{ github.event.inputs.enable_metrics || 'false' }} - WORKER_THREADS: ${{ github.event.inputs.worker_threads || '10' }} + WORKER_THREADS: ${{ github.event.inputs.worker_threads || '5' }} run: | cd tests/monitoring need_restart=0 From 2a2dd6dca65df7e3b3f686780c6680ac7221b447 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 22:06:49 -0700 Subject: [PATCH 177/469] Shorten workflow_dispatch input descriptions Long descriptions clutter the workflow_dispatch UI. One-liners suffice; the rationale lives in the commit history and step body. --- .github/workflows/nightly-stress-test.yml | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/.github/workflows/nightly-stress-test.yml b/.github/workflows/nightly-stress-test.yml index 67b47c55a52b..1f70a3f0dff8 100644 --- a/.github/workflows/nightly-stress-test.yml +++ b/.github/workflows/nightly-stress-test.yml @@ -10,31 +10,12 @@ on: required: true default: '30m' enable_metrics: - description: >- - Enable OpenTelemetry metrics on the salt-master by setting - ``metrics.enabled: true`` in the master config before the - stress starts. Unchecked (default) leaves ``metrics.enabled`` - at its default (false), which matches every stock salt-master - install: the ``opentelemetry`` package is still shipped from - ``requirements/base.txt`` but is never imported thanks to the - lazy-load in ``salt.utils.metrics._load_otel``, so the package - being on disk has zero runtime cost. Check the box to measure - the memory / CPU footprint when tracing is actually turned on. + description: Enable OpenTelemetry metrics (metrics.enabled) required: false default: false type: boolean worker_threads: - description: >- - Number of MWorker processes the salt-master should spawn (the - ``worker_threads`` master config option). Defaults to ``'5'``, - which matches the salt master's own default in - ``salt/config/__init__.py``. ``tests/monitoring/master.conf`` - currently pins it to 10; the step will overwrite that to the - input value so unchanged runs measure the stock-default - worker layout. Adjust to sweep the memory / throughput - trade-off -- more workers = more parallelism but more - per-worker RSS floor; fewer = tighter contention but smaller - container footprint. + description: Salt master worker_threads required: false default: '5' type: string From ba5a7d1f89346eaca475eb7535ee656019678abd Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 22:35:18 -0700 Subject: [PATCH 178/469] Fix OptsDict.__len__ to honor ancestor-chain deletions The first cut of the ``__len__`` optimization in #69939 subtracted only ``_DELETED`` sentinels found in ``self._local``. That misses the case where a key was deleted in an intermediate ancestor: the ancestor's ``_local`` holds the sentinel, ``self._local`` does not, and the key survived the count -- even though ``__contains__`` correctly reported the key absent. Route ``__len__`` through ``__contains__`` (which already walks the parent chain in the correct order, closest layer wins). Lock is ``RLock`` so reentrance is safe. Added the regression test provided by @charzl on the PR review. Fixes #69947 --- salt/utils/optsdict.py | 36 ++++++++++++++--------- tests/pytests/unit/utils/test_optsdict.py | 17 +++++++++++ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/salt/utils/optsdict.py b/salt/utils/optsdict.py index 484cd6626b95..6ad58df07580 100644 --- a/salt/utils/optsdict.py +++ b/salt/utils/optsdict.py @@ -694,22 +694,30 @@ def _get_all_keys(self): def __len__(self) -> int: """ - Return total number of keys, excluding those deleted locally. - - The previous implementation called ``iter(self)``, which walked the - entire parent chain, allocated a fresh ``items`` dict holding every - key/value pair, cleared the underlying dict, and re-inserted every - entry -- all just to return ``dict.__len__(self)``. Under - salt-api stress that produced ~1 MB of transient allocation per - call. ``len()`` never needs the underlying-dict sync that - ``__iter__`` performs (Python's ``len()`` slot dispatches through - this override, not through the underlying dict), so count via the - cheap key set and subtract locally-deleted keys. + Return total number of keys, honoring ``_DELETED`` markers anywhere + in the parent chain. + + ``len()`` was previously implemented as ``iter(self)`` + + ``dict.__len__(self)``, which walked the entire parent chain, + allocated a fresh ``items`` dict holding every key/value pair, + cleared the underlying dict, and re-inserted every entry -- all + just to return the count. Under salt-api stress that produced + ~1 MB of transient allocation per call. ``len()`` never needs + the underlying-dict sync that ``__iter__`` performs (Python's + ``len()`` slot dispatches through this override, not through + the underlying dict). + + The first cut of this optimization subtracted only ``_DELETED`` + markers found in ``self._local``, which was incorrect: a key + deleted in an intermediate ancestor (say a parent between the + current node and the root ``_base``) was still counted because + the deletion marker never appears in ``self._local``. Match + ``__contains__`` semantics instead -- it already walks the + parent chain in the correct order and returns ``False`` for + any key whose closest layer marks it deleted. """ with self._ensure_lock(): - return len(self._get_all_keys()) - sum( - 1 for v in self._local.values() if v is _DELETED - ) + return sum(1 for key in self._get_all_keys() if key in self) def __contains__(self, key: str) -> bool: """Check if key exists in local, parent chain, or base (excluding deleted keys).""" diff --git a/tests/pytests/unit/utils/test_optsdict.py b/tests/pytests/unit/utils/test_optsdict.py index b07713e60663..7dfe0f0223a5 100644 --- a/tests/pytests/unit/utils/test_optsdict.py +++ b/tests/pytests/unit/utils/test_optsdict.py @@ -201,6 +201,23 @@ def test_len_across_parent_chain(self): assert len(root) == 3 assert set(iter(child)) == {"b", "c", "d"} + def test_len_excludes_key_deleted_in_ancestor(self): + """A key deleted in an ancestor (not in self) must not count. + + Regression test for the bug in the first cut of the + ``__len__`` rewrite (#69939): counting only ``_DELETED`` + markers in ``self._local`` missed markers on intermediate + parents. Reported by @charzl on the PR review. + """ + grandparent = OptsDict.from_dict({"a": 1, "b": 2, "c": 3}, name="grandparent") + parent = OptsDict.from_parent(grandparent, name="parent") + del parent["b"] + + child = OptsDict.from_parent(parent, name="child") + + assert "b" not in child + assert len(child) == 2 + def test_len_after_delete_of_local_only_key(self): """Deleting a key that lives only in ``_local`` truly removes it and does not leave a ``_DELETED`` sentinel to skew the count.""" From 3705a874004e702311bd9284285db0d1cd7c03e9 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 00:02:46 -0700 Subject: [PATCH 179/469] Rewrite OptsDict.__len__ docstring for clarity Drop the historical narrative about the previous implementation and the first cut of the optimization; describe the current behavior and why the walk-plus-__contains__ approach is correct. Requested in review on PR #69948. --- salt/utils/optsdict.py | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/salt/utils/optsdict.py b/salt/utils/optsdict.py index 6ad58df07580..681b59d49469 100644 --- a/salt/utils/optsdict.py +++ b/salt/utils/optsdict.py @@ -694,27 +694,19 @@ def _get_all_keys(self): def __len__(self) -> int: """ - Return total number of keys, honoring ``_DELETED`` markers anywhere - in the parent chain. - - ``len()`` was previously implemented as ``iter(self)`` + - ``dict.__len__(self)``, which walked the entire parent chain, - allocated a fresh ``items`` dict holding every key/value pair, - cleared the underlying dict, and re-inserted every entry -- all - just to return the count. Under salt-api stress that produced - ~1 MB of transient allocation per call. ``len()`` never needs - the underlying-dict sync that ``__iter__`` performs (Python's - ``len()`` slot dispatches through this override, not through - the underlying dict). - - The first cut of this optimization subtracted only ``_DELETED`` - markers found in ``self._local``, which was incorrect: a key - deleted in an intermediate ancestor (say a parent between the - current node and the root ``_base``) was still counted because - the deletion marker never appears in ``self._local``. Match - ``__contains__`` semantics instead -- it already walks the - parent chain in the correct order and returns ``False`` for - any key whose closest layer marks it deleted. + Return the number of live keys visible from this node. + + A key is live when the closest layer that defines it (``self._local``, + then each ancestor's ``_local``, then the root ``_base``) does not + mark it ``_DELETED``. ``_get_all_keys`` yields the union of every + name reachable through the parent chain; ``key in self`` applies the + deletion-aware lookup, so a key deleted at any level -- including an + intermediate ancestor whose ``_DELETED`` sentinel never appears in + ``self._local`` -- is correctly excluded from the count. + + The count is computed without materialising a fresh ``items`` dict + or triggering the underlying-dict sync that ``__iter__`` performs; + Python's ``len()`` slot dispatches directly through this override. """ with self._ensure_lock(): return sum(1 for key in self._get_all_keys() if key in self) From 8c88fa0be4d5de8b5ec926be53b57fb756a45472 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 4 Aug 2026 11:55:40 +0200 Subject: [PATCH 180/469] Add test for issue #69954 --- .../test_certificate_managed_wrapper.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/pytests/integration/ssh/x509_v2/test_certificate_managed_wrapper.py b/tests/pytests/integration/ssh/x509_v2/test_certificate_managed_wrapper.py index c1fcef666635..907ce45a2ff6 100644 --- a/tests/pytests/integration/ssh/x509_v2/test_certificate_managed_wrapper.py +++ b/tests/pytests/integration/ssh/x509_v2/test_certificate_managed_wrapper.py @@ -65,6 +65,7 @@ def cert_args_exts(): @pytest.fixture(scope="module", autouse=True) def cm_wrapper(x509_salt_master): + name = "cert" state_contents = """ {{ salt["x509.certificate_managed_wrapper"]( @@ -80,8 +81,8 @@ def cm_wrapper(x509_salt_master): ) | yaml(false) }} """ - with x509_salt_master.state_tree.base.temp_file("cert.sls", state_contents): - yield + with x509_salt_master.state_tree.base.temp_file(f"{name}.sls", state_contents): + yield name @pytest.fixture @@ -140,6 +141,20 @@ def test_certificate_managed_remote(x509_salt_ssh_cli, cert_args, ca_key, rsa_pr assert _belongs_to(cert, rsa_privkey) +def test_certificate_managed_remote_file_managed_kwargs( + x509_salt_ssh_cli, cert_args, ca_key, cm_wrapper +): + cert_args["certificate_managed"]["mode"] = "0400" + ret = x509_salt_ssh_cli.run("state.apply", cm_wrapper, pillar={"args": cert_args}) + assert ret.returncode == 0 + cert = _get_cert(cert_args["name"]) + assert cert.subject.rfc4514_string() == "CN=from_signing_policy" + assert _signed_by(cert, ca_key) + ret = x509_salt_ssh_cli.run("file.get_mode", cert_args["name"]) + assert ret.returncode == 0 + assert ret.data == "0400" + + def test_certificate_managed_remote_with_privkey_managed( x509_salt_ssh_cli, cert_args, tmp_path, ca_key ): From 0b34d0e63139a42ee7ff4171a0131e9a5bffff73 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 4 Aug 2026 12:07:36 +0200 Subject: [PATCH 181/469] Correctly pass file.managed kwargs --- changelog/69954.fixed.md | 1 + salt/client/ssh/wrapper/x509_v2.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog/69954.fixed.md diff --git a/changelog/69954.fixed.md b/changelog/69954.fixed.md new file mode 100644 index 000000000000..bfa13ca15adc --- /dev/null +++ b/changelog/69954.fixed.md @@ -0,0 +1 @@ +Fixed `x509_v2.certificate_managed_wrapper` swallowing arguments in `certificate_managed` intended for `file.managed` diff --git a/salt/client/ssh/wrapper/x509_v2.py b/salt/client/ssh/wrapper/x509_v2.py index 530ae4c49aca..56f7557886b0 100644 --- a/salt/client/ssh/wrapper/x509_v2.py +++ b/salt/client/ssh/wrapper/x509_v2.py @@ -937,7 +937,7 @@ def certificate_managed_wrapper( ret[name + "_crt"] = { "x509.certificate_managed_ssh": [{k: v} for k, v in cert_ret.items()] } - ret[name + "_crt"]["x509.certificate_managed_ssh"].append( + ret[name + "_crt"]["x509.certificate_managed_ssh"].extend( {k: v} for k, v in cert_file_args.items() ) except (CommandExecutionError, SaltInvocationError) as err: From 0a238ab3b1d38376a86f7c7ed751f32faabf7fae Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 4 Aug 2026 12:08:27 +0200 Subject: [PATCH 182/469] Adjust ssh_pki tests The previous test for file.managed args could not have succeeded, apparently it wasn't run in CI. bcrypt is only required for handling encrypted private keys, which were not tested in the ssh_pki.certificate_managed_wrapper tests so far. --- .../test_certificate_managed_wrapper_ssh.py | 56 +++++-------------- 1 file changed, 14 insertions(+), 42 deletions(-) diff --git a/tests/pytests/integration/ssh/ssh_pki/test_certificate_managed_wrapper_ssh.py b/tests/pytests/integration/ssh/ssh_pki/test_certificate_managed_wrapper_ssh.py index 05f3ea5725e5..287171c5cbfe 100644 --- a/tests/pytests/integration/ssh/ssh_pki/test_certificate_managed_wrapper_ssh.py +++ b/tests/pytests/integration/ssh/ssh_pki/test_certificate_managed_wrapper_ssh.py @@ -200,7 +200,6 @@ def existing_symlink(request): test_file.unlink(missing_ok=True) -@pytest.mark.usefixtures("_check_bcrypt") def test_certificate_managed_remote(ssh_salt_ssh_cli, cert_args, ca_key, rsa_privkey): ret = ssh_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) assert ret.returncode == 0 @@ -210,39 +209,25 @@ def test_certificate_managed_remote(ssh_salt_ssh_cli, cert_args, ca_key, rsa_pri assert _belongs_to(cert, rsa_privkey) -@pytest.fixture -def cm_file_args(sshpki_salt_master): - state_contents = """ - {{ - salt["ssh_pki.certificate_managed_wrapper"]( - pillar["args"]["name"], - ca_server=pillar["args"]["ca_server"], - signing_policy=pillar["args"]["signing_policy"], - backend=pillar["args"].get("backend"), - backend_args=pillar["args"].get("backend_args"), - private_key_managed=pillar["args"].get("private_key_managed"), - private_key=pillar["args"].get("private_key"), - private_key_passphrase=pillar["args"].get("private_key_passphrase"), - public_key=pillar["args"].get("public_key"), - certificate_managed=pillar["args"].get("certificate_managed"), - test=opts.get("test"), - mode="0400" - ) | yaml(false) - }} - """ - with sshpki_salt_master.state_tree.base.temp_file( - "cert_file_args.sls", state_contents - ): - yield +@pytest.mark.usefixtures("_check_bcrypt") +def test_certificate_managed_remote_privkey_enc( + ssh_salt_ssh_cli, cert_args, ca_key, rsa_privkey +): + cert_args["private_key"] += "_enc" + cert_args["private_key_passphrase"] = "hunter1" + ret = ssh_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) + assert ret.returncode == 0 + cert = _get_cert(cert_args["name"]) + assert cert.key_id == b"from_signing_policy" + assert _signed_by(cert, ca_key) + assert _belongs_to(cert, rsa_privkey) -@pytest.mark.usefixtures("_check_bcrypt", "cm_file_args") def test_certificate_managed_remote_file_managed_kwargs( ssh_salt_ssh_cli, cert_args, ca_key, rsa_privkey ): - ret = ssh_salt_ssh_cli.run( - "state.apply", "cert_file_args", pillar={"args": cert_args} - ) + cert_args["certificate_managed"]["mode"] = "0400" + ret = ssh_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) assert ret.returncode == 0 cert = _get_cert(cert_args["name"]) assert cert.key_id == b"from_signing_policy" @@ -253,7 +238,6 @@ def test_certificate_managed_remote_file_managed_kwargs( assert ret.data == "0400" -@pytest.mark.usefixtures("_check_bcrypt") def test_certificate_managed_remote_with_privkey_managed( ssh_salt_ssh_cli, cert_args, tmp_path, ca_key ): @@ -273,7 +257,6 @@ def test_certificate_managed_remote_with_privkey_managed( assert ret.data[state]["changes"] -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.usefixtures("existing_cert") def test_certificate_managed_remote_no_changes(ssh_salt_ssh_cli, cert_args): ret = ssh_salt_ssh_cli.run("state.apply", "cert", pillar={"args": cert_args}) @@ -281,7 +264,6 @@ def test_certificate_managed_remote_no_changes(ssh_salt_ssh_cli, cert_args): assert ret.data[next(iter(ret.data))]["changes"] == {} -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.usefixtures("existing_cert") @pytest.mark.parametrize("existing_cert", ({"private_key_managed": {}},), indirect=True) def test_certificate_managed_remote_no_changes_with_privkey_managed( @@ -300,7 +282,6 @@ def test_certificate_managed_remote_no_changes_with_privkey_managed( assert ret.data[state]["changes"] == {} -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.usefixtures("existing_cert") def test_certificate_managed_remote_policy_change(ssh_salt_ssh_cli, cert_args): cert_args["signing_policy"] = "testchangepolicy" @@ -311,7 +292,6 @@ def test_certificate_managed_remote_policy_change(ssh_salt_ssh_cli, cert_args): assert cert.key_id == b"from_changed_signing_policy" -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.usefixtures("existing_cert") @pytest.mark.parametrize("existing_cert", ({"private_key_managed": {}},), indirect=True) def test_certificate_managed_remote_policy_change_with_privkey_managed( @@ -338,7 +318,6 @@ def test_certificate_managed_remote_policy_change_with_privkey_managed( assert not ret.data[state]["changes"] -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.usefixtures("existing_cert") @pytest.mark.parametrize( "existing_cert", ({"private_key_managed": {"new": True}},), indirect=True @@ -369,7 +348,6 @@ def test_certificate_managed_remote_policy_change_with_privkey_managed_new( assert not ret.data[state]["changes"] -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.usefixtures("existing_cert") def test_certificate_managed_remote_signing_key_change(ssh_salt_ssh_cli, cert_args): cert_args["signing_policy"] = "testchangecapolicy" @@ -381,7 +359,6 @@ def test_certificate_managed_remote_signing_key_change(ssh_salt_ssh_cli, cert_ar assert "signing_private_key" in changes -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.usefixtures("existing_cert") def test_certificate_managed_remote_no_changes_signing_policy_override( ssh_salt_ssh_cli, cert_args @@ -394,7 +371,6 @@ def test_certificate_managed_remote_no_changes_signing_policy_override( assert ret.data[next(iter(ret.data))]["changes"] == {} -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.parametrize("overwrite", (False, True)) def test_certificate_managed_privkey_managed_existing_not_a_privkey( ssh_salt_ssh_cli, cert_args, ca_key, existing_file, overwrite @@ -408,7 +384,6 @@ def test_certificate_managed_privkey_managed_existing_not_a_privkey( ) -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.parametrize("overwrite", (False, True)) def test_certificate_managed_privkey_managed_existing_symlink( ssh_salt_ssh_cli, cert_args, ca_key, existing_symlink, overwrite @@ -455,7 +430,6 @@ def _test_certificate_managed_existing_path( assert bool(ret.data[state]["changes"]) is ("symlink" in existing.name) -@pytest.mark.usefixtures("_check_bcrypt") def test_certificate_managed_existing_not_a_cert( ssh_salt_ssh_cli, cert_args, existing_file, rsa_privkey, ca_key ): @@ -474,7 +448,6 @@ def test_certificate_managed_existing_not_a_cert( assert _belongs_to(cert, rsa_privkey) -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.usefixtures("existing_cert") def test_certificate_managed_remote_renew(ssh_salt_ssh_cli, cert_args): cert_cur = _get_cert(cert_args["name"]) @@ -498,7 +471,6 @@ def test_certificate_managed_different_backend(ssh_salt_ssh_cli, cert_args, cert assert cert.public_bytes().decode().strip() == cert_exts -@pytest.mark.usefixtures("_check_bcrypt") @pytest.mark.usefixtures("other_backend") @pytest.mark.usefixtures("existing_cert") def test_certificate_managed_existing_different_backend( From ae2b4032a7eedbb4ac0340042368223248308bab Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 00:02:51 -0700 Subject: [PATCH 183/469] Add per-daemon RSS/FD metrics to nightly stress test The nightly stress workflow's Grafana dashboard has always displayed ``salt_master_rss_bytes`` but the /proc exporter that emits it was never started, so the aggregate series stayed empty and only cAdvisor container RSS ever plotted. Even once that gap is closed, the aggregate hides which daemon is actually growing under load (leaking MWorker vs runaway Maintenance vs PubServerChannel etc.). Extend ``fd_exporter.py`` to emit one gauge per master/salt-api daemon labelled by process name (parsed from setproctitle in /proc//cmdline), keeping the aggregates intact. Labels are stable across restarts so a killed-and-respawned Maintenance / MWorker continues the same Prometheus series rather than starting a new line. Start the exporter alongside the event flooder in ``stress_test.sh``. Add ``Per-Master-Daemon RSS`` and ``Per-Salt-API-Process RSS`` panels to ``salt_monitoring.json``; ``render_panels.py`` picks them up automatically (it iterates every timeseries panel), and the workflow's Publish/Summary steps already glob the panels directory so no workflow edit is needed. --- .../dashboards/salt_monitoring.json | 71 ++++ tests/monitoring/srv/salt/fd_exporter.py | 347 +++++++++++++----- tests/monitoring/stress_test.sh | 8 + 3 files changed, 340 insertions(+), 86 deletions(-) diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json index 929d844b8aec..952c8053549b 100644 --- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json +++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json @@ -606,6 +606,77 @@ ], "title": "API Resource Usage (FDs & Processes)", "type": "timeseries" + }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 43 + }, + "id": 105, + "title": "Per-Daemon Breakdown", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 44 + }, + "id": 60, + "targets": [ + { + "expr": "salt_master_process_rss_bytes", + "legendFormat": "{{process}}", + "refId": "A" + } + ], + "title": "Per-Master-Daemon RSS", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 44 + }, + "id": 61, + "targets": [ + { + "expr": "salt_api_process_rss_bytes", + "legendFormat": "{{process}}", + "refId": "A" + } + ], + "title": "Per-Salt-API-Process RSS", + "type": "timeseries" } ], "refresh": "10s", diff --git a/tests/monitoring/srv/salt/fd_exporter.py b/tests/monitoring/srv/salt/fd_exporter.py index 26ffd15cfcb6..dd1598f870e1 100644 --- a/tests/monitoring/srv/salt/fd_exporter.py +++ b/tests/monitoring/srv/salt/fd_exporter.py @@ -1,102 +1,277 @@ # pylint: disable=resource-leakage +"""HTTP exporter that scrapes /proc for salt-master and salt-api processes. + +Emits three tiers of gauges: + +* Aggregate counters (unchanged from the original): + ``salt_master_rss_bytes``, ``salt_master_open_fds``, + ``salt_master_process_count`` and their ``salt_api_*`` counterparts. + +* Per-daemon gauges labelled by process name (not pid) so restart of a + worker or the ``Maintenance`` daemon continues the same Prometheus + series rather than starting a new line on the dashboard: + ``salt_master_process_rss_bytes{process="MWorker-default-0"}`` etc. + Parallel ``salt_api_process_*`` metrics cover the salt-api daemons. + +Process names come from the trailing tokens of ``/proc//cmdline`` +(salt renames its worker processes via ``setproctitle`` so the last +argv slot holds the daemon's role -- e.g. ``EventPublisher``, +``RequestServer MWorker-default-2``, ``PubServerChannel._publish_daemon``). +The main master/api MainProcess is disambiguated by whether ``salt-api`` +appears anywhere in the argv. +""" import http.server import os +def _classify(cmdline): + """Return ``(daemon, process_name)`` for a salt-master/salt-api pid. + + ``daemon`` is either ``"master"`` or ``"api"``. ``process_name`` is + the label the caller emits into + ``salt_{daemon}_process_rss_bytes{process="..."}``. + + Returns ``None`` if ``cmdline`` does not belong to a salt master or + salt-api process (or is the exporter itself). + """ + if not cmdline: + return None + if "fd_exporter.py" in cmdline: + return None + + is_api = "salt-api" in cmdline + is_master = "salt-master" in cmdline and not is_api + if not (is_api or is_master): + return None + + # Skip the entrypoint shell wrapper (docker-compose runs the master + # under ``sh -c '... salt-master -d && salt-api'``, which matches + # both keywords but is not itself a salt daemon). + if cmdline.startswith(("sh -c", "/bin/sh -c", "/usr/bin/tini")): + return None + + daemon = "api" if is_api else "master" + + # Salt's ``setproctitle`` payload lands in the trailing argv slots + # (space-separated inside the null-terminated ``cmdline`` blob we've + # already normalised to spaces by the caller). Look at the tail. + tokens = cmdline.split() + + # ``RequestServer MWorker-default-2`` -> just ``MWorker-default-2``. + # ``PubServerChannel._publish_daemon`` stays intact. + # ``ReqServer_ProcessManager`` stays intact. + # ``Maintenance``, ``EventPublisher``, ``EventMonitor``, + # ``BatchManager``, ``FileServerUpdate`` all stand alone. + if not tokens: + return daemon, "unknown" + + last = tokens[-1] + + # Bare ``salt-api`` / ``salt-master`` invocations with no proctitle + # suffix mean the process hasn't renamed itself yet (or is the + # top-level launcher). Collapse to a stable label. + if last.endswith("salt-api"): + return daemon, "salt-api-launcher" + if last.endswith("salt-master") or last == "-d": + return daemon, "master-launcher" + + if last == "MainProcess": + return daemon, "salt-api-main" if is_api else "master-main" + + # ``RunNetapi(salt.loaded.int.netapi.rest_cherrypy)`` and its + # siblings all identify a CherryPy-serving api worker; the parens + # payload varies per module so collapse on the ``RunNetapi`` prefix. + if is_api and last.startswith("RunNetapi"): + return daemon, "salt-api-cherrypy" + + # ``RequestServer MWorker-default-2`` -> daemon label + # ``MWorker-default-2``. ``MWorkerQueue`` stands alone. + if len(tokens) >= 2 and tokens[-2] == "RequestServer": + return daemon, last + + return daemon, last + + +def _read_cmdline(pid): + with open(f"/proc/{pid}/cmdline", "rb") as fh: + return fh.read().replace(b"\0", b" ").decode(errors="ignore") + + +def _read_rss_bytes(pid): + """Return RSS in bytes from ``/proc//stat`` field 24 (pages).""" + with open(f"/proc/{pid}/stat", encoding="utf-8") as fh: + stat = fh.read().split() + rss_pages = int(stat[23]) + return rss_pages * 4096 # Linux page size on all supported CI runners + + +def _count_fds(pid): + return len(os.listdir(f"/proc/{pid}/fd")) + + +def _format_series(name, help_text, samples): + """Return the # HELP/# TYPE header plus one line per label value. + + ``samples`` is ``{process_label: value}``. Only currently-live + processes appear -- when a process exits Prometheus interpolates + across the gap and, when a replacement forks under the same + process name, the series continues naturally. + """ + lines = [f"# HELP {name} {help_text}", f"# TYPE {name} gauge"] + for process, value in sorted(samples.items()): + # Escape backslash and double-quote per the Prometheus text + # exposition spec. Salt daemon names never contain either but + # the escape keeps this defensive. + safe = process.replace("\\", "\\\\").replace('"', '\\"') + lines.append(f'{name}{{process="{safe}"}} {value}') + return lines + + class FDHandler(http.server.BaseHTTPRequestHandler): def log_message(self, format, *args): # Silence logs return def do_GET(self): - if self.path == "/metrics": - self.send_response(200) - self.send_header("Content-Type", "text/plain") - self.end_headers() - - master_fds = 0 - master_procs = 0 - master_rss = 0 - api_fds = 0 - api_procs = 0 - api_rss = 0 - - try: - # Iterate over /proc directly once for efficiency - for pid_dir in os.listdir("/proc"): - if not pid_dir.isdigit(): - continue - - try: - pid = pid_dir - with open(f"/proc/{pid}/cmdline", "rb") as f: - cmdline = ( - f.read().replace(b"\0", b" ").decode(errors="ignore") - ) - - # Skip if it's the exporter itself - if "fd_exporter.py" in cmdline: - continue - - is_api = "salt-api" in cmdline - is_master = "salt-master" in cmdline and not is_api - - if is_master or is_api: - # FD count - try: - fd_count = len(os.listdir(f"/proc/{pid}/fd")) - except (OSError, PermissionError): - fd_count = 0 - - # RSS Memory (from /proc/[pid]/stat, field 24 is RSS in pages) - try: - with open(f"/proc/{pid}/stat", encoding="utf-8") as f: - stat = f.read().split() - rss_pages = int(stat[23]) - rss_bytes = rss_pages * 4096 # Assuming 4KB pages - except (OSError, ValueError, IndexError): - rss_bytes = 0 - - if is_master: - master_fds += fd_count - master_procs += 1 - master_rss += rss_bytes - if is_api: - api_fds += fd_count - api_procs += 1 - api_rss += rss_bytes - except (FileNotFoundError, ProcessLookupError, PermissionError): - # Process died while we were reading it - continue - except OSError: - continue - except OSError: - pass - - lines = [ - "# HELP salt_master_open_fds Number of open file descriptors for master", - "# TYPE salt_master_open_fds gauge", - f"salt_master_open_fds {master_fds}", - "# HELP salt_master_process_count Number of master processes", - "# TYPE salt_master_process_count gauge", - f"salt_master_process_count {master_procs}", - "# HELP salt_master_rss_bytes RSS memory usage for master in bytes", - "# TYPE salt_master_rss_bytes gauge", - f"salt_master_rss_bytes {master_rss}", - "# HELP salt_api_open_fds Number of open file descriptors for salt-api", - "# TYPE salt_api_open_fds gauge", - f"salt_api_open_fds {api_fds}", - "# HELP salt_api_process_count Number of salt-api processes", - "# TYPE salt_api_process_count gauge", - f"salt_api_process_count {api_procs}", - "# HELP salt_api_rss_bytes RSS memory usage for salt-api in bytes", - "# TYPE salt_api_rss_bytes gauge", - f"salt_api_rss_bytes {api_rss}", - ] - self.wfile.write(("\n".join(lines) + "\n").encode()) - else: + if self.path != "/metrics": self.send_response(404) self.end_headers() + return + + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + + master_fds = 0 + master_procs = 0 + master_rss = 0 + api_fds = 0 + api_procs = 0 + api_rss = 0 + + # Per-daemon buckets. A given label may appear on multiple pids + # transiently (e.g. an old Maintenance pid is exiting while its + # replacement has just forked); sum in that case so the series + # never dips artificially. + master_proc_rss = {} + master_proc_fds = {} + api_proc_rss = {} + api_proc_fds = {} + + try: + for pid_dir in os.listdir("/proc"): + if not pid_dir.isdigit(): + continue + pid = pid_dir + try: + cmdline = _read_cmdline(pid) + except ( + FileNotFoundError, + ProcessLookupError, + PermissionError, + OSError, + ): + continue + + classified = _classify(cmdline) + if classified is None: + continue + daemon, process_name = classified + + try: + fd_count = _count_fds(pid) + except ( + FileNotFoundError, + ProcessLookupError, + PermissionError, + OSError, + ): + fd_count = 0 + + try: + rss_bytes = _read_rss_bytes(pid) + except ( + FileNotFoundError, + ProcessLookupError, + PermissionError, + ValueError, + IndexError, + OSError, + ): + rss_bytes = 0 + + if daemon == "master": + master_fds += fd_count + master_procs += 1 + master_rss += rss_bytes + master_proc_rss[process_name] = ( + master_proc_rss.get(process_name, 0) + rss_bytes + ) + master_proc_fds[process_name] = ( + master_proc_fds.get(process_name, 0) + fd_count + ) + else: + api_fds += fd_count + api_procs += 1 + api_rss += rss_bytes + api_proc_rss[process_name] = ( + api_proc_rss.get(process_name, 0) + rss_bytes + ) + api_proc_fds[process_name] = ( + api_proc_fds.get(process_name, 0) + fd_count + ) + except OSError: + pass + + lines = [ + "# HELP salt_master_open_fds Number of open file descriptors for master", + "# TYPE salt_master_open_fds gauge", + f"salt_master_open_fds {master_fds}", + "# HELP salt_master_process_count Number of master processes", + "# TYPE salt_master_process_count gauge", + f"salt_master_process_count {master_procs}", + "# HELP salt_master_rss_bytes RSS memory usage for master in bytes", + "# TYPE salt_master_rss_bytes gauge", + f"salt_master_rss_bytes {master_rss}", + "# HELP salt_api_open_fds Number of open file descriptors for salt-api", + "# TYPE salt_api_open_fds gauge", + f"salt_api_open_fds {api_fds}", + "# HELP salt_api_process_count Number of salt-api processes", + "# TYPE salt_api_process_count gauge", + f"salt_api_process_count {api_procs}", + "# HELP salt_api_rss_bytes RSS memory usage for salt-api in bytes", + "# TYPE salt_api_rss_bytes gauge", + f"salt_api_rss_bytes {api_rss}", + ] + lines.extend( + _format_series( + "salt_master_process_rss_bytes", + "RSS bytes per salt-master daemon process, labelled by process name", + master_proc_rss, + ) + ) + lines.extend( + _format_series( + "salt_master_process_fds", + "Open FDs per salt-master daemon process, labelled by process name", + master_proc_fds, + ) + ) + lines.extend( + _format_series( + "salt_api_process_rss_bytes", + "RSS bytes per salt-api daemon process, labelled by process name", + api_proc_rss, + ) + ) + lines.extend( + _format_series( + "salt_api_process_fds", + "Open FDs per salt-api daemon process, labelled by process name", + api_proc_fds, + ) + ) + self.wfile.write(("\n".join(lines) + "\n").encode()) if __name__ == "__main__": diff --git a/tests/monitoring/stress_test.sh b/tests/monitoring/stress_test.sh index 3742d6dee8f9..1fd0547e8e23 100755 --- a/tests/monitoring/stress_test.sh +++ b/tests/monitoring/stress_test.sh @@ -7,6 +7,14 @@ echo "Starting aggressive stress test..." echo "Launching event flooder..." docker exec -d salt-master python3 /srv/salt/flood_events.py +# 1b. Start the /proc RSS+FD exporter that feeds Grafana's per-daemon +# panels. Nothing else starts it -- docker-compose only launches the +# aggregate salt_metrics_exporter -- so without this the +# salt_master_process_rss_bytes series would stay empty and the +# ``Per-Master-Daemon RSS`` panel would render blank. +echo "Launching /proc fd_exporter..." +docker exec -d salt-master python3 /srv/salt/fd_exporter.py + # 2. Loop Highstates on all minions echo "Starting Highstate loop..." ( From e189d61ce44e6cf853663c3d2000ac6953673334 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 00:30:12 -0700 Subject: [PATCH 184/469] Rename Grafana panels + docs from Daemon to Process The salt-master and salt-api are the two 'daemons' -- MWorker, EventPublisher, Maintenance, MWorkerQueue etc. are individual processes those daemons supervise. Rename the user-facing text (panel titles, help text, docstrings) accordingly. Internal 'daemon' variable (used to categorise master vs api) kept as-is; salt's own PubServerChannel._publish_daemon proctitle string is unchanged. --- .../dashboards/salt_monitoring.json | 4 ++-- tests/monitoring/srv/salt/fd_exporter.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json index 952c8053549b..6f7861f045fe 100644 --- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json +++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json @@ -615,7 +615,7 @@ "y": 43 }, "id": 105, - "title": "Per-Daemon Breakdown", + "title": "Per-Process Breakdown", "type": "row" }, { @@ -645,7 +645,7 @@ "refId": "A" } ], - "title": "Per-Master-Daemon RSS", + "title": "Per-Master-Process RSS", "type": "timeseries" }, { diff --git a/tests/monitoring/srv/salt/fd_exporter.py b/tests/monitoring/srv/salt/fd_exporter.py index dd1598f870e1..91c8b4e4cc37 100644 --- a/tests/monitoring/srv/salt/fd_exporter.py +++ b/tests/monitoring/srv/salt/fd_exporter.py @@ -7,15 +7,15 @@ ``salt_master_rss_bytes``, ``salt_master_open_fds``, ``salt_master_process_count`` and their ``salt_api_*`` counterparts. -* Per-daemon gauges labelled by process name (not pid) so restart of a - worker or the ``Maintenance`` daemon continues the same Prometheus +* Per-process gauges labelled by process name (not pid) so restart of a + worker or the ``Maintenance`` process continues the same Prometheus series rather than starting a new line on the dashboard: ``salt_master_process_rss_bytes{process="MWorker-default-0"}`` etc. - Parallel ``salt_api_process_*`` metrics cover the salt-api daemons. + Parallel ``salt_api_process_*`` metrics cover the salt-api side. Process names come from the trailing tokens of ``/proc//cmdline`` (salt renames its worker processes via ``setproctitle`` so the last -argv slot holds the daemon's role -- e.g. ``EventPublisher``, +argv slot holds the process's role -- e.g. ``EventPublisher``, ``RequestServer MWorker-default-2``, ``PubServerChannel._publish_daemon``). The main master/api MainProcess is disambiguated by whether ``salt-api`` appears anywhere in the argv. @@ -149,7 +149,7 @@ def do_GET(self): api_procs = 0 api_rss = 0 - # Per-daemon buckets. A given label may appear on multiple pids + # Per-process buckets. A given label may appear on multiple pids # transiently (e.g. an old Maintenance pid is exiting while its # replacement has just forked); sum in that case so the series # never dips artificially. @@ -246,28 +246,28 @@ def do_GET(self): lines.extend( _format_series( "salt_master_process_rss_bytes", - "RSS bytes per salt-master daemon process, labelled by process name", + "RSS bytes per salt-master process, labelled by process name", master_proc_rss, ) ) lines.extend( _format_series( "salt_master_process_fds", - "Open FDs per salt-master daemon process, labelled by process name", + "Open FDs per salt-master process, labelled by process name", master_proc_fds, ) ) lines.extend( _format_series( "salt_api_process_rss_bytes", - "RSS bytes per salt-api daemon process, labelled by process name", + "RSS bytes per salt-api process, labelled by process name", api_proc_rss, ) ) lines.extend( _format_series( "salt_api_process_fds", - "Open FDs per salt-api daemon process, labelled by process name", + "Open FDs per salt-api process, labelled by process name", api_proc_fds, ) ) From 05fc839db55d756526a6f87b88eea99a0027316d Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 01:07:32 -0700 Subject: [PATCH 185/469] Add PSS metrics alongside RSS to avoid COW double-count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naïve per-process RSS sums over-count every COW-shared page N times (one per sibling fork), inflating the master's aggregate ~2x on a 5-worker rig (measured 1081 MiB RSS sum vs 556 MiB actual container RSS). Add Proportional Set Size (PSS) from ``/proc//smaps_rollup`` alongside the existing RSS metrics. PSS divides each shared page by its sharer count, so ``sum(PSS across siblings) ~= physical RAM``. New series (RSS series retained for backward compatibility): - ``salt_master_pss_bytes`` / ``salt_api_pss_bytes`` (aggregates) - ``salt_master_process_pss_bytes{process}`` / ``salt_api_process_pss_bytes{process}`` Master Memory panel now plots RSS sum + PSS sum + Container together so the COW gap is visible at a glance. Two new "Per-Master-Process PSS" / "Per-Salt-API-Process PSS" panels sit alongside the RSS ones. --- .../dashboards/salt_monitoring.json | 69 ++++++++++++++++++- tests/monitoring/srv/salt/fd_exporter.py | 68 ++++++++++++++++-- 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json index 6f7861f045fe..139df40ec563 100644 --- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json +++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json @@ -121,16 +121,21 @@ "targets": [ { "expr": "salt_master_rss_bytes", - "legendFormat": "Master Process RSS", + "legendFormat": "Master Process RSS (sum, over-counts COW)", "refId": "A" }, + { + "expr": "salt_master_pss_bytes", + "legendFormat": "Master Process PSS (sum, ~= physical RAM)", + "refId": "C" + }, { "expr": "container_memory_rss{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-master\"}", "legendFormat": "Total Container RSS", "refId": "B" } ], - "title": "Master Memory RSS (Process vs Container)", + "title": "Master Memory (RSS Sum vs PSS Sum vs Container)", "type": "timeseries" }, { @@ -677,6 +682,66 @@ ], "title": "Per-Salt-API-Process RSS", "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 53 + }, + "id": 62, + "targets": [ + { + "expr": "salt_master_process_pss_bytes", + "legendFormat": "{{process}}", + "refId": "A" + } + ], + "title": "Per-Master-Process PSS (COW-adjusted, sum ~= physical RAM)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 53 + }, + "id": 63, + "targets": [ + { + "expr": "salt_api_process_pss_bytes", + "legendFormat": "{{process}}", + "refId": "A" + } + ], + "title": "Per-Salt-API-Process PSS (COW-adjusted)", + "type": "timeseries" } ], "refresh": "10s", diff --git a/tests/monitoring/srv/salt/fd_exporter.py b/tests/monitoring/srv/salt/fd_exporter.py index 91c8b4e4cc37..28c97e35c9f0 100644 --- a/tests/monitoring/srv/salt/fd_exporter.py +++ b/tests/monitoring/srv/salt/fd_exporter.py @@ -105,6 +105,22 @@ def _read_rss_bytes(pid): return rss_pages * 4096 # Linux page size on all supported CI runners +def _read_pss_bytes(pid): + """Return PSS (Proportional Set Size) in bytes from ``/proc//smaps_rollup``. + + PSS divides each shared page by the number of processes mapping it, so + ``sum(PSS across sibling forks) ~= physical RAM used`` -- unlike naive + RSS which double-counts every COW-shared page and inflates the total + ~2x for a many-process salt-master. + """ + with open(f"/proc/{pid}/smaps_rollup", encoding="utf-8") as fh: + for line in fh: + if line.startswith("Pss:"): + # ``Pss: 12345 kB`` + return int(line.split()[1]) * 1024 + return 0 + + def _count_fds(pid): return len(os.listdir(f"/proc/{pid}/fd")) @@ -145,17 +161,21 @@ def do_GET(self): master_fds = 0 master_procs = 0 master_rss = 0 + master_pss = 0 api_fds = 0 api_procs = 0 api_rss = 0 + api_pss = 0 # Per-process buckets. A given label may appear on multiple pids # transiently (e.g. an old Maintenance pid is exiting while its # replacement has just forked); sum in that case so the series # never dips artificially. master_proc_rss = {} + master_proc_pss = {} master_proc_fds = {} api_proc_rss = {} + api_proc_pss = {} api_proc_fds = {} try: @@ -200,13 +220,29 @@ def do_GET(self): ): rss_bytes = 0 + try: + pss_bytes = _read_pss_bytes(pid) + except ( + FileNotFoundError, + ProcessLookupError, + PermissionError, + ValueError, + IndexError, + OSError, + ): + pss_bytes = 0 + if daemon == "master": master_fds += fd_count master_procs += 1 master_rss += rss_bytes + master_pss += pss_bytes master_proc_rss[process_name] = ( master_proc_rss.get(process_name, 0) + rss_bytes ) + master_proc_pss[process_name] = ( + master_proc_pss.get(process_name, 0) + pss_bytes + ) master_proc_fds[process_name] = ( master_proc_fds.get(process_name, 0) + fd_count ) @@ -214,9 +250,13 @@ def do_GET(self): api_fds += fd_count api_procs += 1 api_rss += rss_bytes + api_pss += pss_bytes api_proc_rss[process_name] = ( api_proc_rss.get(process_name, 0) + rss_bytes ) + api_proc_pss[process_name] = ( + api_proc_pss.get(process_name, 0) + pss_bytes + ) api_proc_fds[process_name] = ( api_proc_fds.get(process_name, 0) + fd_count ) @@ -230,26 +270,39 @@ def do_GET(self): "# HELP salt_master_process_count Number of master processes", "# TYPE salt_master_process_count gauge", f"salt_master_process_count {master_procs}", - "# HELP salt_master_rss_bytes RSS memory usage for master in bytes", + "# HELP salt_master_rss_bytes RSS memory usage for master in bytes (sum of per-process RSS -- over-counts COW-shared pages ~Nx)", "# TYPE salt_master_rss_bytes gauge", f"salt_master_rss_bytes {master_rss}", + "# HELP salt_master_pss_bytes PSS (Proportional Set Size) for master in bytes (shared pages divided by N -- sum approximates actual physical RAM)", + "# TYPE salt_master_pss_bytes gauge", + f"salt_master_pss_bytes {master_pss}", "# HELP salt_api_open_fds Number of open file descriptors for salt-api", "# TYPE salt_api_open_fds gauge", f"salt_api_open_fds {api_fds}", "# HELP salt_api_process_count Number of salt-api processes", "# TYPE salt_api_process_count gauge", f"salt_api_process_count {api_procs}", - "# HELP salt_api_rss_bytes RSS memory usage for salt-api in bytes", + "# HELP salt_api_rss_bytes RSS memory usage for salt-api in bytes (sum of per-process RSS -- over-counts COW-shared pages)", "# TYPE salt_api_rss_bytes gauge", f"salt_api_rss_bytes {api_rss}", + "# HELP salt_api_pss_bytes PSS for salt-api in bytes (sum approximates actual physical RAM)", + "# TYPE salt_api_pss_bytes gauge", + f"salt_api_pss_bytes {api_pss}", ] lines.extend( _format_series( "salt_master_process_rss_bytes", - "RSS bytes per salt-master process, labelled by process name", + "RSS bytes per salt-master process, labelled by process name (over-counts COW-shared pages -- prefer PSS for aggregate math)", master_proc_rss, ) ) + lines.extend( + _format_series( + "salt_master_process_pss_bytes", + "PSS (Proportional Set Size) bytes per salt-master process, labelled by process name (shared pages divided by N -- sum approximates actual physical RAM)", + master_proc_pss, + ) + ) lines.extend( _format_series( "salt_master_process_fds", @@ -260,10 +313,17 @@ def do_GET(self): lines.extend( _format_series( "salt_api_process_rss_bytes", - "RSS bytes per salt-api process, labelled by process name", + "RSS bytes per salt-api process, labelled by process name (over-counts COW-shared pages -- prefer PSS for aggregate math)", api_proc_rss, ) ) + lines.extend( + _format_series( + "salt_api_process_pss_bytes", + "PSS (Proportional Set Size) bytes per salt-api process, labelled by process name", + api_proc_pss, + ) + ) lines.extend( _format_series( "salt_api_process_fds", From b7c6a8c1cd142a29750f8faa740feb5527fea764 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 01:11:10 -0700 Subject: [PATCH 186/469] Replace master RSS with PSS on the Master Memory panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naïve RSS sum inflates the master's aggregate ~2x by COW-double-count. PSS gives the accurate physical-RAM number and tracks the container gauge closely, so the Process-vs-Container comparison is meaningful. The RSS series is still emitted (and still surfaced on the 'Per-Master-Process RSS' panel) for anyone who wants the raw sum. --- .../provisioning/dashboards/salt_monitoring.json | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json index 139df40ec563..74c538294868 100644 --- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json +++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json @@ -119,15 +119,10 @@ }, "id": 10, "targets": [ - { - "expr": "salt_master_rss_bytes", - "legendFormat": "Master Process RSS (sum, over-counts COW)", - "refId": "A" - }, { "expr": "salt_master_pss_bytes", - "legendFormat": "Master Process PSS (sum, ~= physical RAM)", - "refId": "C" + "legendFormat": "Master Process PSS", + "refId": "A" }, { "expr": "container_memory_rss{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-master\"}", @@ -135,7 +130,7 @@ "refId": "B" } ], - "title": "Master Memory (RSS Sum vs PSS Sum vs Container)", + "title": "Master Memory (Process vs Container)", "type": "timeseries" }, { From 9669b235cc6ecdb93089d71672ccf844cb7fbce4 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 01:44:03 -0700 Subject: [PATCH 187/469] Split master PSS panel into main daemons vs worker sub-processes The all-in-one Per-Master-Process PSS panel puts 14+ lines on one timeseries -- MWorker-default-N churn drowns out the near-flat main daemons and vice versa. Split into two panels: - 'Main Master Processes PSS' -- everything except MWorker-default-N (EventPublisher, PubServerChannel._publish_daemon, MWorkerQueue, Maintenance, EventMonitor, BatchManager, FileServerUpdate, ReqServer_ProcessManager, master-main) - 'Master Worker Sub-processes PSS' -- just the MWorker-default-N workers Each panel now has a small enough legend to spot a single-process leak at a glance. Salt-api panels left as-is. --- .../dashboards/salt_monitoring.json | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json index 74c538294868..308ec7b1b0a5 100644 --- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json +++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json @@ -700,12 +700,42 @@ "id": 62, "targets": [ { - "expr": "salt_master_process_pss_bytes", + "expr": "salt_master_process_pss_bytes{process!~\"MWorker-default-.*\"}", "legendFormat": "{{process}}", "refId": "A" } ], - "title": "Per-Master-Process PSS (COW-adjusted, sum ~= physical RAM)", + "title": "Main Master Processes PSS", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 62 + }, + "id": 64, + "targets": [ + { + "expr": "salt_master_process_pss_bytes{process=~\"MWorker-default-.*\"}", + "legendFormat": "{{process}}", + "refId": "A" + } + ], + "title": "Master Worker Sub-processes PSS (MWorker-default-N)", "type": "timeseries" }, { From 4fb4f931d80af845cf858bd41210e7b643e2cea8 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 02:44:16 -0700 Subject: [PATCH 188/469] Split Per-Master-Process into one panel per process Replace the all-in-one master + salt-api process breakdown panels (14+ lines each) with a dedicated PSS timeseries for every master process: EventPublisher, PubServerChannel._publish_daemon, MWorkerQueue, ReqServer_ProcessManager, Maintenance, EventMonitor, BatchManager, FileServerUpdate, master-main, and MWorker-default-0..4. Each panel isolates one process's memory trajectory so a leak or outlier is obvious at a glance. Dropped the salt-api process breakdown panels -- master-side focus. Row header renamed 'Per-Master-Process Memory (PSS)'. --- .../dashboards/salt_monitoring.json | 340 ++++++++++++++++-- 1 file changed, 305 insertions(+), 35 deletions(-) diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json index 308ec7b1b0a5..330deaee280f 100644 --- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json +++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json @@ -615,7 +615,7 @@ "y": 43 }, "id": 105, - "title": "Per-Process Breakdown", + "title": "Per-Master-Process Memory (PSS)", "type": "row" }, { @@ -632,20 +632,20 @@ } }, "gridPos": { - "h": 9, - "w": 12, + "h": 6, + "w": 8, "x": 0, "y": 44 }, - "id": 60, + "id": 200, "targets": [ { - "expr": "salt_master_process_rss_bytes", - "legendFormat": "{{process}}", + "expr": "salt_master_process_pss_bytes{process=\"EventPublisher\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Per-Master-Process RSS", + "title": "EventPublisher", "type": "timeseries" }, { @@ -662,20 +662,20 @@ } }, "gridPos": { - "h": 9, - "w": 12, - "x": 12, + "h": 6, + "w": 8, + "x": 8, "y": 44 }, - "id": 61, + "id": 201, "targets": [ { - "expr": "salt_api_process_rss_bytes", - "legendFormat": "{{process}}", + "expr": "salt_master_process_pss_bytes{process=\"PubServerChannel._publish_daemon\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Per-Salt-API-Process RSS", + "title": "PubServerChannel._publish_daemon", "type": "timeseries" }, { @@ -692,20 +692,170 @@ } }, "gridPos": { - "h": 9, - "w": 12, + "h": 6, + "w": 8, + "x": 16, + "y": 44 + }, + "id": 202, + "targets": [ + { + "expr": "salt_master_process_pss_bytes{process=\"MWorkerQueue\"}", + "legendFormat": "PSS", + "refId": "A" + } + ], + "title": "MWorkerQueue", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 6, + "w": 8, "x": 0, - "y": 53 + "y": 50 + }, + "id": 203, + "targets": [ + { + "expr": "salt_master_process_pss_bytes{process=\"ReqServer_ProcessManager\"}", + "legendFormat": "PSS", + "refId": "A" + } + ], + "title": "ReqServer_ProcessManager", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 50 + }, + "id": 204, + "targets": [ + { + "expr": "salt_master_process_pss_bytes{process=\"Maintenance\"}", + "legendFormat": "PSS", + "refId": "A" + } + ], + "title": "Maintenance", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 50 + }, + "id": 205, + "targets": [ + { + "expr": "salt_master_process_pss_bytes{process=\"EventMonitor\"}", + "legendFormat": "PSS", + "refId": "A" + } + ], + "title": "EventMonitor", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 56 + }, + "id": 206, + "targets": [ + { + "expr": "salt_master_process_pss_bytes{process=\"BatchManager\"}", + "legendFormat": "PSS", + "refId": "A" + } + ], + "title": "BatchManager", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" }, - "id": 62, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 56 + }, + "id": 207, "targets": [ { - "expr": "salt_master_process_pss_bytes{process!~\"MWorker-default-.*\"}", - "legendFormat": "{{process}}", + "expr": "salt_master_process_pss_bytes{process=\"FileServerUpdate\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Main Master Processes PSS", + "title": "FileServerUpdate", "type": "timeseries" }, { @@ -722,20 +872,50 @@ } }, "gridPos": { - "h": 9, - "w": 12, + "h": 6, + "w": 8, + "x": 16, + "y": 56 + }, + "id": 208, + "targets": [ + { + "expr": "salt_master_process_pss_bytes{process=\"master-main\"}", + "legendFormat": "PSS", + "refId": "A" + } + ], + "title": "master-main", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 6, + "w": 8, "x": 0, "y": 62 }, - "id": 64, + "id": 209, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=~\"MWorker-default-.*\"}", - "legendFormat": "{{process}}", + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-0\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Master Worker Sub-processes PSS (MWorker-default-N)", + "title": "MWorker-default-0", "type": "timeseries" }, { @@ -752,20 +932,110 @@ } }, "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 53 + "h": 6, + "w": 8, + "x": 8, + "y": 62 + }, + "id": 210, + "targets": [ + { + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-1\"}", + "legendFormat": "PSS", + "refId": "A" + } + ], + "title": "MWorker-default-1", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 62 + }, + "id": 211, + "targets": [ + { + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-2\"}", + "legendFormat": "PSS", + "refId": "A" + } + ], + "title": "MWorker-default-2", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 68 + }, + "id": 212, + "targets": [ + { + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-3\"}", + "legendFormat": "PSS", + "refId": "A" + } + ], + "title": "MWorker-default-3", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 68 }, - "id": 63, + "id": 213, "targets": [ { - "expr": "salt_api_process_pss_bytes", - "legendFormat": "{{process}}", + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-4\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Per-Salt-API-Process PSS (COW-adjusted)", + "title": "MWorker-default-4", "type": "timeseries" } ], From 973ce605d87cbdca8d625d70219933aba9de4863 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 02:59:18 -0700 Subject: [PATCH 189/469] Move Per-Master-Process panels directly below Salt Master row Panels are now: Current Time -> Salt Master (Memory/CPU/Resource) -> Per-Master-Process (14 individual panels) -> Minion 1/2/3 -> Salt API. Puts the per-process detail next to the master aggregate so correlating a container-level spike with the responsible process takes no scrolling. --- .../dashboards/salt_monitoring.json | 456 +++++++++--------- 1 file changed, 228 insertions(+), 228 deletions(-) diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json index 330deaee280f..7461867d4138 100644 --- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json +++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json @@ -205,8 +205,8 @@ "x": 0, "y": 11 }, - "id": 101, - "title": "Minion 1", + "id": 105, + "title": "Per-Master-Process Memory (PSS)", "type": "row" }, { @@ -223,20 +223,20 @@ } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 0, "y": 12 }, - "id": 20, + "id": 200, "targets": [ { - "expr": "container_memory_rss{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-1\"}", - "legendFormat": "Minion 1 RSS", + "expr": "salt_master_process_pss_bytes{process=\"EventPublisher\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Minion 1 Memory RSS", + "title": "EventPublisher", "type": "timeseries" }, { @@ -249,24 +249,24 @@ "color": { "mode": "palette-classic" }, - "unit": "percentunit" + "unit": "bytes" } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 8, "y": 12 }, - "id": 21, + "id": 201, "targets": [ { - "expr": "rate(container_cpu_usage_seconds_total{cpu=\"total\",container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-1\"}[1m])", - "legendFormat": "Minion 1 CPU", + "expr": "salt_master_process_pss_bytes{process=\"PubServerChannel._publish_daemon\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Minion 1 CPU Usage", + "title": "PubServerChannel._publish_daemon", "type": "timeseries" }, { @@ -279,37 +279,26 @@ "color": { "mode": "palette-classic" }, - "unit": "short" + "unit": "bytes" } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 16, "y": 12 }, - "id": 22, + "id": 202, "targets": [ { - "expr": "sum(container_fs_inodes_total{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-1\"}) by (name) - sum(container_fs_inodes_free{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-1\"}) by (name)", - "legendFormat": "Minion 1 Inodes", + "expr": "salt_master_process_pss_bytes{process=\"MWorkerQueue\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Minion Inodes (Disk Files)", + "title": "MWorkerQueue", "type": "timeseries" }, - { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 19 - }, - "id": 102, - "title": "Minion 2", - "type": "row" - }, { "datasource": { "type": "prometheus", @@ -324,20 +313,20 @@ } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 0, - "y": 20 + "y": 18 }, - "id": 30, + "id": 203, "targets": [ { - "expr": "container_memory_rss{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-2\"}", - "legendFormat": "Minion 2 RSS", + "expr": "salt_master_process_pss_bytes{process=\"ReqServer_ProcessManager\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Minion 2 Memory RSS", + "title": "ReqServer_ProcessManager", "type": "timeseries" }, { @@ -350,24 +339,24 @@ "color": { "mode": "palette-classic" }, - "unit": "percentunit" + "unit": "bytes" } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 8, - "y": 20 + "y": 18 }, - "id": 31, + "id": 204, "targets": [ { - "expr": "rate(container_cpu_usage_seconds_total{cpu=\"total\",container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-2\"}[1m])", - "legendFormat": "Minion 2 CPU", + "expr": "salt_master_process_pss_bytes{process=\"Maintenance\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Minion 2 CPU Usage", + "title": "Maintenance", "type": "timeseries" }, { @@ -380,37 +369,26 @@ "color": { "mode": "palette-classic" }, - "unit": "short" + "unit": "bytes" } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 16, - "y": 20 + "y": 18 }, - "id": 32, + "id": 205, "targets": [ { - "expr": "sum(container_fs_inodes_total{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-2\"}) by (name) - sum(container_fs_inodes_free{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-2\"}) by (name)", - "legendFormat": "Minion 2 Inodes", + "expr": "salt_master_process_pss_bytes{process=\"EventMonitor\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Minion Inodes (Disk Files)", + "title": "EventMonitor", "type": "timeseries" }, - { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 27 - }, - "id": 103, - "title": "Minion 3", - "type": "row" - }, { "datasource": { "type": "prometheus", @@ -425,20 +403,20 @@ } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 0, - "y": 28 + "y": 24 }, - "id": 40, + "id": 206, "targets": [ { - "expr": "container_memory_rss{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-3\"}", - "legendFormat": "Minion 3 RSS", + "expr": "salt_master_process_pss_bytes{process=\"BatchManager\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Minion 3 Memory RSS", + "title": "BatchManager", "type": "timeseries" }, { @@ -451,24 +429,24 @@ "color": { "mode": "palette-classic" }, - "unit": "percentunit" + "unit": "bytes" } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 8, - "y": 28 + "y": 24 }, - "id": 41, + "id": 207, "targets": [ { - "expr": "rate(container_cpu_usage_seconds_total{cpu=\"total\",container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-3\"}[1m])", - "legendFormat": "Minion 3 CPU", + "expr": "salt_master_process_pss_bytes{process=\"FileServerUpdate\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Minion 3 CPU Usage", + "title": "FileServerUpdate", "type": "timeseries" }, { @@ -481,37 +459,26 @@ "color": { "mode": "palette-classic" }, - "unit": "short" + "unit": "bytes" } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 16, - "y": 28 + "y": 24 }, - "id": 42, + "id": 208, "targets": [ { - "expr": "sum(container_fs_inodes_total{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-3\"}) by (name) - sum(container_fs_inodes_free{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-3\"}) by (name)", - "legendFormat": "Minion 3 Inodes", + "expr": "salt_master_process_pss_bytes{process=\"master-main\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "Minion 3 Inodes (Disk Files)", + "title": "master-main", "type": "timeseries" }, - { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 35 - }, - "id": 104, - "title": "Salt API", - "type": "row" - }, { "datasource": { "type": "prometheus", @@ -526,20 +493,20 @@ } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 0, - "y": 36 + "y": 30 }, - "id": 50, + "id": 209, "targets": [ { - "expr": "salt_api_rss_bytes", - "legendFormat": "API Process RSS", + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-0\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "API Process Memory RSS", + "title": "MWorker-default-0", "type": "timeseries" }, { @@ -552,24 +519,24 @@ "color": { "mode": "palette-classic" }, - "unit": "percentunit" + "unit": "bytes" } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 8, - "y": 36 + "y": 30 }, - "id": 51, + "id": 210, "targets": [ { - "expr": "rate(container_cpu_usage_seconds_total{cpu=\"total\",container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-master\"}[1m])", - "legendFormat": "API CPU", + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-1\"}", + "legendFormat": "PSS", "refId": "A" } ], - "title": "API CPU Usage", + "title": "MWorker-default-1", "type": "timeseries" }, { @@ -582,42 +549,26 @@ "color": { "mode": "palette-classic" }, - "unit": "short" + "unit": "bytes" } }, "gridPos": { - "h": 7, + "h": 6, "w": 8, "x": 16, - "y": 36 + "y": 30 }, - "id": 52, + "id": 211, "targets": [ { - "expr": "salt_api_open_fds", - "legendFormat": "Total Open FDs", + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-2\"}", + "legendFormat": "PSS", "refId": "A" - }, - { - "expr": "salt_api_process_count", - "legendFormat": "Process Count", - "refId": "B" } ], - "title": "API Resource Usage (FDs & Processes)", + "title": "MWorker-default-2", "type": "timeseries" }, - { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 43 - }, - "id": 105, - "title": "Per-Master-Process Memory (PSS)", - "type": "row" - }, { "datasource": { "type": "prometheus", @@ -635,17 +586,17 @@ "h": 6, "w": 8, "x": 0, - "y": 44 + "y": 36 }, - "id": 200, + "id": 212, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"EventPublisher\"}", + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-3\"}", "legendFormat": "PSS", "refId": "A" } ], - "title": "EventPublisher", + "title": "MWorker-default-3", "type": "timeseries" }, { @@ -665,19 +616,30 @@ "h": 6, "w": 8, "x": 8, - "y": 44 + "y": 36 }, - "id": 201, + "id": 213, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"PubServerChannel._publish_daemon\"}", + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-4\"}", "legendFormat": "PSS", "refId": "A" } ], - "title": "PubServerChannel._publish_daemon", + "title": "MWorker-default-4", "type": "timeseries" }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 101, + "title": "Minion 1", + "type": "row" + }, { "datasource": { "type": "prometheus", @@ -692,20 +654,20 @@ } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 16, - "y": 44 + "x": 0, + "y": 43 }, - "id": 202, + "id": 20, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorkerQueue\"}", - "legendFormat": "PSS", + "expr": "container_memory_rss{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-1\"}", + "legendFormat": "Minion 1 RSS", "refId": "A" } ], - "title": "MWorkerQueue", + "title": "Minion 1 Memory RSS", "type": "timeseries" }, { @@ -718,24 +680,24 @@ "color": { "mode": "palette-classic" }, - "unit": "bytes" + "unit": "percentunit" } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 0, - "y": 50 + "x": 8, + "y": 43 }, - "id": 203, + "id": 21, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"ReqServer_ProcessManager\"}", - "legendFormat": "PSS", + "expr": "rate(container_cpu_usage_seconds_total{cpu=\"total\",container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-1\"}[1m])", + "legendFormat": "Minion 1 CPU", "refId": "A" } ], - "title": "ReqServer_ProcessManager", + "title": "Minion 1 CPU Usage", "type": "timeseries" }, { @@ -748,26 +710,37 @@ "color": { "mode": "palette-classic" }, - "unit": "bytes" + "unit": "short" } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 8, - "y": 50 + "x": 16, + "y": 43 }, - "id": 204, + "id": 22, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"Maintenance\"}", - "legendFormat": "PSS", + "expr": "sum(container_fs_inodes_total{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-1\"}) by (name) - sum(container_fs_inodes_free{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-1\"}) by (name)", + "legendFormat": "Minion 1 Inodes", "refId": "A" } ], - "title": "Maintenance", + "title": "Minion Inodes (Disk Files)", "type": "timeseries" }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 50 + }, + "id": 102, + "title": "Minion 2", + "type": "row" + }, { "datasource": { "type": "prometheus", @@ -782,20 +755,20 @@ } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 16, - "y": 50 + "x": 0, + "y": 51 }, - "id": 205, + "id": 30, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"EventMonitor\"}", - "legendFormat": "PSS", + "expr": "container_memory_rss{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-2\"}", + "legendFormat": "Minion 2 RSS", "refId": "A" } ], - "title": "EventMonitor", + "title": "Minion 2 Memory RSS", "type": "timeseries" }, { @@ -808,24 +781,24 @@ "color": { "mode": "palette-classic" }, - "unit": "bytes" + "unit": "percentunit" } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 0, - "y": 56 + "x": 8, + "y": 51 }, - "id": 206, + "id": 31, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"BatchManager\"}", - "legendFormat": "PSS", + "expr": "rate(container_cpu_usage_seconds_total{cpu=\"total\",container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-2\"}[1m])", + "legendFormat": "Minion 2 CPU", "refId": "A" } ], - "title": "BatchManager", + "title": "Minion 2 CPU Usage", "type": "timeseries" }, { @@ -838,26 +811,37 @@ "color": { "mode": "palette-classic" }, - "unit": "bytes" + "unit": "short" } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 8, - "y": 56 + "x": 16, + "y": 51 }, - "id": 207, + "id": 32, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"FileServerUpdate\"}", - "legendFormat": "PSS", + "expr": "sum(container_fs_inodes_total{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-2\"}) by (name) - sum(container_fs_inodes_free{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-2\"}) by (name)", + "legendFormat": "Minion 2 Inodes", "refId": "A" } ], - "title": "FileServerUpdate", + "title": "Minion Inodes (Disk Files)", "type": "timeseries" }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 58 + }, + "id": 103, + "title": "Minion 3", + "type": "row" + }, { "datasource": { "type": "prometheus", @@ -872,20 +856,20 @@ } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 16, - "y": 56 + "x": 0, + "y": 59 }, - "id": 208, + "id": 40, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"master-main\"}", - "legendFormat": "PSS", + "expr": "container_memory_rss{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-3\"}", + "legendFormat": "Minion 3 RSS", "refId": "A" } ], - "title": "master-main", + "title": "Minion 3 Memory RSS", "type": "timeseries" }, { @@ -898,24 +882,24 @@ "color": { "mode": "palette-classic" }, - "unit": "bytes" + "unit": "percentunit" } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 0, - "y": 62 + "x": 8, + "y": 59 }, - "id": 209, + "id": 41, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-0\"}", - "legendFormat": "PSS", + "expr": "rate(container_cpu_usage_seconds_total{cpu=\"total\",container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-3\"}[1m])", + "legendFormat": "Minion 3 CPU", "refId": "A" } ], - "title": "MWorker-default-0", + "title": "Minion 3 CPU Usage", "type": "timeseries" }, { @@ -928,26 +912,37 @@ "color": { "mode": "palette-classic" }, - "unit": "bytes" + "unit": "short" } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 8, - "y": 62 + "x": 16, + "y": 59 }, - "id": 210, + "id": 42, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-1\"}", - "legendFormat": "PSS", + "expr": "sum(container_fs_inodes_total{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-3\"}) by (name) - sum(container_fs_inodes_free{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-minion-3\"}) by (name)", + "legendFormat": "Minion 3 Inodes", "refId": "A" } ], - "title": "MWorker-default-1", + "title": "Minion 3 Inodes (Disk Files)", "type": "timeseries" }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 66 + }, + "id": 104, + "title": "Salt API", + "type": "row" + }, { "datasource": { "type": "prometheus", @@ -962,20 +957,20 @@ } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 16, - "y": 62 + "x": 0, + "y": 67 }, - "id": 211, + "id": 50, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-2\"}", - "legendFormat": "PSS", + "expr": "salt_api_rss_bytes", + "legendFormat": "API Process RSS", "refId": "A" } ], - "title": "MWorker-default-2", + "title": "API Process Memory RSS", "type": "timeseries" }, { @@ -988,24 +983,24 @@ "color": { "mode": "palette-classic" }, - "unit": "bytes" + "unit": "percentunit" } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 0, - "y": 68 + "x": 8, + "y": 67 }, - "id": 212, + "id": 51, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-3\"}", - "legendFormat": "PSS", + "expr": "rate(container_cpu_usage_seconds_total{cpu=\"total\",container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-master\"}[1m])", + "legendFormat": "API CPU", "refId": "A" } ], - "title": "MWorker-default-3", + "title": "API CPU Usage", "type": "timeseries" }, { @@ -1018,24 +1013,29 @@ "color": { "mode": "palette-classic" }, - "unit": "bytes" + "unit": "short" } }, "gridPos": { - "h": 6, + "h": 7, "w": 8, - "x": 8, - "y": 68 + "x": 16, + "y": 67 }, - "id": 213, + "id": 52, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-4\"}", - "legendFormat": "PSS", + "expr": "salt_api_open_fds", + "legendFormat": "Total Open FDs", "refId": "A" + }, + { + "expr": "salt_api_process_count", + "legendFormat": "Process Count", + "refId": "B" } ], - "title": "MWorker-default-4", + "title": "API Resource Usage (FDs & Processes)", "type": "timeseries" } ], From 198dbfe3b90155525dcdaa0e3d830c625ce0e680 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 03:05:16 -0700 Subject: [PATCH 190/469] Switch Per-Master-Process panels from PSS to RSS Per-process RSS is the natural per-process view -- the COW-adjusted PSS distinction only matters when summing across siblings. The aggregate 'Master Memory (Process vs Container)' panel above still plots PSS for the accurate sum-vs-container comparison. --- .../dashboards/salt_monitoring.json | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json index 7461867d4138..52a904a0f7ee 100644 --- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json +++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json @@ -206,7 +206,7 @@ "y": 11 }, "id": 105, - "title": "Per-Master-Process Memory (PSS)", + "title": "Per-Master-Process Memory (RSS)", "type": "row" }, { @@ -231,8 +231,8 @@ "id": 200, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"EventPublisher\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"EventPublisher\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -261,8 +261,8 @@ "id": 201, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"PubServerChannel._publish_daemon\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"PubServerChannel._publish_daemon\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -291,8 +291,8 @@ "id": 202, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorkerQueue\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"MWorkerQueue\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -321,8 +321,8 @@ "id": 203, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"ReqServer_ProcessManager\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"ReqServer_ProcessManager\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -351,8 +351,8 @@ "id": 204, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"Maintenance\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"Maintenance\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -381,8 +381,8 @@ "id": 205, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"EventMonitor\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"EventMonitor\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -411,8 +411,8 @@ "id": 206, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"BatchManager\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"BatchManager\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -441,8 +441,8 @@ "id": 207, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"FileServerUpdate\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"FileServerUpdate\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -471,8 +471,8 @@ "id": 208, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"master-main\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"master-main\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -501,8 +501,8 @@ "id": 209, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-0\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"MWorker-default-0\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -531,8 +531,8 @@ "id": 210, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-1\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"MWorker-default-1\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -561,8 +561,8 @@ "id": 211, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-2\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"MWorker-default-2\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -591,8 +591,8 @@ "id": 212, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-3\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"MWorker-default-3\"}", + "legendFormat": "RSS", "refId": "A" } ], @@ -621,8 +621,8 @@ "id": 213, "targets": [ { - "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-4\"}", - "legendFormat": "PSS", + "expr": "salt_master_process_rss_bytes{process=\"MWorker-default-4\"}", + "legendFormat": "RSS", "refId": "A" } ], From 0f9473bbfef86bdec627d2d16acdf4d14fd19dee Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 03:06:48 -0700 Subject: [PATCH 191/469] Plot both RSS and PSS on each Per-Master-Process panel Every per-process panel now shows two lines: RSS (raw /proc/PID/stat number) and PSS (COW-adjusted). Their per-process gap visualises how much of each process's apparent memory is shared with siblings -- useful for spotting a process whose PSS is climbing (real growth) vs one whose RSS is climbing purely because a sibling forked (shared pages inherited). Row title updated. --- .../dashboards/salt_monitoring.json | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json index 52a904a0f7ee..0827f22f1348 100644 --- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json +++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json @@ -206,7 +206,7 @@ "y": 11 }, "id": 105, - "title": "Per-Master-Process Memory (RSS)", + "title": "Per-Master-Process Memory (RSS + PSS)", "type": "row" }, { @@ -234,6 +234,11 @@ "expr": "salt_master_process_rss_bytes{process=\"EventPublisher\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"EventPublisher\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "EventPublisher", @@ -264,6 +269,11 @@ "expr": "salt_master_process_rss_bytes{process=\"PubServerChannel._publish_daemon\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"PubServerChannel._publish_daemon\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "PubServerChannel._publish_daemon", @@ -294,6 +304,11 @@ "expr": "salt_master_process_rss_bytes{process=\"MWorkerQueue\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"MWorkerQueue\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "MWorkerQueue", @@ -324,6 +339,11 @@ "expr": "salt_master_process_rss_bytes{process=\"ReqServer_ProcessManager\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"ReqServer_ProcessManager\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "ReqServer_ProcessManager", @@ -354,6 +374,11 @@ "expr": "salt_master_process_rss_bytes{process=\"Maintenance\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"Maintenance\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "Maintenance", @@ -384,6 +409,11 @@ "expr": "salt_master_process_rss_bytes{process=\"EventMonitor\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"EventMonitor\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "EventMonitor", @@ -414,6 +444,11 @@ "expr": "salt_master_process_rss_bytes{process=\"BatchManager\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"BatchManager\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "BatchManager", @@ -444,6 +479,11 @@ "expr": "salt_master_process_rss_bytes{process=\"FileServerUpdate\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"FileServerUpdate\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "FileServerUpdate", @@ -474,6 +514,11 @@ "expr": "salt_master_process_rss_bytes{process=\"master-main\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"master-main\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "master-main", @@ -504,6 +549,11 @@ "expr": "salt_master_process_rss_bytes{process=\"MWorker-default-0\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-0\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "MWorker-default-0", @@ -534,6 +584,11 @@ "expr": "salt_master_process_rss_bytes{process=\"MWorker-default-1\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-1\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "MWorker-default-1", @@ -564,6 +619,11 @@ "expr": "salt_master_process_rss_bytes{process=\"MWorker-default-2\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-2\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "MWorker-default-2", @@ -594,6 +654,11 @@ "expr": "salt_master_process_rss_bytes{process=\"MWorker-default-3\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-3\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "MWorker-default-3", @@ -624,6 +689,11 @@ "expr": "salt_master_process_rss_bytes{process=\"MWorker-default-4\"}", "legendFormat": "RSS", "refId": "A" + }, + { + "expr": "salt_master_process_pss_bytes{process=\"MWorker-default-4\"}", + "legendFormat": "PSS", + "refId": "B" } ], "title": "MWorker-default-4", From 9eb3f9acfdf634a849cfe3fd1cf26aaf345c8cb7 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 03:08:02 -0700 Subject: [PATCH 192/469] Fix FileserverUpdate panel label (was FileServerUpdate, lowercase s) Salt's proctitle for the fileserver-update daemon is 'FileserverUpdate' with a lowercase 's', not 'FileServerUpdate'. The exporter labels match the proctitle exactly, so the panel filter querying for process="FileServerUpdate" returned 'No data'. --- .../grafana/provisioning/dashboards/salt_monitoring.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json index 0827f22f1348..25baa252fc83 100644 --- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json +++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json @@ -476,17 +476,17 @@ "id": 207, "targets": [ { - "expr": "salt_master_process_rss_bytes{process=\"FileServerUpdate\"}", + "expr": "salt_master_process_rss_bytes{process=\"FileserverUpdate\"}", "legendFormat": "RSS", "refId": "A" }, { - "expr": "salt_master_process_pss_bytes{process=\"FileServerUpdate\"}", + "expr": "salt_master_process_pss_bytes{process=\"FileserverUpdate\"}", "legendFormat": "PSS", "refId": "B" } ], - "title": "FileServerUpdate", + "title": "FileserverUpdate", "type": "timeseries" }, { From 696868cc5d30360632d1d949fe5e6190f95ffa72 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 13:30:44 -0400 Subject: [PATCH 193/469] Fix zenoss.monitored returning None for changes The zenoss.monitored state set ret["changes"] to None on the already-monitored and failed-add code paths. The state output validator (OutputUnifier.content_check) requires changes to be a dictionary and raises "'Changes' should be a dictionary.", so both paths failed with result=False and that exception comment. Return an empty dict, which correctly means "no changes" and passes validation. Fixes #53966 --- changelog/53966.fixed.md | 1 + salt/states/zenoss.py | 4 +- tests/pytests/unit/states/test_zenoss.py | 142 +++++++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 changelog/53966.fixed.md create mode 100644 tests/pytests/unit/states/test_zenoss.py diff --git a/changelog/53966.fixed.md b/changelog/53966.fixed.md new file mode 100644 index 000000000000..037089fdd30b --- /dev/null +++ b/changelog/53966.fixed.md @@ -0,0 +1 @@ +Fixed zenoss.monitored state raising "'Changes' should be a dictionary." by returning an empty changes dict instead of None on the already-monitored and failed-add paths. diff --git a/salt/states/zenoss.py b/salt/states/zenoss.py index ed06e9a249dd..2494fc5435d0 100644 --- a/salt/states/zenoss.py +++ b/salt/states/zenoss.py @@ -53,7 +53,7 @@ def monitored(name, device_class=None, collector="localhost", prod_state=None): device = __salt__["zenoss.find_device"](name) if device: ret["result"] = True - ret["changes"] = None + ret["changes"] = {} ret["comment"] = f"{name} is already monitored" # if prod_state is set, ensure it matches with the current state @@ -89,6 +89,6 @@ def monitored(name, device_class=None, collector="localhost", prod_state=None): ret["comment"] = f"{name} has been added to Zenoss" else: ret["result"] = False - ret["changes"] = None + ret["changes"] = {} ret["comment"] = f"Unable to add {name} to Zenoss" return ret diff --git a/tests/pytests/unit/states/test_zenoss.py b/tests/pytests/unit/states/test_zenoss.py new file mode 100644 index 000000000000..f8c75eaee449 --- /dev/null +++ b/tests/pytests/unit/states/test_zenoss.py @@ -0,0 +1,142 @@ +""" +Test cases for salt.states.zenoss +""" + +import pytest + +import salt.states.zenoss as zenoss +from salt.utils.decorators.state import OutputUnifier +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return { + zenoss: { + "__opts__": {"test": False}, + "__salt__": {}, + } + } + + +def _content_check(ret): + """ + Run ``ret`` through the exact policy stack the state compiler applies to + every state return: ``OutputUnifier("content_check", "unify")`` (see + salt.state.State.call). content_check raises "'Changes' should be a + dictionary." when ``changes`` is not a dict; the unifier traps that and + rewrites the return to result=False with an "An exception occurred" + comment. Returning the post-policy dict lets the tests assert on the + production-visible symptom. + """ + return OutputUnifier("content_check", "unify")(lambda: ret)() + + +def test_already_monitored_no_prod_state_53966(): + """ + Device already known to Zenoss and no prod_state requested. This is the + reproducer from issue #53966: the early-return path used to set + changes=None, which fails content_check with "'Changes' should be a + dictionary." It must now be an empty dict and survive the policy stack. + """ + salt_mock = { + "zenoss.find_device": MagicMock(return_value={"productionState": 1000}), + "zenoss.set_prod_state": MagicMock(), + } + with patch.dict(zenoss.__salt__, salt_mock): + # prod_state defaults to None, matching the reported state (no prod_state key) + ret = zenoss.monitored("centos7-5.local", device_class="/Server/SSH/Linux") + + assert ret["changes"] == {} + assert ret["result"] is True + + checked = _content_check(ret) + assert "'Changes' should be a dictionary." not in checked["comment"] + assert checked["result"] is True + + +def test_failed_add_53966(): + """ + Device is not in Zenoss and add_device fails. The failure path used to set + changes=None, tripping the same content_check exception. It must now be an + empty dict while still reporting result=False. + """ + salt_mock = { + "zenoss.find_device": MagicMock(return_value=None), + "zenoss.add_device": MagicMock(return_value=False), + } + with patch.dict(zenoss.__salt__, salt_mock): + ret = zenoss.monitored("centos7-5.local", device_class="/Server/SSH/Linux") + + assert ret["result"] is False + assert ret["changes"] == {} + + checked = _content_check(ret) + assert "'Changes' should be a dictionary." not in checked["comment"] + assert checked["result"] is False + + +def test_already_monitored_prod_state_update(): + """ + Inverse / must-not-regress: already monitored but the requested prod_state + differs from the current one. This branch always populated a proper changes + dict, so it passes with and without the fix. Guards that the None -> {} + change did not disturb the real-change path. + """ + set_prod_state = MagicMock() + salt_mock = { + "zenoss.find_device": MagicMock(return_value={"productionState": 500}), + "zenoss.set_prod_state": set_prod_state, + } + with patch.dict(zenoss.__salt__, salt_mock): + ret = zenoss.monitored("centos7-5.local", prod_state=1000) + + assert ret["result"] is True + assert ret["changes"] == { + "old": "prodState == 500", + "new": "prodState == 1000", + } + set_prod_state.assert_called_once_with(1000, "centos7-5.local") + assert _content_check(ret)["result"] is True + + +def test_add_device_success(): + """ + Inverse / must-not-regress: device absent and add_device succeeds. This + branch always populated a proper changes dict, so it passes with and + without the fix. + """ + salt_mock = { + "zenoss.find_device": MagicMock(return_value=None), + "zenoss.add_device": MagicMock(return_value=True), + } + with patch.dict(zenoss.__salt__, salt_mock): + ret = zenoss.monitored("centos7-5.local", device_class="/Server/SSH/Linux") + + assert ret["result"] is True + assert ret["changes"] == { + "old": "monitored == False", + "new": "monitored == True", + } + assert _content_check(ret)["result"] is True + + +def test_add_device_test_mode(): + """ + Peripheral coverage: device absent under test=True reports a pending change + with result=None and a proper changes dict. + """ + salt_mock = { + "zenoss.find_device": MagicMock(return_value=None), + "zenoss.add_device": MagicMock(return_value=True), + } + with patch.dict(zenoss.__opts__, {"test": True}), patch.dict( + zenoss.__salt__, salt_mock + ): + ret = zenoss.monitored("centos7-5.local", device_class="/Server/SSH/Linux") + + assert ret["result"] is None + assert ret["changes"] == { + "old": "monitored == False", + "new": "monitored == True", + } From 2d8d831df70059ddf5f1d92c2b038bfdb190667d Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 13:33:41 -0400 Subject: [PATCH 194/469] Fix TypeError in file clean when a require ID contains "file" _gen_keep_files filtered requisites with `"file" in comp`, which for a bare-string requisite ID degraded to a substring match. Any ID containing the substring "file" then hit `comp["file"]` and raised "TypeError: string indices must be integers". Guard the membership test with an isinstance check so only dict requisites are considered; bare strings are ignored instead of crashing. Fixes #53692 and Fixes #61042 --- changelog/53692.fixed.md | 1 + changelog/61042.fixed.md | 1 + salt/states/file.py | 4 +- .../unit/states/file/test_find_keep_files.py | 80 +++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 changelog/53692.fixed.md create mode 100644 changelog/61042.fixed.md diff --git a/changelog/53692.fixed.md b/changelog/53692.fixed.md new file mode 100644 index 000000000000..4df1fb67555b --- /dev/null +++ b/changelog/53692.fixed.md @@ -0,0 +1 @@ +Fixed a TypeError in file.recurse/file.directory with clean when a require requisite is a bare state ID string containing the substring "file"; such requisites are now ignored instead of crashing. diff --git a/changelog/61042.fixed.md b/changelog/61042.fixed.md new file mode 100644 index 000000000000..8b2b7ac5f79c --- /dev/null +++ b/changelog/61042.fixed.md @@ -0,0 +1 @@ +Fixed _gen_keep_files so the require filter only matches dict requisites; a bare-string requisite ID containing "file" no longer raises "string indices must be integers". diff --git a/salt/states/file.py b/salt/states/file.py index 96bc5cab9141..9b6bdf2e5337 100644 --- a/salt/states/file.py +++ b/salt/states/file.py @@ -634,7 +634,9 @@ def _process(name): keep = set() if isinstance(require, list): - required_files = [comp for comp in require if "file" in comp] + required_files = [ + comp for comp in require if isinstance(comp, dict) and "file" in comp + ] for comp in required_files: for low in __lowstate__: # A requirement should match either the ID and the name of diff --git a/tests/pytests/unit/states/file/test_find_keep_files.py b/tests/pytests/unit/states/file/test_find_keep_files.py index 6854bd3e61ee..f94b2358361f 100644 --- a/tests/pytests/unit/states/file/test_find_keep_files.py +++ b/tests/pytests/unit/states/file/test_find_keep_files.py @@ -3,6 +3,7 @@ import pytest import salt.states.file as filestate +from tests.support.mock import patch log = logging.getLogger(__name__) @@ -81,3 +82,82 @@ def test__find_keep_files_darwin(): actual = sorted(list(keep)) expected = [] assert actual == expected + + +def test__gen_keep_files_bare_string_requisite_53692(): + """ + A bare-string requisite ID that happens to contain the substring "file" + must not crash _gen_keep_files. This is the file.recurse(clean=True) + reproducer from #53692: the require list holds a plain state ID string + (written as ``- p_files_recurse_test_recurse_one``) instead of the dict + form ``- file: ``. Before the fix the "file" in comp membership test + degraded to a substring match, then comp["file"] indexed a str with a str + and raised ``TypeError: string indices must be integers``. + """ + # require here mirrors a state's ``require`` requisite list as passed from + # the file.recurse / file.directory clean handlers; the bare string is the + # requisite ID form ``- p_files_recurse_test_recurse_one``. + lowstate = [ + { + "name": "/test1", + "__id__": "p_files_recurse_test_recurse_one", + "fun": "recurse", + } + ] + with patch.object(filestate, "__lowstate__", lowstate, create=True): + keep = filestate._gen_keep_files("/test2", ["p_files_recurse_test_recurse_one"]) + assert keep == [] + + +def test__gen_keep_files_bare_string_requisite_61042(): + """ + Same crash as #53692 via the #61042 MCVE: a bare-string requisite ID + ``aaa_file`` (contains "file") passed to file.recurse(clean=True) must be + ignored, not raise TypeError. + """ + # Bare requisite ID form ``- aaa_file`` from the #61042 reproducer. + lowstate = [{"name": "/srv/aaa", "__id__": "aaa_file", "fun": "managed"}] + with patch.object(filestate, "__lowstate__", lowstate, create=True): + keep = filestate._gen_keep_files("/srv/target", ["aaa_file"]) + assert keep == [] + + +def test__gen_keep_files_dict_requisite_not_regressed_53692(): + """ + Inverse / must-not-regress for #53692: a normal dict requisite + ``{"file": }`` that matches a low state must still contribute its file + to the keep list. Passes with and without the fix, proving the isinstance + guard does not disturb the supported dict requisite path. + """ + lowstate = [{"name": "/nonexistent/kept", "__id__": "kept_id", "fun": "managed"}] + with patch.object(filestate, "__lowstate__", lowstate, create=True): + with patch("os.path.isdir", return_value=False): + keep = filestate._gen_keep_files("/parent", [{"file": "kept_id"}]) + assert keep == ["/nonexistent/kept"] + + +def test__gen_keep_files_dict_requisite_not_regressed_61042(): + """ + Inverse / must-not-regress for #61042: the dict requisite form + ``{"file": "bbb"}`` still retains the required file. Passes before and + after the fix. + """ + lowstate = [{"name": "/nonexistent/bbb", "__id__": "bbb", "fun": "managed"}] + with patch.object(filestate, "__lowstate__", lowstate, create=True): + with patch("os.path.isdir", return_value=False): + keep = filestate._gen_keep_files("/parent", [{"file": "bbb"}]) + assert keep == ["/nonexistent/bbb"] + + +def test__gen_keep_files_bare_string_without_file_ignored(): + """ + Peripheral coverage: a bare-string requisite that does NOT contain the + substring "file" was, and remains, silently ignored by _gen_keep_files + (it never matches the dict-key check). This documents that the fix only + changes the crashing substring-match case and preserves the pre-existing + drop of bare-string requisites. Passes with and without the fix. + """ + lowstate = [{"name": "/test1", "__id__": "aaa", "fun": "managed"}] + with patch.object(filestate, "__lowstate__", lowstate, create=True): + keep = filestate._gen_keep_files("/test2", ["aaa"]) + assert keep == [] From c7a5d71e27740fe8d440c7817da50ce675623021 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 13:32:27 -0400 Subject: [PATCH 195/469] Fix saltclass literal '^' list-override marker for single class The '^' list-override marker was only stripped when the target dict already contained a matching list to override. When a class defined an override list with no prior list present, dict_merge plain-assigned the value (including the nested "pillars" dict) by reference and never descended to the list, leaving a literal '^' element in the merged pillar. Descend into dicts and honour a leading '^' on a list in the key-absent branch as well. Fixes #50755 --- changelog/50755.fixed.md | 1 + salt/utils/saltclass.py | 11 +++- tests/pytests/unit/utils/test_saltclass.py | 63 ++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 changelog/50755.fixed.md create mode 100644 tests/pytests/unit/utils/test_saltclass.py diff --git a/changelog/50755.fixed.md b/changelog/50755.fixed.md new file mode 100644 index 000000000000..0a2278408716 --- /dev/null +++ b/changelog/50755.fixed.md @@ -0,0 +1 @@ +Fixed saltclass leaving a literal ``^`` list-override marker in the merged pillar when a list is overridden by a single class and no existing list is present to override. diff --git a/salt/utils/saltclass.py b/salt/utils/saltclass.py index 25b7a838fc16..dde313a16d84 100644 --- a/salt/utils/saltclass.py +++ b/salt/utils/saltclass.py @@ -134,7 +134,16 @@ def dict_merge(a, b, path=None): else: a[key] = b[key] else: - a[key] = b[key] + # Key is absent from a. Descend into dicts so nested override + # markers are still processed, and honour a leading '^' marker on + # a list even when there is no existing list to override. + if isinstance(b[key], list) and b[key] and b[key][0] == "^": + a[key] = b[key][1:] + elif isinstance(b[key], dict): + a[key] = {} + dict_merge(a[key], b[key], path + [str(key)]) + else: + a[key] = b[key] return a diff --git a/tests/pytests/unit/utils/test_saltclass.py b/tests/pytests/unit/utils/test_saltclass.py new file mode 100644 index 000000000000..145ab730d4b3 --- /dev/null +++ b/tests/pytests/unit/utils/test_saltclass.py @@ -0,0 +1,63 @@ +import salt.utils.saltclass as saltclass + + +def test_dict_merge_list_override_single_class_50755(): + """ + A leading '^' override marker on a list must be honoured even when the + target dict has no existing list to override (single-class case). + + This mirrors the production call in expanded_dict_from_minion, where the + caller does dict_merge(pillars_dict, exp_dict) with an initially empty + pillars_dict and the override list nested under the "pillars" key. + """ + assert saltclass.dict_merge({}, {"pillars": {"pkgs": ["^", "three"]}}) == { + "pillars": {"pkgs": ["three"]} + } + + +def test_dict_merge_list_override_deeper_nesting_50755(): + """ + The override marker must be stripped for a list nested at arbitrary depth + below an absent key, not only directly under "pillars". + """ + assert saltclass.dict_merge({}, {"a": {"b": ["^", "x"]}}) == {"a": {"b": ["x"]}} + + +def test_dict_merge_list_override_key_present_50755(): + """ + Inverse / must-not-regress: the override marker already worked when the + target dict contained a matching list. This passes with and without the + fix and guards the existing list+list code path. + """ + assert saltclass.dict_merge( + {"pillars": {"pkgs": ["one", "two"]}}, + {"pillars": {"pkgs": ["^", "three"]}}, + ) == {"pillars": {"pkgs": ["three"]}} + + +def test_dict_merge_plain_list_key_absent_no_marker_50755(): + """ + Inverse / must-not-regress: a marker-free list assigned into an absent key + must be copied through verbatim. Passes with and without the fix. + """ + assert saltclass.dict_merge({}, {"pkgs": ["one", "two"]}) == { + "pkgs": ["one", "two"] + } + + +def test_dict_merge_empty_list_key_absent_50755(): + """ + Peripheral coverage: an empty list assigned into an absent key must not + raise IndexError while checking for the '^' marker. + """ + assert saltclass.dict_merge({}, {"pkgs": []}) == {"pkgs": []} + + +def test_dict_merge_plain_extend_key_present_50755(): + """ + Peripheral coverage: marker-free lists on a present key are extended, not + replaced. This is the default (non-override) merge behaviour. + """ + assert saltclass.dict_merge({"pkgs": ["one"]}, {"pkgs": ["two"]}) == { + "pkgs": ["one", "two"] + } From 7d709d0dda5c1434e704912bc3b87b6ad8fe84b7 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 12:44:04 -0400 Subject: [PATCH 196/469] Catch UnicodeError in is_reachable_host for overlong names salt.utils.network.is_reachable_host only caught socket.gaierror, but socket.getaddrinfo raises UnicodeError (an idna "label too long" error, which is not a subclass of gaierror) when a name contains a DNS label longer than 63 characters. A long salt-ssh -E/--pcre target triggers this, so _expand_target crashed instead of treating the target as not a reachable host. Also catch UnicodeError and return False. Fixes #57207 --- changelog/57207.fixed.md | 1 + salt/utils/network.py | 4 ++- tests/pytests/unit/utils/test_network.py | 45 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 changelog/57207.fixed.md diff --git a/changelog/57207.fixed.md b/changelog/57207.fixed.md new file mode 100644 index 000000000000..574125cb1c55 --- /dev/null +++ b/changelog/57207.fixed.md @@ -0,0 +1 @@ +Fixed salt-ssh crashing with an uncaught UnicodeError when a long ``-E``/``--pcre`` target produces an overlong IDNA label in ``is_reachable_host`` diff --git a/salt/utils/network.py b/salt/utils/network.py index fe299a28f6b6..fa63a8058860 100644 --- a/salt/utils/network.py +++ b/salt/utils/network.py @@ -302,7 +302,9 @@ def is_reachable_host(entity_name): try: assert type(socket.getaddrinfo(entity_name, 0, 0, 0, 0)) == list ret = True - except socket.gaierror: + except (socket.gaierror, UnicodeError): + # UnicodeError is raised (not a subclass of socket.gaierror) when the + # name has an overlong IDNA label, e.g. a long salt-ssh -E/--pcre target. ret = False return ret diff --git a/tests/pytests/unit/utils/test_network.py b/tests/pytests/unit/utils/test_network.py index c8f7044fb5ac..b44ea8ec576b 100644 --- a/tests/pytests/unit/utils/test_network.py +++ b/tests/pytests/unit/utils/test_network.py @@ -1635,3 +1635,48 @@ def test_ip_addrs(linux_interfaces_dict): ): ret = network.ip_addrs6("eth0") assert ret == ["fe80::e23f:49ff:fe85:6aaf"] + + +def test_is_reachable_host_57207(): + """ + A long salt-ssh -E/--pcre target has a single DNS label longer than 63 + characters. socket.getaddrinfo raises UnicodeError ("label too long") from + the idna codec before any network lookup. UnicodeError is not a subclass of + socket.gaierror, so it must be caught explicitly and reported as not + reachable rather than crashing salt-ssh's _expand_target. + """ + # Production-exact call: _expand_target passes the raw target as the single + # positional arg, e.g. salt.utils.network.is_reachable_host(hostname). + # This is the verbatim -E target from the issue; the real getaddrinfo raises + # UnicodeError deterministically (69-char label, no network needed). + assert ( + network.is_reachable_host( + "some-host|some-host|some-host|some-host|some-host|some-host|some-host" + ) + is False + ) + + +def test_is_reachable_host_gaierror_unchanged(): + """ + Inverse / must-not-regress: broadening the except clause to also catch + UnicodeError must not change the pre-existing socket.gaierror path. A name + that fails normal resolution still returns False. Passes with and without + the fix because the gaierror branch is untouched. + """ + with patch.object(socket, "getaddrinfo", MagicMock(side_effect=socket.gaierror)): + assert network.is_reachable_host("nope.invalid") is False + + +def test_is_reachable_host_resolvable_returns_true(): + """ + Peripheral coverage of the success branch: when getaddrinfo returns a list + the host is reported reachable. Guards against the fix accidentally swallowing + a successful lookup. + """ + with patch.object( + socket, + "getaddrinfo", + MagicMock(return_value=[(socket.AF_INET, 0, 0, "", ("127.0.0.1", 0))]), + ): + assert network.is_reachable_host("localhost") is True From 03f55735f0aee5839f9e3ddca874b414d052e4b0 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 4 Aug 2026 00:12:44 -0700 Subject: [PATCH 197/469] Nightly stress: duration input as 0.5h..5.5h dropdown Replace the free-text ``duration`` input with a ``type: choice`` dropdown of 0.5h increments from 0.5h to 5.5h. Stops at 5.5h because GitHub-hosted runners hard-cap job runtime at 6h -- a longer value in the box would just get killed at the platform level. Default flips from ``30m`` to the functionally-identical ``0.5h`` for consistency with the other options. ``sleep \$DURATION`` in the step already handles the decimal-hour format ("1.5h" etc.) with no other changes needed. --- .github/workflows/nightly-stress-test.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/nightly-stress-test.yml b/.github/workflows/nightly-stress-test.yml index 1f70a3f0dff8..b96168a0bad1 100644 --- a/.github/workflows/nightly-stress-test.yml +++ b/.github/workflows/nightly-stress-test.yml @@ -6,9 +6,22 @@ on: workflow_dispatch: inputs: duration: - description: 'Duration of the stress test (e.g., 30m, 1h)' + description: Stress test duration (GitHub-hosted runner caps at 6h) required: true - default: '30m' + default: '0.5h' + type: choice + options: + - '0.5h' + - '1h' + - '1.5h' + - '2h' + - '2.5h' + - '3h' + - '3.5h' + - '4h' + - '4.5h' + - '5h' + - '5.5h' enable_metrics: description: Enable OpenTelemetry metrics (metrics.enabled) required: false @@ -158,7 +171,7 @@ jobs: STRESS_PID=$! # Default to 30m if not workflow_dispatch - DURATION="${{ github.event.inputs.duration || '30m' }}" + DURATION="${{ github.event.inputs.duration || '0.5h' }}" echo "Running stress test for $DURATION..." # Use sleep with suffix support (m, h) From db1b0d1c7f9409bf846519958c2e5a32a9917b48 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 05:26:24 -0400 Subject: [PATCH 198/469] Re-pack proxy loader __pillar__ on pillar refresh Minion.pillar_refresh compiles a fresh pillar and rebinds self.opts["pillar"] to a new dict. A proxy minion packs the pillar into its proxy module loader once, at init, by reference (self.proxy.pack["__pillar__"] = self.opts["pillar"]). The rebind orphans the dict that pack still aliases, so already-loaded proxy modules keep serving the pillar they were first packed with until the proxy restarts. Re-pack the loader with the freshly compiled pillar after the rebind, guarded so a regular (non-proxy) minion is unaffected. This mirrors the existing deltaproxy __grains__ re-pack and avoids reload_modules(), so a module's connection state and __context__ are preserved. Deltaproxy is covered by the same change: handle_event dispatches each sub-proxy's pillar_refresh to that sub-proxy instance, so the re-pack runs per sub-proxy against its own loader and opts. Out of scope: __opts__, __opts__["pillar"] and values a module copied out of pillar during init() are load-time snapshots and stay stale under any non-reload fix. Fixes #58197 --- changelog/58197.fixed.md | 1 + salt/minion.py | 16 + .../metaproxy/test_proxy_pillar_refresh.py | 419 ++++++++++++++++++ .../test_proxy_pillar_refresh_routing.py | 84 ++++ 4 files changed, 520 insertions(+) create mode 100644 changelog/58197.fixed.md create mode 100644 tests/pytests/functional/metaproxy/test_proxy_pillar_refresh.py create mode 100644 tests/pytests/unit/metaproxy/test_proxy_pillar_refresh_routing.py diff --git a/changelog/58197.fixed.md b/changelog/58197.fixed.md new file mode 100644 index 000000000000..f11589d49cd1 --- /dev/null +++ b/changelog/58197.fixed.md @@ -0,0 +1 @@ +Proxy minions now update `__pillar__` for already-loaded proxy modules when `saltutil.refresh_pillar` runs, so proxy modules see refreshed pillar data without restarting the proxy. Deltaproxy sub-proxies are refreshed individually with their own pillar. diff --git a/salt/minion.py b/salt/minion.py index 229ec76956d4..132bc235d9c9 100644 --- a/salt/minion.py +++ b/salt/minion.py @@ -3436,6 +3436,22 @@ def pillar_refresh(self, force_refresh=False, clean_cache=False): current_schedule, new_schedule ) self.opts["pillar"] = new_pillar + + # On a proxy minion, re-pack the freshly compiled pillar into + # the proxy loader so already-loaded proxy modules see the + # updated __pillar__ on their next call. Rebinding + # self.opts["pillar"] above orphans the dict that the proxy + # loader's pack still aliased, leaving proxy modules with stale + # pillar (#58197). Mirrors the deltaproxy __grains__ re-pack and + # avoids reload_modules(), so each module's connection state and + # __context__ are preserved. Gated on opts["proxy"] so regular + # minions -- which also build a lazy proxy loader in + # gen_modules() -- are untouched. For deltaproxy this covers + # every sub-proxy too: handle_event dispatches each sub-proxy's + # pillar_refresh to that sub-proxy instance, so ``self`` is the + # sub-proxy and its own loader is re-packed here. + if self.opts.get("proxy") and getattr(self, "proxy", None): + self.proxy.pack["__pillar__"] = self.opts["pillar"] finally: async_pillar.destroy() self.matchers_refresh() diff --git a/tests/pytests/functional/metaproxy/test_proxy_pillar_refresh.py b/tests/pytests/functional/metaproxy/test_proxy_pillar_refresh.py new file mode 100644 index 000000000000..abef955dd33d --- /dev/null +++ b/tests/pytests/functional/metaproxy/test_proxy_pillar_refresh.py @@ -0,0 +1,419 @@ +""" +Functional regression test for issue #58197. + +A proxy minion packs the compiled pillar into its proxy module loader once, +at init, by reference:: + + self.proxy.pack["__pillar__"] = self.opts["pillar"] + +``Minion.pillar_refresh`` compiles a fresh pillar and *rebinds* +``self.opts["pillar"]`` to the new dict. That rebind orphans the dict the +proxy loader's ``pack["__pillar__"]`` still aliases, so already-loaded proxy +modules keep serving the pillar they were first packed with -- forever, until +the proxy is restarted. + +The fix re-packs the loader after the rebind:: + + if getattr(self, "proxy", None): + self.proxy.pack["__pillar__"] = self.opts["pillar"] + +These tests stand up a *real* ``salt.loader.proxy`` loader against an on-disk +``extension_modules`` tree containing a purpose-built proxy module that reads +``__pillar__`` at call time (the stock ``dummy`` proxy does not). They assert +that after a refresh an already-loaded proxy module observes the new pillar, +that the fix does not reload the modules (same ``LoadedFunc`` identity, the +``init()``-populated ``__context__`` connection object survives), and -- for +deltaproxy -- that each sub-proxy is refreshed with *its own* pillar. + +The out-of-scope boundary is deliberately not asserted here: ``__opts__``, +``__opts__["pillar"]`` and any value a module copied out of pillar during +``init()`` are load-time snapshots that stay stale under any non-reload fix. +""" + +import textwrap + +import pytest + +import salt.ext.tornado.concurrent +import salt.ext.tornado.ioloop +import salt.loader +import salt.minion +from salt.exceptions import SaltClientError +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def echo_extension_modules(tmp_path): + """ + Lay down an ``extension_modules`` tree with a proxy module that reads + ``__pillar__`` at call time and stashes a sentinel "connection" object in + ``__context__`` during ``init()``. + """ + ext = tmp_path / "ext_echo" + proxy_dir = ext / "proxy" + proxy_dir.mkdir(parents=True) + (ext / "__init__.py").write_text("") + + proxy_module = textwrap.dedent( + ''' + """ + Test-only proxy module for tests/pytests/functional/metaproxy. + + ``read_pillar_key`` resolves ``__pillar__`` through the loader's + ``NamedLoaderContext`` on every call, so it reflects whatever the + loader currently has packed for ``__pillar__``. ``init()`` stashes a + unique object in ``__context__`` so a test can prove the module was + not reloaded / re-initialised across a pillar refresh. + """ + + __proxyenabled__ = ["*"] + + + def __virtual__(): + return True + + + def init(opts): + __context__["conn"] = object() + return True + + + def initialized(): + return "conn" in __context__ + + + def read_pillar_key(key): + return __pillar__.get(key) + + + def conn_token(): + return __context__.get("conn") + + + def shutdown(opts): + return True + + + def ping(): + return True + ''' + ).lstrip() + (proxy_dir / "echo_pillar_proxy.py").write_text(proxy_module) + return ext + + +def _proxy_opts(minion_opts, echo_extension_modules, tmp_path, pillar): + """ + Build a proxy ``opts`` dict wired to the echo proxymodule tree. + """ + opts = dict(minion_opts) + opts.update( + { + "id": "proxy_echo", + "cachedir": str(tmp_path / "cache"), + "extension_modules": str(echo_extension_modules), + "saltenv": "base", + "pillarenv": None, + "grains": {}, + "pillar": pillar, + "proxy": {"proxytype": "echo_pillar_proxy"}, + } + ) + (tmp_path / "cache").mkdir(parents=True, exist_ok=True) + return opts + + +def _build_proxy_loader(opts): + """ + Build a real proxy loader packed exactly like ``salt.metaproxy.proxy``: + ``pack["__pillar__"]`` is the *same dict object* as ``opts["pillar"]``. + Then load and initialise the echo module. + """ + proxy = salt.loader.proxy(opts, utils=salt.loader.utils(opts)) + # Mirror salt/metaproxy/proxy.py: pack the pillar by reference. + proxy.pack["__pillar__"] = opts["pillar"] + # Load + init the echo module so __context__ gets its sentinel and the + # LoadedFunc objects are materialised (as they are on a live proxy). + assert proxy["echo_pillar_proxy.init"](opts) is True + return proxy + + +def _resolved_future(result): + future = salt.ext.tornado.concurrent.Future() + future.set_result(result) + return future + + +def _make_minion(opts, proxy, io_loop): + """ + A ``ProxyMinion`` instance with only the attributes ``pillar_refresh`` + touches, built without running the real (network/event-loop) ``__init__``. + The collaborators that are not part of the fix are stubbed; the loader, + ``self.opts`` and ``pillar_schedule_refresh`` are real. + """ + minion = salt.minion.ProxyMinion.__new__(salt.minion.ProxyMinion) + minion.opts = opts + minion.proxy = proxy + minion.connected = True + minion.io_loop = io_loop + minion.module_refresh = MagicMock() + minion.matchers_refresh = MagicMock() + minion.beacons_refresh = MagicMock() + return minion + + +def _run_pillar_refresh(minion, new_pillar=None, compile_error=False): + """ + Drive the real ``Minion.pillar_refresh`` coroutine to completion with the + pillar compile and event bus mocked out. Returns nothing; inspect the + minion / loader afterwards. + """ + compiler = MagicMock() + if compile_error: + compiler.compile_pillar.side_effect = SaltClientError("master down") + else: + compiler.compile_pillar.return_value = _resolved_future(new_pillar) + compiler.destroy.return_value = None + + event_ctx = MagicMock() + event_obj = MagicMock() + event_obj.fire_event_async.return_value = _resolved_future(None) + event_ctx.__enter__.return_value = event_obj + event_ctx.__exit__.return_value = False + + with patch("salt.pillar.get_async_pillar", MagicMock(return_value=compiler)), patch( + "salt.utils.event.get_event", MagicMock(return_value=event_ctx) + ): + minion.io_loop.run_sync(lambda: minion.pillar_refresh()) + + +@pytest.mark.slow_test +def test_repack_refreshes_loaded_proxy_module( + minion_opts, echo_extension_modules, tmp_path +): + """ + Loader-altitude pin of the #58197 mechanism, independent of the Minion. + + A module loaded against ``pack["__pillar__"] = opts["pillar"]`` sees v1. + Rebinding ``opts["pillar"]`` (what ``pillar_refresh`` does) leaves the + module stale; re-packing the loader (what the fix does) makes it fresh -- + with no reload. + """ + pillar_v1 = {"role": "v1"} + opts = _proxy_opts(minion_opts, echo_extension_modules, tmp_path, pillar_v1) + proxy = _build_proxy_loader(opts) + + func = proxy["echo_pillar_proxy.read_pillar_key"] + # The stable "did we reload?" handle is the underlying module function + # (LoadedFunc is a thin wrapper re-created on each subscript access). + underlying = func.func + assert func("role") == "v1" + + # Simulate the pillar_refresh rebind: opts["pillar"] now points at a new + # dict, but the loader's pack still aliases the original -> stale. + pillar_v2 = {"role": "v2"} + opts["pillar"] = pillar_v2 + assert proxy["echo_pillar_proxy.read_pillar_key"]("role") == "v1" + + # The fix: re-pack. The already-loaded module now sees v2 on its next + # call, and it is backed by the *same* function object (no reload). + proxy.pack["__pillar__"] = opts["pillar"] + assert proxy["echo_pillar_proxy.read_pillar_key"].func is underlying + assert func("role") == "v2" + + +@pytest.mark.slow_test +def test_pillar_refresh_repacks_proxy_loader( + minion_opts, echo_extension_modules, tmp_path +): + """ + Direct pin of the production line: drive the real ``pillar_refresh`` and + assert the freshly compiled pillar reaches an already-loaded proxy module, + without a reload. + + This is the test that fails if the ``self.proxy.pack["__pillar__"]`` + re-pack is removed from ``Minion.pillar_refresh``. + """ + pillar_v1 = {"role": "v1", "token": "old"} + opts = _proxy_opts(minion_opts, echo_extension_modules, tmp_path, pillar_v1) + proxy = _build_proxy_loader(opts) + + read = proxy["echo_pillar_proxy.read_pillar_key"] + read_func = read.func + conn_before = proxy["echo_pillar_proxy.conn_token"]() + assert conn_before is not None + assert read("role") == "v1" + + pillar_v2 = {"role": "v2", "token": "new"} + io_loop = salt.ext.tornado.ioloop.IOLoop() + try: + minion = _make_minion(opts, proxy, io_loop) + _run_pillar_refresh(minion, new_pillar=pillar_v2) + finally: + io_loop.close() + + # opts was rebound to the new pillar ... + assert minion.opts["pillar"] is pillar_v2 + # ... and the loader was re-packed to the very same object (the fix). + assert proxy.pack["__pillar__"] is pillar_v2 + + # End-to-end: the already-loaded proxy module now serves the new pillar. + assert read("role") == "v2" + assert read("token") == "new" + + # Reload lock-out: the module function backing the loader entry is the + # same object (no reload), and init() was not re-run so the __context__ + # connection sentinel survives untouched. + assert proxy["echo_pillar_proxy.read_pillar_key"].func is read_func + assert proxy["echo_pillar_proxy.conn_token"]() is conn_before + + +@pytest.mark.slow_test +def test_pillar_refresh_saltclienterror_keeps_pack_identity( + minion_opts, echo_extension_modules, tmp_path +): + """ + Inverse: if the pillar compile raises ``SaltClientError`` the refresh must + not rebind opts["pillar"] and must leave ``pack["__pillar__"]`` pointing at + the original object (no torn / half-applied state). + """ + pillar_v1 = {"role": "v1"} + opts = _proxy_opts(minion_opts, echo_extension_modules, tmp_path, pillar_v1) + proxy = _build_proxy_loader(opts) + packed_before = proxy.pack["__pillar__"] + assert packed_before is pillar_v1 + + io_loop = salt.ext.tornado.ioloop.IOLoop() + try: + minion = _make_minion(opts, proxy, io_loop) + _run_pillar_refresh(minion, compile_error=True) + finally: + io_loop.close() + + assert minion.opts["pillar"] is pillar_v1 + assert proxy.pack["__pillar__"] is pillar_v1 + assert proxy["echo_pillar_proxy.read_pillar_key"]("role") == "v1" + + +@pytest.mark.slow_test +def test_pillar_refresh_non_proxy_minion_does_not_raise(minion_opts, tmp_path): + """ + Inverse: a regular (non-proxy) minion has ``self.proxy is None``. The + guarded re-pack must be skipped -- no ``AttributeError`` -- and the refresh + must still rebind opts["pillar"] as usual. + """ + opts = dict(minion_opts) + opts.update( + { + "id": "regular_minion", + "saltenv": "base", + "pillarenv": None, + "grains": {}, + "pillar": {"role": "v1"}, + } + ) + pillar_v2 = {"role": "v2"} + io_loop = salt.ext.tornado.ioloop.IOLoop() + try: + minion = salt.minion.Minion.__new__(salt.minion.Minion) + minion.opts = opts + minion.proxy = None + minion.connected = True + minion.io_loop = io_loop + minion.module_refresh = MagicMock() + minion.matchers_refresh = MagicMock() + minion.beacons_refresh = MagicMock() + _run_pillar_refresh(minion, new_pillar=pillar_v2) + finally: + io_loop.close() + + assert minion.opts["pillar"] is pillar_v2 + + +@pytest.mark.slow_test +def test_pillar_refresh_regular_minion_with_proxy_loader_skips_repack( + minion_opts, tmp_path +): + """ + Scoping guard: a regular minion still builds a lazy proxy loader in + ``gen_modules()`` (``self.proxy`` is a truthy LazyLoader, not None), but it + is not a proxy minion (no ``opts["proxy"]``). The re-pack must be gated on + ``opts["proxy"]`` so it does NOT touch that loader's pack on a regular + minion -- otherwise the guard would fire wherever a proxy loader exists. + """ + opts = dict(minion_opts) + opts.update( + { + "id": "regular_minion", + "saltenv": "base", + "pillarenv": None, + "grains": {}, + "pillar": {"role": "v1"}, + } + ) + opts.pop("proxy", None) + pillar_v2 = {"role": "v2"} + # A truthy stand-in for the lazy proxy loader a regular minion carries. + proxy_loader = MagicMock() + io_loop = salt.ext.tornado.ioloop.IOLoop() + try: + minion = salt.minion.Minion.__new__(salt.minion.Minion) + minion.opts = opts + minion.proxy = proxy_loader + minion.connected = True + minion.io_loop = io_loop + minion.module_refresh = MagicMock() + minion.matchers_refresh = MagicMock() + minion.beacons_refresh = MagicMock() + _run_pillar_refresh(minion, new_pillar=pillar_v2) + finally: + io_loop.close() + + assert minion.opts["pillar"] is pillar_v2 + # The proxy loader's pack must never have been written on a regular minion. + proxy_loader.pack.__setitem__.assert_not_called() + + +@pytest.mark.slow_test +def test_pillar_refresh_per_subproxy_isolated( + minion_opts, echo_extension_modules, tmp_path +): + """ + Deltaproxy coverage. + + Every deltaproxy sub-proxy is a ``ProxyMinion`` with its own loader and + its own ``opts``. ``handle_event`` routes each sub-proxy's ``pillar_refresh`` + event to *that* sub-proxy instance (``_minion = + self.deltaproxy_objs[proxy_target]``), so the single re-pack in + ``Minion.pillar_refresh`` runs per sub-proxy against its own loader. + + Drive ``pillar_refresh`` on two independent sub-proxy objects with distinct + freshly-compiled pillars and assert each loader ends up with *its own* new + pillar -- no cross-contamination. + """ + opts_a = _proxy_opts( + minion_opts, echo_extension_modules, tmp_path / "a", {"device": "a-old"} + ) + opts_a["id"] = "sub-a" + opts_b = _proxy_opts( + minion_opts, echo_extension_modules, tmp_path / "b", {"device": "b-old"} + ) + opts_b["id"] = "sub-b" + proxy_a = _build_proxy_loader(opts_a) + proxy_b = _build_proxy_loader(opts_b) + + new_a = {"device": "a-new"} + new_b = {"device": "b-new"} + io_loop = salt.ext.tornado.ioloop.IOLoop() + try: + sub_a = _make_minion(opts_a, proxy_a, io_loop) + sub_b = _make_minion(opts_b, proxy_b, io_loop) + _run_pillar_refresh(sub_a, new_pillar=new_a) + _run_pillar_refresh(sub_b, new_pillar=new_b) + finally: + io_loop.close() + + assert proxy_a.pack["__pillar__"] is new_a + assert proxy_b.pack["__pillar__"] is new_b + assert proxy_a["echo_pillar_proxy.read_pillar_key"]("device") == "a-new" + assert proxy_b["echo_pillar_proxy.read_pillar_key"]("device") == "b-new" diff --git a/tests/pytests/unit/metaproxy/test_proxy_pillar_refresh_routing.py b/tests/pytests/unit/metaproxy/test_proxy_pillar_refresh_routing.py new file mode 100644 index 000000000000..21e3aabd0f36 --- /dev/null +++ b/tests/pytests/unit/metaproxy/test_proxy_pillar_refresh_routing.py @@ -0,0 +1,84 @@ +""" +Unit tests pinning how a ``pillar_refresh`` event is routed to the object +whose ``pillar_refresh`` re-packs the proxy loader (issue #58197). + +The #58197 fix lives in ``Minion.pillar_refresh``. For a standard proxy that +is the proxy minion itself. For deltaproxy, ``Minion.handle_event`` dispatches +each sub-proxy's ``pillar_refresh`` event to *that sub-proxy* instance +(``_minion = self.deltaproxy_objs[proxy_target]``), so the same single re-pack +runs per sub-proxy against the sub-proxy's own loader and opts. These tests +lock that routing down so the deltaproxy coverage cannot silently regress. +""" + +import salt.ext.tornado.concurrent +import salt.ext.tornado.ioloop +import salt.minion +import salt.utils.event +from tests.support.mock import MagicMock, patch + + +def _resolved_future(result=None): + future = salt.ext.tornado.concurrent.Future() + future.set_result(result) + return future + + +def _drive_handle_event(minion, tag, data): + io_loop = salt.ext.tornado.ioloop.IOLoop() + try: + with patch.object( + salt.utils.event.SaltEvent, "unpack", return_value=(tag, data) + ): + io_loop.run_sync(lambda: minion.handle_event(b"package")) + finally: + io_loop.close() + + +def test_pillar_refresh_event_routes_to_subproxy(): + """ + A ``pillar_refresh`` event carrying ``proxy_target`` must be handled by the + matching sub-proxy's ``pillar_refresh`` (where the loader re-pack happens), + not the control proxy's. + """ + minion = salt.minion.ProxyMinion.__new__(salt.minion.ProxyMinion) + minion.ready = True + minion.opts = {"metaproxy": "deltaproxy", "master": "master"} + minion.pillar_refresh = MagicMock(return_value=_resolved_future()) + + sub_a = MagicMock() + sub_a.pillar_refresh.return_value = _resolved_future() + sub_b = MagicMock() + sub_b.pillar_refresh.return_value = _resolved_future() + minion.deltaproxy_objs = {"sub-a": sub_a, "sub-b": sub_b} + + _drive_handle_event( + minion, + "pillar_refresh", + {"proxy_target": "sub-a", "clean_cache": False, "force_refresh": False}, + ) + + sub_a.pillar_refresh.assert_called_once() + sub_b.pillar_refresh.assert_not_called() + minion.pillar_refresh.assert_not_called() + + +def test_pillar_refresh_event_without_proxy_target_routes_to_self(): + """ + Without ``proxy_target`` (standard proxy / control proxy) the event is + handled by the minion itself, so its own ``pillar_refresh`` re-packs its + own loader. + """ + minion = salt.minion.ProxyMinion.__new__(salt.minion.ProxyMinion) + minion.ready = True + minion.opts = {"metaproxy": "deltaproxy", "master": "master"} + minion.pillar_refresh = MagicMock(return_value=_resolved_future()) + minion.deltaproxy_objs = {"sub-a": MagicMock()} + + _drive_handle_event( + minion, + "pillar_refresh", + {"clean_cache": False, "force_refresh": False}, + ) + + minion.pillar_refresh.assert_called_once() + minion.deltaproxy_objs["sub-a"].pillar_refresh.assert_not_called() From 9288b00aadf9961218f1b7ef7c201a655cc6f672 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 06:11:50 -0400 Subject: [PATCH 199/469] Refresh proxy exec-module loaders after pillar rebind Minion.pillar_refresh calls module_refresh() at the top, rebuilding the execution-module loaders (functions/returners/executors/utils) from self.opts, and only afterwards compiles the new pillar and rebinds self.opts["pillar"]. Each exec loader snapshots opts["pillar"] by value when it is built, so those freshly rebuilt loaders capture the OLD pillar. A regular minion masks this: every job rebuilds the loaders through gen_modules() before running. A proxy minion's metaproxy job path never does, so an exec module keeps serving the previous refresh's __pillar__ until the next refresh_pillar -- the "run the same command twice, get two answers" symptom. Re-run module_refresh() after the rebind so the exec loaders are rebuilt against the freshly compiled pillar. It sits next to the #58197 proxy re-pack in the success branch and is gated on opts["proxy"], so a regular minion pays no extra loader rebuild and a failed compile triggers none. Fixes #59393 --- changelog/59393.fixed.md | 1 + salt/minion.py | 14 + .../test_proxy_exec_pillar_refresh.py | 356 ++++++++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 changelog/59393.fixed.md create mode 100644 tests/pytests/functional/metaproxy/test_proxy_exec_pillar_refresh.py diff --git a/changelog/59393.fixed.md b/changelog/59393.fixed.md new file mode 100644 index 000000000000..aaf21b9068ca --- /dev/null +++ b/changelog/59393.fixed.md @@ -0,0 +1 @@ +Rebuild a proxy minion's execution-module loaders after the pillar rebind in `pillar_refresh`, so exec modules see the freshly compiled `__pillar__` instead of the previous refresh's value diff --git a/salt/minion.py b/salt/minion.py index 132bc235d9c9..1bb9874fe988 100644 --- a/salt/minion.py +++ b/salt/minion.py @@ -3452,6 +3452,20 @@ def pillar_refresh(self, force_refresh=False, clean_cache=False): # sub-proxy and its own loader is re-packed here. if self.opts.get("proxy") and getattr(self, "proxy", None): self.proxy.pack["__pillar__"] = self.opts["pillar"] + + # The exec-module loaders (functions/returners/executors/ + # utils) snapshot opts["pillar"] by value when they are + # built, so re-packing the proxy loader above does not + # freshen them. module_refresh() is already called at the + # top of pillar_refresh, but that runs before the rebind + # above, so those loaders captured the OLD pillar. On a + # regular minion this is masked because every job rebuilds + # the loaders via gen_modules(); a proxy minion's job path + # never does, so exec modules would serve stale __pillar__ + # until the next refresh (#59393). Rebuild them here, after + # the rebind, so they see the freshly compiled pillar. + # Proxy-scoped so a regular minion pays no extra rebuild. + self.module_refresh(force_refresh) finally: async_pillar.destroy() self.matchers_refresh() diff --git a/tests/pytests/functional/metaproxy/test_proxy_exec_pillar_refresh.py b/tests/pytests/functional/metaproxy/test_proxy_exec_pillar_refresh.py new file mode 100644 index 000000000000..4581dbe33b16 --- /dev/null +++ b/tests/pytests/functional/metaproxy/test_proxy_exec_pillar_refresh.py @@ -0,0 +1,356 @@ +""" +Functional regression test for issue #59393. + +``Minion.pillar_refresh`` calls ``module_refresh()`` at the top -- rebuilding +the execution-module loaders (``functions``/``returners``/``executors`` and +``utils``) from ``self.opts`` -- and only *afterwards* compiles the new pillar +and rebinds ``self.opts["pillar"]``. Each exec loader snapshots +``opts["pillar"]`` by value when it is built (``salt.loader.lazy`` deep-copies +opts and stashes the pillar in its context dict), so those loaders capture the +*old* pillar. + +On a regular minion this is masked: every job rebuilds the loaders through +``gen_modules()`` before it runs. A proxy minion's ``metaproxy`` job path never +does, so an exec module keeps serving the previous refresh's ``__pillar__`` +until the *next* ``refresh_pillar`` -- the confusing "run the same command +twice, get two answers" symptom. + +The fix re-runs ``module_refresh()`` after the rebind, gated on ``opts["proxy"]`` +so a regular minion pays no extra rebuild:: + + if self.opts.get("proxy") and getattr(self, "proxy", None): + self.proxy.pack["__pillar__"] = self.opts["pillar"] + self.module_refresh(force_refresh) + +These tests drive the *real* ``Minion.pillar_refresh`` (with the pillar compile +and event bus mocked, exactly like the #58197 tests). The direct pin builds a +*real* execution-module loader (``salt.loader.minion_mods``) over an on-disk +module that reads ``__pillar__`` at call time, and asserts that after a single +refresh that exec module observes the freshly compiled pillar. The remaining +tests pin the ordering (second refresh runs after the rebind, sees the new +pillar) and the no-regression boundary (a regular minion still refreshes its +modules exactly once; a failed compile triggers no second rebuild). +""" + +import textwrap + +import pytest + +import salt.ext.tornado.concurrent +import salt.ext.tornado.ioloop +import salt.loader +import salt.minion +from salt.exceptions import SaltClientError +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def echo_extension_modules(tmp_path): + """ + Lay down an ``extension_modules`` tree with: + + * ``modules/echo_pillar_exec.py`` -- an *execution* module that reads + ``__pillar__`` at call time (what #59393 is about), and + * ``proxy/echo_pillar_proxy.py`` -- a minimal proxy module so a real + ``salt.loader.proxy`` loader can be built to satisfy the proxy gate in + ``pillar_refresh``. + """ + ext = tmp_path / "ext_echo" + modules_dir = ext / "modules" + proxy_dir = ext / "proxy" + modules_dir.mkdir(parents=True) + proxy_dir.mkdir(parents=True) + (ext / "__init__.py").write_text("") + + exec_module = textwrap.dedent( + ''' + """ + Test-only execution module for tests/pytests/functional/metaproxy. + + ``read_pillar_key`` resolves ``__pillar__`` through the loader's + ``NamedLoaderContext`` on every call, so it reflects whatever pillar + the loader snapshotted when it was built. + """ + + __virtualname__ = "echo_pillar_exec" + + + def __virtual__(): + return True + + + def read_pillar_key(key): + return __pillar__.get(key) + ''' + ).lstrip() + (modules_dir / "echo_pillar_exec.py").write_text(exec_module) + + proxy_module = textwrap.dedent( + ''' + """ + Minimal proxy module so a real proxy loader can be built. + """ + + __proxyenabled__ = ["*"] + + + def __virtual__(): + return True + + + def init(opts): + return True + + + def shutdown(opts): + return True + + + def ping(): + return True + ''' + ).lstrip() + (proxy_dir / "echo_pillar_proxy.py").write_text(proxy_module) + return ext + + +def _proxy_opts(minion_opts, echo_extension_modules, tmp_path, pillar): + """ + Build a proxy ``opts`` dict wired to the echo extension-module tree. + """ + opts = dict(minion_opts) + opts.update( + { + "id": "proxy_echo", + "cachedir": str(tmp_path / "cache"), + "extension_modules": str(echo_extension_modules), + "saltenv": "base", + "pillarenv": None, + "grains": {}, + "pillar": pillar, + "proxy": {"proxytype": "echo_pillar_proxy"}, + } + ) + (tmp_path / "cache").mkdir(parents=True, exist_ok=True) + return opts + + +def _build_proxy_loader(opts): + """ + A real proxy loader packed like ``salt.metaproxy.proxy`` so the proxy gate + in ``pillar_refresh`` (``opts["proxy"]`` and ``self.proxy``) is satisfied. + """ + proxy = salt.loader.proxy(opts, utils=salt.loader.utils(opts)) + proxy.pack["__pillar__"] = opts["pillar"] + assert proxy["echo_pillar_proxy.init"](opts) is True + return proxy + + +def _build_exec_loader(opts, proxy): + """ + A real execution-module loader, exactly as ``_load_modules`` builds it: + ``minion_mods`` snapshots ``opts["pillar"]`` by value at construction. + """ + return salt.loader.minion_mods( + opts, utils=salt.loader.utils(opts, proxy=proxy), proxy=proxy + ) + + +def _resolved_future(result): + future = salt.ext.tornado.concurrent.Future() + future.set_result(result) + return future + + +def _make_minion(opts, proxy, io_loop, module_refresh): + """ + A ``ProxyMinion`` carrying only what ``pillar_refresh`` touches. ``proxy``, + ``opts`` and ``pillar_schedule_refresh`` are real; ``module_refresh`` is + supplied by the caller (a real rebuild for the direct pin, a mock for the + ordering/count pins). + """ + minion = salt.minion.ProxyMinion.__new__(salt.minion.ProxyMinion) + minion.opts = opts + minion.proxy = proxy + minion.connected = True + minion.io_loop = io_loop + minion.module_refresh = module_refresh + minion.matchers_refresh = MagicMock() + minion.beacons_refresh = MagicMock() + return minion + + +def _run_pillar_refresh(minion, new_pillar=None, compile_error=False): + """ + Drive the real ``Minion.pillar_refresh`` coroutine to completion with the + pillar compile and event bus mocked out. + """ + compiler = MagicMock() + if compile_error: + compiler.compile_pillar.side_effect = SaltClientError("master down") + else: + compiler.compile_pillar.return_value = _resolved_future(new_pillar) + compiler.destroy.return_value = None + + event_ctx = MagicMock() + event_obj = MagicMock() + event_obj.fire_event_async.return_value = _resolved_future(None) + event_ctx.__enter__.return_value = event_obj + event_ctx.__exit__.return_value = False + + with patch("salt.pillar.get_async_pillar", MagicMock(return_value=compiler)), patch( + "salt.utils.event.get_event", MagicMock(return_value=event_ctx) + ): + minion.io_loop.run_sync(lambda: minion.pillar_refresh()) + + +@pytest.mark.slow_test +def test_pillar_refresh_refreshes_exec_module_pillar( + minion_opts, echo_extension_modules, tmp_path +): + """ + Direct pin of #59393. + + Drive the real ``pillar_refresh`` on a proxy minion and assert that a + *real* execution-module loader reflects the freshly compiled pillar after a + single refresh. ``module_refresh`` here is a faithful rebuild of the exec + loader from ``self.opts`` -- the same thing production's ``module_refresh`` + does to ``self.functions``. + + Fails on the pre-fix code: the only ``module_refresh`` runs *before* the + rebind, so the exec loader is snapshotted against the old pillar and the + read returns "v1". + """ + pillar_v1 = {"role": "v1", "token": "old"} + opts = _proxy_opts(minion_opts, echo_extension_modules, tmp_path, pillar_v1) + proxy = _build_proxy_loader(opts) + + io_loop = salt.ext.tornado.ioloop.IOLoop() + try: + minion = salt.minion.ProxyMinion.__new__(salt.minion.ProxyMinion) + minion.opts = opts + minion.proxy = proxy + minion.connected = True + minion.io_loop = io_loop + minion.matchers_refresh = MagicMock() + minion.beacons_refresh = MagicMock() + + def module_refresh(force_refresh=False, notify=False): + # Mirror production module_refresh's exec-loader rebuild: a fresh + # minion_mods off the current opts (which snapshots opts["pillar"]). + minion.functions = _build_exec_loader(minion.opts, minion.proxy) + + minion.module_refresh = module_refresh + + # Baseline: before the refresh the exec loader serves the old pillar. + module_refresh() + assert minion.functions["echo_pillar_exec.read_pillar_key"]("role") == "v1" + + pillar_v2 = {"role": "v2", "token": "new"} + _run_pillar_refresh(minion, new_pillar=pillar_v2) + finally: + io_loop.close() + + assert minion.opts["pillar"] is pillar_v2 + # The exec loader was rebuilt after the rebind, so it now serves v2. + assert minion.functions["echo_pillar_exec.read_pillar_key"]("role") == "v2" + assert minion.functions["echo_pillar_exec.read_pillar_key"]("token") == "new" + + +@pytest.mark.slow_test +def test_proxy_reruns_module_refresh_after_pillar_rebind( + minion_opts, echo_extension_modules, tmp_path +): + """ + Ordering pin: on a proxy minion ``module_refresh`` runs twice -- once before + the rebind (against the old pillar) and once after (against the new pillar). + The post-rebind call is what freshens the exec loaders. + + Fails on the pre-fix code, which calls ``module_refresh`` exactly once. + """ + pillar_v1 = {"role": "v1"} + opts = _proxy_opts(minion_opts, echo_extension_modules, tmp_path, pillar_v1) + proxy = _build_proxy_loader(opts) + pillar_v2 = {"role": "v2"} + + seen = [] + module_refresh = MagicMock( + side_effect=lambda *a, **k: seen.append(minion.opts["pillar"]) + ) + + io_loop = salt.ext.tornado.ioloop.IOLoop() + try: + minion = _make_minion(opts, proxy, io_loop, module_refresh) + _run_pillar_refresh(minion, new_pillar=pillar_v2) + finally: + io_loop.close() + + assert module_refresh.call_count == 2 + assert seen[0] is pillar_v1 + assert seen[1] is pillar_v2 + + +@pytest.mark.slow_test +def test_compile_error_skips_second_module_refresh( + minion_opts, echo_extension_modules, tmp_path +): + """ + Inverse: a failed pillar compile must not trigger the post-rebind rebuild. + The second ``module_refresh`` lives in the success (``else``) branch, so a + ``SaltClientError`` leaves ``module_refresh`` at its single pre-compile call + and does not rebind ``opts["pillar"]``. + """ + pillar_v1 = {"role": "v1"} + opts = _proxy_opts(minion_opts, echo_extension_modules, tmp_path, pillar_v1) + proxy = _build_proxy_loader(opts) + module_refresh = MagicMock() + + io_loop = salt.ext.tornado.ioloop.IOLoop() + try: + minion = _make_minion(opts, proxy, io_loop, module_refresh) + _run_pillar_refresh(minion, compile_error=True) + finally: + io_loop.close() + + assert module_refresh.call_count == 1 + assert minion.opts["pillar"] is pillar_v1 + + +@pytest.mark.slow_test +def test_regular_minion_module_refresh_not_rerun(minion_opts, tmp_path): + """ + No-regression: a regular (non-proxy) minion is untouched by the fix. Its + ``pillar_refresh`` still rebinds ``opts["pillar"]`` and still calls + ``module_refresh`` exactly once -- the proxy-gated second rebuild does not + fire, so a regular minion pays no extra loader rebuild. + """ + opts = dict(minion_opts) + opts.update( + { + "id": "regular_minion", + "saltenv": "base", + "pillarenv": None, + "grains": {}, + "pillar": {"role": "v1"}, + } + ) + opts.pop("proxy", None) + pillar_v2 = {"role": "v2"} + module_refresh = MagicMock() + + io_loop = salt.ext.tornado.ioloop.IOLoop() + try: + minion = salt.minion.Minion.__new__(salt.minion.Minion) + minion.opts = opts + minion.proxy = None + minion.connected = True + minion.io_loop = io_loop + minion.module_refresh = module_refresh + minion.matchers_refresh = MagicMock() + minion.beacons_refresh = MagicMock() + _run_pillar_refresh(minion, new_pillar=pillar_v2) + finally: + io_loop.close() + + assert minion.opts["pillar"] is pillar_v2 + assert module_refresh.call_count == 1 From 2a0382b1fe76fd705bb671591ed3b54789700047 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 09:41:24 -0400 Subject: [PATCH 200/469] Drop unnecessary lambda in proxy pillar-refresh tests pillar_refresh is a coroutine method, so io_loop.run_sync can call it directly; the no-arg wrapper lambda tripped pylint's unnecessary-lambda (W0108) in the lint-tests job. --- .../functional/metaproxy/test_proxy_exec_pillar_refresh.py | 2 +- tests/pytests/functional/metaproxy/test_proxy_pillar_refresh.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/pytests/functional/metaproxy/test_proxy_exec_pillar_refresh.py b/tests/pytests/functional/metaproxy/test_proxy_exec_pillar_refresh.py index 4581dbe33b16..33273d0582c7 100644 --- a/tests/pytests/functional/metaproxy/test_proxy_exec_pillar_refresh.py +++ b/tests/pytests/functional/metaproxy/test_proxy_exec_pillar_refresh.py @@ -201,7 +201,7 @@ def _run_pillar_refresh(minion, new_pillar=None, compile_error=False): with patch("salt.pillar.get_async_pillar", MagicMock(return_value=compiler)), patch( "salt.utils.event.get_event", MagicMock(return_value=event_ctx) ): - minion.io_loop.run_sync(lambda: minion.pillar_refresh()) + minion.io_loop.run_sync(minion.pillar_refresh) @pytest.mark.slow_test diff --git a/tests/pytests/functional/metaproxy/test_proxy_pillar_refresh.py b/tests/pytests/functional/metaproxy/test_proxy_pillar_refresh.py index abef955dd33d..058936de0d02 100644 --- a/tests/pytests/functional/metaproxy/test_proxy_pillar_refresh.py +++ b/tests/pytests/functional/metaproxy/test_proxy_pillar_refresh.py @@ -184,7 +184,7 @@ def _run_pillar_refresh(minion, new_pillar=None, compile_error=False): with patch("salt.pillar.get_async_pillar", MagicMock(return_value=compiler)), patch( "salt.utils.event.get_event", MagicMock(return_value=event_ctx) ): - minion.io_loop.run_sync(lambda: minion.pillar_refresh()) + minion.io_loop.run_sync(minion.pillar_refresh) @pytest.mark.slow_test From 0321d455abd32fee5cf81d6c466eed3f5ef6eaac Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 04:03:24 -0400 Subject: [PATCH 201/469] Use target minion id for pillar-render execution modules on the master Pillar compilation loads execution modules (for salt[...] calls in pillar templates) with the incoming opts when file_client is "local". On the master that branch is always taken, and the incoming opts carry the master's id, so salt["match.compound"] and friends matched against the master rather than the minion the pillar was being compiled for (#58407). Load them with self.opts, which __gen_opts stamps with the target minion_id, matching the non-local branch. Masterless is unaffected (opts["id"] already equals the minion_id there). Fixes #58407 --- changelog/58407.fixed.md | 1 + salt/pillar/__init__.py | 7 ++++- tests/pytests/unit/test_pillar.py | 46 +++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 changelog/58407.fixed.md diff --git a/changelog/58407.fixed.md b/changelog/58407.fixed.md new file mode 100644 index 000000000000..c280b7f7a82d --- /dev/null +++ b/changelog/58407.fixed.md @@ -0,0 +1 @@ +Fixed ``salt['match.compound']`` (and other execution modules called from pillar templates) matching against the master's id instead of the target minion's id during master-side pillar compilation. diff --git a/salt/pillar/__init__.py b/salt/pillar/__init__.py index e3a0ec8355fc..ff139273f68b 100644 --- a/salt/pillar/__init__.py +++ b/salt/pillar/__init__.py @@ -568,8 +568,13 @@ def __init__( if functions is None: utils = salt.loader.utils(opts, file_client=self.client) if opts.get("file_client", "") == "local": + # Use self.opts, which __gen_opts has stamped with the target + # minion_id, so execution modules called from pillar templates + # (e.g. salt["match.compound"]) resolve against the minion the + # pillar is being compiled for rather than the master's own id. + # This matches the non-local branch below (#58407). self.functions = salt.loader.minion_mods( - opts, + self.opts, utils=utils, file_client=salt.fileclient.ContextlessFileClient(self.fileclient), ) diff --git a/tests/pytests/unit/test_pillar.py b/tests/pytests/unit/test_pillar.py index 79c66a5fd2e3..8938dbf8e86a 100644 --- a/tests/pytests/unit/test_pillar.py +++ b/tests/pytests/unit/test_pillar.py @@ -1440,3 +1440,49 @@ async def crypted_transfer_mock(): pillar.channel.crypted_transfer_decode_dictentry = crypted_transfer_mock with pytest.raises(salt.exceptions.SaltClientError): await pillar.compile_pillar() + + +def _min_pillar_opts(**extra): + opts = { + "optimization_order": [0, 1, 2], + "renderer": "json", + "renderer_blacklist": [], + "renderer_whitelist": [], + "state_top": "", + "pillar_roots": {"base": []}, + "file_roots": {"base": []}, + "extension_modules": "", + "fileserver_backend": "", + "cachedir": "", + "file_client": "local", + } + opts.update(extra) + return opts + + +def test_pillar_functions_use_target_minion_id_on_master(): + """ + Regression test for #58407. + + On the master, file_client is "local", so pillar execution modules were + loaded with the raw master opts, giving salt["match.compound"] and friends + the master's id instead of the minion the pillar is being compiled for. + The loaded functions must see the target minion_id (which __gen_opts stamps + into self.opts). Pins the bug: before the fix functions.opts["id"] is + "master". + """ + with patch("salt.pillar.compile_template"): + opts = _min_pillar_opts(id="master") + pillar = salt.pillar.Pillar(opts, {"os": "Ubuntu"}, "target-minion", "base") + assert pillar.functions.opts["id"] == "target-minion" + + +def test_pillar_functions_masterless_id_unchanged(): + """ + Inverse of #58407: in masterless mode opts["id"] already equals the + minion_id, so the fix is a no-op and the loaded functions keep that id. + """ + with patch("salt.pillar.compile_template"): + opts = _min_pillar_opts(id="myminion") + pillar = salt.pillar.Pillar(opts, {"os": "Ubuntu"}, "myminion", "base") + assert pillar.functions.opts["id"] == "myminion" From 812ebad7f2466a023af860b6a61276f9f61c9e26 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 11 Jul 2026 06:12:07 -0400 Subject: [PATCH 202/469] Fix test_pillar_match_filter_by_minion_id expectation for the documented example The #58407 fix makes execution modules called during pillar rendering resolve against the target minion, so the documented match.filter_by example (which omits the minion_id argument) now correctly matches the minion and gets the db role. The parametrized expectation still asserted the pre-fix web* roles, which contradicted the fix and failed in CI. --- tests/pytests/integration/modules/test_pillar.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/pytests/integration/modules/test_pillar.py b/tests/pytests/integration/modules/test_pillar.py index 69dcc607925a..637728d2a907 100644 --- a/tests/pytests/integration/modules/test_pillar.py +++ b/tests/pytests/integration/modules/test_pillar.py @@ -490,7 +490,10 @@ def test_pillar_refresh_pillar_scheduler(salt_master, salt_cli, salt_minion): @pytest.mark.parametrize( "jinja_file,app,caching,db", [ - ("test_jinja_sls_as_documented", True, True, False), + # With the #58407 fix, even the documented example (which omits the + # minion_id argument) resolves against the target minion, so all three + # forms correctly match the minion and get the "db" role. + ("test_jinja_sls_as_documented", False, False, True), ("test_jinja_sls_with_minion_id", False, False, True), ("test_jinja_sls_with_minion_id_regex_compound", False, False, True), ], @@ -549,9 +552,10 @@ def test_pillar_match_filter_by_minion_id( ), } - # test the brokenness of the currently documented example - # it lacks the "minion_id" argument for "match.filter_by" and thereby - # assigns the wrong roles + # The documented example omits the "minion_id" argument; before #58407 it + # matched against the master's id and assigned the wrong (web*) roles. With + # the fix, execution modules called during pillar rendering resolve against + # the target minion, so it now correctly matches the minion's id. with salt_master.pillar_tree.base.temp_file("top.sls", top_sls): with salt_master.pillar_tree.base.temp_file( "test_jinja.sls", sls_files[jinja_file] From 282efbbafd219ee887e577775745ba51ae89e7d2 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 03:56:47 -0400 Subject: [PATCH 203/469] Add functional API tests for the env and yaml sdb backends Adds functional coverage for salt.sdb.env (set/get via environment variables, including the setdefault no-overwrite behavior) and salt.sdb.yaml (get with nested/colon traversal, multi-file merge, missing keys, and the read-only set raising NotImplemented). These are the two sdb backends that require no external service. Refs #61260 --- tests/pytests/functional/sdb/test_env.py | 28 +++++++++++++ tests/pytests/functional/sdb/test_yaml.py | 50 +++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/pytests/functional/sdb/test_env.py create mode 100644 tests/pytests/functional/sdb/test_yaml.py diff --git a/tests/pytests/functional/sdb/test_env.py b/tests/pytests/functional/sdb/test_env.py new file mode 100644 index 000000000000..c921598596df --- /dev/null +++ b/tests/pytests/functional/sdb/test_env.py @@ -0,0 +1,28 @@ +import salt.sdb.env as env + + +def test_set_and_get(monkeypatch): + """ + A value set through sdb.env can be read back through it. + """ + monkeypatch.delenv("SALT_SDB_ENV_TEST", raising=False) + assert env.set_("SALT_SDB_ENV_TEST", "hello") == "hello" + assert env.get("SALT_SDB_ENV_TEST") == "hello" + + +def test_get_missing_returns_none(monkeypatch): + """ + Looking up an unset environment variable returns None. + """ + monkeypatch.delenv("SALT_SDB_ENV_MISSING", raising=False) + assert env.get("SALT_SDB_ENV_MISSING") is None + + +def test_set_does_not_overwrite_existing(monkeypatch): + """ + sdb.env.set_ uses ``os.environ.setdefault``, so it leaves an already-set + variable untouched and returns the existing value. + """ + monkeypatch.setenv("SALT_SDB_ENV_EXISTING", "original") + assert env.set_("SALT_SDB_ENV_EXISTING", "new") == "original" + assert env.get("SALT_SDB_ENV_EXISTING") == "original" diff --git a/tests/pytests/functional/sdb/test_yaml.py b/tests/pytests/functional/sdb/test_yaml.py new file mode 100644 index 000000000000..522bde78f4c9 --- /dev/null +++ b/tests/pytests/functional/sdb/test_yaml.py @@ -0,0 +1,50 @@ +import pytest + +import salt.exceptions +import salt.sdb.yaml as yaml_sdb + + +@pytest.fixture +def configure_loader_modules(minion_opts): + return {yaml_sdb: {"__opts__": minion_opts}} + + +@pytest.fixture +def yaml_profile(tmp_path): + data_file = tmp_path / "sdb.yaml" + data_file.write_text("top: value\nnested:\n inner: deep\n", encoding="utf-8") + return {"files": [str(data_file)]} + + +def test_get_top_level(yaml_profile): + assert yaml_sdb.get("top", profile=yaml_profile) == "value" + + +def test_get_nested_dict(yaml_profile): + assert yaml_sdb.get("nested", profile=yaml_profile) == {"inner": "deep"} + + +def test_get_nested_key_via_colon(yaml_profile): + assert yaml_sdb.get("nested:inner", profile=yaml_profile) == "deep" + + +def test_get_missing_returns_none(yaml_profile): + assert yaml_sdb.get("does-not-exist", profile=yaml_profile) is None + + +def test_get_merges_multiple_files(tmp_path): + first = tmp_path / "a.yaml" + first.write_text("a: 1\n", encoding="utf-8") + second = tmp_path / "b.yaml" + second.write_text("b: 2\n", encoding="utf-8") + profile = {"files": [str(first), str(second)]} + assert yaml_sdb.get("a", profile=profile) == 1 + assert yaml_sdb.get("b", profile=profile) == 2 + + +def test_set_is_not_supported(): + """ + The yaml sdb backend is read-only; set raises NotImplemented. + """ + with pytest.raises(salt.exceptions.NotImplemented): + yaml_sdb.set_("key", "value") From fd37126d34c6b20759415dec91aa829c05eaedd6 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 03:50:50 -0400 Subject: [PATCH 204/469] Match filter_by keys exactly before treating them as globs salt.utils.data.filter_by ran every lookup_dict key through fnmatch.fnmatchcase, so a literal key containing glob metacharacters (the "[" and "]" in GPU/PCI model strings such as "GP104GL [Quadro P4000]") was parsed as a character class and never matched its own value, falling through to the default (#60976). Try an exact string comparison before the fnmatch fallback. The change is additive: literal keys now match and existing glob patterns are unaffected. This also covers grains.filter_by, pillar.filter_by, and match.filter_by, which all delegate here. Fixes #60976 --- changelog/60976.fixed.md | 1 + salt/utils/data.py | 7 ++++- tests/pytests/unit/utils/test_data.py | 38 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 changelog/60976.fixed.md diff --git a/changelog/60976.fixed.md b/changelog/60976.fixed.md new file mode 100644 index 000000000000..d269b6cb9363 --- /dev/null +++ b/changelog/60976.fixed.md @@ -0,0 +1 @@ +Fixed ``grains.filter_by`` (and ``pillar.filter_by``/``match.filter_by``) failing to match lookup keys that contain fnmatch glob metacharacters such as ``[`` and ``]`` (for example GPU/PCI model strings); keys are now matched exactly before being treated as a glob. diff --git a/salt/utils/data.py b/salt/utils/data.py index 7ec5d686a155..71e156740dfe 100644 --- a/salt/utils/data.py +++ b/salt/utils/data.py @@ -745,7 +745,12 @@ def filter_by(lookup_dict, lookup, traverse, merge=None, default="default", base for key in lookup_dict: test_key = key if isinstance(key, str) else str(key) test_each = each if isinstance(each, str) else str(each) - if fnmatch.fnmatchcase(test_each, test_key): + # Prefer an exact match before treating the key as an fnmatch glob, + # so literal keys containing glob metacharacters (e.g. the "[" and + # "]" in GPU/PCI model strings like "GP104GL [Quadro P4000]") still + # match their own value instead of being parsed as a character + # class (#60976). + if test_each == test_key or fnmatch.fnmatchcase(test_each, test_key): ret = lookup_dict[key] break if ret is not None: diff --git a/tests/pytests/unit/utils/test_data.py b/tests/pytests/unit/utils/test_data.py index 4feaef9ba751..95b0566c092a 100644 --- a/tests/pytests/unit/utils/test_data.py +++ b/tests/pytests/unit/utils/test_data.py @@ -1529,3 +1529,41 @@ def test_ignore_missing_keys_recursive(): assert expected_result == salt.utils.data.recursive_diff( dict_one, dict_two, ignore_missing_keys=True ) + + +def test_filter_by_literal_key_with_glob_metacharacters(): + """ + Regression test for #60976. + + filter_by treats each lookup_dict key as an fnmatch glob. A literal key + containing glob metacharacters (e.g. the brackets in a GPU model string + like "GP104GL [Quadro P4000]") must still match its own value via an exact + comparison, instead of being parsed as a character class and falling + through to the default. Pins the bug: without the exact-match preference + this returns "fallback". + """ + lookup_dict = {"GP104GL [Quadro P4000]": "quadro", "default": "fallback"} + result = salt.utils.data.filter_by( + lookup_dict, "gpu", {"gpu": "GP104GL [Quadro P4000]"} + ) + assert result == "quadro" + + +def test_filter_by_glob_pattern_still_matches(): + """ + Inverse of #60976: existing glob patterns in lookup_dict keys keep working; + the exact-match preference is additive, not a replacement. + """ + lookup_dict = {"Ubuntu*": "debian-like", "default": "other"} + result = salt.utils.data.filter_by(lookup_dict, "os", {"os": "Ubuntu"}) + assert result == "debian-like" + + +def test_filter_by_plain_literal_key(): + """ + A plain literal key with no metacharacters continues to match, so the + common case is unaffected. + """ + lookup_dict = {"CentOS": "rhel-like", "default": "other"} + result = salt.utils.data.filter_by(lookup_dict, "os", {"os": "CentOS"}) + assert result == "rhel-like" From 44e2148c79b0a11c68be6404d26f12e90293e454 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 03:40:45 -0400 Subject: [PATCH 205/469] Coerce keysize to int in wheel key.gen wheel.key.gen passed keysize straight to salt.crypt.gen_keys, which feeds it to rsa.generate_private_key (key_size must be an int). The salt-api rest_cherrypy POST /keys endpoint passes keysize as a string (cherrypy form values are always strings), so a request specifying a keysize raised TypeError and returned a 500 (#56425). Coerce keysize to an int and enforce the 2048-bit minimum that gen's docstring already documents. Fixes #56425 --- changelog/56425.fixed.md | 1 + salt/wheel/key.py | 4 ++ tests/pytests/unit/wheel/test_key.py | 62 ++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 changelog/56425.fixed.md create mode 100644 tests/pytests/unit/wheel/test_key.py diff --git a/changelog/56425.fixed.md b/changelog/56425.fixed.md new file mode 100644 index 000000000000..57dd572baa92 --- /dev/null +++ b/changelog/56425.fixed.md @@ -0,0 +1 @@ +Fixed ``wheel.key.gen``/``gen_accept`` (used by the salt-api ``rest_cherrypy`` ``POST /keys`` endpoint) erroring on a string ``keysize``; the value is now coerced to an integer and the documented 2048-bit minimum is enforced. diff --git a/salt/wheel/key.py b/salt/wheel/key.py index 29c9cca54a79..4d4941ae8049 100644 --- a/salt/wheel/key.py +++ b/salt/wheel/key.py @@ -414,6 +414,10 @@ def gen(id_=None, keysize=2048): else: id_ = clean.filename(id_) ret = {"priv": "", "pub": ""} + # keysize may arrive as a string (for example from the salt-api /keys + # endpoint, where cherrypy form values are always strings). Coerce it and + # honor the documented 2048-bit minimum before generating the key pair. + keysize = max(2048, int(keysize)) priv = salt.crypt.gen_keys(__opts__["pki_dir"], id_, keysize) pub = "{}.pub".format(priv[: priv.rindex(".")]) with salt.utils.files.fopen(priv) as fp_: diff --git a/tests/pytests/unit/wheel/test_key.py b/tests/pytests/unit/wheel/test_key.py new file mode 100644 index 000000000000..a36ddbea6bc6 --- /dev/null +++ b/tests/pytests/unit/wheel/test_key.py @@ -0,0 +1,62 @@ +import pytest + +import salt.wheel.key as key +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(tmp_path): + return {key: {"__opts__": {"pki_dir": str(tmp_path)}}} + + +def _stub_gen_keys(tmp_path): + """ + Return a MagicMock standing in for salt.crypt.gen_keys, backed by real + on-disk pem/pub files so gen() can read them after the (mocked) call. + """ + priv = tmp_path / "minion.pem" + priv.write_text("priv", encoding="utf-8") + (tmp_path / "minion.pub").write_text("pub", encoding="utf-8") + return MagicMock(return_value=str(priv)) + + +def test_gen_coerces_string_keysize(tmp_path): + """ + Regression test for #56425. + + The salt-api ``POST /keys`` endpoint passes ``keysize`` as a string, because + cherrypy form values are always strings. ``gen()`` must coerce it to an int + before handing it to ``salt.crypt.gen_keys`` (which feeds it to + ``rsa.generate_private_key``, whose ``key_size`` must be an int). This pins + the bug: without the coercion ``gen_keys`` receives the raw ``"4096"`` + string and this assertion fails. + """ + gen_keys = _stub_gen_keys(tmp_path) + with patch("salt.crypt.gen_keys", gen_keys): + key.gen(id_="minion", keysize="4096") + passed_keysize = gen_keys.call_args[0][2] + assert passed_keysize == 4096 + assert isinstance(passed_keysize, int) + + +def test_gen_enforces_2048_floor(tmp_path): + """ + #56425: a keysize below the documented 2048-bit minimum is rounded up to + 2048, which ``gen()``'s docstring has always promised but the code never + implemented. + """ + gen_keys = _stub_gen_keys(tmp_path) + with patch("salt.crypt.gen_keys", gen_keys): + key.gen(id_="minion", keysize="1024") + assert gen_keys.call_args[0][2] == 2048 + + +def test_gen_int_keysize_unchanged(tmp_path): + """ + Inverse of #56425: a valid integer keysize at or above the floor passes + through unchanged, so the coercion never alters a correct caller. + """ + gen_keys = _stub_gen_keys(tmp_path) + with patch("salt.crypt.gen_keys", gen_keys): + key.gen(id_="minion", keysize=4096) + assert gen_keys.call_args[0][2] == 4096 From 4cdf561b844a4c86f46be2f6810e5c23adaf24ea Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 4 Jul 2026 16:33:38 -0400 Subject: [PATCH 206/469] Fix file state UnicodeDecodeError on encoding mismatch The file.comment, file.append and file.prepend states read the target file as bytes and decode it with the system encoding purely to build a diff. A strict decode raised UnicodeDecodeError and aborted the state when the file contained bytes invalid in that encoding. Decode with errors="replace" since the result is only used for the diff. Fixes #50903 --- changelog/50903.fixed.md | 1 + salt/states/file.py | 14 +++--- tests/pytests/unit/states/file/test_append.py | 48 +++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 changelog/50903.fixed.md create mode 100644 tests/pytests/unit/states/file/test_append.py diff --git a/changelog/50903.fixed.md b/changelog/50903.fixed.md new file mode 100644 index 000000000000..912dd06030c2 --- /dev/null +++ b/changelog/50903.fixed.md @@ -0,0 +1 @@ +Fixed the file.comment, file.append, and file.prepend states failing with UnicodeDecodeError when the target file contains bytes that are not valid in the system encoding. diff --git a/salt/states/file.py b/salt/states/file.py index 9b6bdf2e5337..26774825ce50 100644 --- a/salt/states/file.py +++ b/salt/states/file.py @@ -6308,7 +6308,7 @@ def comment(name, regex, char="#", backup=".bak", ignore_missing=False): with salt.utils.files.fopen(name, "rb") as fp_: slines = fp_.read() - slines = slines.decode(__salt_system_encoding__) + slines = slines.decode(__salt_system_encoding__, errors="replace") slines = slines.splitlines(True) # Perform the edit @@ -6316,7 +6316,7 @@ def comment(name, regex, char="#", backup=".bak", ignore_missing=False): with salt.utils.files.fopen(name, "rb") as fp_: nlines = fp_.read() - nlines = nlines.decode(__salt_system_encoding__) + nlines = nlines.decode(__salt_system_encoding__, errors="replace") nlines = nlines.splitlines(True) # Check the result @@ -6645,7 +6645,7 @@ def append( with salt.utils.files.fopen(name, "rb") as fp_: slines = fp_.read() - slines = slines.decode(__salt_system_encoding__) + slines = slines.decode(__salt_system_encoding__, errors="replace") slines = slines.splitlines() append_lines = [] @@ -6691,7 +6691,7 @@ def append( with salt.utils.files.fopen(name, "rb") as fp_: nlines = fp_.read() - nlines = nlines.decode(__salt_system_encoding__) + nlines = nlines.decode(__salt_system_encoding__, errors="replace") nlines = nlines.splitlines() if slines != nlines: @@ -6930,7 +6930,7 @@ def prepend( with salt.utils.files.fopen(name, "rb") as fp_: slines = fp_.read() - slines = slines.decode(__salt_system_encoding__) + slines = slines.decode(__salt_system_encoding__, errors="replace") slines = slines.splitlines(True) count = 0 @@ -6978,7 +6978,7 @@ def prepend( with salt.utils.files.fopen(name, "rb") as fp_: # read as many lines of target file as length of user input contents = fp_.read() - contents = contents.decode(__salt_system_encoding__) + contents = contents.decode(__salt_system_encoding__, errors="replace") contents = contents.splitlines(True) target_head = contents[0 : len(preface)] target_lines = [] @@ -6997,7 +6997,7 @@ def prepend( with salt.utils.files.fopen(name, "rb") as fp_: nlines = fp_.read() - nlines = nlines.decode(__salt_system_encoding__) + nlines = nlines.decode(__salt_system_encoding__, errors="replace") nlines = nlines.splitlines(True) if slines != nlines: diff --git a/tests/pytests/unit/states/file/test_append.py b/tests/pytests/unit/states/file/test_append.py new file mode 100644 index 000000000000..65c8c166171d --- /dev/null +++ b/tests/pytests/unit/states/file/test_append.py @@ -0,0 +1,48 @@ +import builtins + +import pytest + +import salt.states.file as filestate +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return { + filestate: { + "__env__": "base", + "__salt__": {}, + "__opts__": {"test": False, "cachedir": ""}, + "__instance_id__": "", + "__low__": {}, + "__utils__": {}, + } + } + + +def test_append_file_encoding_mismatch(tmp_path): + """ + file.append must not raise UnicodeDecodeError when the target file + contains bytes that are not valid in the system encoding. The decoded + contents are only used to build the diff, so undecodable bytes should + be tolerated rather than aborting the state. + + Regression test for #50903. + """ + name = tmp_path / "bugfile" + # 0xed is not valid ASCII and not valid UTF-8 on its own + name.write_bytes(b"abc\xedxyz\n") + + salt_mock = { + "file.search": MagicMock(return_value=False), + "file.append": MagicMock(return_value=None), + } + utils_mock = {"files.is_text": MagicMock(return_value=True)} + + with patch.object(builtins, "__salt_system_encoding__", "ascii"), patch.dict( + filestate.__salt__, salt_mock + ), patch.dict(filestate.__utils__, utils_mock): + result = filestate.append(name=str(name), text="cheese") + + assert result["result"] is True + salt_mock["file.append"].assert_called_once() From 0c1c4e840cf3ff56f5b682ab5007185ce67aee22 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 5 Jul 2026 23:24:00 -0400 Subject: [PATCH 207/469] Add direct and inverse regression tests for file state encoding mismatch fix The direct tests call file.comment and file.prepend (the two changed state functions previously untested for this case) with production-shape args (name+regex / name+text, as the state compiler passes from an SLS) against a real file holding a 0xed byte while __salt_system_encoding__ is ascii, pinning the errors="replace" decode fix; both fail with UnicodeDecodeError when the fix is reverted. The inverse test guards against overcorrection: file.append on a cleanly-decodable UTF-8 file must still produce an unmangled diff with no U+FFFD replacement characters, and passes with and without the fix. Claude-Session: https://claude.ai/code/session_01MF2AuQNhBZg4HDt1x6xxCu --- tests/pytests/unit/states/file/test_append.py | 41 +++++++++++++++++++ .../pytests/unit/states/file/test_comment.py | 33 +++++++++++++++ .../pytests/unit/states/file/test_prepend.py | 32 +++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/tests/pytests/unit/states/file/test_append.py b/tests/pytests/unit/states/file/test_append.py index 65c8c166171d..af788fee8165 100644 --- a/tests/pytests/unit/states/file/test_append.py +++ b/tests/pytests/unit/states/file/test_append.py @@ -3,6 +3,7 @@ import pytest import salt.states.file as filestate +import salt.utils.files from tests.support.mock import MagicMock, patch @@ -46,3 +47,43 @@ def test_append_file_encoding_mismatch(tmp_path): assert result["result"] is True salt_mock["file.append"].assert_called_once() + + +def test_append_clean_encoding_unaffected_50903(tmp_path): + """ + Guard against overcorrection of the #50903 fix: decoding with + errors="replace" must not change behaviour for files that decode + cleanly in the system encoding. The diff must still be generated, + contain the original non-ASCII text unmangled, and hold no U+FFFD + replacement characters. This test passes with and without the fix. + """ + name = tmp_path / "cleanfile" + # Valid UTF-8 content that decodes cleanly with the utf-8 system encoding + name.write_bytes("h\u00e9llo\n".encode("utf-8")) + + def fake_append(fname, args=None): + # Perform the real append so the state's second read sees a change + with salt.utils.files.fopen(fname, "a", encoding="utf-8") as fp_: + for line in args: + fp_.write(line + "\n") + + salt_mock = { + "file.search": MagicMock(return_value=False), + "file.append": MagicMock(side_effect=fake_append), + } + utils_mock = {"files.is_text": MagicMock(return_value=True)} + + # Production callers (the state compiler running an SLS file.append) + # pass name and text; __salt_system_encoding__ is the locale-derived + # builtin read by the decode sites, so it is patched rather than passed. + with patch.object(builtins, "__salt_system_encoding__", "utf-8"), patch.dict( + filestate.__salt__, salt_mock + ), patch.dict(filestate.__utils__, utils_mock): + result = filestate.append(name=str(name), text="cheese") + + assert result["result"] is True + assert result["comment"] == "Appended 1 lines" + diff = result["changes"]["diff"] + assert "+cheese" in diff + assert "h\u00e9llo" in diff + assert "\ufffd" not in diff diff --git a/tests/pytests/unit/states/file/test_comment.py b/tests/pytests/unit/states/file/test_comment.py index 6ef8b72de28c..82e67b67337e 100644 --- a/tests/pytests/unit/states/file/test_comment.py +++ b/tests/pytests/unit/states/file/test_comment.py @@ -1,3 +1,4 @@ +import builtins import logging import os @@ -115,6 +116,38 @@ def test_comment(): assert filestate.comment(name, regex) == ret +def test_comment_file_encoding_mismatch_50903(tmp_path): + """ + file.comment must not raise UnicodeDecodeError when the target file + contains bytes that are not valid in the system encoding. The decoded + contents are only used to build the diff, so undecodable bytes should + be tolerated rather than aborting the state. + + Regression test for #50903. + """ + name = tmp_path / "fstab" + # 0xed is not valid ASCII and not valid UTF-8 on its own + name.write_bytes(b"bind 127.0.0.1\nabc\xedxyz\n") + + salt_mock = { + # First search: uncommented pattern found; second: commented after edit + "file.search": MagicMock(side_effect=[True, True]), + "file.comment_line": MagicMock(return_value=True), + } + + # Production callers (the state compiler running an SLS file.comment) + # pass name and regex; __salt_system_encoding__ is the locale-derived + # builtin read by the decode sites, so it is patched rather than passed. + with patch.object(builtins, "__salt_system_encoding__", "ascii"), patch.dict( + filestate.__salt__, salt_mock + ): + result = filestate.comment(str(name), "^bind 127.0.0.1") + + assert result["result"] is True + assert result["comment"] == "Commented lines successfully" + salt_mock["file.comment_line"].assert_called_once() + + # 'uncomment' function tests: 1 def test_uncomment(): """ diff --git a/tests/pytests/unit/states/file/test_prepend.py b/tests/pytests/unit/states/file/test_prepend.py index db528519ec0f..60f66a30eff3 100644 --- a/tests/pytests/unit/states/file/test_prepend.py +++ b/tests/pytests/unit/states/file/test_prepend.py @@ -1,3 +1,4 @@ +import builtins import logging import os @@ -114,3 +115,34 @@ def test_prepend(): comt = "Prepended 1 lines" ret.update({"comment": comt, "result": True, "changes": {}}) assert filestate.prepend(name, text=text) == ret + + +def test_prepend_file_encoding_mismatch_50903(tmp_path): + """ + file.prepend must not raise UnicodeDecodeError when the target file + contains bytes that are not valid in the system encoding. The decoded + contents are only used to build the diff, so undecodable bytes should + be tolerated rather than aborting the state. + + Regression test for #50903. + """ + name = tmp_path / "motd" + # 0xed is not valid ASCII and not valid UTF-8 on its own + name.write_bytes(b"abc\xedxyz\n") + + salt_mock = { + "file.search": MagicMock(return_value=False), + "file.prepend": MagicMock(return_value=True), + } + + # Production callers (the state compiler running an SLS file.prepend) + # pass name and text; __salt_system_encoding__ is the locale-derived + # builtin read by the decode sites, so it is patched rather than passed. + with patch.object(builtins, "__salt_system_encoding__", "ascii"), patch.dict( + filestate.__salt__, salt_mock + ): + result = filestate.prepend(name=str(name), text="Trust no one") + + assert result["result"] is True + assert result["comment"] == "Prepended 1 lines" + salt_mock["file.prepend"].assert_called_once() From 5ea5f9578e90a1ea56c308831c9944d4e62351ee Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 23:09:51 -0400 Subject: [PATCH 208/469] Drop redundant utf-8 argument from str.encode() pyupgrade (--py310-plus) flags the explicit "utf-8" as redundant since it is the default encoding for str.encode(); the pre-commit hook fails otherwise. Behaviour is unchanged. --- tests/pytests/unit/states/file/test_append.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/pytests/unit/states/file/test_append.py b/tests/pytests/unit/states/file/test_append.py index af788fee8165..d111abfbd0bd 100644 --- a/tests/pytests/unit/states/file/test_append.py +++ b/tests/pytests/unit/states/file/test_append.py @@ -59,7 +59,7 @@ def test_append_clean_encoding_unaffected_50903(tmp_path): """ name = tmp_path / "cleanfile" # Valid UTF-8 content that decodes cleanly with the utf-8 system encoding - name.write_bytes("h\u00e9llo\n".encode("utf-8")) + name.write_bytes("h\u00e9llo\n".encode()) def fake_append(fname, args=None): # Perform the real append so the state's second read sees a change From fe2c66356b597acd188787df9db9fa12c72a38fe Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 19:25:58 -0400 Subject: [PATCH 209/469] Add encoding and encoding_errors params to file comment/append/prepend Replace the hardcoded errors="replace" in the file.comment, file.append and file.prepend states with configurable encoding and encoding_errors parameters, mirroring file.managed. The default encoding_errors is "strict" (Python's own default); a file whose bytes are not valid in the system encoding is handled by setting encoding_errors=replace or a matching encoding, rather than the state aborting while building the change diff. Addresses review feedback on #50903. --- changelog/50903.fixed.md | 2 +- salt/states/file.py | 108 ++++++++++++++++-- tests/pytests/unit/states/file/test_append.py | 71 +++++++++--- .../pytests/unit/states/file/test_comment.py | 40 +++++-- .../pytests/unit/states/file/test_prepend.py | 40 +++++-- 5 files changed, 217 insertions(+), 44 deletions(-) diff --git a/changelog/50903.fixed.md b/changelog/50903.fixed.md index 912dd06030c2..4a51629648e8 100644 --- a/changelog/50903.fixed.md +++ b/changelog/50903.fixed.md @@ -1 +1 @@ -Fixed the file.comment, file.append, and file.prepend states failing with UnicodeDecodeError when the target file contains bytes that are not valid in the system encoding. +Added ``encoding`` and ``encoding_errors`` parameters to the file.comment, file.append, and file.prepend states, mirroring file.managed. A file whose bytes are not valid in the system encoding can now be handled by setting ``encoding_errors: replace`` (or a matching ``encoding``) instead of the state aborting with a UnicodeDecodeError while building the change diff. diff --git a/salt/states/file.py b/salt/states/file.py index 26774825ce50..5290e4fd3d10 100644 --- a/salt/states/file.py +++ b/salt/states/file.py @@ -6226,7 +6226,15 @@ def blockreplace( return ret -def comment(name, regex, char="#", backup=".bak", ignore_missing=False): +def comment( + name, + regex, + char="#", + backup=".bak", + ignore_missing=False, + encoding=None, + encoding_errors="strict", +): """ .. versionadded:: 0.9.5 .. versionchanged:: 3005 @@ -6262,6 +6270,28 @@ def comment(name, regex, char="#", backup=".bak", ignore_missing=False): .. versionadded:: 3005 + encoding + If specified, this encoding is used to decode the file when building + the diff used for change detection. Otherwise the system locale + encoding (usually UTF-8) is used. This does not affect the file's + contents, which are modified as raw bytes by the underlying execution + module. See + https://docs.python.org/3/library/codecs.html#standard-encodings for + the list of available encodings. + + .. versionadded:: 3006.28 + + encoding_errors + Error handling scheme used when decoding the file for the diff. + Default is ``'strict'``, matching Python's default, which raises a + ``UnicodeDecodeError`` if the file contains bytes that are not valid in + the chosen encoding. Set to ``'replace'`` / ``'ignore'`` (or supply the + correct ``encoding``) to handle such files. See + https://docs.python.org/3/library/codecs.html#error-handlers for the + list of available schemes. + + .. versionadded:: 3006.28 + Usage: .. code-block:: yaml @@ -6308,7 +6338,9 @@ def comment(name, regex, char="#", backup=".bak", ignore_missing=False): with salt.utils.files.fopen(name, "rb") as fp_: slines = fp_.read() - slines = slines.decode(__salt_system_encoding__, errors="replace") + slines = slines.decode( + encoding or __salt_system_encoding__, errors=encoding_errors + ) slines = slines.splitlines(True) # Perform the edit @@ -6316,7 +6348,9 @@ def comment(name, regex, char="#", backup=".bak", ignore_missing=False): with salt.utils.files.fopen(name, "rb") as fp_: nlines = fp_.read() - nlines = nlines.decode(__salt_system_encoding__, errors="replace") + nlines = nlines.decode( + encoding or __salt_system_encoding__, errors=encoding_errors + ) nlines = nlines.splitlines(True) # Check the result @@ -6445,6 +6479,8 @@ def append( defaults=None, context=None, ignore_whitespace=True, + encoding=None, + encoding_errors="strict", ): """ Ensure that some text appears at the end of a file. @@ -6536,6 +6572,28 @@ def append( appending content, one space or multiple tabs are the same for salt. Set this option to ``False`` if you want to change this behavior. + encoding + If specified, this encoding is used to decode the file when building + the diff used for change detection. Otherwise the system locale + encoding (usually UTF-8) is used. This does not affect the file's + contents, which are modified as raw bytes by the underlying execution + module. See + https://docs.python.org/3/library/codecs.html#standard-encodings for + the list of available encodings. + + .. versionadded:: 3006.28 + + encoding_errors + Error handling scheme used when decoding the file for the diff. + Default is ``'strict'``, matching Python's default, which raises a + ``UnicodeDecodeError`` if the file contains bytes that are not valid in + the chosen encoding. Set to ``'replace'`` / ``'ignore'`` (or supply the + correct ``encoding``) to handle such files. See + https://docs.python.org/3/library/codecs.html#error-handlers for the + list of available schemes. + + .. versionadded:: 3006.28 + Multi-line example: .. code-block:: yaml @@ -6645,7 +6703,9 @@ def append( with salt.utils.files.fopen(name, "rb") as fp_: slines = fp_.read() - slines = slines.decode(__salt_system_encoding__, errors="replace") + slines = slines.decode( + encoding or __salt_system_encoding__, errors=encoding_errors + ) slines = slines.splitlines() append_lines = [] @@ -6691,7 +6751,9 @@ def append( with salt.utils.files.fopen(name, "rb") as fp_: nlines = fp_.read() - nlines = nlines.decode(__salt_system_encoding__, errors="replace") + nlines = nlines.decode( + encoding or __salt_system_encoding__, errors=encoding_errors + ) nlines = nlines.splitlines() if slines != nlines: @@ -6718,6 +6780,8 @@ def prepend( defaults=None, context=None, header=None, + encoding=None, + encoding_errors="strict", ): """ Ensure that some text appears at the beginning of a file @@ -6809,6 +6873,28 @@ def prepend( appending content, one space or multiple tabs are the same for salt. Set this option to ``False`` if you want to change this behavior. + encoding + If specified, this encoding is used to decode the file when building + the diff used for change detection. Otherwise the system locale + encoding (usually UTF-8) is used. This does not affect the file's + contents, which are modified as raw bytes by the underlying execution + module. See + https://docs.python.org/3/library/codecs.html#standard-encodings for + the list of available encodings. + + .. versionadded:: 3006.28 + + encoding_errors + Error handling scheme used when decoding the file for the diff. + Default is ``'strict'``, matching Python's default, which raises a + ``UnicodeDecodeError`` if the file contains bytes that are not valid in + the chosen encoding. Set to ``'replace'`` / ``'ignore'`` (or supply the + correct ``encoding``) to handle such files. See + https://docs.python.org/3/library/codecs.html#error-handlers for the + list of available schemes. + + .. versionadded:: 3006.28 + Multi-line example: .. code-block:: yaml @@ -6930,7 +7016,9 @@ def prepend( with salt.utils.files.fopen(name, "rb") as fp_: slines = fp_.read() - slines = slines.decode(__salt_system_encoding__, errors="replace") + slines = slines.decode( + encoding or __salt_system_encoding__, errors=encoding_errors + ) slines = slines.splitlines(True) count = 0 @@ -6978,7 +7066,9 @@ def prepend( with salt.utils.files.fopen(name, "rb") as fp_: # read as many lines of target file as length of user input contents = fp_.read() - contents = contents.decode(__salt_system_encoding__, errors="replace") + contents = contents.decode( + encoding or __salt_system_encoding__, errors=encoding_errors + ) contents = contents.splitlines(True) target_head = contents[0 : len(preface)] target_lines = [] @@ -6997,7 +7087,9 @@ def prepend( with salt.utils.files.fopen(name, "rb") as fp_: nlines = fp_.read() - nlines = nlines.decode(__salt_system_encoding__, errors="replace") + nlines = nlines.decode( + encoding or __salt_system_encoding__, errors=encoding_errors + ) nlines = nlines.splitlines(True) if slines != nlines: diff --git a/tests/pytests/unit/states/file/test_append.py b/tests/pytests/unit/states/file/test_append.py index d111abfbd0bd..1ccdeb0a7c70 100644 --- a/tests/pytests/unit/states/file/test_append.py +++ b/tests/pytests/unit/states/file/test_append.py @@ -21,14 +21,13 @@ def configure_loader_modules(): } -def test_append_file_encoding_mismatch(tmp_path): +def test_append_encoding_mismatch_strict_raises_50903(tmp_path): """ - file.append must not raise UnicodeDecodeError when the target file - contains bytes that are not valid in the system encoding. The decoded - contents are only used to build the diff, so undecodable bytes should - be tolerated rather than aborting the state. - - Regression test for #50903. + With the default encoding_errors="strict" (matching Python's own default), + file.append aborts with a UnicodeDecodeError when the target file contains + bytes that are not valid in the encoding used to build the diff. This + documents the default behaviour for #50903; encoding_errors="replace" (or a + matching encoding) is the supported escape hatch, covered below. """ name = tmp_path / "bugfile" # 0xed is not valid ASCII and not valid UTF-8 on its own @@ -43,7 +42,54 @@ def test_append_file_encoding_mismatch(tmp_path): with patch.object(builtins, "__salt_system_encoding__", "ascii"), patch.dict( filestate.__salt__, salt_mock ), patch.dict(filestate.__utils__, utils_mock): - result = filestate.append(name=str(name), text="cheese") + with pytest.raises(UnicodeDecodeError): + filestate.append(name=str(name), text="cheese") + + +def test_append_encoding_mismatch_replace_50903(tmp_path): + """ + Setting encoding_errors="replace" lets file.append proceed on a file whose + bytes are not valid in the diff encoding, instead of aborting. This is the + #50903 escape hatch, called with the production-exact argument shape. + """ + name = tmp_path / "bugfile" + name.write_bytes(b"abc\xedxyz\n") + + salt_mock = { + "file.search": MagicMock(return_value=False), + "file.append": MagicMock(return_value=None), + } + utils_mock = {"files.is_text": MagicMock(return_value=True)} + + with patch.object(builtins, "__salt_system_encoding__", "ascii"), patch.dict( + filestate.__salt__, salt_mock + ), patch.dict(filestate.__utils__, utils_mock): + result = filestate.append( + name=str(name), text="cheese", encoding_errors="replace" + ) + + assert result["result"] is True + salt_mock["file.append"].assert_called_once() + + +def test_append_encoding_override_handles_mismatch_50903(tmp_path): + """ + Supplying an encoding that can decode the file (latin-1 maps every byte) is + an alternative to encoding_errors: the state proceeds under strict errors. + """ + name = tmp_path / "bugfile" + name.write_bytes(b"abc\xedxyz\n") + + salt_mock = { + "file.search": MagicMock(return_value=False), + "file.append": MagicMock(return_value=None), + } + utils_mock = {"files.is_text": MagicMock(return_value=True)} + + with patch.object(builtins, "__salt_system_encoding__", "ascii"), patch.dict( + filestate.__salt__, salt_mock + ), patch.dict(filestate.__utils__, utils_mock): + result = filestate.append(name=str(name), text="cheese", encoding="latin-1") assert result["result"] is True salt_mock["file.append"].assert_called_once() @@ -51,11 +97,10 @@ def test_append_file_encoding_mismatch(tmp_path): def test_append_clean_encoding_unaffected_50903(tmp_path): """ - Guard against overcorrection of the #50903 fix: decoding with - errors="replace" must not change behaviour for files that decode - cleanly in the system encoding. The diff must still be generated, - contain the original non-ASCII text unmangled, and hold no U+FFFD - replacement characters. This test passes with and without the fix. + Adding the encoding params must not change behaviour for files that decode + cleanly under the default strict handling. The diff is still generated, + contains the original non-ASCII text unmangled, and holds no U+FFFD + replacement characters. This test passes with and without the change. """ name = tmp_path / "cleanfile" # Valid UTF-8 content that decodes cleanly with the utf-8 system encoding diff --git a/tests/pytests/unit/states/file/test_comment.py b/tests/pytests/unit/states/file/test_comment.py index 82e67b67337e..56c8ddbb4fd3 100644 --- a/tests/pytests/unit/states/file/test_comment.py +++ b/tests/pytests/unit/states/file/test_comment.py @@ -116,32 +116,50 @@ def test_comment(): assert filestate.comment(name, regex) == ret -def test_comment_file_encoding_mismatch_50903(tmp_path): +def test_comment_encoding_mismatch_strict_raises_50903(tmp_path): """ - file.comment must not raise UnicodeDecodeError when the target file - contains bytes that are not valid in the system encoding. The decoded - contents are only used to build the diff, so undecodable bytes should - be tolerated rather than aborting the state. - - Regression test for #50903. + With the default encoding_errors="strict" (matching Python's own default), + file.comment aborts with a UnicodeDecodeError when the target file contains + bytes not valid in the encoding used to build the diff. This documents the + #50903 default; encoding_errors="replace" is the escape hatch, covered next. """ name = tmp_path / "fstab" # 0xed is not valid ASCII and not valid UTF-8 on its own name.write_bytes(b"bind 127.0.0.1\nabc\xedxyz\n") + salt_mock = { + "file.search": MagicMock(side_effect=[True, True]), + "file.comment_line": MagicMock(return_value=True), + } + + with patch.object(builtins, "__salt_system_encoding__", "ascii"), patch.dict( + filestate.__salt__, salt_mock + ): + with pytest.raises(UnicodeDecodeError): + filestate.comment(str(name), "^bind 127.0.0.1") + + +def test_comment_encoding_mismatch_replace_50903(tmp_path): + """ + Setting encoding_errors="replace" lets file.comment proceed on a file whose + bytes are not valid in the diff encoding, instead of aborting. The #50903 + escape hatch, called with the production-exact argument shape. + """ + name = tmp_path / "fstab" + name.write_bytes(b"bind 127.0.0.1\nabc\xedxyz\n") + salt_mock = { # First search: uncommented pattern found; second: commented after edit "file.search": MagicMock(side_effect=[True, True]), "file.comment_line": MagicMock(return_value=True), } - # Production callers (the state compiler running an SLS file.comment) - # pass name and regex; __salt_system_encoding__ is the locale-derived - # builtin read by the decode sites, so it is patched rather than passed. with patch.object(builtins, "__salt_system_encoding__", "ascii"), patch.dict( filestate.__salt__, salt_mock ): - result = filestate.comment(str(name), "^bind 127.0.0.1") + result = filestate.comment( + str(name), "^bind 127.0.0.1", encoding_errors="replace" + ) assert result["result"] is True assert result["comment"] == "Commented lines successfully" diff --git a/tests/pytests/unit/states/file/test_prepend.py b/tests/pytests/unit/states/file/test_prepend.py index 60f66a30eff3..38eebb02ab1f 100644 --- a/tests/pytests/unit/states/file/test_prepend.py +++ b/tests/pytests/unit/states/file/test_prepend.py @@ -117,14 +117,12 @@ def test_prepend(): assert filestate.prepend(name, text=text) == ret -def test_prepend_file_encoding_mismatch_50903(tmp_path): +def test_prepend_encoding_mismatch_strict_raises_50903(tmp_path): """ - file.prepend must not raise UnicodeDecodeError when the target file - contains bytes that are not valid in the system encoding. The decoded - contents are only used to build the diff, so undecodable bytes should - be tolerated rather than aborting the state. - - Regression test for #50903. + With the default encoding_errors="strict" (matching Python's own default), + file.prepend aborts with a UnicodeDecodeError when the target file contains + bytes not valid in the encoding used to build the diff. This documents the + #50903 default; encoding_errors="replace" is the escape hatch, covered next. """ name = tmp_path / "motd" # 0xed is not valid ASCII and not valid UTF-8 on its own @@ -135,13 +133,33 @@ def test_prepend_file_encoding_mismatch_50903(tmp_path): "file.prepend": MagicMock(return_value=True), } - # Production callers (the state compiler running an SLS file.prepend) - # pass name and text; __salt_system_encoding__ is the locale-derived - # builtin read by the decode sites, so it is patched rather than passed. with patch.object(builtins, "__salt_system_encoding__", "ascii"), patch.dict( filestate.__salt__, salt_mock ): - result = filestate.prepend(name=str(name), text="Trust no one") + with pytest.raises(UnicodeDecodeError): + filestate.prepend(name=str(name), text="Trust no one") + + +def test_prepend_encoding_mismatch_replace_50903(tmp_path): + """ + Setting encoding_errors="replace" lets file.prepend proceed on a file whose + bytes are not valid in the diff encoding, instead of aborting. The #50903 + escape hatch, called with the production-exact argument shape. + """ + name = tmp_path / "motd" + name.write_bytes(b"abc\xedxyz\n") + + salt_mock = { + "file.search": MagicMock(return_value=False), + "file.prepend": MagicMock(return_value=True), + } + + with patch.object(builtins, "__salt_system_encoding__", "ascii"), patch.dict( + filestate.__salt__, salt_mock + ): + result = filestate.prepend( + name=str(name), text="Trust no one", encoding_errors="replace" + ) assert result["result"] is True assert result["comment"] == "Prepended 1 lines" From f85e76093bd318ba7ec9ae76ec32dc0915933411 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 4 Jul 2026 16:29:54 -0400 Subject: [PATCH 210/469] Fix thorium reg.list crash on non-string add value reg.list_() called add.split(",") whenever add was not a list, which raised "AttributeError: 'int' object has no attribute 'split'" when an integer (or other scalar) was passed via SLS. Only split strings and wrap any other non-list scalar in a single-element list instead. Fixes #43364 --- changelog/43364.fixed.md | 1 + salt/thorium/reg.py | 4 ++- tests/pytests/unit/thorium/__init__.py | 0 tests/pytests/unit/thorium/test_reg.py | 49 ++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 changelog/43364.fixed.md create mode 100644 tests/pytests/unit/thorium/__init__.py create mode 100644 tests/pytests/unit/thorium/test_reg.py diff --git a/changelog/43364.fixed.md b/changelog/43364.fixed.md new file mode 100644 index 000000000000..b11330e00e99 --- /dev/null +++ b/changelog/43364.fixed.md @@ -0,0 +1 @@ +Fixed thorium reg.list to accept a non-string, non-list add value (such as an integer) instead of raising AttributeError. diff --git a/salt/thorium/reg.py b/salt/thorium/reg.py index 57842202cdfa..b187037f1b2d 100644 --- a/salt/thorium/reg.py +++ b/salt/thorium/reg.py @@ -60,8 +60,10 @@ def list_(name, add, match, stamp=False, prune=0): - stamp: True """ ret = {"name": name, "changes": {}, "comment": "", "result": True} - if not isinstance(add, list): + if isinstance(add, str): add = add.split(",") + elif not isinstance(add, list): + add = [add] if name not in __reg__: __reg__[name] = {} __reg__[name]["val"] = [] diff --git a/tests/pytests/unit/thorium/__init__.py b/tests/pytests/unit/thorium/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/pytests/unit/thorium/test_reg.py b/tests/pytests/unit/thorium/test_reg.py new file mode 100644 index 000000000000..b726536f3e67 --- /dev/null +++ b/tests/pytests/unit/thorium/test_reg.py @@ -0,0 +1,49 @@ +""" + tests.pytests.unit.thorium.test_reg + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Unit tests for the thorium reg module +""" + +import pytest + +import salt.thorium.reg as reg + + +@pytest.fixture +def setup_reg_dunders(): + # The thorium loader injects ``__reg__`` and ``__events__`` as module + # globals. Set them directly since they are not standard salt dunders + # handled by the loader mock fixture. + reg.__dict__["__reg__"] = {} + reg.__dict__["__events__"] = [ + { + "tag": "phil/was/here", + "data": {"data": {42: "the answer"}, "_stamp": "2017-09-06"}, + } + ] + try: + yield + finally: + reg.__dict__.pop("__reg__", None) + reg.__dict__.pop("__events__", None) + + +def test_list_integer_add(setup_reg_dunders): + """ + An integer ``add`` value must not raise AttributeError (no .split on int) + and the integer key should be looked up in the event data. + """ + ret = reg.list_("myregister", add=42, match="phil/was/here") + assert ret["result"] is True + assert reg.__dict__["__reg__"]["myregister"]["val"] == [{42: "the answer"}] + + +def test_list_string_add_still_splits(setup_reg_dunders): + """ + A comma separated string ``add`` value must still be split into keys. + """ + reg.__dict__["__events__"][0]["data"]["data"] = {"a": 1, "b": 2} + ret = reg.list_("myregister", add="a,b", match="phil/was/here") + assert ret["result"] is True + assert reg.__dict__["__reg__"]["myregister"]["val"] == [{"a": 1, "b": 2}] From be5c4112db866759d0bd8644f39557df79ab812b Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Tue, 14 Jul 2026 21:46:47 -0400 Subject: [PATCH 211/469] Reject unusable reg.list 'add' types instead of crashing or silently dropping Following review: the scalar-wrapping fix stopped the integer .split crash, but a dict or set 'add' still raised TypeError (unhashable) at 'key in event_data', and a tuple was wrapped and silently produced empty results. Extract the normalization into _normalize_add: a list is used as-is, a string is comma split, a lone scalar (int/float/None -- event data can carry non-string keys) is wrapped as a single key, and anything else is rejected with a SaltInvocationError naming the offending type. Thorium catches register exceptions per-state, so this surfaces the misconfiguration cleanly rather than crashing the reactor loop. Adds list-passthrough coverage and a parametrized reject test for dict/tuple/set. --- changelog/43364.fixed.md | 2 +- salt/thorium/reg.py | 31 ++++++++++++++++++++++---- tests/pytests/unit/thorium/test_reg.py | 24 ++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/changelog/43364.fixed.md b/changelog/43364.fixed.md index b11330e00e99..20923cf219ca 100644 --- a/changelog/43364.fixed.md +++ b/changelog/43364.fixed.md @@ -1 +1 @@ -Fixed thorium reg.list to accept a non-string, non-list add value (such as an integer) instead of raising AttributeError. +Fixed thorium reg.list handling of a non-string, non-list ``add`` value: a scalar (such as an integer) is now treated as a single key instead of raising AttributeError, and a type that cannot be used as event-data keys (dict, tuple, set) is rejected with a clear SaltInvocationError rather than crashing or silently adding nothing. diff --git a/salt/thorium/reg.py b/salt/thorium/reg.py index b187037f1b2d..9886b9fe0345 100644 --- a/salt/thorium/reg.py +++ b/salt/thorium/reg.py @@ -4,6 +4,7 @@ """ import salt.utils.stringutils +from salt.exceptions import SaltInvocationError __func_alias__ = { "set_": "set", @@ -41,6 +42,31 @@ def set_(name, add, match): return ret +def _normalize_add(add): + """ + Coerce the ``add`` argument of :func:`list` into the list of event-data keys + the function iterates over. + + ``add`` is consumed as ``for key in add: if key in event_data``, so each + element must be a hashable key. A bare string is split on commas, a list is + taken as-is, and a lone scalar (``int``/``float``/``None`` -- event data can + carry non-string keys) is wrapped as a single key. Any other type (``dict``, + ``tuple``, ``set``, ...) cannot serve as event-data keys: it would either be + silently dropped or raise ``TypeError: unhashable type`` mid-loop, so reject + it up front with a clear message instead. + """ + if isinstance(add, list): + return add + if isinstance(add, str): + return add.split(",") + if add is None or isinstance(add, (int, float)): + return [add] + raise SaltInvocationError( + f"reg.list 'add' must be a string, list, or scalar value, got " + f"{type(add).__name__}" + ) + + def list_(name, add, match, stamp=False, prune=0): """ Add the specified values to the named list @@ -60,10 +86,7 @@ def list_(name, add, match, stamp=False, prune=0): - stamp: True """ ret = {"name": name, "changes": {}, "comment": "", "result": True} - if isinstance(add, str): - add = add.split(",") - elif not isinstance(add, list): - add = [add] + add = _normalize_add(add) if name not in __reg__: __reg__[name] = {} __reg__[name]["val"] = [] diff --git a/tests/pytests/unit/thorium/test_reg.py b/tests/pytests/unit/thorium/test_reg.py index b726536f3e67..f65f064a3f0e 100644 --- a/tests/pytests/unit/thorium/test_reg.py +++ b/tests/pytests/unit/thorium/test_reg.py @@ -8,6 +8,7 @@ import pytest import salt.thorium.reg as reg +from salt.exceptions import SaltInvocationError @pytest.fixture @@ -47,3 +48,26 @@ def test_list_string_add_still_splits(setup_reg_dunders): ret = reg.list_("myregister", add="a,b", match="phil/was/here") assert ret["result"] is True assert reg.__dict__["__reg__"]["myregister"]["val"] == [{"a": 1, "b": 2}] + + +def test_list_add_list_passthrough(setup_reg_dunders): + """ + A list ``add`` value is used as-is as the set of keys to extract. + """ + reg.__dict__["__events__"][0]["data"]["data"] = {"a": 1, "b": 2} + ret = reg.list_("myregister", add=["a", "b"], match="phil/was/here") + assert ret["result"] is True + assert reg.__dict__["__reg__"]["myregister"]["val"] == [{"a": 1, "b": 2}] + + +@pytest.mark.parametrize("bad_add", [{"a": 1}, ("a", "b"), {"a", "b"}]) +def test_list_rejects_unusable_add_types(setup_reg_dunders, bad_add): + """ + ``add`` values that cannot serve as event-data keys are rejected with a + clear error rather than crashing mid-loop (dict/set are unhashable so raise + ``TypeError`` at ``key in event_data``) or silently producing empty results + (a tuple is hashable but never matches, so it used to succeed with nothing + added). + """ + with pytest.raises(SaltInvocationError): + reg.list_("myregister", add=bad_add, match="phil/was/here") From 0cbe138a0ab8c24003ca9a84cf82b3e2b1531463 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 4 Jul 2026 16:18:17 -0400 Subject: [PATCH 212/469] Honour allow_updates for pkg.installed sources installs _find_install_targets discarded the allow_updates flag whenever a package came from sources: (allow_updates = bool(not sources and ...)), forcing an exact-version match against the version reported by the source package. A self-updating agent installed via sources would therefore be reinstalled or downgraded on every highstate. Drop the 'not sources' guard so allow_updates is honoured for sources installs, and document that behaviour. Fixes #35385 --- changelog/35385.fixed.md | 1 + salt/states/pkg.py | 6 ++-- tests/pytests/unit/states/test_pkg.py | 42 +++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 changelog/35385.fixed.md diff --git a/changelog/35385.fixed.md b/changelog/35385.fixed.md new file mode 100644 index 000000000000..4e3d9feb3552 --- /dev/null +++ b/changelog/35385.fixed.md @@ -0,0 +1 @@ +Fixed pkg.installed to honour allow_updates for packages installed via sources, so a newer installed version is no longer reinstalled or downgraded on every run. diff --git a/salt/states/pkg.py b/salt/states/pkg.py index 3ed2305553e5..db43285a33de 100644 --- a/salt/states/pkg.py +++ b/salt/states/pkg.py @@ -822,7 +822,7 @@ def _find_install_targets( altered_files[package_name] = verify_result continue version_fulfilled = False - allow_updates = bool(not sources and kwargs.get("allow_updates")) + allow_updates = bool(kwargs.get("allow_updates")) try: version_fulfilled = _fulfills_version_string( cver, verstr, ignore_epoch=ignore_epoch, allow_updates=allow_updates @@ -1285,7 +1285,9 @@ def installed( Allow the package to be updated outside Salt's control (e.g. auto updates on Windows). This means a package on the Minion can have a newer version than the latest available in the repository without - enforcing a re-installation of the package. + enforcing a re-installation of the package. This also applies to + packages installed via ``sources``, where a newer installed version + will not be downgraded to the version reported by the source package. .. versionadded:: 2014.7.0 diff --git a/tests/pytests/unit/states/test_pkg.py b/tests/pytests/unit/states/test_pkg.py index 782a48412401..ba4630ce8dee 100644 --- a/tests/pytests/unit/states/test_pkg.py +++ b/tests/pytests/unit/states/test_pkg.py @@ -602,6 +602,48 @@ def test_installed_with_sources(list_pkgs, tmp_path): raise exc from None +def test_installed_sources_allow_updates_no_downgrade(): + """ + pkg.installed with ``sources`` and ``allow_updates=True`` should not + reinstall/downgrade when the installed version is newer than the version + reported by the source package. Regression test for #35385. + """ + source = "salt://server/check-mk-agent.deb" + + def _list_pkgs(**kwargs): + if kwargs.get("purge_desired"): + return {} + return {"check-mk-agent": ["2.0.0"]} + + with patch.dict( + pkg.__salt__, + { + "pkg.list_pkgs": MagicMock(side_effect=_list_pkgs), + "pkg_resource.pack_sources": MagicMock( + return_value={"check-mk-agent": source} + ), + "cp.cache_file": MagicMock(return_value="/cached/check-mk-agent.deb"), + "lowpkg.bin_pkg_info": MagicMock( + return_value={"name": "check-mk-agent", "version": "1.0.0"} + ), + }, + ), patch("salt.states.pkg.os.path.exists", return_value=True): + result = pkg._find_install_targets( + name="check-mk-agent", + sources=[{"check-mk-agent": source}], + allow_updates=True, + saltenv="base", + ) + + # When nothing needs to be installed, _find_install_targets returns a dict + # (result True). If allow_updates were ignored for sources, the newer + # installed version would be flagged for reinstall and a tuple of targets + # would be returned instead. + assert isinstance(result, dict) + assert result["result"] is True + assert result["changes"] == {} + + @pytest.mark.parametrize("action", ["removed", "purged"]) def test_removed_purged_with_changes_test_true(list_pkgs, action): """ From 10a6ec6e5edf4dd33f7bde9fd810cb1b44477bce Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 5 Jul 2026 23:18:25 -0400 Subject: [PATCH 213/469] Add direct and inverse regression tests for pkg.installed sources allow_updates The direct test already existed in this branch and calls _find_install_targets with sources and allow_updates=True, the exact kwarg pkg.installed forwards (kwargs["allow_updates"] = allow_updates). This adds the two inverse guards: with allow_updates left at its default of False a newer installed version must still be targeted for downgrade to the source package's version, and with allow_updates=True an older installed version must still be targeted for upgrade, so the fix cannot silently widen into ignoring version mismatches for sources installs. Claude-Session: https://claude.ai/code/session_01MF2AuQNhBZg4HDt1x6xxCu --- tests/pytests/unit/states/test_pkg.py | 82 +++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/pytests/unit/states/test_pkg.py b/tests/pytests/unit/states/test_pkg.py index ba4630ce8dee..bb91d2435ac7 100644 --- a/tests/pytests/unit/states/test_pkg.py +++ b/tests/pytests/unit/states/test_pkg.py @@ -644,6 +644,88 @@ def _list_pkgs(**kwargs): assert result["changes"] == {} +def test_installed_sources_without_allow_updates_still_downgrades_35385(): + """ + Inverse of the #35385 fix, guarding against overcorrection: with + ``sources`` and ``allow_updates`` left at its default (False), a newer + installed version must STILL be targeted for reinstall/downgrade to the + version reported by the source package. The fix must only change + behaviour when allow_updates is explicitly enabled. + """ + source = "salt://server/check-mk-agent.deb" + + def _list_pkgs(**kwargs): + if kwargs.get("purge_desired"): + return {} + return {"check-mk-agent": ["2.0.0"]} + + with patch.dict( + pkg.__salt__, + { + "pkg.list_pkgs": MagicMock(side_effect=_list_pkgs), + "pkg_resource.pack_sources": MagicMock( + return_value={"check-mk-agent": source} + ), + "cp.cache_file": MagicMock(return_value="/cached/check-mk-agent.deb"), + "lowpkg.bin_pkg_info": MagicMock( + return_value={"name": "check-mk-agent", "version": "1.0.0"} + ), + }, + ), patch("salt.states.pkg.os.path.exists", return_value=True): + # allow_updates=False is what pkg.installed passes in kwargs by + # default (kwargs["allow_updates"] = allow_updates) + result = pkg._find_install_targets( + name="check-mk-agent", + sources=[{"check-mk-agent": source}], + allow_updates=False, + saltenv="base", + ) + + # A tuple means targets were found; targets is the second element + assert isinstance(result, tuple) + targets = result[1] + assert targets == {"check-mk-agent": source} + + +def test_installed_sources_allow_updates_still_upgrades_35385(): + """ + Inverse of the #35385 fix, guarding against overcorrection: + ``allow_updates=True`` must only tolerate a NEWER installed version. When + the installed version is older than the one reported by the source + package, the package must still be targeted for installation. + """ + source = "salt://server/check-mk-agent.deb" + + def _list_pkgs(**kwargs): + if kwargs.get("purge_desired"): + return {} + return {"check-mk-agent": ["1.0.0"]} + + with patch.dict( + pkg.__salt__, + { + "pkg.list_pkgs": MagicMock(side_effect=_list_pkgs), + "pkg_resource.pack_sources": MagicMock( + return_value={"check-mk-agent": source} + ), + "cp.cache_file": MagicMock(return_value="/cached/check-mk-agent.deb"), + "lowpkg.bin_pkg_info": MagicMock( + return_value={"name": "check-mk-agent", "version": "2.0.0"} + ), + }, + ), patch("salt.states.pkg.os.path.exists", return_value=True): + result = pkg._find_install_targets( + name="check-mk-agent", + sources=[{"check-mk-agent": source}], + allow_updates=True, + saltenv="base", + ) + + assert isinstance(result, tuple) + targets = result[1] + assert targets == {"check-mk-agent": source} + + @pytest.mark.parametrize("action", ["removed", "purged"]) def test_removed_purged_with_changes_test_true(list_pkgs, action): """ From ec6a7dd1313188759b9ab0ad1afe0ee4171ab407 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 16 Jul 2026 14:59:17 -0400 Subject: [PATCH 214/469] Make custom grains override built-in grains of the same name (#54694) The "rest of the grains" pass in salt.loader.grains() evaluated every non-core grain in loader iteration order with last-writer-wins. Built-in ("int") grain modules are inserted after custom ("ext", extension_modules/_grains) modules, so they ran last and won -- inverting the documented precedence, under which a custom grain should override a built-in grain of the same name (e.g. a custom "interfaces" grain could not override the built-in napalm one). Partition the non-core grains by origin using each function's namespaced __module__ (.int.grains. vs .ext.grains., the same signal the loader uses at import) and evaluate the built-in group before the custom group. Relative order within each group is preserved, so int-vs-int and ext-vs-ext behaviour is unchanged; only int-vs-ext collisions flip to favour the custom grain. core.* grains still run first and remain overridable. --- changelog/54694.fixed.md | 1 + salt/loader/__init__.py | 16 ++- .../unit/loader/test_grains_precedence.py | 109 ++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 changelog/54694.fixed.md create mode 100644 tests/pytests/unit/loader/test_grains_precedence.py diff --git a/changelog/54694.fixed.md b/changelog/54694.fixed.md new file mode 100644 index 000000000000..895c59db56a6 --- /dev/null +++ b/changelog/54694.fixed.md @@ -0,0 +1 @@ +Fixed grain precedence so a custom grain (from ``extension_modules``/``_grains``) overrides a built-in grain of the same name, matching the documented behaviour. Previously the built-in non-core grains were evaluated after custom grains and won, so a custom grain could not override, for example, the ``interfaces`` grain. diff --git a/salt/loader/__init__.py b/salt/loader/__init__.py index f886d470b090..eff92aaa49a0 100644 --- a/salt/loader/__init__.py +++ b/salt/loader/__init__.py @@ -1167,8 +1167,22 @@ def grains(opts, force_refresh=False, proxy=None, context=None, loaded_base_name else: grains_data.update(ret) - # Run the rest of the grains + # Run the rest of the grains. Evaluate built-in ("int") grain modules before + # custom ("ext", i.e. extension_modules/_grains) ones so that a custom grain + # overrides a built-in grain of the same name, matching the documented + # precedence (see #54694). Relative order within each group is preserved. The + # origin is taken from each function's namespaced __module__ + # (.int.grains. vs .ext.grains.), the same signal the + # loader uses when importing the module. + ext_prefix = f"{funcs.loaded_base_name}.ext." + builtin_keys = [] + custom_keys = [] for key in funcs: + if getattr(funcs[key], "__module__", "").startswith(ext_prefix): + custom_keys.append(key) + else: + builtin_keys.append(key) + for key in builtin_keys + custom_keys: if key.startswith("core.") or key == "_errors": continue try: diff --git a/tests/pytests/unit/loader/test_grains_precedence.py b/tests/pytests/unit/loader/test_grains_precedence.py new file mode 100644 index 000000000000..0a9e17882987 --- /dev/null +++ b/tests/pytests/unit/loader/test_grains_precedence.py @@ -0,0 +1,109 @@ +""" +Precedence tests for salt.loader.grains(): custom (ext) grain modules must +override built-in (int) grain modules of the same name, matching the documented +precedence (issue #54694). +""" + +import salt.loader +from tests.support.mock import patch + + +def _grain(retval, module): + """Build a grain function whose namespaced ``__module__`` marks it int/ext.""" + + def _func(**kwargs): + return retval + + _func.__module__ = module + return _func + + +class _StubGrainLoader: + """Minimal stand-in for the grain_funcs LazyLoader.""" + + loaded_base_name = "salt.loaded" + + def __init__(self, funcs, order): + self._funcs = funcs + self._order = order + + def __iter__(self): + return iter(self._order) + + def __getitem__(self, key): + return self._funcs[key] + + def clear(self): + pass + + def clean_modules(self): + pass + + +def _run_grains(stub, tmp_path): + opts = {"cachedir": str(tmp_path), "grains_cache": False} + with patch("salt.loader.grain_funcs", return_value=stub): + return salt.loader.grains(opts) + + +def test_custom_grain_overrides_builtin(tmp_path): + # A built-in (int) grain and a custom (ext) grain both emit "interfaces". + # The loader iterates the custom one first, so before the fix the built-in + # ran last and won; the custom value must win regardless of order (#54694). + builtin = _grain({"interfaces": ["Loopback"]}, "salt.loaded.int.grains.napalm") + custom = _grain({"interfaces": "from_custom_grain"}, "salt.loaded.ext.grains.issue") + stub = _StubGrainLoader( + {"napalm.interfaces": builtin, "issue.interfaces": custom}, + order=["issue.interfaces", "napalm.interfaces"], + ) + result = _run_grains(stub, tmp_path) + assert result["interfaces"] == "from_custom_grain" + + +def test_builtin_relative_order_preserved(tmp_path): + # Two built-in grains colliding: the later-iterated one still wins, i.e. the + # partition must not reorder within the built-in group. + first = _grain({"role": "first"}, "salt.loaded.int.grains.a") + second = _grain({"role": "second"}, "salt.loaded.int.grains.b") + stub = _StubGrainLoader( + {"a.role": first, "b.role": second}, order=["a.role", "b.role"] + ) + result = _run_grains(stub, tmp_path) + assert result["role"] == "second" + + +def test_custom_relative_order_preserved(tmp_path): + # Two custom grains colliding: later-iterated still wins (order preserved + # within the custom group). + first = _grain({"site": "first"}, "salt.loaded.ext.grains.a") + second = _grain({"site": "second"}, "salt.loaded.ext.grains.b") + stub = _StubGrainLoader( + {"a.site": first, "b.site": second}, order=["a.site", "b.site"] + ) + result = _run_grains(stub, tmp_path) + assert result["site"] == "second" + + +def test_noncolliding_builtin_and_custom_both_present(tmp_path): + # Non-colliding grains from both origins are all present. + builtin = _grain({"kernel": "Linux"}, "salt.loaded.int.grains.kernelinfo") + custom = _grain({"datacenter": "dc1"}, "salt.loaded.ext.grains.location") + stub = _StubGrainLoader( + {"kernelinfo.kernel": builtin, "location.datacenter": custom}, + order=["location.datacenter", "kernelinfo.kernel"], + ) + result = _run_grains(stub, tmp_path) + assert result["kernel"] == "Linux" + assert result["datacenter"] == "dc1" + + +def test_core_grain_overridable_by_custom(tmp_path): + # core.* grains run first; a custom grain of the same name overrides them. + core = _grain({"osrelease": "9.9"}, "salt.loaded.int.grains.core") + custom = _grain({"osrelease": "custom"}, "salt.loaded.ext.grains.override") + stub = _StubGrainLoader( + {"core.osrelease": core, "override.osrelease": custom}, + order=["core.osrelease", "override.osrelease"], + ) + result = _run_grains(stub, tmp_path) + assert result["osrelease"] == "custom" From 6e83268b7001de0b4847f8623791b9363a83d103 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 23:16:31 -0700 Subject: [PATCH 215/469] Allow libyaml-linked PyYAML wheel in Linux onedir builds The Linux onedir build passes ``--no-binary=:all:`` to pip so every runtime dependency is compiled against the relenv toolchain and linked against the vendored openssl/krb5/etc. PyYAML's setup.py autodetects libyaml at compile time; because the relenv toolchain does not build or ship libyaml, the source build silently falls back to a pure-Python parser and the resulting onedir has no ``yaml.CSafeLoader`` and no ``_yaml.so`` extension. Salt's ``yamlloader`` uses ``getattr(yaml, "CSafeLoader", yaml.SafeLoader)`` so it does not crash, but every YAML load (configs, pillars, states, returners, mine, event bus, etc.) runs through the pure-Python parser, which is 10-20x slower. Users with segmented configs have reported ``salt-run salt.cmd test.ping`` taking ~20s where a libyaml-linked build completes in well under a second. Add ``pyyaml`` to the Linux ``--only-binary`` allow-list so pip uses PyYAML's manylinux2014 wheel, which bundles libyaml (MIT-licensed) and targets glibc 2.17+ (compatible with every relenv Linux target). This mirrors the existing precedent for ``maturin``, ``cassandra-driver``, ``hatchling``, ``cmake``, ``ninja``, and ``protobuf``. Fixes #69907 --- changelog/69907.fixed.md | 5 +++++ tools/pkg/build.py | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 changelog/69907.fixed.md diff --git a/changelog/69907.fixed.md b/changelog/69907.fixed.md new file mode 100644 index 000000000000..36df6f8c2dea --- /dev/null +++ b/changelog/69907.fixed.md @@ -0,0 +1,5 @@ +Include PyYAML manylinux wheel in Linux onedir builds so ``yaml.CSafeLoader`` +(and the libyaml-backed emitter) are available. Previously the ``--no-binary=:all:`` +pip invocation forced a PyYAML source build under the relenv toolchain, which +lacks libyaml headers; PyYAML silently fell back to the pure-Python parser, +significantly slowing config, pillar, and state parsing on large deployments. diff --git a/tools/pkg/build.py b/tools/pkg/build.py index 3a68ca812c73..58d5a318b2fb 100644 --- a/tools/pkg/build.py +++ b/tools/pkg/build.py @@ -699,8 +699,16 @@ def onedir_dependencies( env["RELENV_BUILDENV"] = "1" python_bin = env_scripts_dir / "python3" install_args.append("--no-binary=:all:") + # PyYAML's source build silently falls back to the pure-Python parser + # when libyaml headers are absent, and the relenv toolchain does not + # ship libyaml. That produces an onedir where yaml.CSafeLoader is + # missing, which makes salt fall back to the pure-Python SafeLoader + # and can slow config/pillar/state parsing by an order of magnitude + # on large deployments. The upstream PyYAML manylinux2014 wheel + # bundles libyaml (MIT-licensed) and is compatible with the relenv + # target platform, so allow it through --no-binary=:all: here. install_args.append( - "--only-binary=maturin,apache-libcloud,pymssql,cassandra-driver,hatchling,cmake,ninja,protobuf" + "--only-binary=maturin,apache-libcloud,pymssql,cassandra-driver,hatchling,cmake,ninja,protobuf,pyyaml" ) # CMake 4.x removed support for cmake_minimum_required(VERSION < 3.5). # pyzmq's bundled libzmq still declares an older floor; set the policy From 6cf49f5364e5e716852a747682196646c8af1801 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 17:29:33 -0700 Subject: [PATCH 216/469] Restore mtime-based eviction on get_rsa_key The server-side PKI refactor (PR #67799) collapsed the two-layer memoize helper into a single decorated get_rsa_key(path, passphrase), dropping the file mtime from the memoize key. salt.utils.decorators.memoize is a plain str-keyed dict cache with no mtime handling, so a private key rotated on disk was served stale from the process's in-memory cache until restart. Restore the pre-refactor pattern: a memoized _get_key_with_evict(path, timestamp, passphrase) inner helper called from get_rsa_key with str(os.path.getmtime(path)) so an mtime bump invalidates the cache entry and the fresh key is loaded from disk. Fixes #69941 --- changelog/69941.fixed.md | 2 + salt/crypt.py | 24 ++++++- .../unit/crypt/test_crypt_cryptography.py | 63 +++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 changelog/69941.fixed.md diff --git a/changelog/69941.fixed.md b/changelog/69941.fixed.md new file mode 100644 index 000000000000..11b3c91ce2f0 --- /dev/null +++ b/changelog/69941.fixed.md @@ -0,0 +1,2 @@ +Restore mtime-based cache eviction on ``salt.crypt.get_rsa_key`` so a rotated +private key on disk is reloaded without requiring a process restart. diff --git a/salt/crypt.py b/salt/crypt.py index bbdc7b2e248f..ecac8cda3fba 100644 --- a/salt/crypt.py +++ b/salt/crypt.py @@ -479,14 +479,32 @@ def __init__(self, data): @salt.utils.decorators.memoize -def get_rsa_key(path, passphrase): +def _get_key_with_evict(path, timestamp, passphrase): """ - Read a private key off the disk. we memoize the constructed private key - based on the input args. + Load a private key from disk. ``timestamp`` is intended to be the + timestamp of the file's last modification. This function is memoized so + that when it is called with the same ``(path, timestamp, passphrase)`` + tuple a second time the result is returned from the memoization. When the + file on disk is modified its mtime changes, the memoize key differs, and + the private key is re-loaded from disk. """ return PrivateKey.from_file(path, passphrase).key +def get_rsa_key(path, passphrase): + """ + Read a private key off the disk. Poor man's simple cache in effect here, + we memoize the result of calling :func:`_get_key_with_evict`. This means + the first time :func:`_get_key_with_evict` is called with a path and a + timestamp the result is cached. If the file (the private key) does not + change then its timestamp will not change and the next time the result is + returned from the cache. If the key DOES change on disk, the next call + has different parameters and the function runs fully to retrieve the key + from disk. + """ + return _get_key_with_evict(path, str(os.path.getmtime(path)), passphrase) + + def get_rsa_pub_key(path): """ Return a public key from bytes diff --git a/tests/pytests/unit/crypt/test_crypt_cryptography.py b/tests/pytests/unit/crypt/test_crypt_cryptography.py index c8de3481f7a8..dd1418c73941 100644 --- a/tests/pytests/unit/crypt/test_crypt_cryptography.py +++ b/tests/pytests/unit/crypt/test_crypt_cryptography.py @@ -1,11 +1,13 @@ import hashlib import hmac import os +import time from pathlib import Path import pytest from cryptography.hazmat.backends.openssl import backend from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa as _rsa import salt.config import salt.crypt as crypt @@ -341,6 +343,67 @@ def test_loading_encrypted_openssl_format(openssl_encrypted_key, passphrase, tmp pytest.fail(f"Unexpected exception: {exc}") +def _write_priv_pem(path): + key = _rsa.generate_private_key(65537, 2048) + path.write_bytes( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + +def _pub_bytes(priv): + return priv.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + +def test_get_rsa_key_evicts_on_mtime_change(tmp_path): + """ + get_rsa_key must return the current key material after the file is + rewritten on disk. Regression: after the server-side PKI refactor + (PR #67799) the mtime was dropped from the memoize key so a rotated + private key was not reloaded until the process restarted. + """ + keypath = tmp_path / "minion.pem" + _write_priv_pem(keypath) + + k1 = salt.crypt.get_rsa_key(str(keypath), None) + pub1 = _pub_bytes(k1) + + # Rotate the key on disk with new material and bump mtime past the + # 1-second filesystem resolution. + time.sleep(1.1) + _write_priv_pem(keypath) + now = time.time() + 2 + os.utime(keypath, (now, now)) + + k2 = salt.crypt.get_rsa_key(str(keypath), None) + pub2 = _pub_bytes(k2) + + on_disk = serialization.load_pem_private_key(keypath.read_bytes(), None) + pub_disk = _pub_bytes(on_disk) + + assert pub_disk != pub1, "test setup: rewrite failed to produce a new key" + assert pub2 == pub_disk, "get_rsa_key returned a stale cached key" + + +def test_get_rsa_key_uses_cache_without_mtime_change(tmp_path): + """ + Without an mtime change the memoize should still short-circuit and + return the same in-memory key object. + """ + keypath = tmp_path / "minion.pem" + _write_priv_pem(keypath) + + k1 = salt.crypt.get_rsa_key(str(keypath), None) + k2 = salt.crypt.get_rsa_key(str(keypath), None) + assert k1 is k2 + + @pytest.mark.skipif(not FIPS_TESTRUN, reason="Only valid when in FIPS mode") def test_fips_bad_signing_algo(private_key, passphrase): key = salt.crypt.PrivateKey.from_file(private_key, passphrase) From add022f6dd18dd523156b2f82ddba80fc1583c94 Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Wed, 15 Jul 2026 13:01:13 -0600 Subject: [PATCH 217/469] Add pillar_mask_output config option and fix full-value pillar masking VCOPS-98852: harden pillar output masking (VCOPS-77716, VCOPS-84671). - salt.utils.secret.serial() only redacted non-empty strings; truthy int/float/bool and non-empty bytes leaked through pillar.get() and related functions with their real value even though the repr path already redacted those types. Extracted a shared _is_redactable_scalar() predicate used by both _masked_repr() and serial() so the two can't drift apart again. - Added the pillar_mask_output master/minion config option (default True) as a global killswitch, seeded via salt.utils.secret.configure() from salt.pillar.get_pillar()/get_async_pillar(). When False, hide()/serial()/mask_output() no-op and pillar values are never wrapped or redacted. - Documented pillar_mask_output in doc/ref/configuration/master.rst. - Updated tests that encoded the old (buggy) passthrough behavior and added coverage for bytes redaction and the new config toggle. Co-Authored-By: Claude Sonnet 5 --- changelog/98852.added.md | 1 + changelog/98852.fixed.md | 1 + doc/ref/configuration/master.rst | 22 ++++ salt/config/__init__.py | 5 + salt/pillar/__init__.py | 4 + salt/utils/secret.py | 60 ++++++++-- .../functional/pillar/test_pillar_masking.py | 34 ++++++ tests/pytests/unit/modules/test_pillar.py | 14 ++- tests/pytests/unit/utils/test_secret.py | 106 ++++++++++++++++-- 9 files changed, 226 insertions(+), 21 deletions(-) create mode 100644 changelog/98852.added.md create mode 100644 changelog/98852.fixed.md diff --git a/changelog/98852.added.md b/changelog/98852.added.md new file mode 100644 index 000000000000..915807e8e6c7 --- /dev/null +++ b/changelog/98852.added.md @@ -0,0 +1 @@ +Added the ``pillar_mask_output`` master/minion config option to globally enable or disable pillar output masking (redaction of sensitive pillar values in ``pillar.get``/``pillar.items``/etc., ``no_log`` state output, and general CLI output). Defaults to ``True`` (masking stays on), matching existing behavior. diff --git a/changelog/98852.fixed.md b/changelog/98852.fixed.md new file mode 100644 index 000000000000..cca0224ceb7d --- /dev/null +++ b/changelog/98852.fixed.md @@ -0,0 +1 @@ +Fixed pillar output masking (``salt.utils.secret.serial``) only redacting string values — truthy ``int``/``float``/``bool`` and non-empty ``bytes`` pillar values were returned unmasked through ``pillar.get`` and related functions even with masking enabled. Masking of these types is now consistent with how they were already redacted in ``repr``/``str`` output. diff --git a/doc/ref/configuration/master.rst b/doc/ref/configuration/master.rst index c62d6ce308b6..caac4207c1c0 100644 --- a/doc/ref/configuration/master.rst +++ b/doc/ref/configuration/master.rst @@ -5670,6 +5670,28 @@ Recursively merge lists by aggregating them instead of replacing them. pillar_merge_lists: False +.. conf_master:: pillar_mask_output + +``pillar_mask_output`` +********************** + +.. versionadded:: 3008.3 + +Default: ``True`` + +Globally enable or disable redaction of pillar values in logs and state +output. When ``True`` (the default), sensitive pillar values are replaced +with ``**********`` in ``pillar.get`` and related execution module output, +``no_log`` state results, and general CLI output, unless a caller explicitly +requests the real value (e.g. ``pillar.get(key, unmask=True)``). + +Set this option to ``False`` to disable pillar masking entirely and always +return real values, matching pre-masking behavior. + +.. code-block:: yaml + + pillar_mask_output: True + .. conf_master:: pillar_includes_override_sls ``pillar_includes_override_sls`` diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 42253f176a85..8db545b2bb8a 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -698,6 +698,9 @@ def _gather_buffer_space(): "pillar_source_merging_strategy": str, # Recursively merge lists by aggregating them instead of replacing them. "pillar_merge_lists": bool, + # Globally enable/disable redaction of pillar values in logs and state + # output (pillar.get, no_log states, CLI output, etc.). + "pillar_mask_output": bool, # If True, values from included pillar SLS targets will override "pillar_includes_override_sls": bool, # How to merge multiple top files from multiple salt environments @@ -1173,6 +1176,7 @@ def _gather_buffer_space(): "pillar_opts": False, "pillar_source_merging_strategy": "smart", "pillar_merge_lists": False, + "pillar_mask_output": True, "pillar_includes_override_sls": False, # ``pillar_cache``, ``pillar_cache_ttl``, ``pillar_cache_backend``, # ``gpg_cache``, ``gpg_cache_ttl`` and ``gpg_cache_backend`` @@ -1648,6 +1652,7 @@ def _gather_buffer_space(): "pillar_safe_render_error": True, "pillar_source_merging_strategy": "smart", "pillar_merge_lists": False, + "pillar_mask_output": True, "pillar_includes_override_sls": False, "pillar_cache": False, "pillar_cache_ttl": 3600, diff --git a/salt/pillar/__init__.py b/salt/pillar/__init__.py index 399a2660c9ab..3005af834ecb 100644 --- a/salt/pillar/__init__.py +++ b/salt/pillar/__init__.py @@ -46,6 +46,9 @@ def get_pillar( """ Return the correct pillar driver based on the file_client option """ + # Seed the pillar-masking killswitch from this process's own opts before + # any pillar compile/wrap happens (salt.utils.secret.hide()/serial()). + salt.utils.secret.configure(opts) # When file_client is 'local' this makes the minion masterless # but sometimes we want the minion to read its files from the local # filesystem instead of asking for them from the master, but still @@ -107,6 +110,7 @@ def get_async_pillar( """ Return the correct pillar driver based on the file_client option """ + salt.utils.secret.configure(opts) file_client = opts["file_client"] if opts.get("master_type") == "disable" and file_client == "remote": file_client = "local" diff --git a/salt/utils/secret.py b/salt/utils/secret.py index 1ed2e588385b..54489f967c6d 100644 --- a/salt/utils/secret.py +++ b/salt/utils/secret.py @@ -45,6 +45,25 @@ REDACT_PLACEHOLDER = "**********" +# Global on/off switch for the whole pillar-masking feature, seeded from the +# ``pillar_mask_output`` config option. Unlike ``mask_pillar`` above (a +# per-render-context toggle), this is an administrator-facing killswitch: +# when False, hide()/serial() never wrap or redact, regardless of context. +_ENABLED = True + + +def configure(opts): + """Seed the global masking killswitch from ``pillar_mask_output``. + + Called from ``salt.pillar.get_pillar()`` / ``get_async_pillar()`` — the + choke point where both minion and master-side pillar-compile flows + already receive the full ``opts`` dict — so this stays in sync with the + process's own config without threading an extra parameter through every + masking call site. + """ + global _ENABLED + _ENABLED = bool(opts.get("pillar_mask_output", True)) + # --------------------------------------------------------------------------- # Internal helpers @@ -62,6 +81,19 @@ def _mask_wrap(value): return value +def _is_redactable_scalar(value) -> bool: + """True if value is a non-empty/truthy str, bytes, int, float, or bool leaf. + + Shared by ``_masked_repr`` (display) and ``serial`` (actual output + boundary) so the two can't drift apart on which leaf values count as + sensitive — that drift is exactly what let non-string values leak + through ``serial()`` unmasked. + """ + if isinstance(value, (str, bytes, int, float, bool)): + return bool(value) + return False + + def _masked_repr(value) -> str: """Build a redacted repr string for a MaskedDict or MaskedList.""" if isinstance(value, dict): @@ -69,11 +101,9 @@ def _masked_repr(value) -> str: return "{" + pairs + "}" if isinstance(value, list): return "[" + ", ".join(_masked_repr(v) for v in value) + "]" - if isinstance(value, str) and value: - return repr(REDACT_PLACEHOLDER) - if isinstance(value, bytes) and value: + if isinstance(value, bytes) and _is_redactable_scalar(value): return repr(REDACT_PLACEHOLDER.encode()) - if isinstance(value, (int, float, bool)) and value: + if _is_redactable_scalar(value): return repr(REDACT_PLACEHOLDER) return repr(value) @@ -208,7 +238,11 @@ def hide(value): Scalar values (str, int, bool, None …) are returned unchanged — they are stored plain inside the container and only redacted in the container's repr. Already-wrapped values are returned as-is (idempotent). + + No-ops when the global masking killswitch (``pillar_mask_output``) is off. """ + if not _ENABLED: + return value return _mask_wrap(value) @@ -244,7 +278,8 @@ def expose(value, _seen=None): def serial(value, _seen=None): - """Aggressively redact: replace ALL non-empty strings with REDACT_PLACEHOLDER. + """Aggressively redact: replace every non-empty/truthy scalar leaf value + (str, bytes, int, float, bool) with a redacted placeholder. Use at explicit pillar output boundaries (``pillar.get``, ``pillar.items``, ``pillar.item``, ``pillar.ext``) and inside ``no_log_mask``. @@ -252,13 +287,20 @@ def serial(value, _seen=None): Because ``MaskedDict.__getitem__`` returns plain strings (the scalar leaves are stored unwrapped), this function must handle plain str/dict/list values in addition to MaskedDict / MaskedList containers. + + No-ops (returns *value* unchanged) when the global masking killswitch + (``pillar_mask_output``) is off. """ + if not _ENABLED: + return value if _seen is None: _seen = set() - if isinstance(value, str) and value: + if isinstance(value, bytes) and _is_redactable_scalar(value): + return REDACT_PLACEHOLDER.encode() + if _is_redactable_scalar(value): return REDACT_PLACEHOLDER if not isinstance(value, (dict, list)): - # int, float, bool, None, empty string, bytes — pass through + # int, float, bool, None, empty string, empty bytes — pass through return value vid = id(value) if vid in _seen: @@ -291,7 +333,11 @@ def mask_output(value, _seen=None): Use as a safety net in ``output/__init__.py`` to prevent accidental pillar leakage in general Salt output without redacting ordinary result strings (state comments, module names, etc.). + + No-ops when the global masking killswitch (``pillar_mask_output``) is off. """ + if not _ENABLED: + return value if _seen is None: _seen = set() if isinstance(value, (MaskedDict, MaskedList)): diff --git a/tests/pytests/functional/pillar/test_pillar_masking.py b/tests/pytests/functional/pillar/test_pillar_masking.py index 82ba16dd410d..014d54af00a0 100644 --- a/tests/pytests/functional/pillar/test_pillar_masking.py +++ b/tests/pytests/functional/pillar/test_pillar_masking.py @@ -117,3 +117,37 @@ def test_masked_pillar_redacts_outside_render_bracket(): assert salt.utils.secret.REDACT_PLACEHOLDER in repr(pillar) assert "host1" not in repr(pillar) assert salt.utils.secret.REDACT_PLACEHOLDER in str(pillar["hosts"]) + + +def test_get_pillar_wires_pillar_mask_output_config_option(minion_opts, grains): + """VCOPS-98852: ``pillar_mask_output`` is the standard-config-path toggle. + + ``salt.pillar.get_pillar()`` is the choke point that receives ``opts`` + for every pillar-compile flow (minion and master-side); it must seed + ``salt.utils.secret``'s global killswitch so hide()/serial() honor the + config option without every consumer having to thread opts through. + """ + opts = dict(minion_opts) + opts["file_client"] = "local" + opts["pillar_cache"] = False + opts["minion_data_cache"] = False + + try: + opts["pillar_mask_output"] = False + salt.pillar.get_pillar(opts, grains, "test-minion", "base") + assert salt.utils.secret.hide({"k": "v"}) == {"k": "v"} + assert not isinstance( + salt.utils.secret.hide({"k": "v"}), salt.utils.secret.MaskedDict + ) + assert salt.utils.secret.serial("hunter2") == "hunter2" + + opts["pillar_mask_output"] = True + salt.pillar.get_pillar(opts, grains, "test-minion", "base") + assert isinstance( + salt.utils.secret.hide({"k": "v"}), salt.utils.secret.MaskedDict + ) + assert ( + salt.utils.secret.serial("hunter2") == salt.utils.secret.REDACT_PLACEHOLDER + ) + finally: + salt.utils.secret.configure({"pillar_mask_output": True}) diff --git a/tests/pytests/unit/modules/test_pillar.py b/tests/pytests/unit/modules/test_pillar.py index 820a2a8d10e7..f3f7c9c81af9 100644 --- a/tests/pytests/unit/modules/test_pillar.py +++ b/tests/pytests/unit/modules/test_pillar.py @@ -3,6 +3,7 @@ import pytest import salt.modules.pillar as pillarmod +import salt.utils.secret as secret from tests.support.mock import MagicMock, call, patch @@ -135,20 +136,27 @@ def test_pillar_get_default_merge_regression_38558(): """Test for pillar.get(key=..., default=..., merge=True) Do not update the ``default`` value when using ``merge=True``. See: https://github.com/saltstack/salt/issues/38558 + + ``res`` values below are masked (VCOPS-98852: pillar.get()'s default + output redacts truthy int/float/bool leaves too, not just strings) — use + ``unmask=True`` to assert against the real values. ``default`` is a plain + Python literal passed in by the caller, never itself redacted, so its + non-mutation check still compares real values. """ with patch.dict(pillarmod.__pillar__, {"l1": {"l2": {"l3": 42}}}): res = pillarmod.get(key="l1") - assert {"l2": {"l3": 42}} == res + assert {"l2": {"l3": secret.REDACT_PLACEHOLDER}} == res + assert {"l2": {"l3": 42}} == pillarmod.get(key="l1", unmask=True) default = {"l2": {"l3": 43}} res = pillarmod.get(key="l1", default=default) - assert {"l2": {"l3": 42}} == res + assert {"l2": {"l3": secret.REDACT_PLACEHOLDER}} == res assert {"l2": {"l3": 43}} == default res = pillarmod.get(key="l1", default=default, merge=True) - assert {"l2": {"l3": 42}} == res + assert {"l2": {"l3": secret.REDACT_PLACEHOLDER}} == res assert {"l2": {"l3": 43}} == default diff --git a/tests/pytests/unit/utils/test_secret.py b/tests/pytests/unit/utils/test_secret.py index df29e9079e56..9b53ad2c3ea8 100644 --- a/tests/pytests/unit/utils/test_secret.py +++ b/tests/pytests/unit/utils/test_secret.py @@ -252,31 +252,60 @@ def test_serial_leaves_empty_string(): assert secret.serial("") == "" -def test_serial_leaves_non_string_scalars(): - assert secret.serial(42) == 42 - assert secret.serial(True) is True +def test_serial_redacts_truthy_non_string_scalars(): + # VCOPS-98852: serial() must redact ALL pillar value types, not just str, + # so it stays consistent with the repr path (_masked_repr), which already + # redacted truthy int/float/bool. Before this fix, serial(42) == 42 — + # a real leak through the exact function pillar.get() relies on. + assert secret.serial(42) == secret.REDACT_PLACEHOLDER + assert secret.serial(True) == secret.REDACT_PLACEHOLDER + assert secret.serial(3.14) == secret.REDACT_PLACEHOLDER + + +def test_serial_leaves_falsy_non_string_scalars(): + # Falsy/zero values and None are not treated as secrets (matches the + # pre-existing repr convention for _masked_repr). + assert secret.serial(0) == 0 + assert secret.serial(False) is False assert secret.serial(None) is None +def test_serial_redacts_bytes(): + assert secret.serial(b"topsecret") == secret.REDACT_PLACEHOLDER.encode() + + +def test_serial_leaves_empty_bytes(): + assert secret.serial(b"") == b"" + + def test_serial_redacts_masked_dict_strings(): d = secret.MaskedDict({"password": "hunter2", "count": 3}) result = secret.serial(d) - assert result == {"password": secret.REDACT_PLACEHOLDER, "count": 3} + assert result == { + "password": secret.REDACT_PLACEHOLDER, + "count": secret.REDACT_PLACEHOLDER, + } def test_serial_redacts_plain_dict_strings(): - # serial is aggressive — also redacts strings in plain dicts - d = {"k": "v", "n": 1} + # serial is aggressive — also redacts strings (and other truthy scalars) + # in plain dicts + d = {"k": "v", "n": 1, "z": 0} result = secret.serial(d) - assert result == {"k": secret.REDACT_PLACEHOLDER, "n": 1} + assert result == { + "k": secret.REDACT_PLACEHOLDER, + "n": secret.REDACT_PLACEHOLDER, + "z": 0, + } def test_serial_redacts_nested(): - d = secret.MaskedDict({"sub": {"s": "secret"}, "lst": ["a", 1]}) + d = secret.MaskedDict({"sub": {"s": "secret"}, "lst": ["a", 1, 0]}) result = secret.serial(d) assert result["sub"]["s"] == secret.REDACT_PLACEHOLDER assert result["lst"][0] == secret.REDACT_PLACEHOLDER - assert result["lst"][1] == 1 + assert result["lst"][1] == secret.REDACT_PLACEHOLDER + assert result["lst"][2] == 0 # --------------------------------------------------------------------------- @@ -300,10 +329,11 @@ def test_mask_output_redacts_masked_dict(): def test_mask_output_redacts_masked_list(): - d = {"items": secret.MaskedList(["sensitive", 1])} + d = {"items": secret.MaskedList(["sensitive", 1, 0])} result = secret.mask_output(d) assert result["items"][0] == secret.REDACT_PLACEHOLDER - assert result["items"][1] == 1 + assert result["items"][1] == secret.REDACT_PLACEHOLDER + assert result["items"][2] == 0 def test_mask_output_nested_plain_dicts_not_redacted(): @@ -401,3 +431,57 @@ def test_masked_nested_repr_respects_context_var(): r = repr(d) assert secret.REDACT_PLACEHOLDER in r assert "host1" not in r + + +# --------------------------------------------------------------------------- +# configure() / global masking killswitch (VCOPS-98852: pillar_mask_output) +# --------------------------------------------------------------------------- + + +def test_configure_defaults_to_enabled_when_opt_absent(): + secret.configure({}) + try: + assert secret.serial("hunter2") == secret.REDACT_PLACEHOLDER + finally: + secret.configure({"pillar_mask_output": True}) + + +def test_configure_true_enables_masking(): + secret.configure({"pillar_mask_output": True}) + try: + assert secret.serial("hunter2") == secret.REDACT_PLACEHOLDER + assert isinstance(secret.hide({"k": "v"}), secret.MaskedDict) + finally: + secret.configure({"pillar_mask_output": True}) + + +def test_configure_false_disables_serial_redaction(): + secret.configure({"pillar_mask_output": False}) + try: + assert secret.serial("hunter2") == "hunter2" + assert secret.serial(42) == 42 + d = secret.MaskedDict({"password": "hunter2"}) + assert secret.serial(d) == {"password": "hunter2"} + finally: + secret.configure({"pillar_mask_output": True}) + + +def test_configure_false_disables_hide_wrapping(): + secret.configure({"pillar_mask_output": False}) + try: + assert secret.hide({"k": "v"}) == {"k": "v"} + assert not isinstance(secret.hide({"k": "v"}), secret.MaskedDict) + assert secret.hide(["a"]) == ["a"] + assert not isinstance(secret.hide(["a"]), secret.MaskedList) + finally: + secret.configure({"pillar_mask_output": True}) + + +def test_configure_false_disables_mask_output(): + secret.configure({"pillar_mask_output": False}) + try: + d = {"pillar_data": secret.MaskedDict({"password": "secret"})} + result = secret.mask_output(d) + assert result["pillar_data"]["password"] == "secret" + finally: + secret.configure({"pillar_mask_output": True}) From 9d22d6848eedbc4dd85873ba42c2e75e9201ada0 Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Wed, 15 Jul 2026 14:05:13 -0600 Subject: [PATCH 218/469] Replace pillar_mask_output global killswitch with explicit opts reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on PR #69812: the module-level _ENABLED flag + configure() seeded from get_pillar() was a one-off pattern not used anywhere else in the codebase. pillar_merge_lists/pillar_safe_render_error are both read inline via self.opts.get(...)/__opts__.get(...) at each call site, with no caching. Replaced with an explicit enabled= parameter on hide()/serial()/ mask_output()/no_log_mask(), with every call site (salt/pillar/__init__.py, salt/modules/pillar.py, salt/client/ssh/wrapper/pillar.py, salt/state.py, salt/output/__init__.py) passing its own opts.get("pillar_mask_output", True) — matching the existing pillar boolean-option pattern exactly, no shared/global state left in salt.utils.secret. Co-Authored-By: Claude Sonnet 5 --- salt/client/ssh/wrapper/pillar.py | 4 +- salt/modules/pillar.py | 28 ++++++-- salt/output/__init__.py | 4 +- salt/pillar/__init__.py | 12 ++-- salt/state.py | 4 +- salt/utils/secret.py | 59 ++++++++--------- .../functional/pillar/test_pillar_masking.py | 34 ---------- tests/pytests/unit/modules/test_pillar.py | 17 +++++ tests/pytests/unit/utils/test_secret.py | 66 ++++++++----------- 9 files changed, 106 insertions(+), 122 deletions(-) diff --git a/salt/client/ssh/wrapper/pillar.py b/salt/client/ssh/wrapper/pillar.py index 2c36ec8d24c0..b49d4d758bf1 100644 --- a/salt/client/ssh/wrapper/pillar.py +++ b/salt/client/ssh/wrapper/pillar.py @@ -83,7 +83,9 @@ def item(*args): ret = {} for arg in args: try: - ret[arg] = salt.utils.secret.serial(__pillar__[arg]) + ret[arg] = salt.utils.secret.serial( + __pillar__[arg], enabled=__opts__.get("pillar_mask_output", True) + ) except KeyError: pass return ret diff --git a/salt/modules/pillar.py b/salt/modules/pillar.py index 72a1edb6b6e8..2fa9a6838be6 100644 --- a/salt/modules/pillar.py +++ b/salt/modules/pillar.py @@ -160,7 +160,9 @@ def get( ) if unmask: return salt.utils.secret.expose(merged) - return salt.utils.secret.serial(merged) + return salt.utils.secret.serial( + merged, enabled=__opts__.get("pillar_mask_output", True) + ) else: log.error( "pillar.get: Default (%s) is a dict, but the returned " @@ -179,7 +181,9 @@ def get( default.extend([x for x in ret if x not in default]) if unmask: return salt.utils.secret.expose(default) - return salt.utils.secret.serial(default) + return salt.utils.secret.serial( + default, enabled=__opts__.get("pillar_mask_output", True) + ) else: log.error( "pillar.get: Default (%s) is a list, but the returned " @@ -203,7 +207,9 @@ def get( if unmask: return salt.utils.secret.expose(ret) - return salt.utils.secret.serial(ret) + return salt.utils.secret.serial( + ret, enabled=__opts__.get("pillar_mask_output", True) + ) def items( @@ -301,7 +307,9 @@ def items( if unmask: return salt.utils.secret.expose(ret) else: - return salt.utils.secret.serial(ret) + return salt.utils.secret.serial( + ret, enabled=__opts__.get("pillar_mask_output", True) + ) # Allow pillar.data to also be used to return pillar data @@ -592,7 +600,9 @@ def item( if unmask: return salt.utils.secret.expose(ret) else: - return salt.utils.secret.serial(ret) + return salt.utils.secret.serial( + ret, enabled=__opts__.get("pillar_mask_output", True) + ) def raw(key=None, unmask=None): @@ -630,7 +640,9 @@ def raw(key=None, unmask=None): if unmask: return salt.utils.secret.expose(value) - return salt.utils.secret.serial(value) + return salt.utils.secret.serial( + value, enabled=__opts__.get("pillar_mask_output", True) + ) def ext(external, pillar=None, unmask=None): @@ -712,7 +724,9 @@ def ext(external, pillar=None, unmask=None): if unmask: return salt.utils.secret.expose(ret) - return salt.utils.secret.serial(ret) + return salt.utils.secret.serial( + ret, enabled=__opts__.get("pillar_mask_output", True) + ) def keys(key, delimiter=DEFAULT_TARGET_DELIM, unmask=None): diff --git a/salt/output/__init__.py b/salt/output/__init__.py index 1d6021528e72..66ebc317f370 100644 --- a/salt/output/__init__.py +++ b/salt/output/__init__.py @@ -32,7 +32,9 @@ def try_printout(data, out, opts, **kwargs): Safely get the string to print out, try the configured outputter, then fall back to nested and then to raw """ - data = salt.utils.secret.mask_output(data) + data = salt.utils.secret.mask_output( + data, enabled=opts.get("pillar_mask_output", True) + ) try: printout = get_printout(out, opts)(data, **kwargs) if printout is not None: diff --git a/salt/pillar/__init__.py b/salt/pillar/__init__.py index 3005af834ecb..c46786065c81 100644 --- a/salt/pillar/__init__.py +++ b/salt/pillar/__init__.py @@ -46,9 +46,6 @@ def get_pillar( """ Return the correct pillar driver based on the file_client option """ - # Seed the pillar-masking killswitch from this process's own opts before - # any pillar compile/wrap happens (salt.utils.secret.hide()/serial()). - salt.utils.secret.configure(opts) # When file_client is 'local' this makes the minion masterless # but sometimes we want the minion to read its files from the local # filesystem instead of asking for them from the master, but still @@ -110,7 +107,6 @@ def get_async_pillar( """ Return the correct pillar driver based on the file_client option """ - salt.utils.secret.configure(opts) file_client = opts["file_client"] if opts.get("master_type") == "disable" and file_client == "remote": file_client = "local" @@ -283,7 +279,9 @@ async def compile_pillar(self): log.exception("Exception getting pillar:") raise SaltClientError("Exception getting pillar.") self.validate_return(ret_pillar) - ret_pillar = salt.utils.secret.hide(ret_pillar) + ret_pillar = salt.utils.secret.hide( + ret_pillar, enabled=self.opts.get("pillar_mask_output", True) + ) return ret_pillar def destroy(self): @@ -375,7 +373,9 @@ def compile_pillar(self): log.exception("Exception getting pillar:") raise SaltClientError("Exception getting pillar.") self.validate_return(ret_pillar) - return salt.utils.secret.hide(ret_pillar) + return salt.utils.secret.hide( + ret_pillar, enabled=self.opts.get("pillar_mask_output", True) + ) def destroy(self): if hasattr(self, "_closing") and self._closing: diff --git a/salt/state.py b/salt/state.py index d66ca0519da2..5a37c6bfe990 100644 --- a/salt/state.py +++ b/salt/state.py @@ -2502,7 +2502,9 @@ def call( ret["__run_num__"] = self.__run_num self.__run_num += 1 if low.get("no_log"): - salt.utils.secret.no_log_mask(ret) + salt.utils.secret.no_log_mask( + ret, enabled=self.opts.get("pillar_mask_output", True) + ) format_log(ret) self.check_refresh(low, ret) utc_finish_time = datetime.datetime.now(tz=datetime.timezone.utc) diff --git a/salt/utils/secret.py b/salt/utils/secret.py index 54489f967c6d..00b513845470 100644 --- a/salt/utils/secret.py +++ b/salt/utils/secret.py @@ -45,25 +45,6 @@ REDACT_PLACEHOLDER = "**********" -# Global on/off switch for the whole pillar-masking feature, seeded from the -# ``pillar_mask_output`` config option. Unlike ``mask_pillar`` above (a -# per-render-context toggle), this is an administrator-facing killswitch: -# when False, hide()/serial() never wrap or redact, regardless of context. -_ENABLED = True - - -def configure(opts): - """Seed the global masking killswitch from ``pillar_mask_output``. - - Called from ``salt.pillar.get_pillar()`` / ``get_async_pillar()`` — the - choke point where both minion and master-side pillar-compile flows - already receive the full ``opts`` dict — so this stays in sync with the - process's own config without threading an extra parameter through every - masking call site. - """ - global _ENABLED - _ENABLED = bool(opts.get("pillar_mask_output", True)) - # --------------------------------------------------------------------------- # Internal helpers @@ -232,16 +213,20 @@ def __deepcopy__(self, memo): # --------------------------------------------------------------------------- -def hide(value): +def hide(value, enabled=True): """Wrap a pillar dict/list in MaskedDict/MaskedList for display masking. Scalar values (str, int, bool, None …) are returned unchanged — they are stored plain inside the container and only redacted in the container's repr. Already-wrapped values are returned as-is (idempotent). - No-ops when the global masking killswitch (``pillar_mask_output``) is off. + enabled + Pass the caller's own ``opts.get("pillar_mask_output", True)`` (or + ``__opts__.get(...)``) — matches the existing pattern for + ``pillar_merge_lists``/``pillar_safe_render_error``, read at each + call site rather than cached. When ``False``, this is a no-op. """ - if not _ENABLED: + if not enabled: return value return _mask_wrap(value) @@ -277,7 +262,7 @@ def expose(value, _seen=None): return value -def serial(value, _seen=None): +def serial(value, _seen=None, enabled=True): """Aggressively redact: replace every non-empty/truthy scalar leaf value (str, bytes, int, float, bool) with a redacted placeholder. @@ -288,10 +273,15 @@ def serial(value, _seen=None): are stored unwrapped), this function must handle plain str/dict/list values in addition to MaskedDict / MaskedList containers. - No-ops (returns *value* unchanged) when the global masking killswitch - (``pillar_mask_output``) is off. + enabled + Pass the caller's own ``opts.get("pillar_mask_output", True)`` (or + ``__opts__.get(...)``) — matches the existing pattern for + ``pillar_merge_lists``/``pillar_safe_render_error``, read at each + call site rather than cached. When ``False``, this is a no-op. + Only checked on the outermost call; recursive calls omit it since + recursion only happens once the outermost call already found it True. """ - if not _ENABLED: + if not enabled: return value if _seen is None: _seen = set() @@ -326,7 +316,7 @@ def serial(value, _seen=None): _seen.discard(vid) -def mask_output(value, _seen=None): +def mask_output(value, _seen=None, enabled=True): """Gently redact: only redact values *inside* MaskedDict / MaskedList containers. Plain dicts, plain lists, and plain scalars pass through unchanged. @@ -334,9 +324,11 @@ def mask_output(value, _seen=None): leakage in general Salt output without redacting ordinary result strings (state comments, module names, etc.). - No-ops when the global masking killswitch (``pillar_mask_output``) is off. + enabled + Pass the caller's own ``opts.get("pillar_mask_output", True)``. When + ``False``, this is a no-op. Only checked on the outermost call. """ - if not _ENABLED: + if not enabled: return value if _seen is None: _seen = set() @@ -357,11 +349,14 @@ def mask_output(value, _seen=None): _seen.discard(vid) -def no_log_mask(state_ret): +def no_log_mask(state_ret, enabled=True): """Replace ``comment`` and ``changes`` in a state return with redacted values. Called by ``salt/state.py`` when a state has ``no_log: True``. Mutates *state_ret* in place. + + enabled + Pass the caller's own ``opts.get("pillar_mask_output", True)``. """ - state_ret["comment"] = serial(state_ret["comment"]) - state_ret["changes"] = serial(state_ret["changes"]) + state_ret["comment"] = serial(state_ret["comment"], enabled=enabled) + state_ret["changes"] = serial(state_ret["changes"], enabled=enabled) diff --git a/tests/pytests/functional/pillar/test_pillar_masking.py b/tests/pytests/functional/pillar/test_pillar_masking.py index 014d54af00a0..82ba16dd410d 100644 --- a/tests/pytests/functional/pillar/test_pillar_masking.py +++ b/tests/pytests/functional/pillar/test_pillar_masking.py @@ -117,37 +117,3 @@ def test_masked_pillar_redacts_outside_render_bracket(): assert salt.utils.secret.REDACT_PLACEHOLDER in repr(pillar) assert "host1" not in repr(pillar) assert salt.utils.secret.REDACT_PLACEHOLDER in str(pillar["hosts"]) - - -def test_get_pillar_wires_pillar_mask_output_config_option(minion_opts, grains): - """VCOPS-98852: ``pillar_mask_output`` is the standard-config-path toggle. - - ``salt.pillar.get_pillar()`` is the choke point that receives ``opts`` - for every pillar-compile flow (minion and master-side); it must seed - ``salt.utils.secret``'s global killswitch so hide()/serial() honor the - config option without every consumer having to thread opts through. - """ - opts = dict(minion_opts) - opts["file_client"] = "local" - opts["pillar_cache"] = False - opts["minion_data_cache"] = False - - try: - opts["pillar_mask_output"] = False - salt.pillar.get_pillar(opts, grains, "test-minion", "base") - assert salt.utils.secret.hide({"k": "v"}) == {"k": "v"} - assert not isinstance( - salt.utils.secret.hide({"k": "v"}), salt.utils.secret.MaskedDict - ) - assert salt.utils.secret.serial("hunter2") == "hunter2" - - opts["pillar_mask_output"] = True - salt.pillar.get_pillar(opts, grains, "test-minion", "base") - assert isinstance( - salt.utils.secret.hide({"k": "v"}), salt.utils.secret.MaskedDict - ) - assert ( - salt.utils.secret.serial("hunter2") == salt.utils.secret.REDACT_PLACEHOLDER - ) - finally: - salt.utils.secret.configure({"pillar_mask_output": True}) diff --git a/tests/pytests/unit/modules/test_pillar.py b/tests/pytests/unit/modules/test_pillar.py index f3f7c9c81af9..8d634f5eab38 100644 --- a/tests/pytests/unit/modules/test_pillar.py +++ b/tests/pytests/unit/modules/test_pillar.py @@ -160,6 +160,23 @@ def test_pillar_get_default_merge_regression_38558(): assert {"l2": {"l3": 43}} == default +def test_pillar_get_respects_pillar_mask_output_config_option(): + """VCOPS-98852: ``pillar_mask_output: False`` disables masking end-to-end + through the standard ``pillar.get`` execution module, reading ``__opts__`` + directly at the call site (matches the existing ``pillar_merge_lists`` + pattern — no cached/global state in ``salt.utils.secret``). + """ + with patch.dict(pillarmod.__pillar__, {"pin": 1234}), patch.dict( + pillarmod.__opts__, {"pillar_mask_output": False} + ): + assert pillarmod.get(key="pin") == 1234 + + with patch.dict(pillarmod.__pillar__, {"pin": 1234}), patch.dict( + pillarmod.__opts__, {"pillar_mask_output": True} + ): + assert pillarmod.get(key="pin") == secret.REDACT_PLACEHOLDER + + def test_pillar_get_default_merge_regression_39062(): """ Confirm that we do not raise an exception if default is None and diff --git a/tests/pytests/unit/utils/test_secret.py b/tests/pytests/unit/utils/test_secret.py index 9b53ad2c3ea8..98b35e4a2fc4 100644 --- a/tests/pytests/unit/utils/test_secret.py +++ b/tests/pytests/unit/utils/test_secret.py @@ -434,54 +434,40 @@ def test_masked_nested_repr_respects_context_var(): # --------------------------------------------------------------------------- -# configure() / global masking killswitch (VCOPS-98852: pillar_mask_output) +# enabled= parameter (VCOPS-98852: pillar_mask_output) — each call site reads +# its own opts.get("pillar_mask_output", True) and passes it in explicitly, +# matching the existing pillar_merge_lists/pillar_safe_render_error pattern +# (no cached/global state in this module). # --------------------------------------------------------------------------- -def test_configure_defaults_to_enabled_when_opt_absent(): - secret.configure({}) - try: - assert secret.serial("hunter2") == secret.REDACT_PLACEHOLDER - finally: - secret.configure({"pillar_mask_output": True}) +def test_enabled_defaults_to_true(): + # Callers that don't pass enabled= (or pass True) keep masking on. + assert secret.serial("hunter2") == secret.REDACT_PLACEHOLDER + assert isinstance(secret.hide({"k": "v"}), secret.MaskedDict) -def test_configure_true_enables_masking(): - secret.configure({"pillar_mask_output": True}) - try: - assert secret.serial("hunter2") == secret.REDACT_PLACEHOLDER - assert isinstance(secret.hide({"k": "v"}), secret.MaskedDict) - finally: - secret.configure({"pillar_mask_output": True}) +def test_enabled_false_disables_serial_redaction(): + assert secret.serial("hunter2", enabled=False) == "hunter2" + assert secret.serial(42, enabled=False) == 42 + d = secret.MaskedDict({"password": "hunter2"}) + assert secret.serial(d, enabled=False) == {"password": "hunter2"} -def test_configure_false_disables_serial_redaction(): - secret.configure({"pillar_mask_output": False}) - try: - assert secret.serial("hunter2") == "hunter2" - assert secret.serial(42) == 42 - d = secret.MaskedDict({"password": "hunter2"}) - assert secret.serial(d) == {"password": "hunter2"} - finally: - secret.configure({"pillar_mask_output": True}) +def test_enabled_false_disables_hide_wrapping(): + assert secret.hide({"k": "v"}, enabled=False) == {"k": "v"} + assert not isinstance(secret.hide({"k": "v"}, enabled=False), secret.MaskedDict) + assert secret.hide(["a"], enabled=False) == ["a"] + assert not isinstance(secret.hide(["a"], enabled=False), secret.MaskedList) -def test_configure_false_disables_hide_wrapping(): - secret.configure({"pillar_mask_output": False}) - try: - assert secret.hide({"k": "v"}) == {"k": "v"} - assert not isinstance(secret.hide({"k": "v"}), secret.MaskedDict) - assert secret.hide(["a"]) == ["a"] - assert not isinstance(secret.hide(["a"]), secret.MaskedList) - finally: - secret.configure({"pillar_mask_output": True}) +def test_enabled_false_disables_mask_output(): + d = {"pillar_data": secret.MaskedDict({"password": "secret"})} + result = secret.mask_output(d, enabled=False) + assert result["pillar_data"]["password"] == "secret" -def test_configure_false_disables_mask_output(): - secret.configure({"pillar_mask_output": False}) - try: - d = {"pillar_data": secret.MaskedDict({"password": "secret"})} - result = secret.mask_output(d) - assert result["pillar_data"]["password"] == "secret" - finally: - secret.configure({"pillar_mask_output": True}) +def test_enabled_false_disables_no_log_mask(): + ret = {"comment": "plaintext_password", "changes": {}, "result": True} + secret.no_log_mask(ret, enabled=False) + assert ret["comment"] == "plaintext_password" From db35024b9779a55920df86c4fae55629ecdcb0d8 Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Thu, 16 Jul 2026 11:33:25 -0600 Subject: [PATCH 219/469] Narrow pillar_mask_output to only change pillar.items()'s default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer feedback on PR #69812: "Don't disable masking wholesale .. The config option should only change the default for pillar.items." Reverted the enabled= parameter and every call site outside pillar.items() (hide()/serial()/mask_output()/no_log_mask() in salt/utils/secret.py are back to their original signatures; salt/pillar/__init__.py, salt/client/ssh/wrapper/pillar.py, salt/state.py, salt/output/__init__.py are unchanged). pillar_mask_output now only affects the unmask-default computation inside salt.modules.pillar.items() — pillar.get/item/raw/ext, no_log state output, and the general CLI output safety net keep masking by default regardless of this option. Callers can still always override via pillar.items(unmask=True/False) explicitly. Updated config/doc/changelog wording and tests to match the narrower scope (added test_items_respects_pillar_mask_output_config_option and test_pillar_get_ignores_pillar_mask_output_config_option). Co-Authored-By: Claude Sonnet 5 --- changelog/98852.added.md | 2 +- doc/ref/configuration/master.rst | 21 +++++--- salt/client/ssh/wrapper/pillar.py | 4 +- salt/config/__init__.py | 5 +- salt/modules/pillar.py | 42 +++++++--------- salt/output/__init__.py | 4 +- salt/pillar/__init__.py | 8 +-- salt/state.py | 4 +- salt/utils/secret.py | 39 +++------------ tests/pytests/unit/modules/test_pillar.py | 61 +++++++++++++++++++---- tests/pytests/unit/utils/test_secret.py | 40 --------------- 11 files changed, 98 insertions(+), 132 deletions(-) diff --git a/changelog/98852.added.md b/changelog/98852.added.md index 915807e8e6c7..4381bd453c03 100644 --- a/changelog/98852.added.md +++ b/changelog/98852.added.md @@ -1 +1 @@ -Added the ``pillar_mask_output`` master/minion config option to globally enable or disable pillar output masking (redaction of sensitive pillar values in ``pillar.get``/``pillar.items``/etc., ``no_log`` state output, and general CLI output). Defaults to ``True`` (masking stays on), matching existing behavior. +Added the ``pillar_mask_output`` master/minion config option. When set to ``False``, changes ``pillar.items``'s default (when the caller doesn't pass ``unmask``) to return unmasked pillar values, for sites relying on the pre-masking ``pillar.items`` behavior. Defaults to ``True`` (masked, matching existing behavior) and does not affect ``pillar.get``/``item``/``raw``/``ext``, ``no_log`` state output, or general CLI output, which keep redacting by default regardless of this setting. diff --git a/doc/ref/configuration/master.rst b/doc/ref/configuration/master.rst index caac4207c1c0..5b63d0dd805e 100644 --- a/doc/ref/configuration/master.rst +++ b/doc/ref/configuration/master.rst @@ -5679,14 +5679,19 @@ Recursively merge lists by aggregating them instead of replacing them. Default: ``True`` -Globally enable or disable redaction of pillar values in logs and state -output. When ``True`` (the default), sensitive pillar values are replaced -with ``**********`` in ``pillar.get`` and related execution module output, -``no_log`` state results, and general CLI output, unless a caller explicitly -requests the real value (e.g. ``pillar.get(key, unmask=True)``). - -Set this option to ``False`` to disable pillar masking entirely and always -return real values, matching pre-masking behavior. +Changes the *default* behavior of :py:func:`pillar.items +` when a caller doesn't explicitly pass +``unmask``. When ``True`` (the default), ``pillar.items`` returns masked +values (``**********``) by default, matching :py:func:`pillar.get +` and friends. Set to ``False`` to make +``pillar.items`` default to returning real, unmasked values instead — +useful for sites relying on the pre-masking ``pillar.items`` behavior. + +This option does **not** disable pillar masking elsewhere: ``pillar.get``, +``pillar.item``, ``pillar.raw``, ``pillar.ext``, ``no_log`` state output, +and the general CLI output safety net are unaffected and keep redacting by +default regardless of this setting. Callers of ``pillar.items`` can always +override the default explicitly with ``unmask=True``/``unmask=False``. .. code-block:: yaml diff --git a/salt/client/ssh/wrapper/pillar.py b/salt/client/ssh/wrapper/pillar.py index b49d4d758bf1..2c36ec8d24c0 100644 --- a/salt/client/ssh/wrapper/pillar.py +++ b/salt/client/ssh/wrapper/pillar.py @@ -83,9 +83,7 @@ def item(*args): ret = {} for arg in args: try: - ret[arg] = salt.utils.secret.serial( - __pillar__[arg], enabled=__opts__.get("pillar_mask_output", True) - ) + ret[arg] = salt.utils.secret.serial(__pillar__[arg]) except KeyError: pass return ret diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 8db545b2bb8a..f36bc7bf43db 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -698,8 +698,9 @@ def _gather_buffer_space(): "pillar_source_merging_strategy": str, # Recursively merge lists by aggregating them instead of replacing them. "pillar_merge_lists": bool, - # Globally enable/disable redaction of pillar values in logs and state - # output (pillar.get, no_log states, CLI output, etc.). + # When False, changes pillar.items()'s default (when the caller + # doesn't pass unmask=) to return unmasked pillar values. Does not + # affect pillar.get/item/raw/ext, no_log states, or general output. "pillar_mask_output": bool, # If True, values from included pillar SLS targets will override "pillar_includes_override_sls": bool, diff --git a/salt/modules/pillar.py b/salt/modules/pillar.py index 2fa9a6838be6..4b518fba8ab0 100644 --- a/salt/modules/pillar.py +++ b/salt/modules/pillar.py @@ -160,9 +160,7 @@ def get( ) if unmask: return salt.utils.secret.expose(merged) - return salt.utils.secret.serial( - merged, enabled=__opts__.get("pillar_mask_output", True) - ) + return salt.utils.secret.serial(merged) else: log.error( "pillar.get: Default (%s) is a dict, but the returned " @@ -181,9 +179,7 @@ def get( default.extend([x for x in ret if x not in default]) if unmask: return salt.utils.secret.expose(default) - return salt.utils.secret.serial( - default, enabled=__opts__.get("pillar_mask_output", True) - ) + return salt.utils.secret.serial(default) else: log.error( "pillar.get: Default (%s) is a list, but the returned " @@ -207,9 +203,7 @@ def get( if unmask: return salt.utils.secret.expose(ret) - return salt.utils.secret.serial( - ret, enabled=__opts__.get("pillar_mask_output", True) - ) + return salt.utils.secret.serial(ret) def items( @@ -260,7 +254,10 @@ def items( :conf_minion:`pillarenv_from_saltenv`, and is otherwise ignored. unmask - If set to ``True``, the pillar data will be unmasked. + If set to ``True``, the pillar data will be unmasked. If not set, the + default is unmasked when either the current render context has + already disabled masking, or the :conf_minion:`pillar_mask_output` + config option is set to ``False``. .. versionadded:: 3008.0 @@ -303,13 +300,18 @@ def items( ) ret = pillar.compile_pillar() if unmask is None: - unmask = not salt.utils.secret.mask_pillar.get() + # VCOPS-98852: pillar_mask_output only changes items()'s *default* + # when the caller didn't explicitly request masked/unmasked output — + # it does not disable masking elsewhere (pillar.get/item/raw/ext, + # no_log states, or the general output safety net keep their own + # existing behavior regardless of this option). + unmask = not salt.utils.secret.mask_pillar.get() or not __opts__.get( + "pillar_mask_output", True + ) if unmask: return salt.utils.secret.expose(ret) else: - return salt.utils.secret.serial( - ret, enabled=__opts__.get("pillar_mask_output", True) - ) + return salt.utils.secret.serial(ret) # Allow pillar.data to also be used to return pillar data @@ -600,9 +602,7 @@ def item( if unmask: return salt.utils.secret.expose(ret) else: - return salt.utils.secret.serial( - ret, enabled=__opts__.get("pillar_mask_output", True) - ) + return salt.utils.secret.serial(ret) def raw(key=None, unmask=None): @@ -640,9 +640,7 @@ def raw(key=None, unmask=None): if unmask: return salt.utils.secret.expose(value) - return salt.utils.secret.serial( - value, enabled=__opts__.get("pillar_mask_output", True) - ) + return salt.utils.secret.serial(value) def ext(external, pillar=None, unmask=None): @@ -724,9 +722,7 @@ def ext(external, pillar=None, unmask=None): if unmask: return salt.utils.secret.expose(ret) - return salt.utils.secret.serial( - ret, enabled=__opts__.get("pillar_mask_output", True) - ) + return salt.utils.secret.serial(ret) def keys(key, delimiter=DEFAULT_TARGET_DELIM, unmask=None): diff --git a/salt/output/__init__.py b/salt/output/__init__.py index 66ebc317f370..1d6021528e72 100644 --- a/salt/output/__init__.py +++ b/salt/output/__init__.py @@ -32,9 +32,7 @@ def try_printout(data, out, opts, **kwargs): Safely get the string to print out, try the configured outputter, then fall back to nested and then to raw """ - data = salt.utils.secret.mask_output( - data, enabled=opts.get("pillar_mask_output", True) - ) + data = salt.utils.secret.mask_output(data) try: printout = get_printout(out, opts)(data, **kwargs) if printout is not None: diff --git a/salt/pillar/__init__.py b/salt/pillar/__init__.py index c46786065c81..399a2660c9ab 100644 --- a/salt/pillar/__init__.py +++ b/salt/pillar/__init__.py @@ -279,9 +279,7 @@ async def compile_pillar(self): log.exception("Exception getting pillar:") raise SaltClientError("Exception getting pillar.") self.validate_return(ret_pillar) - ret_pillar = salt.utils.secret.hide( - ret_pillar, enabled=self.opts.get("pillar_mask_output", True) - ) + ret_pillar = salt.utils.secret.hide(ret_pillar) return ret_pillar def destroy(self): @@ -373,9 +371,7 @@ def compile_pillar(self): log.exception("Exception getting pillar:") raise SaltClientError("Exception getting pillar.") self.validate_return(ret_pillar) - return salt.utils.secret.hide( - ret_pillar, enabled=self.opts.get("pillar_mask_output", True) - ) + return salt.utils.secret.hide(ret_pillar) def destroy(self): if hasattr(self, "_closing") and self._closing: diff --git a/salt/state.py b/salt/state.py index 5a37c6bfe990..d66ca0519da2 100644 --- a/salt/state.py +++ b/salt/state.py @@ -2502,9 +2502,7 @@ def call( ret["__run_num__"] = self.__run_num self.__run_num += 1 if low.get("no_log"): - salt.utils.secret.no_log_mask( - ret, enabled=self.opts.get("pillar_mask_output", True) - ) + salt.utils.secret.no_log_mask(ret) format_log(ret) self.check_refresh(low, ret) utc_finish_time = datetime.datetime.now(tz=datetime.timezone.utc) diff --git a/salt/utils/secret.py b/salt/utils/secret.py index 00b513845470..4411a98c1c9e 100644 --- a/salt/utils/secret.py +++ b/salt/utils/secret.py @@ -213,21 +213,13 @@ def __deepcopy__(self, memo): # --------------------------------------------------------------------------- -def hide(value, enabled=True): +def hide(value): """Wrap a pillar dict/list in MaskedDict/MaskedList for display masking. Scalar values (str, int, bool, None …) are returned unchanged — they are stored plain inside the container and only redacted in the container's repr. Already-wrapped values are returned as-is (idempotent). - - enabled - Pass the caller's own ``opts.get("pillar_mask_output", True)`` (or - ``__opts__.get(...)``) — matches the existing pattern for - ``pillar_merge_lists``/``pillar_safe_render_error``, read at each - call site rather than cached. When ``False``, this is a no-op. """ - if not enabled: - return value return _mask_wrap(value) @@ -262,7 +254,7 @@ def expose(value, _seen=None): return value -def serial(value, _seen=None, enabled=True): +def serial(value, _seen=None): """Aggressively redact: replace every non-empty/truthy scalar leaf value (str, bytes, int, float, bool) with a redacted placeholder. @@ -272,17 +264,7 @@ def serial(value, _seen=None, enabled=True): Because ``MaskedDict.__getitem__`` returns plain strings (the scalar leaves are stored unwrapped), this function must handle plain str/dict/list values in addition to MaskedDict / MaskedList containers. - - enabled - Pass the caller's own ``opts.get("pillar_mask_output", True)`` (or - ``__opts__.get(...)``) — matches the existing pattern for - ``pillar_merge_lists``/``pillar_safe_render_error``, read at each - call site rather than cached. When ``False``, this is a no-op. - Only checked on the outermost call; recursive calls omit it since - recursion only happens once the outermost call already found it True. """ - if not enabled: - return value if _seen is None: _seen = set() if isinstance(value, bytes) and _is_redactable_scalar(value): @@ -316,20 +298,14 @@ def serial(value, _seen=None, enabled=True): _seen.discard(vid) -def mask_output(value, _seen=None, enabled=True): +def mask_output(value, _seen=None): """Gently redact: only redact values *inside* MaskedDict / MaskedList containers. Plain dicts, plain lists, and plain scalars pass through unchanged. Use as a safety net in ``output/__init__.py`` to prevent accidental pillar leakage in general Salt output without redacting ordinary result strings (state comments, module names, etc.). - - enabled - Pass the caller's own ``opts.get("pillar_mask_output", True)``. When - ``False``, this is a no-op. Only checked on the outermost call. """ - if not enabled: - return value if _seen is None: _seen = set() if isinstance(value, (MaskedDict, MaskedList)): @@ -349,14 +325,11 @@ def mask_output(value, _seen=None, enabled=True): _seen.discard(vid) -def no_log_mask(state_ret, enabled=True): +def no_log_mask(state_ret): """Replace ``comment`` and ``changes`` in a state return with redacted values. Called by ``salt/state.py`` when a state has ``no_log: True``. Mutates *state_ret* in place. - - enabled - Pass the caller's own ``opts.get("pillar_mask_output", True)``. """ - state_ret["comment"] = serial(state_ret["comment"], enabled=enabled) - state_ret["changes"] = serial(state_ret["changes"], enabled=enabled) + state_ret["comment"] = serial(state_ret["comment"]) + state_ret["changes"] = serial(state_ret["changes"]) diff --git a/tests/pytests/unit/modules/test_pillar.py b/tests/pytests/unit/modules/test_pillar.py index 8d634f5eab38..095e2bff0093 100644 --- a/tests/pytests/unit/modules/test_pillar.py +++ b/tests/pytests/unit/modules/test_pillar.py @@ -160,19 +160,60 @@ def test_pillar_get_default_merge_regression_38558(): assert {"l2": {"l3": 43}} == default -def test_pillar_get_respects_pillar_mask_output_config_option(): - """VCOPS-98852: ``pillar_mask_output: False`` disables masking end-to-end - through the standard ``pillar.get`` execution module, reading ``__opts__`` - directly at the call site (matches the existing ``pillar_merge_lists`` - pattern — no cached/global state in ``salt.utils.secret``). +def test_items_respects_pillar_mask_output_config_option(): + """VCOPS-98852: ``pillar_mask_output`` only changes ``pillar.items``'s + *default* (when the caller doesn't pass ``unmask``) — per maintainer + feedback on saltstack/salt#69812, it must not disable masking wholesale. + """ + compiled = {"pin": 1234} + pillar_obj = MagicMock() + pillar_obj.compile_pillar = MagicMock(return_value=compiled) + grains = MagicMock() + grains.value = MagicMock(return_value={}) + with patch( + "salt.pillar.get_pillar", MagicMock(return_value=pillar_obj) + ), patch.object(pillarmod, "__grains__", grains, create=True): + with patch.dict( + pillarmod.__opts__, + { + "id": "minion", + "saltenv": "base", + "pillarenv": None, + "pillar_mask_output": False, + }, + ): + assert pillarmod.items() == compiled + + with patch.dict( + pillarmod.__opts__, + { + "id": "minion", + "saltenv": "base", + "pillarenv": None, + "pillar_mask_output": True, + }, + ): + assert pillarmod.items() == {"pin": secret.REDACT_PLACEHOLDER} + + # The caller's explicit unmask= always wins over the config default. + with patch.dict( + pillarmod.__opts__, + { + "id": "minion", + "saltenv": "base", + "pillarenv": None, + "pillar_mask_output": False, + }, + ): + assert pillarmod.items(unmask=False) == {"pin": secret.REDACT_PLACEHOLDER} + + +def test_pillar_get_ignores_pillar_mask_output_config_option(): + """VCOPS-98852: ``pillar.get`` must keep masking by default regardless of + ``pillar_mask_output`` — that option only affects ``pillar.items``. """ with patch.dict(pillarmod.__pillar__, {"pin": 1234}), patch.dict( pillarmod.__opts__, {"pillar_mask_output": False} - ): - assert pillarmod.get(key="pin") == 1234 - - with patch.dict(pillarmod.__pillar__, {"pin": 1234}), patch.dict( - pillarmod.__opts__, {"pillar_mask_output": True} ): assert pillarmod.get(key="pin") == secret.REDACT_PLACEHOLDER diff --git a/tests/pytests/unit/utils/test_secret.py b/tests/pytests/unit/utils/test_secret.py index 98b35e4a2fc4..52d97d747972 100644 --- a/tests/pytests/unit/utils/test_secret.py +++ b/tests/pytests/unit/utils/test_secret.py @@ -431,43 +431,3 @@ def test_masked_nested_repr_respects_context_var(): r = repr(d) assert secret.REDACT_PLACEHOLDER in r assert "host1" not in r - - -# --------------------------------------------------------------------------- -# enabled= parameter (VCOPS-98852: pillar_mask_output) — each call site reads -# its own opts.get("pillar_mask_output", True) and passes it in explicitly, -# matching the existing pillar_merge_lists/pillar_safe_render_error pattern -# (no cached/global state in this module). -# --------------------------------------------------------------------------- - - -def test_enabled_defaults_to_true(): - # Callers that don't pass enabled= (or pass True) keep masking on. - assert secret.serial("hunter2") == secret.REDACT_PLACEHOLDER - assert isinstance(secret.hide({"k": "v"}), secret.MaskedDict) - - -def test_enabled_false_disables_serial_redaction(): - assert secret.serial("hunter2", enabled=False) == "hunter2" - assert secret.serial(42, enabled=False) == 42 - d = secret.MaskedDict({"password": "hunter2"}) - assert secret.serial(d, enabled=False) == {"password": "hunter2"} - - -def test_enabled_false_disables_hide_wrapping(): - assert secret.hide({"k": "v"}, enabled=False) == {"k": "v"} - assert not isinstance(secret.hide({"k": "v"}, enabled=False), secret.MaskedDict) - assert secret.hide(["a"], enabled=False) == ["a"] - assert not isinstance(secret.hide(["a"], enabled=False), secret.MaskedList) - - -def test_enabled_false_disables_mask_output(): - d = {"pillar_data": secret.MaskedDict({"password": "secret"})} - result = secret.mask_output(d, enabled=False) - assert result["pillar_data"]["password"] == "secret" - - -def test_enabled_false_disables_no_log_mask(): - ret = {"comment": "plaintext_password", "changes": {}, "result": True} - secret.no_log_mask(ret, enabled=False) - assert ret["comment"] == "plaintext_password" From dcc235b716327257dc82dc45826a1e6c9940b46c Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Mon, 20 Jul 2026 16:23:43 -0600 Subject: [PATCH 220/469] Redact name under no_log and scan for pillar secrets regardless of no_log VCOPS-77716 follow-up: no_log_mask() only masked comment/changes, leaving state name plaintext even under no_log: True. And secrets templated into non-no_log output (e.g. cmd.run stdout) were never scanned at all, so an operator had to remember no_log: True for every state that might echo a pillar value back. Adds redact_state_ret_secrets()/redact_known_secrets()/ _collect_secret_literals() to salt/utils/secret.py: flattens the minion's compiled pillar into known secret literals (longest-first, >= 6 chars to avoid over-redacting trivial strings) and does literal-substring redaction on name/comment/changes for every state return, unconditionally. Called from salt/state.py before the existing no_log_mask() gate. --- salt/state.py | 1 + salt/utils/secret.py | 82 ++++++++++++++++++++- tests/pytests/unit/utils/test_secret.py | 96 ++++++++++++++++++++++++- 3 files changed, 176 insertions(+), 3 deletions(-) diff --git a/salt/state.py b/salt/state.py index d66ca0519da2..1b82837610d9 100644 --- a/salt/state.py +++ b/salt/state.py @@ -2501,6 +2501,7 @@ def call( ret["__sls__"] = low.get("__sls__") ret["__run_num__"] = self.__run_num self.__run_num += 1 + salt.utils.secret.redact_state_ret_secrets(ret, self.opts.get("pillar")) if low.get("no_log"): salt.utils.secret.no_log_mask(ret) format_log(ret) diff --git a/salt/utils/secret.py b/salt/utils/secret.py index 4411a98c1c9e..811451702afc 100644 --- a/salt/utils/secret.py +++ b/salt/utils/secret.py @@ -26,6 +26,11 @@ # Safety net for general output (output/__init__.py) mask_output(state_return_data) # no-op for plain data + + # Literal-secret scan on every state return (salt/state.py), regardless + # of no_log — catches a pillar secret templated into name/comment/changes + # (e.g. echoed back by cmd.run) even when the operator forgot no_log. + redact_state_ret_secrets(ret, opts.get("pillar")) """ from __future__ import annotations @@ -326,10 +331,85 @@ def mask_output(value, _seen=None): def no_log_mask(state_ret): - """Replace ``comment`` and ``changes`` in a state return with redacted values. + """Replace ``name``, ``comment``, and ``changes`` in a state return with + redacted values. Called by ``salt/state.py`` when a state has ``no_log: True``. Mutates *state_ret* in place. """ + state_ret["name"] = serial(state_ret["name"]) state_ret["comment"] = serial(state_ret["comment"]) state_ret["changes"] = serial(state_ret["changes"]) + + +# Minimum length for a pillar leaf value to be treated as a "known secret" +# for literal-substring scanning. Without a floor, short/common strings +# ("true", "1", "yes") would get redacted anywhere they happen to appear in +# unrelated output. +_MIN_SECRET_LEN = 6 + + +def _collect_secret_literals(pillar) -> list: + """Flatten *pillar* into the ``str`` leaf values worth scanning for. + + Returned longest-first so a short secret can't mask inside a longer one + during substring replacement (e.g. "pass" clobbering "pass1234"). + """ + literals = set() + + def _walk(value): + if isinstance(value, dict): + items = ( + dict.items(value) if isinstance(value, MaskedDict) else value.items() + ) + for _, v in items: + _walk(v) + elif isinstance(value, list): + it = list.__iter__(value) if isinstance(value, MaskedList) else iter(value) + for v in it: + _walk(v) + elif isinstance(value, str) and len(value) >= _MIN_SECRET_LEN: + literals.add(value) + + _walk(pillar) + return sorted(literals, key=len, reverse=True) + + +def redact_known_secrets(value, secrets): + """Redact literal occurrences of *secrets* (longest-first) inside *value*. + + Unlike ``mask_output``, this scans ordinary strings — state ``name``, + ``comment``, ``changes.stdout``, etc. — for pillar secret values that + leaked into output through templating (e.g. a ``cmd.run`` that echoes a + pillar value back), not just ``MaskedDict``/``MaskedList`` containers. + """ + if not secrets: + return value + if isinstance(value, str): + for secret_value in secrets: + if secret_value in value: + value = value.replace(secret_value, REDACT_PLACEHOLDER) + return value + if isinstance(value, dict): + items = dict.items(value) if isinstance(value, MaskedDict) else value.items() + return {k: redact_known_secrets(v, secrets) for k, v in items} + if isinstance(value, list): + it = list.__iter__(value) if isinstance(value, MaskedList) else iter(value) + return [redact_known_secrets(v, secrets) for v in it] + return value + + +def redact_state_ret_secrets(state_ret, pillar): + """Scan a state return for literal pillar secret values and redact them. + + Called unconditionally in ``salt/state.py`` — regardless of ``no_log`` — + so a secret templated into ``name``/``comment``/``changes`` doesn't leak + in plaintext just because the state didn't opt into ``no_log: True``. + Mutates *state_ret* in place. + """ + secrets = _collect_secret_literals(pillar) + if not secrets: + return + for field in ("name", "comment", "changes"): + if field in state_ret: + state_ret[field] = redact_known_secrets(state_ret[field], secrets) diff --git a/tests/pytests/unit/utils/test_secret.py b/tests/pytests/unit/utils/test_secret.py index 52d97d747972..57c276098329 100644 --- a/tests/pytests/unit/utils/test_secret.py +++ b/tests/pytests/unit/utils/test_secret.py @@ -349,7 +349,12 @@ def test_mask_output_nested_plain_dicts_not_redacted(): def test_no_log_mask_redacts_comment(): - ret = {"comment": "Executed command", "changes": {}, "result": True} + ret = { + "name": "irrelevant", + "comment": "Executed command", + "changes": {}, + "result": True, + } secret.no_log_mask(ret) assert ret["comment"] == secret.REDACT_PLACEHOLDER assert ret["result"] is True # result is not touched @@ -357,6 +362,7 @@ def test_no_log_mask_redacts_comment(): def test_no_log_mask_redacts_changes(): ret = { + "name": "irrelevant", "comment": "ok", "changes": {"before": "plaintext_password", "after": "new_pass"}, "result": True, @@ -367,11 +373,97 @@ def test_no_log_mask_redacts_changes(): def test_no_log_mask_empty_comment(): - ret = {"comment": "", "changes": {}, "result": True} + ret = {"name": "irrelevant", "comment": "", "changes": {}, "result": True} secret.no_log_mask(ret) assert ret["comment"] == "" # empty string not redacted +def test_no_log_mask_redacts_name(): + ret = { + "name": "echo 'key sk-test-ABCDEF123456'", + "comment": "ok", + "changes": {}, + "result": True, + } + secret.no_log_mask(ret) + assert ret["name"] == secret.REDACT_PLACEHOLDER + + +# --------------------------------------------------------------------------- +# redact_known_secrets() / redact_state_ret_secrets() +# --------------------------------------------------------------------------- + + +def test_redact_known_secrets_redacts_substring_in_string(): + result = secret.redact_known_secrets( + "Connecting with key sk-test-ABCDEF123456", ["sk-test-ABCDEF123456"] + ) + assert result == f"Connecting with key {secret.REDACT_PLACEHOLDER}" + + +def test_redact_known_secrets_no_secrets_is_noop(): + assert secret.redact_known_secrets("plain text", []) == "plain text" + + +def test_redact_known_secrets_longest_first_avoids_partial_corruption(): + # "password" is a substring of "password1234secret" — redacting the + # shorter one first would leave a mangled remainder instead of a clean + # placeholder for the longer secret. + result = secret.redact_known_secrets( + "value=password1234secret", ["password1234secret", "password"] + ) + assert result == f"value={secret.REDACT_PLACEHOLDER}" + + +def test_redact_known_secrets_recurses_into_dict_and_list(): + value = {"stdout": "key: sk-test-ABCDEF123456", "lines": ["sk-test-ABCDEF123456"]} + result = secret.redact_known_secrets(value, ["sk-test-ABCDEF123456"]) + assert result == { + "stdout": f"key: {secret.REDACT_PLACEHOLDER}", + "lines": [secret.REDACT_PLACEHOLDER], + } + + +def test_collect_secret_literals_filters_short_strings(): + # Below _MIN_SECRET_LEN — must not be treated as a scannable secret. + literals = secret._collect_secret_literals({"flag": "true", "id": "1"}) + assert literals == [] + + +def test_redact_state_ret_secrets_redacts_without_no_log(): + """The gap this closes: a pillar secret echoed into stdout must be + redacted even when the state never set ``no_log: True``.""" + pillar = {"gpg_test_key": "sk-test-ABCDEF123456"} + ret = { + "name": "echo 'Connecting with key sk-test-ABCDEF123456'; exit 1", + "comment": "Command failed", + "changes": {"stdout": "Connecting with key sk-test-ABCDEF123456"}, + "result": False, + } + secret.redact_state_ret_secrets(ret, pillar) + assert secret.REDACT_PLACEHOLDER in ret["name"] + assert "sk-test-ABCDEF123456" not in ret["name"] + assert "sk-test-ABCDEF123456" not in ret["changes"]["stdout"] + + +def test_redact_state_ret_secrets_no_pillar_is_noop(): + ret = {"name": "echo hello", "comment": "ok", "changes": {}} + secret.redact_state_ret_secrets(ret, None) + assert ret["name"] == "echo hello" + + +def test_redact_state_ret_secrets_works_with_masked_pillar(): + pillar = secret.hide({"gpg_test_key": "sk-test-ABCDEF123456"}) + ret = { + "name": "sk-test-ABCDEF123456", + "comment": "ok", + "changes": {}, + "result": True, + } + secret.redact_state_ret_secrets(ret, pillar) + assert ret["name"] == secret.REDACT_PLACEHOLDER + + # --------------------------------------------------------------------------- # mask_pillar ContextVar gates container repr # --------------------------------------------------------------------------- From 2d54aa08382becea8bf69156a34f4ad5ef71bb67 Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Mon, 20 Jul 2026 19:00:02 -0600 Subject: [PATCH 221/469] Fix CI tests broken by full-value pillar masking and literal-secret scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing full-value masking (serial() redacting truthy bool/int/float, not just str) broke test_local_sls_call_multiple_pillar_roots and 4 tests in test_pillar.py that read a boolean pillar value via pillar.get/item/items without unmask=True — add unmask=True to match the convention already used elsewhere in the suite (test_file.py, test_ssh_resource_integration.py). The unconditional literal-secret scan added for VCOPS-77716 redacts any pillar leaf value >=6 chars wherever it appears in state output, including values with no relation to secrets. Two known collisions: the literal string "pytest" happens to exist in this test suite's minion pillar (test-harness metadata) and collides with the "pytest-of-" prefix pytest's own tmp_path fixture always produces, and a CLI pillar override ("myhost": "localhost") is no longer visible verbatim in comment/name. Updated affected assertions to expect the redacted values. --- .../pytests/integration/cli/test_salt_call.py | 12 ++++- .../modules/state/test_state_test.py | 44 ++++++++++++++----- .../integration/modules/test_pillar.py | 8 ++-- tests/pytests/integration/states/test_file.py | 10 ++++- 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/tests/pytests/integration/cli/test_salt_call.py b/tests/pytests/integration/cli/test_salt_call.py index 60dcf61ff261..be0df6b6d5b9 100644 --- a/tests/pytests/integration/cli/test_salt_call.py +++ b/tests/pytests/integration/cli/test_salt_call.py @@ -14,6 +14,7 @@ import salt.utils.files import salt.utils.json import salt.utils.platform +import salt.utils.secret import salt.utils.yaml import tests.conftest import tests.support.helpers @@ -164,6 +165,7 @@ def test_local_sls_call_multiple_pillar_roots(salt_master, salt_call_cli): str(salt_master.pillar_tree.prod.paths[0]), "pillar.get", "some_dict", + unmask=True, ) assert ret.returncode == 0 assert "some_key1" in ret.data @@ -421,9 +423,15 @@ def test_42116_cli_pillar_override(salt_call_cli): ) state_run_dict = next(iter(ret.data.values())) assert state_run_dict["changes"] + # VCOPS-77716: state returns are now scanned for literal pillar values and + # redacted regardless of no_log, so the CLI-overridden value ("localhost") + # no longer appears verbatim in comment/changes/name. The retcode still + # confirms the override took effect (a bad/unreachable host would fail). + assert state_run_dict["changes"]["retcode"] == 0 + expected_comment = f'Command "ping -c 2 {salt.utils.secret.REDACT_PLACEHOLDER}" run' assert ( - state_run_dict["comment"] == 'Command "ping -c 2 localhost" run' - ), "CLI pillar override not found in pillar data. State Run Dictionary:\n{}".format( + state_run_dict["comment"] == expected_comment + ), "Expected pillar-sourced value to be redacted from comment. State Run Dictionary:\n{}".format( pprint.pformat(state_run_dict) ) diff --git a/tests/pytests/integration/modules/state/test_state_test.py b/tests/pytests/integration/modules/state/test_state_test.py index c0c323170ccd..1baddb7e9cf9 100644 --- a/tests/pytests/integration/modules/state/test_state_test.py +++ b/tests/pytests/integration/modules/state/test_state_test.py @@ -2,6 +2,7 @@ import pytest +import salt.utils.secret from tests.support.runtests import RUNTIME_VARS pytestmark = [ @@ -9,6 +10,17 @@ ] +def _redact_pytest_tmp_path(path): + """VCOPS-77716: state returns are now scanned for literal pillar secret + values regardless of no_log. This test suite's minion pillar happens to + contain the literal string "pytest" (test-harness metadata), and + ``tmp_path``-derived paths always contain "pytest" too (pytest's own + naming convention), so that substring gets redacted out of any state + output that echoes the path back. + """ + return str(path).replace("pytest", salt.utils.secret.REDACT_PLACEHOLDER) + + @pytest.fixture(scope="module") def reset_pillar(salt_call_cli): try: @@ -118,15 +130,16 @@ def test_state_sls_id_test(salt_call_cli, testfile_path): test state.sls_id when test is set to true in pillar data """ + redacted_path = _redact_pytest_tmp_path(testfile_path) expected_comment = ( "The file {} is set to be changed\nNote: No changes made, actual changes may\n" "be different due to other states." - ).format(testfile_path) + ).format(redacted_path) ret = salt_call_cli.run("state.sls", "sls-id-test") assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == expected_comment - assert val["changes"] == {"newfile": str(testfile_path)} + assert val["changes"] == {"newfile": redacted_path} @pytest.mark.usefixtures("pillar_test_true") @@ -142,7 +155,7 @@ def test_state_sls_id_test_state_test_post_run(salt_call_cli, testfile_path): assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == "The file {} is in the correct state".format( - testfile_path + _redact_pytest_tmp_path(testfile_path) ) assert val["changes"] == {} @@ -152,15 +165,16 @@ def test_state_sls_id_test_true(salt_call_cli, testfile_path): """ test state.sls_id when test=True is passed as arg """ + redacted_path = _redact_pytest_tmp_path(testfile_path) expected_comment = ( "The file {} is set to be changed\nNote: No changes made, actual changes may\n" "be different due to other states." - ).format(testfile_path) + ).format(redacted_path) ret = salt_call_cli.run("state.sls", "sls-id-test", test=True) assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == expected_comment - assert val["changes"] == {"newfile": str(testfile_path)} + assert val["changes"] == {"newfile": redacted_path} @pytest.mark.usefixtures("pillar_test_empty") @@ -173,14 +187,16 @@ def test_state_sls_id_test_true_post_run(salt_call_cli, testfile_path): assert ret.returncode == 0 assert testfile_path.exists() for val in ret.data.values(): - assert val["comment"] == f"File {testfile_path} updated" + assert ( + val["comment"] == f"File {_redact_pytest_tmp_path(testfile_path)} updated" + ) assert val["changes"]["diff"] == "New file" ret = salt_call_cli.run("state.sls", "sls-id-test", test=True) assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == "The file {} is in the correct state".format( - testfile_path + _redact_pytest_tmp_path(testfile_path) ) assert val["changes"] == {} @@ -195,7 +211,9 @@ def test_state_sls_id_test_false_pillar_true(salt_call_cli, testfile_path): ret = salt_call_cli.run("state.sls", "sls-id-test", test=False) assert ret.returncode == 0 for val in ret.data.values(): - assert val["comment"] == f"File {testfile_path} updated" + assert ( + val["comment"] == f"File {_redact_pytest_tmp_path(testfile_path)} updated" + ) assert val["changes"]["diff"] == "New file" @@ -204,15 +222,16 @@ def test_state_test_pillar_false(salt_call_cli, testfile_path): """ test state.test forces test kwarg to True even when pillar is set to False """ + redacted_path = _redact_pytest_tmp_path(testfile_path) expected_comment = ( "The file {} is set to be changed\nNote: No changes made, actual changes may\n" "be different due to other states." - ).format(testfile_path) + ).format(redacted_path) ret = salt_call_cli.run("state.test", "sls-id-test") assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == expected_comment - assert val["changes"] == {"newfile": str(testfile_path)} + assert val["changes"] == {"newfile": redacted_path} @pytest.mark.usefixtures("pillar_test_false") @@ -221,12 +240,13 @@ def test_state_test_test_false_pillar_false(salt_call_cli, testfile_path): test state.test forces test kwarg to True even when pillar and kwarg are set to False """ + redacted_path = _redact_pytest_tmp_path(testfile_path) expected_comment = ( "The file {} is set to be changed\nNote: No changes made, actual changes may\n" "be different due to other states." - ).format(testfile_path) + ).format(redacted_path) ret = salt_call_cli.run("state.test", "sls-id-test", test=False) assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == expected_comment - assert val["changes"] == {"newfile": str(testfile_path)} + assert val["changes"] == {"newfile": redacted_path} diff --git a/tests/pytests/integration/modules/test_pillar.py b/tests/pytests/integration/modules/test_pillar.py index 29289d226fe5..0258d0b10e3e 100644 --- a/tests/pytests/integration/modules/test_pillar.py +++ b/tests/pytests/integration/modules/test_pillar.py @@ -295,7 +295,7 @@ def test_pillar_refresh_pillar_get(salt_cli, salt_minion, key_pillar): key_pillar_instance.refresh_pillar() # The pillar can now be read from in-memory pillars - ret = salt_cli.run("pillar.get", key, minion_tgt=salt_minion.id) + ret = salt_cli.run("pillar.get", key, minion_tgt=salt_minion.id, unmask=True) assert ret.returncode == 0 val = ret.data assert val is True, repr(val) @@ -328,7 +328,7 @@ def test_pillar_refresh_pillar_item(salt_cli, salt_minion, key_pillar): key_pillar_instance.refresh_pillar() # The pillar can now be read from in-memory pillars - ret = salt_cli.run("pillar.item", key, minion_tgt=salt_minion.id) + ret = salt_cli.run("pillar.item", key, minion_tgt=salt_minion.id, unmask=True) assert ret.returncode == 0 val = ret.data assert key in val @@ -353,7 +353,7 @@ def test_pillar_refresh_pillar_items(salt_cli, salt_minion, key_pillar): # refresh_pillar event is fired. # Calling refresh_pillar to update in-memory pillars key_pillar_instance.refresh_pillar() - ret = salt_cli.run("pillar.items", minion_tgt=salt_minion.id) + ret = salt_cli.run("pillar.items", minion_tgt=salt_minion.id, unmask=True) assert ret.returncode == 0 val = ret.data assert key in val @@ -394,7 +394,7 @@ def test_pillar_refresh_pillar_ping(salt_cli, salt_minion, key_pillar): key_pillar_instance.refresh_pillar() # The pillar can now be read from in-memory pillars - ret = salt_cli.run("pillar.item", key, minion_tgt=salt_minion.id) + ret = salt_cli.run("pillar.item", key, minion_tgt=salt_minion.id, unmask=True) assert ret.returncode == 0 val = ret.data assert key in val diff --git a/tests/pytests/integration/states/test_file.py b/tests/pytests/integration/states/test_file.py index d495694cb280..7a78f6933324 100644 --- a/tests/pytests/integration/states/test_file.py +++ b/tests/pytests/integration/states/test_file.py @@ -16,6 +16,7 @@ import salt.utils.files import salt.utils.path import salt.utils.platform +import salt.utils.secret from salt.utils.versions import Version from tests.conftest import FIPS_TESTRUN @@ -1240,7 +1241,14 @@ def test_state_skip_req( assert ret.data state_runs = list(ret.data.values()) # file.managed returns changes but doesn't trigger reqs - assert state_runs[0]["name"] == str(target_path) + # VCOPS-77716: state returns are scanned for literal pillar secret + # values regardless of no_log. This suite's minion pillar contains + # the literal string "pytest" (test-harness metadata), and + # tmp_path-derived paths always contain "pytest" too, so that + # substring is redacted out of the state's name. + assert state_runs[0]["name"] == str(target_path).replace( + "pytest", salt.utils.secret.REDACT_PLACEHOLDER + ) assert state_runs[0]["result"] is True assert state_runs[0]["changes"] assert state_runs[0]["skip_req"] is True From ae70e90c4252e85ee9a6a12ead1a436d1f72ec16 Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Mon, 3 Aug 2026 10:56:28 -0600 Subject: [PATCH 222/469] Revert unconditional literal-secret scanning (gap #1) Real CI on PR #69812 surfaced that redact_state_ret_secrets()'s _collect_secret_literals() has no cycle-detection guard, unlike its siblings serial()/mask_output()/expose() in the same file. In the state.orchestrate/runner path, self.opts.get("pillar") is an OptsDict/ListProxy with a self-referential __iter__, so the recursive walker crashes with RecursionError - state.orchestrate fails outright in ~15 tests, not just degraded output. Separately, and even setting the crash aside, the literal-substring scan has no way to distinguish an actual secret from an ordinary pillar value used as a template parameter. Confirmed at real scale: the words "branch" and "master" - core Salt/git vocabulary - are present somewhere in the CI harness's minion pillar and got redacted out of unrelated assertions, breaking tests/integration/states/test_git.py (6 tests, every OS in the matrix) plus several salt-ssh suites. ~30 distinct upstream tests failed across ~9 platforms. Removes redact_state_ret_secrets()/redact_known_secrets()/ _collect_secret_literals() and the call site in salt/state.py. Reverts the three test files that were patched only to accommodate this mechanism's fallout (test_state_test.py, test_salt_call.py's test_42116_cli_pillar_override, test_file.py's test_state_skip_req) back to their original assertions. Keeps the unrelated unmask=True fixes (a real, independent pre-existing bug) and gap #2 (name masking under no_log), neither of which caused any CI failures. --- salt/state.py | 1 - salt/utils/secret.py | 78 ------------------- .../pytests/integration/cli/test_salt_call.py | 11 +-- .../modules/state/test_state_test.py | 44 +++-------- tests/pytests/integration/states/test_file.py | 10 +-- tests/pytests/unit/utils/test_secret.py | 75 ------------------ 6 files changed, 15 insertions(+), 204 deletions(-) diff --git a/salt/state.py b/salt/state.py index 1b82837610d9..d66ca0519da2 100644 --- a/salt/state.py +++ b/salt/state.py @@ -2501,7 +2501,6 @@ def call( ret["__sls__"] = low.get("__sls__") ret["__run_num__"] = self.__run_num self.__run_num += 1 - salt.utils.secret.redact_state_ret_secrets(ret, self.opts.get("pillar")) if low.get("no_log"): salt.utils.secret.no_log_mask(ret) format_log(ret) diff --git a/salt/utils/secret.py b/salt/utils/secret.py index 811451702afc..5c8b840aa757 100644 --- a/salt/utils/secret.py +++ b/salt/utils/secret.py @@ -26,11 +26,6 @@ # Safety net for general output (output/__init__.py) mask_output(state_return_data) # no-op for plain data - - # Literal-secret scan on every state return (salt/state.py), regardless - # of no_log — catches a pillar secret templated into name/comment/changes - # (e.g. echoed back by cmd.run) even when the operator forgot no_log. - redact_state_ret_secrets(ret, opts.get("pillar")) """ from __future__ import annotations @@ -340,76 +335,3 @@ def no_log_mask(state_ret): state_ret["name"] = serial(state_ret["name"]) state_ret["comment"] = serial(state_ret["comment"]) state_ret["changes"] = serial(state_ret["changes"]) - - -# Minimum length for a pillar leaf value to be treated as a "known secret" -# for literal-substring scanning. Without a floor, short/common strings -# ("true", "1", "yes") would get redacted anywhere they happen to appear in -# unrelated output. -_MIN_SECRET_LEN = 6 - - -def _collect_secret_literals(pillar) -> list: - """Flatten *pillar* into the ``str`` leaf values worth scanning for. - - Returned longest-first so a short secret can't mask inside a longer one - during substring replacement (e.g. "pass" clobbering "pass1234"). - """ - literals = set() - - def _walk(value): - if isinstance(value, dict): - items = ( - dict.items(value) if isinstance(value, MaskedDict) else value.items() - ) - for _, v in items: - _walk(v) - elif isinstance(value, list): - it = list.__iter__(value) if isinstance(value, MaskedList) else iter(value) - for v in it: - _walk(v) - elif isinstance(value, str) and len(value) >= _MIN_SECRET_LEN: - literals.add(value) - - _walk(pillar) - return sorted(literals, key=len, reverse=True) - - -def redact_known_secrets(value, secrets): - """Redact literal occurrences of *secrets* (longest-first) inside *value*. - - Unlike ``mask_output``, this scans ordinary strings — state ``name``, - ``comment``, ``changes.stdout``, etc. — for pillar secret values that - leaked into output through templating (e.g. a ``cmd.run`` that echoes a - pillar value back), not just ``MaskedDict``/``MaskedList`` containers. - """ - if not secrets: - return value - if isinstance(value, str): - for secret_value in secrets: - if secret_value in value: - value = value.replace(secret_value, REDACT_PLACEHOLDER) - return value - if isinstance(value, dict): - items = dict.items(value) if isinstance(value, MaskedDict) else value.items() - return {k: redact_known_secrets(v, secrets) for k, v in items} - if isinstance(value, list): - it = list.__iter__(value) if isinstance(value, MaskedList) else iter(value) - return [redact_known_secrets(v, secrets) for v in it] - return value - - -def redact_state_ret_secrets(state_ret, pillar): - """Scan a state return for literal pillar secret values and redact them. - - Called unconditionally in ``salt/state.py`` — regardless of ``no_log`` — - so a secret templated into ``name``/``comment``/``changes`` doesn't leak - in plaintext just because the state didn't opt into ``no_log: True``. - Mutates *state_ret* in place. - """ - secrets = _collect_secret_literals(pillar) - if not secrets: - return - for field in ("name", "comment", "changes"): - if field in state_ret: - state_ret[field] = redact_known_secrets(state_ret[field], secrets) diff --git a/tests/pytests/integration/cli/test_salt_call.py b/tests/pytests/integration/cli/test_salt_call.py index be0df6b6d5b9..fdea2d214087 100644 --- a/tests/pytests/integration/cli/test_salt_call.py +++ b/tests/pytests/integration/cli/test_salt_call.py @@ -14,7 +14,6 @@ import salt.utils.files import salt.utils.json import salt.utils.platform -import salt.utils.secret import salt.utils.yaml import tests.conftest import tests.support.helpers @@ -423,15 +422,9 @@ def test_42116_cli_pillar_override(salt_call_cli): ) state_run_dict = next(iter(ret.data.values())) assert state_run_dict["changes"] - # VCOPS-77716: state returns are now scanned for literal pillar values and - # redacted regardless of no_log, so the CLI-overridden value ("localhost") - # no longer appears verbatim in comment/changes/name. The retcode still - # confirms the override took effect (a bad/unreachable host would fail). - assert state_run_dict["changes"]["retcode"] == 0 - expected_comment = f'Command "ping -c 2 {salt.utils.secret.REDACT_PLACEHOLDER}" run' assert ( - state_run_dict["comment"] == expected_comment - ), "Expected pillar-sourced value to be redacted from comment. State Run Dictionary:\n{}".format( + state_run_dict["comment"] == 'Command "ping -c 2 localhost" run' + ), "CLI pillar override not found in pillar data. State Run Dictionary:\n{}".format( pprint.pformat(state_run_dict) ) diff --git a/tests/pytests/integration/modules/state/test_state_test.py b/tests/pytests/integration/modules/state/test_state_test.py index 1baddb7e9cf9..c0c323170ccd 100644 --- a/tests/pytests/integration/modules/state/test_state_test.py +++ b/tests/pytests/integration/modules/state/test_state_test.py @@ -2,7 +2,6 @@ import pytest -import salt.utils.secret from tests.support.runtests import RUNTIME_VARS pytestmark = [ @@ -10,17 +9,6 @@ ] -def _redact_pytest_tmp_path(path): - """VCOPS-77716: state returns are now scanned for literal pillar secret - values regardless of no_log. This test suite's minion pillar happens to - contain the literal string "pytest" (test-harness metadata), and - ``tmp_path``-derived paths always contain "pytest" too (pytest's own - naming convention), so that substring gets redacted out of any state - output that echoes the path back. - """ - return str(path).replace("pytest", salt.utils.secret.REDACT_PLACEHOLDER) - - @pytest.fixture(scope="module") def reset_pillar(salt_call_cli): try: @@ -130,16 +118,15 @@ def test_state_sls_id_test(salt_call_cli, testfile_path): test state.sls_id when test is set to true in pillar data """ - redacted_path = _redact_pytest_tmp_path(testfile_path) expected_comment = ( "The file {} is set to be changed\nNote: No changes made, actual changes may\n" "be different due to other states." - ).format(redacted_path) + ).format(testfile_path) ret = salt_call_cli.run("state.sls", "sls-id-test") assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == expected_comment - assert val["changes"] == {"newfile": redacted_path} + assert val["changes"] == {"newfile": str(testfile_path)} @pytest.mark.usefixtures("pillar_test_true") @@ -155,7 +142,7 @@ def test_state_sls_id_test_state_test_post_run(salt_call_cli, testfile_path): assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == "The file {} is in the correct state".format( - _redact_pytest_tmp_path(testfile_path) + testfile_path ) assert val["changes"] == {} @@ -165,16 +152,15 @@ def test_state_sls_id_test_true(salt_call_cli, testfile_path): """ test state.sls_id when test=True is passed as arg """ - redacted_path = _redact_pytest_tmp_path(testfile_path) expected_comment = ( "The file {} is set to be changed\nNote: No changes made, actual changes may\n" "be different due to other states." - ).format(redacted_path) + ).format(testfile_path) ret = salt_call_cli.run("state.sls", "sls-id-test", test=True) assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == expected_comment - assert val["changes"] == {"newfile": redacted_path} + assert val["changes"] == {"newfile": str(testfile_path)} @pytest.mark.usefixtures("pillar_test_empty") @@ -187,16 +173,14 @@ def test_state_sls_id_test_true_post_run(salt_call_cli, testfile_path): assert ret.returncode == 0 assert testfile_path.exists() for val in ret.data.values(): - assert ( - val["comment"] == f"File {_redact_pytest_tmp_path(testfile_path)} updated" - ) + assert val["comment"] == f"File {testfile_path} updated" assert val["changes"]["diff"] == "New file" ret = salt_call_cli.run("state.sls", "sls-id-test", test=True) assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == "The file {} is in the correct state".format( - _redact_pytest_tmp_path(testfile_path) + testfile_path ) assert val["changes"] == {} @@ -211,9 +195,7 @@ def test_state_sls_id_test_false_pillar_true(salt_call_cli, testfile_path): ret = salt_call_cli.run("state.sls", "sls-id-test", test=False) assert ret.returncode == 0 for val in ret.data.values(): - assert ( - val["comment"] == f"File {_redact_pytest_tmp_path(testfile_path)} updated" - ) + assert val["comment"] == f"File {testfile_path} updated" assert val["changes"]["diff"] == "New file" @@ -222,16 +204,15 @@ def test_state_test_pillar_false(salt_call_cli, testfile_path): """ test state.test forces test kwarg to True even when pillar is set to False """ - redacted_path = _redact_pytest_tmp_path(testfile_path) expected_comment = ( "The file {} is set to be changed\nNote: No changes made, actual changes may\n" "be different due to other states." - ).format(redacted_path) + ).format(testfile_path) ret = salt_call_cli.run("state.test", "sls-id-test") assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == expected_comment - assert val["changes"] == {"newfile": redacted_path} + assert val["changes"] == {"newfile": str(testfile_path)} @pytest.mark.usefixtures("pillar_test_false") @@ -240,13 +221,12 @@ def test_state_test_test_false_pillar_false(salt_call_cli, testfile_path): test state.test forces test kwarg to True even when pillar and kwarg are set to False """ - redacted_path = _redact_pytest_tmp_path(testfile_path) expected_comment = ( "The file {} is set to be changed\nNote: No changes made, actual changes may\n" "be different due to other states." - ).format(redacted_path) + ).format(testfile_path) ret = salt_call_cli.run("state.test", "sls-id-test", test=False) assert ret.returncode == 0 for val in ret.data.values(): assert val["comment"] == expected_comment - assert val["changes"] == {"newfile": redacted_path} + assert val["changes"] == {"newfile": str(testfile_path)} diff --git a/tests/pytests/integration/states/test_file.py b/tests/pytests/integration/states/test_file.py index 7a78f6933324..d495694cb280 100644 --- a/tests/pytests/integration/states/test_file.py +++ b/tests/pytests/integration/states/test_file.py @@ -16,7 +16,6 @@ import salt.utils.files import salt.utils.path import salt.utils.platform -import salt.utils.secret from salt.utils.versions import Version from tests.conftest import FIPS_TESTRUN @@ -1241,14 +1240,7 @@ def test_state_skip_req( assert ret.data state_runs = list(ret.data.values()) # file.managed returns changes but doesn't trigger reqs - # VCOPS-77716: state returns are scanned for literal pillar secret - # values regardless of no_log. This suite's minion pillar contains - # the literal string "pytest" (test-harness metadata), and - # tmp_path-derived paths always contain "pytest" too, so that - # substring is redacted out of the state's name. - assert state_runs[0]["name"] == str(target_path).replace( - "pytest", salt.utils.secret.REDACT_PLACEHOLDER - ) + assert state_runs[0]["name"] == str(target_path) assert state_runs[0]["result"] is True assert state_runs[0]["changes"] assert state_runs[0]["skip_req"] is True diff --git a/tests/pytests/unit/utils/test_secret.py b/tests/pytests/unit/utils/test_secret.py index 57c276098329..bc8c73c28b6c 100644 --- a/tests/pytests/unit/utils/test_secret.py +++ b/tests/pytests/unit/utils/test_secret.py @@ -389,81 +389,6 @@ def test_no_log_mask_redacts_name(): assert ret["name"] == secret.REDACT_PLACEHOLDER -# --------------------------------------------------------------------------- -# redact_known_secrets() / redact_state_ret_secrets() -# --------------------------------------------------------------------------- - - -def test_redact_known_secrets_redacts_substring_in_string(): - result = secret.redact_known_secrets( - "Connecting with key sk-test-ABCDEF123456", ["sk-test-ABCDEF123456"] - ) - assert result == f"Connecting with key {secret.REDACT_PLACEHOLDER}" - - -def test_redact_known_secrets_no_secrets_is_noop(): - assert secret.redact_known_secrets("plain text", []) == "plain text" - - -def test_redact_known_secrets_longest_first_avoids_partial_corruption(): - # "password" is a substring of "password1234secret" — redacting the - # shorter one first would leave a mangled remainder instead of a clean - # placeholder for the longer secret. - result = secret.redact_known_secrets( - "value=password1234secret", ["password1234secret", "password"] - ) - assert result == f"value={secret.REDACT_PLACEHOLDER}" - - -def test_redact_known_secrets_recurses_into_dict_and_list(): - value = {"stdout": "key: sk-test-ABCDEF123456", "lines": ["sk-test-ABCDEF123456"]} - result = secret.redact_known_secrets(value, ["sk-test-ABCDEF123456"]) - assert result == { - "stdout": f"key: {secret.REDACT_PLACEHOLDER}", - "lines": [secret.REDACT_PLACEHOLDER], - } - - -def test_collect_secret_literals_filters_short_strings(): - # Below _MIN_SECRET_LEN — must not be treated as a scannable secret. - literals = secret._collect_secret_literals({"flag": "true", "id": "1"}) - assert literals == [] - - -def test_redact_state_ret_secrets_redacts_without_no_log(): - """The gap this closes: a pillar secret echoed into stdout must be - redacted even when the state never set ``no_log: True``.""" - pillar = {"gpg_test_key": "sk-test-ABCDEF123456"} - ret = { - "name": "echo 'Connecting with key sk-test-ABCDEF123456'; exit 1", - "comment": "Command failed", - "changes": {"stdout": "Connecting with key sk-test-ABCDEF123456"}, - "result": False, - } - secret.redact_state_ret_secrets(ret, pillar) - assert secret.REDACT_PLACEHOLDER in ret["name"] - assert "sk-test-ABCDEF123456" not in ret["name"] - assert "sk-test-ABCDEF123456" not in ret["changes"]["stdout"] - - -def test_redact_state_ret_secrets_no_pillar_is_noop(): - ret = {"name": "echo hello", "comment": "ok", "changes": {}} - secret.redact_state_ret_secrets(ret, None) - assert ret["name"] == "echo hello" - - -def test_redact_state_ret_secrets_works_with_masked_pillar(): - pillar = secret.hide({"gpg_test_key": "sk-test-ABCDEF123456"}) - ret = { - "name": "sk-test-ABCDEF123456", - "comment": "ok", - "changes": {}, - "result": True, - } - secret.redact_state_ret_secrets(ret, pillar) - assert ret["name"] == secret.REDACT_PLACEHOLDER - - # --------------------------------------------------------------------------- # mask_pillar ContextVar gates container repr # --------------------------------------------------------------------------- From 09a68781b13f94e6965bdfb8554367fe87c605cf Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 11 Jul 2026 15:36:41 -0400 Subject: [PATCH 223/469] Isolate the active-HighState stack per execution context (#63056) Concurrent state/orchestration renders shared a single class-level HighState.stack list. When the reactor renders orchestrations in parallel worker threads (it runs them inline, without forking), one render's push_active was visible to another, so HighState.get_active could return the wrong HighState in the middle of a render. This surfaced as an IndexError popping an empty pydsl render stack, or KeyError: '__env__' from a spuriously detected conflicting ID. Store the stack in a contextvars.ContextVar instead, mirroring salt.loader's loader_ctxvar, so each execution context (and therefore each reactor worker thread) gets its own stack. The pydsl top-file matches, previously cached in a module-level SLS_MATCHES global with the same sharing problem, are now cached per HighState instance. SSHHighState.push_active was a redundant override reaching for the removed class attribute, so it now inherits the context-backed base method. The conflicting-ID error formatter also uses .get() so a genuine conflict reports cleanly instead of raising KeyError. --- changelog/63056.fixed.md | 1 + salt/client/ssh/state.py | 4 +- salt/state.py | 54 +++++++++--- salt/utils/pydsl.py | 15 ++-- .../unit/state/test_active_highstate_stack.py | 85 +++++++++++++++++++ 5 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 changelog/63056.fixed.md create mode 100644 tests/pytests/unit/state/test_active_highstate_stack.py diff --git a/changelog/63056.fixed.md b/changelog/63056.fixed.md new file mode 100644 index 000000000000..c6d60db7a87d --- /dev/null +++ b/changelog/63056.fixed.md @@ -0,0 +1 @@ +Fixed a race in concurrent state/orchestration renders where the active-HighState stack was shared on the class, so parallel reactor renders corrupted one another and failed with ``IndexError`` (empty pydsl render stack) or ``KeyError: '__env__'`` (spurious conflicting-ID). The stack and the cached pydsl top-file matches are now isolated per execution context. diff --git a/salt/client/ssh/state.py b/salt/client/ssh/state.py index e52f41cbc647..8785e3582560 100644 --- a/salt/client/ssh/state.py +++ b/salt/client/ssh/state.py @@ -140,9 +140,7 @@ def __init__( self._pydsl_all_decls = {} self._pydsl_render_stack = [] - - def push_active(self): - salt.state.HighState.stack.append(self) + self._pydsl_sls_matches = None def load_dynamic(self, matches): """ diff --git a/salt/state.py b/salt/state.py index 5a2829d2c054..b0daae00540c 100644 --- a/salt/state.py +++ b/salt/state.py @@ -11,6 +11,7 @@ } """ +import contextvars import copy import datetime import fnmatch @@ -4863,10 +4864,10 @@ def merge_included_states(self, highstate, state, errors): " conflicting ID is '{}' and is found in SLS" " '{}:{}' and SLS '{}:{}'".format( id_, - highstate[id_]["__env__"], - highstate[id_]["__sls__"], - state[id_]["__env__"], - state[id_]["__sls__"], + highstate[id_].get("__env__"), + highstate[id_].get("__sls__"), + state[id_].get("__env__"), + state[id_].get("__sls__"), ) ) try: @@ -5083,6 +5084,14 @@ def __exit__(self, *_): self.destroy() +# The stack of active HighState objects during a state run is kept per +# execution context rather than on the class, so concurrent runs -- e.g. +# reactor orchestrations rendered in parallel reactor worker threads -- each +# get their own stack instead of corrupting a shared class-level list +# (#63056). This mirrors salt.loader's loader_ctxvar. +_active_highstates = contextvars.ContextVar("salt_active_highstates") + + class HighState(BaseHighState): """ Generate and execute the salt "High State". The High State is the @@ -5090,8 +5099,9 @@ class HighState(BaseHighState): salt master or in the local cache. """ - # a stack of active HighState objects during a state.highstate run - stack = [] + # The stack of active HighState objects during a state run is stored per + # execution context in the module-level ``_active_highstates`` ContextVar; + # see ``_active_stack`` and the push/pop/get/clear accessors below. def __init__( self, @@ -5151,26 +5161,44 @@ def __init__( # a stack of current rendering Sls objects, maintained and used by the pydsl renderer. self._pydsl_render_stack = [] + # cached top-file matches for pydsl includes, computed once per run. + # Held on the instance (not a module global) so concurrent runs do not + # share one another's matches (#63056). + self._pydsl_sls_matches = None + + @classmethod + def _active_stack(cls): + # The active-HighState stack for the current execution context, created + # lazily on first use. A default= on the ContextVar would share one + # list object across every context, defeating the isolation, so the + # per-context list is set explicitly here instead. + try: + return _active_highstates.get() + except LookupError: + stack = [] + _active_highstates.set(stack) + return stack + def push_active(self): - self.stack.append(self) + self._active_stack().append(self) @classmethod def clear_active(cls): # Nuclear option # - # Blow away the entire stack. Used primarily by the test runner but also - # useful in custom wrappers of the HighState class, to reset the stack - # to a fresh state. - cls.stack = [] + # Blow away the active-HighState stack for the current execution + # context. Used primarily by the test runner but also useful in custom + # wrappers of the HighState class, to reset the stack to a fresh state. + _active_highstates.set([]) @classmethod def pop_active(cls): - cls.stack.pop() + cls._active_stack().pop() @classmethod def get_active(cls): try: - return cls.stack[-1] + return cls._active_stack()[-1] except IndexError: return None diff --git a/salt/utils/pydsl.py b/salt/utils/pydsl.py index 516a4305b7ee..94ce3aae0a30 100644 --- a/salt/utils/pydsl.py +++ b/salt/utils/pydsl.py @@ -103,9 +103,6 @@ def __getattr__(self, name): return self.get(name) -SLS_MATCHES = None - - class Sls: def __init__(self, sls, saltenv, rendered_sls): self.name = sls @@ -146,9 +143,13 @@ def include(self, *sls_names, **kws): HIGHSTATE = HighState.get_active() - global SLS_MATCHES - if SLS_MATCHES is None: - SLS_MATCHES = HIGHSTATE.top_matches(HIGHSTATE.get_top()) + # Cache the top-file matches on the active HighState instead of a module + # global so concurrent runs don't share each other's matches (#63056). + sls_matches = HIGHSTATE._pydsl_sls_matches + if sls_matches is None: + sls_matches = HIGHSTATE._pydsl_sls_matches = HIGHSTATE.top_matches( + HIGHSTATE.get_top() + ) highstate = self.included_highstate slsmods = [] # a list of pydsl sls modules rendered. @@ -159,7 +160,7 @@ def include(self, *sls_names, **kws): sls ) # needed in case the starting sls uses the pydsl renderer. histates, errors = HIGHSTATE.render_state( - sls, saltenv, self.rendered_sls, SLS_MATCHES + sls, saltenv, self.rendered_sls, sls_matches ) HIGHSTATE.merge_included_states(highstate, histates, errors) if errors: diff --git a/tests/pytests/unit/state/test_active_highstate_stack.py b/tests/pytests/unit/state/test_active_highstate_stack.py new file mode 100644 index 000000000000..fd8845eaf738 --- /dev/null +++ b/tests/pytests/unit/state/test_active_highstate_stack.py @@ -0,0 +1,85 @@ +""" +Tests for the per-execution-context active-HighState stack (#63056). + +Concurrent state runs -- e.g. reactor orchestrations rendered in parallel +reactor worker threads -- previously shared a single class-level +``HighState.stack`` list. One run's ``push_active`` was therefore visible to +another, so ``HighState.get_active`` could return the wrong HighState in the +middle of a render (surfacing downstream as ``IndexError`` popping an empty +pydsl render stack, or ``KeyError: '__env__'`` from a spuriously detected +conflicting ID). The stack is now isolated per execution context via a +ContextVar. +""" + +import threading + +import salt.state + + +class _Marker(salt.state.HighState): + # A cheap stand-in that skips HighState's heavy __init__ but inherits the + # real push/pop/get/clear accessors under test. + def __init__(self, tag): # pylint: disable=super-init-not-called + self.tag = tag + + +def test_active_stack_push_pop_get_clear(): + HighState = salt.state.HighState + HighState.clear_active() + assert HighState.get_active() is None + + a = _Marker("a") + b = _Marker("b") + + a.push_active() + assert HighState.get_active() is a + b.push_active() + assert HighState.get_active() is b + b.pop_active() + assert HighState.get_active() is a + a.pop_active() + assert HighState.get_active() is None + + # clear_active() resets the stack for the current context. + a.push_active() + HighState.clear_active() + assert HighState.get_active() is None + + +def test_active_stack_isolated_across_threads(): + HighState = salt.state.HighState + HighState.clear_active() + + results = {} + # Two barriers make the failure deterministic on a *shared* stack: every + # thread pushes before any reads (so the shared top holds both markers), + # and every thread reads before any pops (so a fast pop can't restore the + # reader's own marker by luck). With a shared stack both threads then read + # whichever marker was pushed last -- two identical tags, never {A, B}. + both_pushed = threading.Barrier(2) + both_read = threading.Barrier(2) + + def worker(tag): + marker = _Marker(tag) + marker.push_active() + both_pushed.wait(timeout=10) + try: + active = HighState.get_active() + results[tag] = None if active is None else active.tag + both_read.wait(timeout=10) + finally: + marker.pop_active() + + threads = [ + threading.Thread(target=worker, args=("A",)), + threading.Thread(target=worker, args=("B",)), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=15) + + # Each thread sees only the HighState it pushed, despite the concurrent + # push from the other thread. On the old shared stack both threads would + # read whichever marker was pushed last. + assert results == {"A": "A", "B": "B"} From 09e472be16997e93f765214977d22fc5892d5441 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 13 Jul 2026 06:51:09 -0400 Subject: [PATCH 224/469] Move active-stack accessors to BaseHighState so SSHHighState keeps push_active The #63056 refactor put the active-HighState stack accessors (push_active/pop_active/get_active/clear_active) on HighState. But SSHHighState subclasses BaseHighState, not HighState, and the salt-ssh state wrapper calls st_.push_active() on every run -- so every salt-ssh state execution raised "'SSHHighState' object has no attribute 'push_active'". Move the accessors to BaseHighState (their shared ancestor) so both HighState and SSHHighState resolve them and share the one context-isolated stack, restoring the original shared-stack semantics without an SSH-specific override. Add a regression test that exercises SSHHighState directly -- the original test only covered HighState, which is why the break reached CI. --- salt/state.py | 72 +++++++++---------- .../unit/state/test_active_highstate_stack.py | 39 ++++++++++ 2 files changed, 75 insertions(+), 36 deletions(-) diff --git a/salt/state.py b/salt/state.py index b0daae00540c..984dba8ba443 100644 --- a/salt/state.py +++ b/salt/state.py @@ -3911,6 +3911,42 @@ def __init__(self, opts): self.avail = self.__gather_avail() self.building_highstate = HashableOrderedDict() + @classmethod + def _active_stack(cls): + # The active-HighState stack for the current execution context, created + # lazily on first use. A default= on the ContextVar would share one + # list object across every context, defeating the isolation, so the + # per-context list is set explicitly here instead. + try: + return _active_highstates.get() + except LookupError: + stack = [] + _active_highstates.set(stack) + return stack + + def push_active(self): + self._active_stack().append(self) + + @classmethod + def clear_active(cls): + # Nuclear option + # + # Blow away the active-HighState stack for the current execution + # context. Used primarily by the test runner but also useful in custom + # wrappers of the HighState class, to reset the stack to a fresh state. + _active_highstates.set([]) + + @classmethod + def pop_active(cls): + cls._active_stack().pop() + + @classmethod + def get_active(cls): + try: + return cls._active_stack()[-1] + except IndexError: + return None + def __gather_avail(self): """ Lazily gather the lists of available sls data from the master @@ -5166,42 +5202,6 @@ def __init__( # share one another's matches (#63056). self._pydsl_sls_matches = None - @classmethod - def _active_stack(cls): - # The active-HighState stack for the current execution context, created - # lazily on first use. A default= on the ContextVar would share one - # list object across every context, defeating the isolation, so the - # per-context list is set explicitly here instead. - try: - return _active_highstates.get() - except LookupError: - stack = [] - _active_highstates.set(stack) - return stack - - def push_active(self): - self._active_stack().append(self) - - @classmethod - def clear_active(cls): - # Nuclear option - # - # Blow away the active-HighState stack for the current execution - # context. Used primarily by the test runner but also useful in custom - # wrappers of the HighState class, to reset the stack to a fresh state. - _active_highstates.set([]) - - @classmethod - def pop_active(cls): - cls._active_stack().pop() - - @classmethod - def get_active(cls): - try: - return cls._active_stack()[-1] - except IndexError: - return None - def destroy(self): if not self.preserve_client: self.client.destroy() diff --git a/tests/pytests/unit/state/test_active_highstate_stack.py b/tests/pytests/unit/state/test_active_highstate_stack.py index fd8845eaf738..e06a066592b1 100644 --- a/tests/pytests/unit/state/test_active_highstate_stack.py +++ b/tests/pytests/unit/state/test_active_highstate_stack.py @@ -83,3 +83,42 @@ def worker(tag): # push from the other thread. On the old shared stack both threads would # read whichever marker was pushed last. assert results == {"A": "A", "B": "B"} + + +def test_sshhighstate_shares_active_stack_with_highstate(): + """ + salt-ssh's ``SSHHighState`` subclasses ``BaseHighState`` (not ``HighState``) + and its state wrapper calls ``st_.push_active()`` on every run. The + active-stack accessors therefore have to live on ``BaseHighState``: when + they lived on ``HighState``, ``SSHHighState`` had no ``push_active`` and + every salt-ssh state execution raised + ``'SSHHighState' object has no attribute 'push_active'``. + + Also pins the cross-subclass contract the pydsl renderer depends on: a + non-``HighState`` ``BaseHighState`` subclass that pushes itself is visible + to ``HighState.get_active()``. + """ + from salt.client.ssh.state import SSHHighState + + assert issubclass(SSHHighState, salt.state.BaseHighState) + assert not issubclass(SSHHighState, salt.state.HighState) + for name in ("push_active", "pop_active", "get_active", "clear_active"): + assert hasattr(SSHHighState, name), f"SSHHighState lost {name}" + # Inherited from the shared base, not redefined per subclass. + assert SSHHighState.push_active is salt.state.BaseHighState.push_active + + class _SSHMarker(SSHHighState): + # Skip SSHHighState's heavy __init__; only the inherited accessors are + # under test. + def __init__(self): # pylint: disable=super-init-not-called + pass + + salt.state.BaseHighState.clear_active() + try: + marker = _SSHMarker() + marker.push_active() + assert salt.state.HighState.get_active() is marker + marker.pop_active() + assert salt.state.HighState.get_active() is None + finally: + salt.state.BaseHighState.clear_active() From ada0f64276ce3b4666757ad1bc37d964ff730ace Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 15 Jul 2026 13:34:42 -0400 Subject: [PATCH 225/469] Bundle typing_extensions in the salt-ssh thin for py3.6 targets This PR adds `import contextvars` to salt/state.py, which is imported at every salt-call startup (salt.minion -> salt.utils.state -> salt.state). A Python 3.6 target has no stdlib contextvars, so the thin ships a backport; that backport imports immutables, which imports typing_extensions -- not previously bundled -- so salt-call died with `ModuleNotFoundError: No module named 'typing_extensions'`. Bundle typing_extensions alongside the immutables the thin already ships for py3.6, mirroring the has_immutables pattern, and extend the get_tops tests to expect it. This is what integration/ssh/test_log.py (a python 3.6 container) was tripping on. --- salt/utils/thin.py | 13 +++++++++++++ tests/pytests/unit/utils/test_thin.py | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/salt/utils/thin.py b/salt/utils/thin.py index 045a51cfa089..c1659825534e 100644 --- a/salt/utils/thin.py +++ b/salt/utils/thin.py @@ -41,6 +41,17 @@ except ImportError: pass +# ``immutables`` (bundled above for the Python 3.6 contextvars backport) imports +# ``typing_extensions``; without it in the thin, a py3.6 target crashes on +# ``import contextvars`` with ModuleNotFoundError. Bundle it alongside. +has_typing_extensions = False +try: + import typing_extensions + + has_typing_extensions = True +except ImportError: + pass + try: import zlib @@ -452,6 +463,8 @@ def get_tops(extra_mods="", so_mods=""): mods.append(contextvars) if has_immutables: mods.append(immutables) + if has_typing_extensions: + mods.append(typing_extensions) for mod in mods: if mod: log.debug('Adding module to the tops: "%s"', mod.__name__) diff --git a/tests/pytests/unit/utils/test_thin.py b/tests/pytests/unit/utils/test_thin.py index d505ce00548e..0ec92c28f84f 100644 --- a/tests/pytests/unit/utils/test_thin.py +++ b/tests/pytests/unit/utils/test_thin.py @@ -485,6 +485,11 @@ def test_get_ext_namespaces_failure(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) +@patch_if( + salt.utils.thin.has_typing_extensions, + "salt.utils.thin.typing_extensions", + type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), +) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops(thin_ctx): """ @@ -512,6 +517,8 @@ def test_get_tops(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) + if salt.utils.thin.has_typing_extensions: + base_tops.extend(["typing_extensions"]) tops = [] for top in thin.get_tops(extra_mods="foo,bar"): if top.find("/") != -1: @@ -596,6 +603,11 @@ def test_get_tops(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) +@patch_if( + salt.utils.thin.has_typing_extensions, + "salt.utils.thin.typing_extensions", + type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), +) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops_extra_mods(thin_ctx): """ @@ -625,6 +637,8 @@ def test_get_tops_extra_mods(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) + if salt.utils.thin.has_typing_extensions: + base_tops.extend(["typing_extensions"]) libs = salt.utils.thin.find_site_modules("contextvars") foo = {"__file__": os.sep + os.path.join("custom", "foo", "__init__.py")} bar = {"__file__": os.sep + os.path.join("custom", "bar")} @@ -717,6 +731,11 @@ def test_get_tops_extra_mods(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) +@patch_if( + salt.utils.thin.has_typing_extensions, + "salt.utils.thin.typing_extensions", + type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), +) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops_so_mods(thin_ctx): """ @@ -746,6 +765,8 @@ def test_get_tops_so_mods(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) + if salt.utils.thin.has_typing_extensions: + base_tops.extend(["typing_extensions"]) libs = salt.utils.thin.find_site_modules("contextvars") with patch("salt.utils.thin.find_site_modules", MagicMock(side_effect=[libs])): with patch( From be57e8806e2d914383554e104d048960dbeffea3 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 15 Jul 2026 18:59:27 -0400 Subject: [PATCH 226/469] Guard the contextvars import for py3.6 salt-ssh; revert thin typing_extensions bundle The previous commit bundled typing_extensions into the salt-ssh thin, but the thin ships the build host's (modern) typing_extensions, whose py3.8+ syntax is a SyntaxError on a Python 3.6 target -- and it shadows the target's own compatible typing_extensions, so importlib_metadata (imported at salt-call startup via salt._compat) crashed before reaching any of this PR's code. That broke salt-ssh on every py3.6 target (Rocky Linux 8, Amazon Linux 2). Reverted. Instead, guard "import contextvars" in salt/state.py. On targets where it can only be resolved through the thin's backport -- which drags in immutables/typing_extensions that may be missing (a py3.9 Debian target) or incompatible (py3.6) -- catch ImportError/SyntaxError and fall back to a shared class-level active-HighState stack. salt-ssh runs one execution per target, so the per-context isolation (#63056) is not needed there; on py3.7+ minions and the onedir master/minion, stdlib contextvars is used and the isolation is unchanged. Validated on live Python 3.6.8 (AlmaLinux 8): a modern typing_extensions is a SyntaxError on py3.6, and the guarded import catches both that and a missing typing_extensions, engaging the fallback without crashing. Adds a test for the fallback path. --- salt/state.py | 28 ++++++++++++++-- salt/utils/thin.py | 13 -------- .../unit/state/test_active_highstate_stack.py | 33 +++++++++++++++++++ tests/pytests/unit/utils/test_thin.py | 21 ------------ 4 files changed, 58 insertions(+), 37 deletions(-) diff --git a/salt/state.py b/salt/state.py index 984dba8ba443..da34efa01e60 100644 --- a/salt/state.py +++ b/salt/state.py @@ -11,7 +11,16 @@ } """ -import contextvars +try: + import contextvars +except (ImportError, SyntaxError): + # Some salt-ssh targets (notably Python 3.6, which lacks a stdlib + # contextvars) resolve ``import contextvars`` to the thin's bundled backport, + # which pulls in immutables/typing_extensions that can be missing or + # syntactically incompatible on the target. Degrade to a shared stack there + # -- salt-ssh runs one execution per target, so the per-context isolation + # contextvars provides (#63056) is not needed. + contextvars = None import copy import datetime import fnmatch @@ -3911,8 +3920,15 @@ def __init__(self, opts): self.avail = self.__gather_avail() self.building_highstate = HashableOrderedDict() + # Fallback shared stack, used only when contextvars is unavailable (see the + # guarded import at the top of the module). salt-ssh runs one execution per + # target, so a shared stack there is safe. + _shared_active_stack = [] + @classmethod def _active_stack(cls): + if _active_highstates is None: + return BaseHighState._shared_active_stack # The active-HighState stack for the current execution context, created # lazily on first use. A default= on the ContextVar would share one # list object across every context, defeating the isolation, so the @@ -3934,7 +3950,10 @@ def clear_active(cls): # Blow away the active-HighState stack for the current execution # context. Used primarily by the test runner but also useful in custom # wrappers of the HighState class, to reset the stack to a fresh state. - _active_highstates.set([]) + if _active_highstates is None: + BaseHighState._shared_active_stack.clear() + else: + _active_highstates.set([]) @classmethod def pop_active(cls): @@ -5125,7 +5144,10 @@ def __exit__(self, *_): # reactor orchestrations rendered in parallel reactor worker threads -- each # get their own stack instead of corrupting a shared class-level list # (#63056). This mirrors salt.loader's loader_ctxvar. -_active_highstates = contextvars.ContextVar("salt_active_highstates") +if contextvars is not None: + _active_highstates = contextvars.ContextVar("salt_active_highstates") +else: + _active_highstates = None class HighState(BaseHighState): diff --git a/salt/utils/thin.py b/salt/utils/thin.py index c1659825534e..045a51cfa089 100644 --- a/salt/utils/thin.py +++ b/salt/utils/thin.py @@ -41,17 +41,6 @@ except ImportError: pass -# ``immutables`` (bundled above for the Python 3.6 contextvars backport) imports -# ``typing_extensions``; without it in the thin, a py3.6 target crashes on -# ``import contextvars`` with ModuleNotFoundError. Bundle it alongside. -has_typing_extensions = False -try: - import typing_extensions - - has_typing_extensions = True -except ImportError: - pass - try: import zlib @@ -463,8 +452,6 @@ def get_tops(extra_mods="", so_mods=""): mods.append(contextvars) if has_immutables: mods.append(immutables) - if has_typing_extensions: - mods.append(typing_extensions) for mod in mods: if mod: log.debug('Adding module to the tops: "%s"', mod.__name__) diff --git a/tests/pytests/unit/state/test_active_highstate_stack.py b/tests/pytests/unit/state/test_active_highstate_stack.py index e06a066592b1..d631e057f306 100644 --- a/tests/pytests/unit/state/test_active_highstate_stack.py +++ b/tests/pytests/unit/state/test_active_highstate_stack.py @@ -14,6 +14,7 @@ import threading import salt.state +from tests.support.mock import patch class _Marker(salt.state.HighState): @@ -122,3 +123,35 @@ def __init__(self): # pylint: disable=super-init-not-called assert salt.state.HighState.get_active() is None finally: salt.state.BaseHighState.clear_active() + + +def test_active_stack_falls_back_when_contextvars_unavailable(): + """ + On a salt-ssh target without a usable ``contextvars`` (e.g. Python 3.6, + where the module is only available through the thin's backport and can pull + in an incompatible ``typing_extensions``), ``import contextvars`` is guarded + and ``_active_highstates`` is ``None``. The active-stack accessors must then + degrade to a shared class-level list instead of raising -- salt-ssh runs a + single execution per target, so a shared stack is safe there. + """ + HighState = salt.state.HighState + with patch.object(salt.state, "_active_highstates", None): + salt.state.BaseHighState._shared_active_stack.clear() + HighState.clear_active() + assert HighState.get_active() is None + + a = _Marker("a") + b = _Marker("b") + a.push_active() + assert HighState.get_active() is a + b.push_active() + assert HighState.get_active() is b + b.pop_active() + assert HighState.get_active() is a + a.pop_active() + assert HighState.get_active() is None + + a.push_active() + HighState.clear_active() + assert HighState.get_active() is None + salt.state.BaseHighState._shared_active_stack.clear() diff --git a/tests/pytests/unit/utils/test_thin.py b/tests/pytests/unit/utils/test_thin.py index 0ec92c28f84f..d505ce00548e 100644 --- a/tests/pytests/unit/utils/test_thin.py +++ b/tests/pytests/unit/utils/test_thin.py @@ -485,11 +485,6 @@ def test_get_ext_namespaces_failure(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) -@patch_if( - salt.utils.thin.has_typing_extensions, - "salt.utils.thin.typing_extensions", - type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), -) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops(thin_ctx): """ @@ -517,8 +512,6 @@ def test_get_tops(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) - if salt.utils.thin.has_typing_extensions: - base_tops.extend(["typing_extensions"]) tops = [] for top in thin.get_tops(extra_mods="foo,bar"): if top.find("/") != -1: @@ -603,11 +596,6 @@ def test_get_tops(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) -@patch_if( - salt.utils.thin.has_typing_extensions, - "salt.utils.thin.typing_extensions", - type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), -) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops_extra_mods(thin_ctx): """ @@ -637,8 +625,6 @@ def test_get_tops_extra_mods(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) - if salt.utils.thin.has_typing_extensions: - base_tops.extend(["typing_extensions"]) libs = salt.utils.thin.find_site_modules("contextvars") foo = {"__file__": os.sep + os.path.join("custom", "foo", "__init__.py")} bar = {"__file__": os.sep + os.path.join("custom", "bar")} @@ -731,11 +717,6 @@ def test_get_tops_extra_mods(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) -@patch_if( - salt.utils.thin.has_typing_extensions, - "salt.utils.thin.typing_extensions", - type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), -) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops_so_mods(thin_ctx): """ @@ -765,8 +746,6 @@ def test_get_tops_so_mods(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) - if salt.utils.thin.has_typing_extensions: - base_tops.extend(["typing_extensions"]) libs = salt.utils.thin.find_site_modules("contextvars") with patch("salt.utils.thin.find_site_modules", MagicMock(side_effect=[libs])): with patch( From 27df29625cec14d32643bb06c24424abd82ba6b7 Mon Sep 17 00:00:00 2001 From: lubinatien <7661774+lubinatien@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:46:52 +0100 Subject: [PATCH 227/469] Fix import for WinRM version check PyPI's package is named pywinrm. The importlib.metadata.version("winrm") call raises PackageNotFoundError which isn't caught by except ImportError, crashing the block and leaving winrm undefined despite being importable. --- salt/utils/cloud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/utils/cloud.py b/salt/utils/cloud.py index c32bbdbb5346..c6ed74a8c1f9 100644 --- a/salt/utils/cloud.py +++ b/salt/utils/cloud.py @@ -82,7 +82,7 @@ # Verify WinRM 0.3.0 or greater - version = importlib.metadata.version("winrm") + version = importlib.metadata.version("pywinrm") if not salt.utils.versions.compare(version, ">=", WINRM_MIN_VER): HAS_WINRM = False else: From 29b2a5bdc8c813b4f204d6b1a6bf5b8aae082d04 Mon Sep 17 00:00:00 2001 From: lubinatien <7661774+lubinatien@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:26:24 +0100 Subject: [PATCH 228/469] Fix winrm detection bug in salt-cloud --- changelog/68768.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/68768.fixed.md diff --git a/changelog/68768.fixed.md b/changelog/68768.fixed.md new file mode 100644 index 000000000000..d4462e9fc8d3 --- /dev/null +++ b/changelog/68768.fixed.md @@ -0,0 +1 @@ +Fixed a winrm detection bug in salt-cloud. From 67ef766e414b55ef6980dc662e76690bf41fb9da Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sat, 6 Jun 2026 14:55:17 -0700 Subject: [PATCH 229/469] Add cross-platform regression test for pywinrm dist-name lookup The existing test was @skip_unless_on_windows and asserted only on the dist name itself, so it would not have caught the regression where salt/utils/cloud.py passed 'winrm' (module name) instead of 'pywinrm' (distribution name) to importlib.metadata.version(). Drop the platform gate and assert on the production symbol (cloud.HAS_WINRM) so the detection path is pinned on every CI runner with pywinrm installed. --- tests/pytests/unit/utils/test_cloud.py | 42 +++++++++----------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/tests/pytests/unit/utils/test_cloud.py b/tests/pytests/unit/utils/test_cloud.py index f3288dbfef80..9f8d7746a8c1 100644 --- a/tests/pytests/unit/utils/test_cloud.py +++ b/tests/pytests/unit/utils/test_cloud.py @@ -454,40 +454,26 @@ def test_deploy_windows_programdata_minion_conf(): mock_smb.put_str.assert_called_with(config, expected, conn=mock_conn) -@pytest.mark.skip_unless_on_windows(reason="Only applicable for Windows.") def test_winrm_pinnned_version(): """ Test that winrm is pinned to a version 0.3.0 or higher. + + Also asserts that ``salt.utils.cloud.HAS_WINRM`` is True when the + pywinrm distribution is importable. This pins the production + detection path so a regression in the dist-name lookup (the bug + that motivated this test) is caught on every platform CI runs on, + not just Windows. """ - mock_true = MagicMock(return_value=True) - mock_tuple = MagicMock(return_value=(0, 0, 0)) - with patch("salt.utils.smb.get_conn", MagicMock()), patch( - "salt.utils.smb.mkdirs", MagicMock() - ), patch("salt.utils.smb.put_file", MagicMock()), patch( - "salt.utils.smb.delete_file", MagicMock() - ), patch( - "salt.utils.smb.delete_directory", MagicMock() - ), patch( - "time.sleep", MagicMock() - ), patch.object( - cloud, "wait_for_port", mock_true - ), patch.object( - cloud, "fire_event", MagicMock() - ), patch.object( - cloud, "wait_for_psexecsvc", mock_true - ), patch.object( - cloud, "run_psexec_command", mock_tuple - ): + try: + import winrm # pylint: disable=unused-import + except ImportError: + raise pytest.skip('The "winrm" python module is not installed in this env.') - try: - import winrm # pylint: disable=unused-import - except ImportError: - raise pytest.skip('The "winrm" python module is not installed in this env.') - else: - from importlib.metadata import version + from importlib.metadata import version - winrm_version = version("pywinrm") - assert winrm_version >= "0.3.0" + winrm_version = version("pywinrm") + assert winrm_version >= "0.3.0" + assert cloud.HAS_WINRM is True def test_ssh_gateway_arguments_default_alive_args(): From b8a6081c92fd5abf7e4ea63c94d60817ce053f2f Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 23:22:46 -0700 Subject: [PATCH 230/469] Allow libyaml-linked PyYAML wheel in Linux onedir builds The Linux onedir build passes ``--no-binary=:all:`` to pip so every runtime dependency is compiled against the relenv toolchain and linked against the vendored openssl/krb5/etc. PyYAML's setup.py autodetects libyaml at compile time; because the relenv toolchain does not build or ship libyaml, the source build silently falls back to a pure-Python parser and the resulting onedir has no ``yaml.CSafeLoader`` and no ``_yaml.so`` extension. Salt's ``yamlloader`` uses ``getattr(yaml, "CSafeLoader", yaml.SafeLoader)`` so it does not crash, but every YAML load (configs, pillars, states, returners, mine, event bus, etc.) runs through the pure-Python parser, which is 10-20x slower. Users with segmented configs have reported ``salt-run salt.cmd test.ping`` taking ~20s where a libyaml-linked build completes in well under a second. Add ``pyyaml`` to the Linux ``--only-binary`` allow-list so pip uses PyYAML's manylinux2014 wheel, which bundles libyaml (MIT-licensed) and targets glibc 2.17+ (compatible with every relenv Linux target). This mirrors the existing precedent for ``maturin``, ``cassandra-driver``, ``hatchling``, ``cmake``, ``ninja``, and ``protobuf``. Fixes #69907 --- changelog/69907.fixed.md | 5 +++++ tools/pkg/build.py | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 changelog/69907.fixed.md diff --git a/changelog/69907.fixed.md b/changelog/69907.fixed.md new file mode 100644 index 000000000000..36df6f8c2dea --- /dev/null +++ b/changelog/69907.fixed.md @@ -0,0 +1,5 @@ +Include PyYAML manylinux wheel in Linux onedir builds so ``yaml.CSafeLoader`` +(and the libyaml-backed emitter) are available. Previously the ``--no-binary=:all:`` +pip invocation forced a PyYAML source build under the relenv toolchain, which +lacks libyaml headers; PyYAML silently fell back to the pure-Python parser, +significantly slowing config, pillar, and state parsing on large deployments. diff --git a/tools/pkg/build.py b/tools/pkg/build.py index 99f0eed070fa..4ed5ef7ba41d 100644 --- a/tools/pkg/build.py +++ b/tools/pkg/build.py @@ -629,8 +629,16 @@ def onedir_dependencies( env["RELENV_BUILDENV"] = "1" python_bin = env_scripts_dir / "python3" install_args.append("--no-binary=:all:") + # PyYAML's source build silently falls back to the pure-Python parser + # when libyaml headers are absent, and the relenv toolchain does not + # ship libyaml. That produces an onedir where yaml.CSafeLoader is + # missing, which makes salt fall back to the pure-Python SafeLoader + # and can slow config/pillar/state parsing by an order of magnitude + # on large deployments. The upstream PyYAML manylinux2014 wheel + # bundles libyaml (MIT-licensed) and is compatible with the relenv + # target platform, so allow it through --no-binary=:all: here. install_args.append( - "--only-binary=maturin,apache-libcloud,pymssql,cassandra-driver,hatchling,cmake,ninja,protobuf" + "--only-binary=maturin,apache-libcloud,pymssql,cassandra-driver,hatchling,cmake,ninja,protobuf,pyyaml" ) # CMake 4.x removed support for cmake_minimum_required(VERSION < 3.5). # pyzmq's bundled libzmq still declares an older floor; set the policy From 5219b258d6165af8c9c483a95c7807c8523efc8e Mon Sep 17 00:00:00 2001 From: Twangboy Date: Mon, 3 Aug 2026 15:32:23 -0600 Subject: [PATCH 231/469] Update bootstrap script to v2026.08.03 --- changelog/69935.fixed.md | 1 + salt/cloud/deploy/bootstrap-salt.sh | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 changelog/69935.fixed.md diff --git a/changelog/69935.fixed.md b/changelog/69935.fixed.md new file mode 100644 index 000000000000..774c98f2e903 --- /dev/null +++ b/changelog/69935.fixed.md @@ -0,0 +1 @@ +Update bootstrap script to v2026.08.03 diff --git a/salt/cloud/deploy/bootstrap-salt.sh b/salt/cloud/deploy/bootstrap-salt.sh index df4666a131ab..b9a9ef81342b 100644 --- a/salt/cloud/deploy/bootstrap-salt.sh +++ b/salt/cloud/deploy/bootstrap-salt.sh @@ -26,7 +26,7 @@ #====================================================================================================================== set -o nounset # Treat unset variables as an error -__ScriptVersion="2026.07.23" +__ScriptVersion="2026.08.03" __ScriptName="bootstrap-salt.sh" __ScriptFullName="$0" @@ -3021,6 +3021,7 @@ __install_saltstack_ubuntu_repository() { # SaltStack's stable Ubuntu repository: __fetch_url "/etc/apt/sources.list.d/salt.sources" "https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources" [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#salt-archive-keyring\.pgp#salt-archive-keyring.gpg#" /etc/apt/sources.list.d/salt.sources + [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#packages\.broadcom\.com/artifactory#${_REPO_URL}#" /etc/apt/sources.list.d/salt.sources __apt_key_fetch "${HTTP_VAL}://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" || return 1 __wait_for_apt apt-get update || return 1 @@ -3074,6 +3075,7 @@ __install_saltstack_ubuntu_onedir_repository() { # SaltStack's stable Ubuntu repository: __fetch_url "/etc/apt/sources.list.d/salt.sources" "https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources" [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#salt-archive-keyring\.pgp#salt-archive-keyring.gpg#" /etc/apt/sources.list.d/salt.sources + [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#packages\.broadcom\.com/artifactory#${_REPO_URL}#" /etc/apt/sources.list.d/salt.sources __apt_key_fetch "${HTTP_VAL}://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" || return 1 __wait_for_apt apt-get update || return 1 @@ -3526,6 +3528,7 @@ __install_saltstack_debian_repository() { __fetch_url "/etc/apt/sources.list.d/salt.sources" "https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources" [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#salt-archive-keyring\.pgp#salt-archive-keyring.gpg#" /etc/apt/sources.list.d/salt.sources + [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#packages\.broadcom\.com/artifactory#${_REPO_URL}#" /etc/apt/sources.list.d/salt.sources __apt_key_fetch "${HTTP_VAL}://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" || return 1 __wait_for_apt apt-get update || return 1 @@ -3572,6 +3575,7 @@ __install_saltstack_debian_onedir_repository() { __fetch_url "/etc/apt/sources.list.d/salt.sources" "https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources" [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#salt-archive-keyring\.pgp#salt-archive-keyring.gpg#" /etc/apt/sources.list.d/salt.sources + [ -f /etc/apt/sources.list.d/salt.sources ] && sed -i "s#packages\.broadcom\.com/artifactory#${_REPO_URL}#" /etc/apt/sources.list.d/salt.sources __apt_key_fetch "${HTTP_VAL}://${_REPO_URL}/api/security/keypair/SaltProjectKey/public" || return 1 __wait_for_apt apt-get update || return 1 From 8385bca37649a7e26bc1c0afc8f27ddb4c5e09f2 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 15:35:36 -0700 Subject: [PATCH 232/469] Add SyncWrapper.__del__ ResourceWarning shim (3006.x) Mirrors ``SaltEvent.__del__`` at ``salt/utils/event.py``: emits ``ResourceWarning`` when a ``SyncWrapper`` is garbage-collected without an explicit ``close()`` (i.e. no ``with`` statement, no ``destroy()``). Deliberately does NOT close the wrapped object / io_loop from ``__del__`` -- doing so during GC or interpreter shutdown risks touching partially-freed tornado / asyncio state. Surfaces missed-close bugs in tests / sentry / log aggregators so they can be fixed at the source, without re-introducing the 'silent GC-time cleanup' trap. The primary RequestClient socket-leak fix from 3008.x PR #69997 is not needed on 3006.x -- that code path is already covered by the ``AsyncReqMessageClient`` hardening from #68637. Fixes #69991 --- changelog/69991.fixed.md | 1 + salt/utils/asynchronous.py | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 changelog/69991.fixed.md diff --git a/changelog/69991.fixed.md b/changelog/69991.fixed.md new file mode 100644 index 000000000000..bc3016482918 --- /dev/null +++ b/changelog/69991.fixed.md @@ -0,0 +1 @@ +Add ``SyncWrapper.__del__`` that emits ``ResourceWarning`` for wrappers that were GC'd without an explicit ``close()`` (mirrors ``SaltEvent.__del__`` at ``salt/utils/event.py``). Surfaces missed-close bugs in tests and monitoring rather than silently leaking event loops and their held resources. Note: the RequestClient socket-leak fix from #69997 is not needed on 3006.x — that path is already covered by the ``AsyncReqMessageClient`` hardening from #68637. diff --git a/salt/utils/asynchronous.py b/salt/utils/asynchronous.py index 5886f8c0fd13..387e1314d27d 100644 --- a/salt/utils/asynchronous.py +++ b/salt/utils/asynchronous.py @@ -6,6 +6,7 @@ import logging import sys import threading +import warnings import salt.ext.tornado.concurrent import salt.ext.tornado.ioloop @@ -178,3 +179,49 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, tb): self.close() + + # pylint: disable=W1701 + def __del__(self): + # PATCH: mirror ``SaltEvent.__del__`` at ``salt/utils/event.py`` + # -- deliberately do NOT close the wrapped ``obj`` / io_loop / + # asyncio_loop from ``__del__``. ``__del__`` fires during GC + # (may be arbitrarily delayed, may skip on reference cycles) + # and during interpreter shutdown, when the world is already + # tearing down and touching a tornado/asyncio loop can raise + # from a partially-freed C extension. Instead, emit a + # ``ResourceWarning`` so callers that missed ``close()`` / + # context-manager surface loudly in tests / sentry / log + # aggregators. + # + # Motivation: ``SyncWrapper``-owned asyncio loops are the + # dominant leak surface on the minion under sustained + # ``saltutil.refresh_pillar`` / re-auth churn -- each abandoned + # wrapper holds a whole IOLoop, its ZMQ context, and the two + # socketpairs backing the master REQ channel. Observed ~451 + # leaked socketpairs (~902 fds) per minion, tripping the + # 1024-file ulimit critical threshold and the minion's own + # sock-throttle logic. + try: + unclosed = getattr(self, "obj", None) is not None or ( + getattr(self, "asyncio_loop", None) is not None + and not self.asyncio_loop.is_closed() + ) + except Exception: # pylint: disable=broad-except + return + if not unclosed: + return + try: + warnings.warn( + f"unclosed {type(self).__name__} for cls=" + f"{getattr(self, 'cls', None)!r}; call ``close()`` or " + f"use as a context manager", + ResourceWarning, + source=self, + ) + except Exception: # pylint: disable=broad-except + # ``warnings.warn`` can raise during interpreter shutdown + # when the ``warnings`` module has already been torn down. + # A finalizer must not propagate exceptions. + pass + + # pylint: enable=W1701 From 11025b945f3acd02ee3eb50f55f36d5ce44944c1 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 15:22:28 -0700 Subject: [PATCH 233/469] Fix MWorkerQueue anon_pipes leak: add heartbeat/keepalive/handover on pooled ROUTER The ROUTER socket in ``zmq_device_pooled`` (which fronts the pooled MWorkerQueue on 3008.x) was missing ZMTP heartbeat, TCP keepalive, and ``ROUTER_HANDOVER`` options. libzmq had no way to detect dead peers, so ``router_t::_anonymous_pipes`` grew unbounded under sustained connect/disconnect churn (minion reconnect storms, transient CLI clients). Observed as ~50-100 MB/hour RSS growth on MWQ. Add the standard socket options that ``zmq_device`` on 3006.x already uses (commit 569db36f49f). Also bump LINGER from 1 to 1000 ms so in-flight replies aren't dropped on process teardown. Fixes #69987 --- changelog/69987.fixed.md | 1 + salt/transport/zeromq.py | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 changelog/69987.fixed.md diff --git a/changelog/69987.fixed.md b/changelog/69987.fixed.md new file mode 100644 index 000000000000..360525e0424d --- /dev/null +++ b/changelog/69987.fixed.md @@ -0,0 +1 @@ +Fix ``MWorkerQueue`` accumulating dead-peer state under sustained connect/disconnect churn. The pooled ``RequestServer`` ROUTER now sets ZMTP heartbeat, TCP keepalive, ``ROUTER_HANDOVER``, and a ``LINGER`` timeout so libzmq detects and reaps dead peers instead of retaining them in ``_anonymous_pipes``. diff --git a/salt/transport/zeromq.py b/salt/transport/zeromq.py index cca553b0895e..4ebafc64ebda 100644 --- a/salt/transport/zeromq.py +++ b/salt/transport/zeromq.py @@ -632,7 +632,23 @@ def zmq_device_pooled(self, worker_pools, secrets=None): # Create frontend ROUTER socket (minions connect here) self.uri = "tcp://{interface}:{ret_port}".format(**self.opts) self.clients = context.socket(zmq.ROUTER) - self.clients.setsockopt(zmq.LINGER, 1) + # PATCH: match the non-pooled ``zmq_device`` socket options exactly. + # The pooled path was only setting ``LINGER=1``, ``IPV4ONLY``, and + # ``BACKLOG`` -- missing ZMTP heartbeat, TCP keepalive, and + # ROUTER_HANDOVER. Without heartbeat / keepalive, libzmq only + # reaps dead peers when the OS default TCP keepalive fires + # (~2h15m on Linux), so anon_pipes / out_pipes entries for + # long-gone peers accumulate without bound (observed 1000+ + # stuck TCP conns / 9+ GB RSS under sustained CLI + salt-api + # churn). + self.clients.setsockopt(zmq.LINGER, 1000) + if hasattr(zmq, "ROUTER_HANDOVER"): + self.clients.setsockopt(zmq.ROUTER_HANDOVER, 1) + _set_zmq_heartbeat(self.clients, self.opts) + self.clients.setsockopt(zmq.TCP_KEEPALIVE, 1) + self.clients.setsockopt(zmq.TCP_KEEPALIVE_IDLE, 60) + self.clients.setsockopt(zmq.TCP_KEEPALIVE_INTVL, 15) + self.clients.setsockopt(zmq.TCP_KEEPALIVE_CNT, 3) if self.opts["ipv6"] is True and hasattr(zmq, "IPV4ONLY"): self.clients.setsockopt(zmq.IPV4ONLY, 0) self.clients.setsockopt(zmq.BACKLOG, self.opts.get("zmq_backlog", 1000)) From b59a7ce548b7bffa96eb801180fe5742fc3367cd Mon Sep 17 00:00:00 2001 From: charliez Date: Mon, 3 Aug 2026 20:58:35 -0700 Subject: [PATCH 234/469] Label y-axis units on stress-test CPU and inode panels Fixes #69944 The CPU Usage panels (Master, Minion 1-3, API) use Grafana's percentunit, a 0-1 ratio where 1.0 == 1 full CPU core -- matching rate(container_cpu_usage_seconds_total[...]) semantics from cAdvisor. Without a label, a value like 1.2 is easy to misread as "1.2% of the host" rather than 1.2 CPU cores. The Minion Inodes panels use Grafana's "short" unit (a plain count) and also had no y-axis label. render_panels.py already special-cases bytes-family units to convert to MB and label the axis; this follows the same pattern for percentunit and short so every rendered panel states what its numbers mean. --- .gitignore | 1 + tests/monitoring/render_panels.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.gitignore b/.gitignore index 21257ad4d499..6d7607c5d284 100644 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,4 @@ nox.*.tar.xz /.gemini venv311/ venv312/ +.cursor-ai/ diff --git a/tests/monitoring/render_panels.py b/tests/monitoring/render_panels.py index 00a46a159fc6..70a297410489 100644 --- a/tests/monitoring/render_panels.py +++ b/tests/monitoring/render_panels.py @@ -122,6 +122,14 @@ def _bytes_unit(unit_hint: str) -> bool: return unit_hint.lower() in ("bytes", "decbytes", "kbytes", "mbytes", "gbytes") +def _is_percentunit(unit_hint: str) -> bool: + return unit_hint.lower() == "percentunit" + + +def _is_count_unit(unit_hint: str) -> bool: + return unit_hint.lower() == "short" + + def render_panel(panel: dict, end_ts: float) -> plt.Figure | None: """Return a matplotlib Figure for ``panel``, or ``None`` if no series.""" targets = panel.get("targets") or [] @@ -130,6 +138,8 @@ def render_panel(panel: dict, end_ts: float) -> plt.Figure | None: unit_hint = panel.get("fieldConfig", {}).get("defaults", {}).get("unit") or "" is_bytes = _bytes_unit(unit_hint) + is_percentunit = _is_percentunit(unit_hint) + is_count = _is_count_unit(unit_hint) fig, ax = plt.subplots(figsize=(11, 4)) series_count = 0 @@ -165,6 +175,14 @@ def render_panel(panel: dict, end_ts: float) -> plt.Figure | None: ax.set_title(panel.get("title") or "panel", fontsize=11) if is_bytes: ax.set_ylabel("MB") + elif is_percentunit: + # Grafana's percentunit is a 0-1 ratio (1.0 == 1 CPU core, not 1% of + # the host); rate(container_cpu_usage_seconds_total[...]) values here + # are already in that ratio, so label explicitly to avoid confusing + # "1.2" with "1.2% of the host" instead of 1.2 CPU cores. + ax.set_ylabel("CPU cores (1.0 = 1 core)") + elif is_count: + ax.set_ylabel("count") ax.xaxis.set_major_formatter(DateFormatter("%H:%M")) ax.tick_params(axis="x", rotation=30, labelsize=8) ax.tick_params(axis="y", labelsize=8) From 1446b936a2416967ceed4195e9b032ca292505af Mon Sep 17 00:00:00 2001 From: charliez Date: Wed, 5 Aug 2026 16:03:07 -0700 Subject: [PATCH 235/469] Revert unrelated .gitignore entry .cursor-ai/ was accidentally picked up while preparing this branch and is unrelated to the y-axis label fix. --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6d7607c5d284..21257ad4d499 100644 --- a/.gitignore +++ b/.gitignore @@ -166,4 +166,3 @@ nox.*.tar.xz /.gemini venv311/ venv312/ -.cursor-ai/ From 25b50ceb615e7448f75bab68b0da7eb6426184f7 Mon Sep 17 00:00:00 2001 From: charliez Date: Wed, 5 Aug 2026 17:05:48 -0700 Subject: [PATCH 236/469] Disambiguate the "short"-unit y-axis label by panel title The "short" unit covers both inode counts (Minion Inodes) and FD/process counts (Master & API Resource Usage), which had been collapsed into the same generic "count" label. Label each by what it actually counts instead. --- tests/monitoring/render_panels.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/monitoring/render_panels.py b/tests/monitoring/render_panels.py index 70a297410489..b80fec354467 100644 --- a/tests/monitoring/render_panels.py +++ b/tests/monitoring/render_panels.py @@ -182,7 +182,16 @@ def render_panel(panel: dict, end_ts: float) -> plt.Figure | None: # "1.2" with "1.2% of the host" instead of 1.2 CPU cores. ax.set_ylabel("CPU cores (1.0 = 1 core)") elif is_count: - ax.set_ylabel("count") + # "short" also covers FD/process counts (Master & API Resource Usage) + # alongside inode counts -- disambiguate from the panel title so the + # label says what's actually being counted, not just "count". + title = (panel.get("title") or "").lower() + if "inode" in title: + ax.set_ylabel("inodes used") + elif "fd" in title or "process" in title: + ax.set_ylabel("count (FDs vs processes)") + else: + ax.set_ylabel("count") ax.xaxis.set_major_formatter(DateFormatter("%H:%M")) ax.tick_params(axis="x", rotation=30, labelsize=8) ax.tick_params(axis="y", labelsize=8) From 5c6dc3fe953371d090991a201575c133a968b91c Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 15:23:39 -0700 Subject: [PATCH 237/469] Fix PubServer wedge on slow subscriber ``PubServer.publish_payload`` awaited each subscriber's write future sequentially. A single slow subscriber (kernel TCP send buffer full) made ``await future`` never resolve; every subsequent publish_payload piled up more coroutines all blocked on the same subscriber, wedging EventPublisher's io_loop. EP stopped draining ``master_event_pull.ipc``, MWorker's ``fire_event -> stream.write`` blocked in the kernel, SyncWrapper's ``thread.join()`` never returned, MWorkers deadlocked, MWQ's DEALER ``send()`` blocked, cascading to minion request timeouts and TCP churn. Move writes to fire-and-forget: ``asyncio.ensure_future`` per subscriber, wrapping the write future in ``asyncio.wait_for(..., timeout=publish_drain_timeout)``. On timeout or ``StreamClosedError``, remove the subscriber from ``self.clients`` and close its stream via new ``_discard_slow_client`` helper. New config: ``publish_drain_timeout: 5.0`` on master. Fixes #69988 --- changelog/69988.fixed.md | 1 + salt/config/__init__.py | 6 +++ salt/transport/tcp.py | 85 +++++++++++++++++++++++++++++++++------- 3 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 changelog/69988.fixed.md diff --git a/changelog/69988.fixed.md b/changelog/69988.fixed.md new file mode 100644 index 000000000000..aeb89befb687 --- /dev/null +++ b/changelog/69988.fixed.md @@ -0,0 +1 @@ +Fix master ``PubServer`` wedge caused by a single slow TCP subscriber. Rewrote ``publish_payload`` to fire-and-forget writes with a per-subscriber ``publish_drain_timeout`` (default 5s). Slow subscribers are closed and removed instead of blocking every subsequent publish. diff --git a/salt/config/__init__.py b/salt/config/__init__.py index f36bc7bf43db..a49217e15bf2 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -554,6 +554,11 @@ def _gather_buffer_space(): # Set the zeromq high water mark on the publisher interface. # http://api.zeromq.org/3-2:zmq-setsockopt "pub_hwm": int, + # Per-subscriber timeout (seconds) for the TCP PubServer to drain + # a single publish write. Subscribers that don't drain within + # this window are closed and removed to keep publish_payload + # from wedging on a slow peer. See #69988. + "publish_drain_timeout": float, # IPC buffer size # Refs https://github.com/saltstack/salt/issues/34215 "ipc_write_buffer": int, @@ -1531,6 +1536,7 @@ def _gather_buffer_space(): "publish_port": 4505, "zmq_backlog": 1000, "pub_hwm": 1000, + "publish_drain_timeout": 5.0, "auth_mode": 1, "user": _MASTER_USER, "worker_threads": 5, diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index 961a85c5f222..c17c21cd667c 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -1364,6 +1364,31 @@ def _cb(): return _cb + def _discard_slow_client(self, client, reason=""): + """ + Close and forget a subscriber whose write future didn't drain in + the ``publish_drain_timeout``. Idempotent -- ``client.close`` + tolerates double-close, and ``set.discard`` is a no-op on absent + entries. + """ + if client not in self.clients and getattr(client, "_slow_closed", False): + return + client._slow_closed = True + log.warning( + "Publisher discarding slow subscriber %s (%s)", + client.address, + reason, + ) + try: + self.remove_presence_callback(client) + except Exception: # pylint: disable=broad-except + pass + self.clients.discard(client) + try: + client.close() + except Exception: # pylint: disable=broad-except + pass + def handle_stream(self, stream, address): cert = None try: @@ -1445,20 +1470,56 @@ async def publish_payload(self, package, topic_list=None): ) payload = salt.transport.frame.frame_msg(package) to_remove = [] - # Start writes to every targeted client concurrently so a single - # slow subscriber can't stall delivery to the rest of the fleet. - # See https://github.com/saltstack/salt/issues/66282 — sequential - # ``yield client.stream.write(...)`` was clogging the event - # publisher loop, growing per-client write buffers and eventually - # wedging the master. - write_futures = [] + + def _make_drain_task(client): + """ + PATCH: drain a subscriber's write future in a fire-and-forget + asyncio task with a bounded per-subscriber timeout. + + Previously ``publish_payload`` awaited each client's write + future sequentially. A single slow subscriber (kernel TCP + send buffer full) made ``await future`` never resolve; every + subsequent broadcast piled up more pending publish_payload + coroutines all blocked on the same subscriber, wedging EP's + io_loop. With EP not draining ``master_event_pull.ipc``, + MWorker's ``fire_event`` -> ``stream.write`` blocked in the + kernel; SyncWrapper's ``thread.join()`` never returned and + every MWorker deadlocked, which in turn wedged MWQ's DEALER + send() and cascaded down to minion request timeouts and TCP + churn. + + Fire-and-forget with a timeout means ``publish_payload`` + returns immediately after queueing writes. Slow subscribers + drain in their own tasks. If a subscriber can't drain in + ``publish_drain_timeout`` seconds, it is closed and removed + from ``self.clients`` -- fixes the wedge; the peer can + reconnect and try again. + """ + drain_timeout = self.opts.get("publish_drain_timeout", 5.0) + + async def _drain(fut): + try: + await asyncio.wait_for(fut, timeout=drain_timeout) + except tornado.iostream.StreamClosedError: + self._discard_slow_client(client, reason="stream closed") + except asyncio.TimeoutError: + self._discard_slow_client( + client, reason=f"drain timeout {drain_timeout}s" + ) + except Exception as exc: # pylint: disable=broad-except + log.warning("Publisher drain to %s failed: %s", client.address, exc) + self._discard_slow_client(client, reason=str(exc)) + + return _drain + if topic_list: for topic in topic_list: sent = False for client in list(self.clients): if topic == client.id_: try: - write_futures.append((client, client.stream.write(payload))) + fut = client.stream.write(payload) + asyncio.ensure_future(_make_drain_task(client)(fut)) sent = True except tornado.iostream.StreamClosedError: to_remove.append(client) @@ -1467,14 +1528,10 @@ async def publish_payload(self, package, topic_list=None): else: for client in list(self.clients): try: - write_futures.append((client, client.stream.write(payload))) + fut = client.stream.write(payload) + asyncio.ensure_future(_make_drain_task(client)(fut)) except tornado.iostream.StreamClosedError: to_remove.append(client) - for client, future in write_futures: - try: - await future - except tornado.iostream.StreamClosedError: - to_remove.append(client) for client in to_remove: log.debug( "Subscriber at %s has disconnected from publisher", client.address From 546d1926d63aed224c7bb57d957f876d1b75c1e0 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 15:29:08 -0700 Subject: [PATCH 238/469] Fix PubServer wedge on slow subscriber (3006.x) ``PubServer.publish_payload`` awaited each subscriber's write future sequentially (via ``yield future``). A single slow subscriber (kernel TCP send buffer full) made ``yield future`` never resolve; every subsequent publish_payload piled up more coroutines all blocked on the same subscriber, wedging EventPublisher's io_loop. EP stopped draining ``master_event_pull.ipc``, MWorker's ``fire_event -> stream.write`` blocked in the kernel, SyncWrapper's ``thread.join()`` never returned, MWorkers deadlocked, MWQ's DEALER ``send()`` blocked, cascading to minion request timeouts and TCP churn. Move writes to fire-and-forget via ``io_loop.spawn_callback``. Each drain coroutine wraps the write future in ``salt.ext.tornado.gen.with_timeout(io_loop.time() + drain_timeout, future)``. On timeout, ``StreamClosedError``, or any other exception, remove the subscriber from ``self.clients`` and close its stream via new ``_discard_slow_client`` helper. Complements the existing concurrent-write commit ``73c6970351b``. New config: ``publish_drain_timeout: 5.0`` on master. 3006.x-specific counterpart to 3008.x PR #69995 (which uses ``asyncio.ensure_future`` instead). Fixes #69988 --- changelog/69988.fixed.md | 1 + salt/config/__init__.py | 5 +++ salt/transport/tcp.py | 89 ++++++++++++++++++++++++++++++++++------ 3 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 changelog/69988.fixed.md diff --git a/changelog/69988.fixed.md b/changelog/69988.fixed.md new file mode 100644 index 000000000000..33aa2e54f3d5 --- /dev/null +++ b/changelog/69988.fixed.md @@ -0,0 +1 @@ +Fix master ``PubServer`` wedge caused by a single slow TCP subscriber. Rewrote ``publish_payload`` to fire-and-forget each write through ``io_loop.spawn_callback`` with a per-subscriber ``publish_drain_timeout`` (default 5s) enforced via ``tornado.gen.with_timeout``. Slow subscribers are closed and removed from ``self.clients`` instead of blocking every subsequent publish. diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 00754e0b5d42..1773fae8a4ff 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -484,6 +484,10 @@ def _gather_buffer_space(): # Set the zeromq high water mark on the publisher interface. # http://api.zeromq.org/3-2:zmq-setsockopt "pub_hwm": int, + # Per-subscriber timeout (seconds) for the TCP PubServer to drain + # a single write to a subscriber before evicting it. Prevents a + # slow/wedged subscriber from stalling the EventPublisher io_loop. + "publish_drain_timeout": float, # IPC buffer size # Refs https://github.com/saltstack/salt/issues/34215 "ipc_write_buffer": int, @@ -1338,6 +1342,7 @@ def _gather_buffer_space(): "publish_port": 4505, "zmq_backlog": 1000, "pub_hwm": 1000, + "publish_drain_timeout": 5.0, "auth_mode": 1, "user": _MASTER_USER, "worker_threads": 5, diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index b5e6e518c0f1..46e5d361e252 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -864,6 +864,31 @@ def __del__(self): # pylint: enable=W1701 + def _discard_slow_client(self, client, reason=""): + """ + Close and forget a subscriber whose write future didn't drain in + the ``publish_drain_timeout``. Idempotent -- ``client.close`` + tolerates double-close, and ``set.discard`` is a no-op on absent + entries. + """ + if client not in self.clients and getattr(client, "_slow_closed", False): + return + client._slow_closed = True + log.warning( + "Publisher discarding slow subscriber %s (%s)", + client.address, + reason, + ) + try: + self.remove_presence_callback(client) + except Exception: # pylint: disable=broad-except + pass + self.clients.discard(client) + try: + client.close() + except Exception: # pylint: disable=broad-except + pass + @salt.ext.tornado.gen.coroutine def _stream_read( self, client, _StreamClosedError=salt.ext.tornado.iostream.StreamClosedError @@ -897,6 +922,45 @@ def handle_stream(self, stream, address): self.clients.add(client) self.io_loop.spawn_callback(self._stream_read, client) + @salt.ext.tornado.gen.coroutine + def _drain_write(self, client, future, drain_timeout): + """ + PATCH: drain a single subscriber's write future with a bounded + timeout, and evict the subscriber if it can't keep up. + + Previously ``publish_payload`` awaited each client's write future + (concurrently, per commit 73c6970351b, but still unbounded). A + single slow subscriber (kernel TCP send buffer full) made + ``yield future`` never resolve; the ``publish_payload`` coroutine + held its reference to the payload, and every subsequent broadcast + piled up more pending ``publish_payload`` coroutines all blocked + on the same subscriber, wedging the EventPublisher io_loop. + + With EP not draining ``master_event_pull.ipc``, MWorker's + ``fire_event`` -> ``stream.write`` blocked in the kernel; + SyncWrapper's ``thread.join()`` never returned and every MWorker + deadlocked, which in turn wedged MWQ's DEALER send() and cascaded + down to minion request timeouts and TCP churn. + + Fire-and-forget (via ``io_loop.spawn_callback``) with a per-write + timeout means ``publish_payload`` returns immediately after + queueing writes. Slow subscribers drain in their own tasks. If + a subscriber can't drain in ``publish_drain_timeout`` seconds, it + is closed and removed from ``self.clients`` -- fixes the wedge; + the peer can reconnect and try again. + """ + try: + yield salt.ext.tornado.gen.with_timeout( + self.io_loop.time() + drain_timeout, future + ) + except salt.ext.tornado.iostream.StreamClosedError: + self._discard_slow_client(client, reason="stream closed") + except salt.ext.tornado.gen.TimeoutError: + self._discard_slow_client(client, reason=f"drain timeout {drain_timeout}s") + except Exception as exc: # pylint: disable=broad-except + log.warning("Publisher drain to %s failed: %s", client.address, exc) + self._discard_slow_client(client, reason=str(exc)) + # TODO: ACK the publish through IPC @salt.ext.tornado.gen.coroutine def publish_payload(self, package, topic_list=None): @@ -905,18 +969,21 @@ def publish_payload(self, package, topic_list=None): to_remove = [] # Start writes to every targeted client concurrently so a single # slow subscriber can't stall delivery to the rest of the fleet. - # See https://github.com/saltstack/salt/issues/66282 — sequential - # ``yield client.stream.write(...)`` was clogging the event - # publisher loop, growing per-client write buffers and eventually - # wedging the master. - write_futures = [] + # See https://github.com/saltstack/salt/issues/66282 — commit + # 73c6970351b made the writes concurrent; this patch adds a + # per-subscriber timeout that evicts wedged subscribers so their + # unresolved write futures can no longer pin the io_loop. + drain_timeout = self.opts.get("publish_drain_timeout", 5.0) if topic_list: for topic in topic_list: sent = False for client in list(self.clients): if topic == client.id_: try: - write_futures.append((client, client.stream.write(payload))) + fut = client.stream.write(payload) + self.io_loop.spawn_callback( + self._drain_write, client, fut, drain_timeout + ) sent = True except salt.ext.tornado.iostream.StreamClosedError: to_remove.append(client) @@ -925,14 +992,12 @@ def publish_payload(self, package, topic_list=None): else: for client in list(self.clients): try: - write_futures.append((client, client.stream.write(payload))) + fut = client.stream.write(payload) + self.io_loop.spawn_callback( + self._drain_write, client, fut, drain_timeout + ) except salt.ext.tornado.iostream.StreamClosedError: to_remove.append(client) - for client, future in write_futures: - try: - yield future - except salt.ext.tornado.iostream.StreamClosedError: - to_remove.append(client) for client in to_remove: log.debug( "Subscriber at %s has disconnected from publisher", client.address From bd8ff5236750303ad6093ec265a88bdd3871873b Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 10 Aug 2026 18:09:06 -0700 Subject: [PATCH 239/469] Raise publish_drain_timeout default from 5s to 60s The 5s per-subscriber drain timeout was too aggressive for the existing ``test_issue_36469_tcp`` regression test, which pushes 20x750KB payloads through a Python collector. On slower CI VMs the collector took >5s to drain a single write, tripping ``_discard_slow_client`` and disconnecting the subscriber mid-stream. The test then hung in ``__exit__`` broadcasting a stop sentinel to a peerless publisher and pytest-timeout killed it at 90s. Bumping the default to 60s preserves the wedge-recovery guarantee (a truly stuck subscriber is still evicted within a minute) without falsely killing subscribers that are alive but slow to drain large payloads. Verified locally: the test now completes in ~24s. --- changelog/69988.fixed.md | 2 +- salt/config/__init__.py | 2 +- salt/transport/tcp.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/changelog/69988.fixed.md b/changelog/69988.fixed.md index 33aa2e54f3d5..2e9cad9d596a 100644 --- a/changelog/69988.fixed.md +++ b/changelog/69988.fixed.md @@ -1 +1 @@ -Fix master ``PubServer`` wedge caused by a single slow TCP subscriber. Rewrote ``publish_payload`` to fire-and-forget each write through ``io_loop.spawn_callback`` with a per-subscriber ``publish_drain_timeout`` (default 5s) enforced via ``tornado.gen.with_timeout``. Slow subscribers are closed and removed from ``self.clients`` instead of blocking every subsequent publish. +Fix master ``PubServer`` wedge caused by a single slow TCP subscriber. Rewrote ``publish_payload`` to fire-and-forget each write through ``io_loop.spawn_callback`` with a per-subscriber ``publish_drain_timeout`` (default 60s) enforced via ``tornado.gen.with_timeout``. Slow subscribers are closed and removed from ``self.clients`` instead of blocking every subsequent publish. diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 1773fae8a4ff..fc30c181836d 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -1342,7 +1342,7 @@ def _gather_buffer_space(): "publish_port": 4505, "zmq_backlog": 1000, "pub_hwm": 1000, - "publish_drain_timeout": 5.0, + "publish_drain_timeout": 60.0, "auth_mode": 1, "user": _MASTER_USER, "worker_threads": 5, diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index 46e5d361e252..a21321b0f65f 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -973,7 +973,7 @@ def publish_payload(self, package, topic_list=None): # 73c6970351b made the writes concurrent; this patch adds a # per-subscriber timeout that evicts wedged subscribers so their # unresolved write futures can no longer pin the io_loop. - drain_timeout = self.opts.get("publish_drain_timeout", 5.0) + drain_timeout = self.opts.get("publish_drain_timeout", 60.0) if topic_list: for topic in topic_list: sent = False From 6b51de0357c6d30cb8ad641461c7633404a998a5 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 5 Aug 2026 14:58:05 -0700 Subject: [PATCH 240/469] Fix loader race that poisoned OS-specific virtualnames Since 3006.26 (b45b7211) the loader recorded a failed __virtual__() under the module's __virtualname__ in missing_modules. When two files share a virtualname (e.g. deb_postgres.py and postgres.py both use "postgres") and the failing one was processed first due to non-deterministic directory iteration, the virtualname was marked missing and the real module was skipped on subsequent lookups, breaking postgres_user/state runs on RHEL/Rocky. Stop reassigning module_name to virtualname on failure in _process_virtual, so missing_modules is only keyed by the actual file basename. Track per-virtualname failure reasons in a new missing_virtualnames mapping consulted by missing_fun_string(), so collision error surfacing (issue #68625) is preserved without the poisoning race. Fixes #69806 --- changelog/69806.fixed.md | 1 + salt/loader/lazy.py | 108 ++++++++++++++++--------- tests/pytests/unit/loader/test_lazy.py | 67 ++++++++++++++- 3 files changed, 135 insertions(+), 41 deletions(-) create mode 100644 changelog/69806.fixed.md diff --git a/changelog/69806.fixed.md b/changelog/69806.fixed.md new file mode 100644 index 000000000000..07aa1fd1309b --- /dev/null +++ b/changelog/69806.fixed.md @@ -0,0 +1 @@ +Fix loader race that could randomly mark OS-specific virtual modules (e.g. ``postgres``) as unavailable when a sibling implementation (e.g. ``deb_postgres``) was evaluated first and poisoned the shared ``__virtualname__`` in the missing-modules cache. diff --git a/salt/loader/lazy.py b/salt/loader/lazy.py index 677371f6734a..372b375776d1 100644 --- a/salt/loader/lazy.py +++ b/salt/loader/lazy.py @@ -310,6 +310,13 @@ def __init__( # names of modules that we don't have (errors, __virtual__, etc.) self.missing_modules = {} # mapping of name -> error + # mapping of __virtualname__ -> list of error reasons from every file + # that claimed the virtualname and whose __virtual__() returned False. + # Kept separate from missing_modules so a failed sibling (e.g. + # deb_postgres) does not poison the shared virtualname (e.g. postgres) + # and prevent the real module from loading. Consulted by + # missing_fun_string() to surface every failure reason. + self.missing_virtualnames = {} self.loaded_modules = set() self.loaded_files = set() # TODO: just remove them from file_mapping? self.static_modules = static_modules if static_modules else [] @@ -370,6 +377,8 @@ def destroy(self): self.loaded_modules.clear() if hasattr(self, "missing_modules"): self.missing_modules.clear() + if hasattr(self, "missing_virtualnames"): + self.missing_virtualnames.clear() def clean_modules(self): """ @@ -491,18 +500,34 @@ def missing_fun_string(self, function_name): mod_name = function_name.split(".")[0] if mod_name in self.loaded_modules: return f"'{function_name}' is not available." - else: - try: - reason = self.missing_modules[mod_name] - except KeyError: - return f"'{function_name}' is not available." - else: - if reason is not None: - return "'{}' __virtual__ returned False: {}".format( - mod_name, reason - ) - else: - return f"'{mod_name}' __virtual__ returned False" + + # Collect reasons from missing_modules (keyed by file basename) and + # from missing_virtualnames (keyed by shared __virtualname__). The + # latter lets us surface every failure reason when multiple files + # collide on a single virtualname (e.g. x509 and x509_v2). + reasons = [] + seen = set() + primary = self.missing_modules.get(mod_name, KeyError) + if primary is not KeyError and primary is not None: + reason_str = str(primary) + if reason_str not in seen: + seen.add(reason_str) + reasons.append(reason_str) + for reason in self.missing_virtualnames.get(mod_name, ()): + if reason is None: + continue + reason_str = str(reason) + if reason_str not in seen: + seen.add(reason_str) + reasons.append(reason_str) + + if reasons: + return "'{}' __virtual__ returned False: {}".format( + mod_name, "; ".join(reasons) + ) + if mod_name in self.missing_modules or mod_name in self.missing_virtualnames: + return f"'{mod_name}' __virtual__ returned False" + return f"'{function_name}' is not available." def _refresh_file_mapping(self): """ @@ -670,6 +695,7 @@ def clear(self): super().clear() # clear the lazy loader self.loaded_files = set() self.missing_modules = {} + self.missing_virtualnames = {} self.loaded_modules = set() # if we have been loaded before, lets clear the file mapping since # we obviously want a re-do @@ -1041,26 +1067,28 @@ def _load_module(self, name): # if _process_virtual returned a non-True value then we are # supposed to not process this module if virtual_ret is not True: - # Always record the per-file reason; `name` is unique. + # Record the failure under both the file path (`name`) + # and the file basename (`module_name`). We intentionally + # do NOT record it under __virtualname__ in + # missing_modules: a sibling file failing (e.g. + # deb_postgres on RHEL) must never poison the shared + # virtualname (e.g. postgres) and block the real module + # from loading (issue #69806). self.missing_modules[name] = virtual_err - # The virtualname (module_name) can collide when multiple - # files declare the same __virtualname__ (e.g. x509 and - # x509_v2 both use "x509"). If we've already recorded a - # reason for this virtualname, append the new one so the - # user sees every failure, not just the first. if module_name not in self.missing_modules: self.missing_modules[module_name] = virtual_err - elif virtual_err is not None: - existing = self.missing_modules[module_name] - if existing is None: - self.missing_modules[module_name] = virtual_err - else: - existing_str = str(existing) - new_str = str(virtual_err) - if new_str and new_str not in existing_str.split("; "): - self.missing_modules[module_name] = ( - f"{existing_str}; {new_str}" - ) + # For error-message quality (issue #68625), track every + # failure reason for a shared __virtualname__ in a + # separate structure that missing_fun_string() consults. + virtualname = getattr(mod, "__virtualname__", None) + if ( + isinstance(virtualname, str) + and virtualname + and virtualname != module_name + ): + reasons = self.missing_virtualnames.setdefault(virtualname, []) + if virtual_err not in reasons: + reasons.append(virtual_err) return False else: virtual_aliases = () @@ -1309,16 +1337,18 @@ def _process_virtual(self, mod, module_name, virtual_func="__virtual__"): module_name, ) - # If the module explicitly declares __virtualname__, report - # the failure under that name so the caller can detect - # collisions with other modules claiming the same name. - if ( - hasattr(mod, "__virtualname__") - and isinstance(virtualname, str) - and virtualname - ): - module_name = virtualname - + # NOTE: Do NOT reassign ``module_name`` to the module's + # __virtualname__ on failure here. Doing so caused the + # caller to poison ``missing_modules[virtualname]`` (issue + # #69806): when a sibling module (e.g. deb_postgres on a + # non-Debian host) failed its __virtual__ check first, the + # real module claiming the same virtualname (postgres) was + # skipped by ``_load()`` because that virtualname was + # already marked missing. The caller now tracks failure + # reasons per-__virtualname__ separately (see + # ``missing_virtualnames``) so error surfacing for + # collisions (issue #68625) is preserved without the + # poisoning race. return (False, module_name, error_reason, virtual_aliases) # At this point, __virtual__ did not return a diff --git a/tests/pytests/unit/loader/test_lazy.py b/tests/pytests/unit/loader/test_lazy.py index cf4cebd3a64c..20d377fea2d9 100644 --- a/tests/pytests/unit/loader/test_lazy.py +++ b/tests/pytests/unit/loader/test_lazy.py @@ -208,11 +208,74 @@ def expires(*args, **kwargs): with pytest.raises(KeyError): _ = loader["x509.expires"] + # The primary failure (x509.py has file basename == virtualname) is + # recorded in missing_modules; the sibling x509_v2 failure is tracked + # separately in missing_virtualnames to avoid poisoning the shared + # virtualname for unrelated modules (see #69806). reason = loader.missing_modules.get("x509") assert reason is not None - assert "Superseded, using x509_v2" in reason - assert "Could not load cryptography" in reason + assert "Superseded, using x509_v2" in str(reason) + + extra = loader.missing_virtualnames.get("x509", []) + assert any("Could not load cryptography" in str(r) for r in extra) msg = loader.missing_fun_string("x509.expires") assert "Superseded, using x509_v2" in msg assert "Could not load cryptography" in msg + + +def test_virtualname_sibling_failure_does_not_poison_real_module(tmp_path): + """ + A sibling module whose __virtual__() returns False must not poison the + shared __virtualname__ in missing_modules and block the real module + from loading. + + Regression test for #69806: on non-Debian OSes, deb_postgres.py's + __virtual__() returns False and (under the buggy code) recorded the + failure under missing_modules["postgres"] via its __virtualname__. On + the next _load("postgres.foo") call the loader early-returned because + "postgres" was already marked missing and the real postgres.py module + was never loaded. + """ + (tmp_path / "deb_postgres.py").write_text( + textwrap.dedent( + """ + __virtualname__ = "postgres" + + def __virtual__(): + return (False, "Not a Debian host") + + def user_create(*args, **kwargs): + return True + """ + ) + ) + (tmp_path / "postgres.py").write_text( + textwrap.dedent( + """ + __virtualname__ = "postgres" + + def __virtual__(): + return True + + def user_create(*args, **kwargs): + return "real-postgres" + """ + ) + ) + + opts = {"optimization_order": [0, 1, 2]} + loader = salt.loader.lazy.LazyLoader([str(tmp_path)], opts) + + # Force the failing sibling to be processed first so we hit the race the + # bug produced (non-deterministic directory ordering in the wild). + loader._load_module("deb_postgres") + + # The failing sibling must be recorded under its own basename, NOT + # under the shared virtualname. + assert "deb_postgres" in loader.missing_modules + assert "postgres" not in loader.missing_modules + + # The real postgres.py must still load and provide postgres.user_create. + fun = loader["postgres.user_create"] + assert fun() == "real-postgres" From b34eab6aa8329859e1eb0e21e26f2f44ce4ab109 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 15:36:26 -0700 Subject: [PATCH 241/469] Cache RSA verifier/signer bridge and PublicKey.from_file (3006.x) Under sustained load a busy MWorker rebuilds the ``cryptography`` + libcrypto RSA state on every public-key operation. ``memray`` on a stressed master showed thousands of ``RSAX931Verifier.__init__`` calls per minute against a matching ``PublicKey.decrypt`` count. Same pattern on the sign side. Three layers of caching (mirrors 3008.x PR #69996): 1. Lazy per-instance ``_verifier`` / ``_signer`` on ``PublicKey`` / ``PrivateKey``. ``self.key`` is immutable after ``__init__``, so the derived libcrypto bridge can be reused for the instance's lifetime. 2. Path-level cache on ``PublicKey.from_file`` keyed on ``(path, mtime)``. Key rotation on disk bumps mtime and invalidates the cache automatically. 3. Retry-on-verify-fail in ``PublicKey.verify`` / ``.decrypt``. Preserves the pre-cache 'always fresh' behavior for edge cases where a rotation preserves mtime (``cp -p``, some NFS setups). Orthogonal to the existing ``_get_key_with_evict`` memoize (which caches at the private-key file-loading layer). Fixes #69989 --- changelog/69989.fixed.md | 1 + salt/channel/client.py | 2 +- salt/channel/server.py | 6 +- salt/crypt.py | 135 ++++++++++++++++++++++++++++++++++----- salt/master.py | 2 +- 5 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 changelog/69989.fixed.md diff --git a/changelog/69989.fixed.md b/changelog/69989.fixed.md new file mode 100644 index 000000000000..2a4f9801035e --- /dev/null +++ b/changelog/69989.fixed.md @@ -0,0 +1 @@ +Cache libcrypto ``RSAX931Verifier``/``RSAX931Signer`` bridge objects on ``PublicKey``/``PrivateKey`` instances and cache ``PublicKey.from_file`` results keyed on file mtime. Under sustained master load these were being rebuilt on every ``verify``/``decrypt`` call, causing significant CPU overhead. Complements the existing ``_get_key_with_evict`` memoize which caches at the private-key file-loading layer. diff --git a/salt/channel/client.py b/salt/channel/client.py index b7ffe406ff41..7fd34b29edad 100644 --- a/salt/channel/client.py +++ b/salt/channel/client.py @@ -316,7 +316,7 @@ def crypted_transfer_decode_dictentry( raise salt.ext.tornado.gen.Return(data["pillar"]) def verify_signature(self, data, sig): - return salt.crypt.PublicKey(self.master_pubkey_path).verify( + return salt.crypt.PublicKey.from_file(self.master_pubkey_path).verify( data, sig, self.opts["signing_algorithm"] ) diff --git a/salt/channel/server.py b/salt/channel/server.py index 8cdd8ac203b4..ae77cc66800b 100644 --- a/salt/channel/server.py +++ b/salt/channel/server.py @@ -289,7 +289,7 @@ def _encrypt_private( key = salt.crypt.Crypticle.generate_key_string() pcrypt = salt.crypt.Crypticle(self.opts, key) try: - pub = salt.crypt.PublicKey(pubfn) + pub = salt.crypt.PublicKey.from_file(pubfn) except (ValueError, IndexError, TypeError): log.error("Bad load from minion") return {"error": "bad load"} @@ -400,7 +400,7 @@ def validate_token(self, payload, required=True): log.warning("Invalid minion id: %s", id_) return False try: - pub = salt.crypt.PublicKey(pub_path) + pub = salt.crypt.PublicKey.from_file(pub_path) except OSError: log.warning( "Salt minion claiming to be %s attempted to communicate with " @@ -765,7 +765,7 @@ def _auth(self, load, sign_messages=False, version=0): # The key payload may sometimes be corrupt when using auto-accept # and an empty request comes in try: - pub = salt.crypt.PublicKey(pubfn) + pub = salt.crypt.PublicKey.from_file(pubfn) except salt.crypt.InvalidKeyError as err: log.error('Corrupt public key "%s": %s', pubfn, err) if sign_messages: diff --git a/salt/crypt.py b/salt/crypt.py index 28e8c55e8c87..9970f45dc492 100644 --- a/salt/crypt.py +++ b/salt/crypt.py @@ -247,14 +247,22 @@ class PrivateKey(BaseKey): def __init__(self, path, passphrase=None): self.key = get_rsa_key(path, passphrase) + # Lazy cache of the libcrypto-backed X9.31 signer. ``self.key`` is + # immutable after __init__ so the derived signer can be reused for the + # lifetime of this instance. When PrivateKey instances are reused via + # the get_rsa_key path-level cache this eliminates repeated PEM + # serialization + libcrypto BIO/RSA allocation on every encrypt(). + self._signer = None def encrypt(self, data): - pem = self.key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - return salt.utils.rsax931.RSAX931Signer(pem).sign(data) + if self._signer is None: + pem = self.key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + self._signer = salt.utils.rsax931.RSAX931Signer(pem) + return self._signer.sign(data) def sign(self, data, algorithm=PKCS1v15_SHA1): _padding = self.parse_padding_for_signing(algorithm) @@ -285,12 +293,30 @@ def decrypt(self, data, algorithm=OAEP_SHA1): class PublicKey(BaseKey): + @classmethod + def from_file(cls, path, *args, **kwargs): + """ + Return a ``PublicKey`` for the on-disk public key at ``path``. + + Routes through the mtime-keyed cache so callers that repeatedly load + the same key file share a single ``PublicKey`` instance (and therefore + a single cached ``RSAX931Verifier``). A key rotation on disk bumps the + file's mtime and invalidates the cache automatically. + """ + return _get_pub_key_with_evict(path, str(os.path.getmtime(path))) + def __init__(self, path): with salt.utils.files.fopen(path, "rb") as fp: try: self.key = serialization.load_pem_public_key(fp.read()) except ValueError as exc: raise InvalidKeyError("Invalid key") + # Lazy cache of the libcrypto-backed X9.31 verifier. ``self.key`` is + # immutable after __init__ so the derived verifier can be reused for + # the lifetime of this instance. When PublicKey instances are reused + # via the from_file() path-level cache this eliminates repeated PEM + # serialization + libcrypto BIO/RSA allocation on every decrypt(). + self._verifier = None def encrypt(self, data, algorithm=OAEP_SHA1): _padding = self.parse_padding_for_encryption(algorithm) @@ -309,7 +335,7 @@ def encrypt(self, data, algorithm=OAEP_SHA1): except cryptography.exceptions.UnsupportedAlgorithm: raise UnsupportedAlgorithm(f"Unsupported algorithm: {algorithm}") - def verify(self, data, signature, algorithm=PKCS1v15_SHA1): + def _verify(self, data, signature, algorithm): _padding = self.parse_padding_for_signing(algorithm) _hash = self.parse_hash(algorithm) if SHA1 in algorithm and fips_enabled(): @@ -330,13 +356,41 @@ def verify(self, data, signature, algorithm=PKCS1v15_SHA1): return False return True + def verify(self, data, signature, algorithm=PKCS1v15_SHA1): + result = self._verify(data, signature, algorithm) + if result: + return True + # Preserve the pre-cache "always fresh" behavior for edge cases where + # a key rotated on disk without bumping mtime (cp -p, NFS mtime cache, + # atomic rename that preserves timestamps). If we own an entry in the + # public-key cache for this instance, evict it and retry once with a + # freshly loaded key. Genuine bad signatures still return False and + # only cost one extra file read + PEM parse per forged attempt. + fresh = _reload_evicted_pub_key(self) + if fresh is None or fresh is self: + return False + return fresh._verify(data, signature, algorithm) + + def _decrypt(self, data): + if self._verifier is None: + pem = self.key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + self._verifier = salt.utils.rsax931.RSAX931Verifier(pem) + return self._verifier.verify(data) + def decrypt(self, data): - pem = self.key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - verifier = salt.utils.rsax931.RSAX931Verifier(pem) - return verifier.verify(data) + try: + return self._decrypt(data) + except ValueError: + # X9.31 verify failed. Mirror verify()'s retry-on-fail semantics + # so a rotated-on-disk key without an mtime bump doesn't wedge a + # cached instance. Genuine bad payloads re-raise after retry. + fresh = _reload_evicted_pub_key(self) + if fresh is None or fresh is self: + raise + return fresh._decrypt(data) @salt.utils.decorators.memoize @@ -380,6 +434,55 @@ def get_rsa_key(path, passphrase): return _get_key_with_evict(path, str(os.path.getmtime(path)), passphrase) +# Path-level cache for PublicKey instances. Keyed on (path, mtime_str) so a +# rotation on disk (which bumps mtime) transparently loads a fresh instance. +# A parallel index (path -> current key) supports the retry-on-verify-fail +# eviction path in PublicKey.verify()/decrypt() for the corner cases where a +# key is replaced on disk without an mtime change (cp -p, NFS mtime cache, +# atomic rename with preserved timestamps). +_pub_key_cache = {} +_pub_key_cache_path_index = {} + + +def _get_pub_key_with_evict(path, timestamp): + """ + Load a ``PublicKey`` from disk, caching it by (path, mtime). + + ``timestamp`` should be the file's mtime as a string so a key rotation on + disk (which bumps mtime) invalidates the cache. Callers should route + through ``PublicKey.from_file`` rather than call this directly. + """ + cache_key = (path, timestamp) + cached = _pub_key_cache.get(cache_key) + if cached is not None: + return cached + pub = PublicKey(path) + _pub_key_cache[cache_key] = pub + _pub_key_cache_path_index[path] = cache_key + return pub + + +def _reload_evicted_pub_key(instance): + """ + Evict ``instance`` from the public-key cache and return a freshly loaded + ``PublicKey`` for the same path, or ``None`` if the instance isn't cached + or the underlying file is no longer readable. + + Used by ``PublicKey.verify``/``decrypt`` to preserve the pre-cache + "always fresh" behavior when a key rotates on disk without an mtime bump. + """ + for path, cache_key in list(_pub_key_cache_path_index.items()): + cached = _pub_key_cache.get(cache_key) + if cached is instance: + _pub_key_cache.pop(cache_key, None) + _pub_key_cache_path_index.pop(path, None) + try: + return _get_pub_key_with_evict(path, str(os.path.getmtime(path))) + except OSError: + return None + return None + + def get_rsa_pub_key(path): """ Read a public key off the disk. @@ -407,7 +510,7 @@ def verify_signature(pubkey_path, message, signature, algorithm=PKCS1v15_SHA1): Returns True for valid signature. """ log.debug("salt.crypt.verify_signature: Loading public key") - return PublicKey(pubkey_path).verify(message, signature, algorithm) + return PublicKey.from_file(pubkey_path).verify(message, signature, algorithm) def gen_signature(priv_path, pub_path, sign_path, passphrase=None): @@ -1153,7 +1256,7 @@ def minion_sign_in_payload(self): payload["autosign_grains"] = autosign_grains try: pubkey_path = os.path.join(self.opts["pki_dir"], self.mpub) - pub = PublicKey(pubkey_path) + pub = PublicKey.from_file(pubkey_path) payload["token"] = pub.encrypt( self.token, self.opts["encryption_algorithm"] ) @@ -1199,7 +1302,7 @@ def decrypt_aes(self, payload, master_pub=True): m_path = os.path.join(self.opts["pki_dir"], self.mpub) if os.path.exists(m_path): try: - mkey = PublicKey(m_path) + mkey = PublicKey.from_file(m_path) except Exception: # pylint: disable=broad-except return "", "" digest = hashlib.sha256(key_str).hexdigest() diff --git a/salt/master.py b/salt/master.py index 5bb537274602..d0c5fc4dae85 100644 --- a/salt/master.py +++ b/salt/master.py @@ -1334,7 +1334,7 @@ def __verify_minion(self, id_, token): pub_path = salt.utils.verify.clean_join(self.opts["pki_dir"], "minions", id_) try: - pub = salt.crypt.PublicKey(pub_path) + pub = salt.crypt.PublicKey.from_file(pub_path) except OSError: log.warning( "Salt minion claiming to be %s attempted to communicate with " From 899bee98bc9e2844fcd10800115ae56853140687 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 10 Aug 2026 17:17:13 -0700 Subject: [PATCH 242/469] Update tests for PublicKey.from_file cache indirection test_verify_signature was calling verify_signature() with a fake path `/keydir/keyname.pub` and patching fopen. PublicKey.from_file now takes os.path.getmtime(path) for the cache key, which raises FileNotFoundError on a fake path. Stub the mtime lookup and clear the pub-key cache so the mocked fopen is actually consulted. test_when_async_req_channel_with_syndic_role_... patched `salt.crypt.PublicKey` and asserted the class was invoked with the syndic master pubkey path. verify_signature now calls `PublicKey.from_file(path)` so the path lands on the from_file classmethod call rather than on the class itself. --- tests/pytests/unit/crypt/test_crypt_cryptography.py | 12 +++++++++--- tests/pytests/unit/transport/test_tcp.py | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/pytests/unit/crypt/test_crypt_cryptography.py b/tests/pytests/unit/crypt/test_crypt_cryptography.py index 53750bf016cf..d704b87a65d5 100644 --- a/tests/pytests/unit/crypt/test_crypt_cryptography.py +++ b/tests/pytests/unit/crypt/test_crypt_cryptography.py @@ -321,10 +321,16 @@ def test_sign_message_with_passphrase(signature, signing_algorithm): def test_verify_signature(signature, signing_algorithm): + # PublicKey.from_file caches by (path, mtime); stub the mtime lookup + # since the fake path is only backed by a mocked fopen. with patch("salt.utils.files.fopen", mock_open(read_data=PUBKEY_DATA.encode())): - assert salt.crypt.verify_signature( - "/keydir/keyname.pub", MSG, signature, algorithm=signing_algorithm - ) + with patch("salt.crypt.os.path.getmtime", return_value=0): + # Ensure a fresh cache entry so the mocked fopen is consulted. + salt.crypt._pub_key_cache.clear() + salt.crypt._pub_key_cache_path_index.clear() + assert salt.crypt.verify_signature( + "/keydir/keyname.pub", MSG, signature, algorithm=signing_algorithm + ) def test_loading_encrypted_openssl_format(openssl_encrypted_key, passphrase, tmp_path): diff --git a/tests/pytests/unit/transport/test_tcp.py b/tests/pytests/unit/transport/test_tcp.py index a95aea15ce68..5af919a27a94 100644 --- a/tests/pytests/unit/transport/test_tcp.py +++ b/tests/pytests/unit/transport/test_tcp.py @@ -429,9 +429,11 @@ async def test_when_async_req_channel_with_syndic_role_should_use_syndic_master_ } client = salt.channel.client.ReqChannel.factory(opts, io_loop=mockloop) assert client.master_pubkey_path == expected_pubkey_path + # verify_signature routes through PublicKey.from_file so the syndic + # master pubkey path shows up on the from_file classmethod call. with patch("salt.crypt.PublicKey", return_value=MagicMock()) as mock: client.verify_signature("mockdata", "mocksig") - assert mock.call_args_list[0][0][0] == expected_pubkey_path + assert mock.from_file.call_args_list[0][0][0] == expected_pubkey_path async def test_mixin_should_use_correct_path_when_syndic( From f949d8ff97f98f9b7759c6b84a2b1d7524cf778c Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 15:25:32 -0700 Subject: [PATCH 243/469] Fix RequestClient socketpair FD leak via graceful drain on close ``RequestClient.close()`` was dropping a ``(None, None)`` sentinel and immediately closing ``self.socket`` + destroying ``self.context``. The running ``send_recv_task`` coroutine was still on the io_loop with its locals holding a reference to the socket; the close raced the task's finally, leaving the underlying socketpair + mailbox FDs unfreed. Under sustained ``saltutil.refresh_pillar`` / re-auth churn this leaked ~451 socketpairs (~902 FDs) per minion, tripping the 1024-FD ulimit throttle at ~924/1024 within minutes. Port the graceful-drain pattern from ``AsyncReqMessageClient`` (from twangboy's #68637 chain: ``6ad90a51d65``, ``aa317c67e51``, ``bcb3778c9c1``, ``b96ddd58ce6``): add ``_send_recv_exit_future`` that ``_send_recv`` sets in a try/finally, then rewrite ``close()`` to schedule an async ``_drain_and_close`` task that awaits the future (with 5s timeout) before closing the socket + destroying the context. Also add ``SyncWrapper.__del__`` that emits ``ResourceWarning`` if the wrapper was GC'd without an explicit ``close()`` -- mirrors ``SaltEvent.__del__`` (``salt/utils/event.py:278-321``) and surfaces future missed-close bugs in tests and sentry rather than silently leaking loops. Validated in a 10-min stress bench: median FD/minion dropped from 924 (throttle threshold) to 35 (idle baseline); zero minions crossed 500 FDs across 50 stressed minions. Fixes #69991 --- changelog/69991.fixed.md | 1 + salt/transport/zeromq.py | 312 ++++++++++++++++++++++++------------- salt/utils/asynchronous.py | 47 ++++++ 3 files changed, 252 insertions(+), 108 deletions(-) create mode 100644 changelog/69991.fixed.md diff --git a/changelog/69991.fixed.md b/changelog/69991.fixed.md new file mode 100644 index 000000000000..383954cf3101 --- /dev/null +++ b/changelog/69991.fixed.md @@ -0,0 +1 @@ +Fix ~451 leaked socketpair FDs per minion under sustained re-auth churn. ``zeromq.RequestClient.close()`` now schedules an async graceful-drain task that awaits ``_send_recv_exit_future`` before tearing down the ZMQ socket and context, mirroring the pattern from ``AsyncReqMessageClient`` (#68637). Adds ``SyncWrapper.__del__`` ``ResourceWarning`` to surface future missed-close bugs. diff --git a/salt/transport/zeromq.py b/salt/transport/zeromq.py index 4ebafc64ebda..e3fafdd848d5 100644 --- a/salt/transport/zeromq.py +++ b/salt/transport/zeromq.py @@ -2038,6 +2038,19 @@ def __init__(self, opts, io_loop, linger=0): # pylint: disable=W0231 self._connect_lock = asyncio.Lock() self.send_recv_task = None self.send_recv_task_id = 0 + # PATCH: mirror ``AsyncReqMessageClient`` (twangboy #68637) -- + # ``_send_recv_exit_future`` is resolved by ``_send_recv`` on + # every exit path so ``close()`` can wait for the task to drain + # before we close the ZMQ socket + destroy the context. Without + # this, ``close()`` races ``_send_recv``: the task's coroutine + # locals still hold a reference to the socket after we close it, + # then GC runs while the io_loop is torn down, and the + # socketpair backing the REQ socket + its internal mailbox never + # gets released. Observed as ~451 leaked socketpairs (~902 + # fds) per minion under sustained ``saltutil.refresh_pillar`` / + # ``AsyncAuth`` re-auth churn, tripping the minion's 1024-file + # ulimit "critical" threshold. + self._send_recv_exit_future = None async def connect(self): # pylint: disable=invalid-overridden-method async with self._connect_lock: @@ -2081,6 +2094,10 @@ def _init_socket(self): self.socket.setsockopt(zmq.IPV4ONLY, 0) self.socket.linger = self.linger self.socket.connect(self.master_uri) + # Fresh exit future per task -- resolved when _send_recv actually + # returns so close() can wait for the socket to be released + # before it's closed. + self._send_recv_exit_future = asyncio.Future() self.send_recv_task = self.io_loop.create_task( self._send_recv(self.socket, self._queue, task_id=self.send_recv_task_id), name="RequestClient._send_recv", @@ -2114,15 +2131,74 @@ def close(self): # shutdown sentinel so TRACE logs and clean teardown match functional # tests (see test_request_client_send_recv_socket_closed). Reconnect # still cancels the task in ``_init_socket``. - if self.socket: - self.socket.close() - self.socket = None - if self.context is not None and not self.context.closed: + # + # PATCH: instead of closing the socket immediately -- which races + # ``_send_recv`` and leaves its coroutine locals holding a + # reference to a closed socket (leaks the underlying socketpair + # + mailbox fds) -- move socket/context tear-down into an async + # task that first awaits ``_send_recv_exit_future``. See + # AsyncReqMessageClient graceful shutdown (twangboy #68637 + # chain). + socket = self.socket + context = self.context + exit_future = self._send_recv_exit_future + self.socket = None + self.context = None + self._send_recv_exit_future = None + + async def _drain_and_close(): + if exit_future is not None: + try: + await asyncio.wait_for(asyncio.shield(exit_future), timeout=5) + except (asyncio.TimeoutError, asyncio.CancelledError): + pass + except Exception: # pylint: disable=broad-except + log.debug( + "RequestClient graceful drain failed", + exc_info=True, + ) + if socket is not None: + try: + socket.close() + except Exception: # pylint: disable=broad-except + pass + if context is not None and not context.closed: + try: + context.destroy(0) + except Exception: # pylint: disable=broad-except + pass + + asyncio_loop = getattr(self.io_loop, "asyncio_loop", None) + if asyncio_loop is None: + asyncio_loop = self.io_loop + try: + loop_running = asyncio_loop.is_running() + except Exception: # pylint: disable=broad-except + loop_running = False + + if loop_running: try: - self.context.destroy(0) + asyncio_loop.call_soon_threadsafe( + lambda: asyncio_loop.create_task(_drain_and_close()) + ) + return + except RuntimeError: + # Loop already closed; fall through to sync path. + pass + + # Fallback: loop is not running. Best-effort sync teardown -- + # ``_send_recv`` is likewise not making progress, so nothing to + # drain; just close the resources directly. + if socket is not None: + try: + socket.close() + except Exception: # pylint: disable=broad-except + pass + if context is not None and not context.closed: + try: + context.destroy(0) except Exception: # pylint: disable=broad-except pass - self.context = None async def _reconnect(self): if self.socket is not None: @@ -2181,97 +2257,88 @@ async def _send_recv( message is sent and the reply socket is polled for a response while checking the future to see if it was timed out. """ + # PATCH: capture the exit future for THIS task instance up front. + # ``self._send_recv_exit_future`` may be swapped out by + # ``_init_socket`` on reconnect while we're still running, so + # remember the one that belongs to us and resolve it in + # ``finally`` -- ``close()`` waits on this to know the socket is + # safe to close without racing our coroutine locals. See + # AsyncReqMessageClient graceful shutdown (twangboy #68637). + exit_future = self._send_recv_exit_future try: asyncio.current_task()._log_destroy_pending = False except (RuntimeError, AttributeError): pass - send_recv_running = True - # Hold on to the socket so we'll still have a reference to it after the - # close method is called. This allows us to fail gracefully once it's - # been closed. - while send_recv_running: - if task_id is not None and task_id != self.send_recv_task_id: - break - - try: - # Use a small timeout to allow periodic task_id checks - future, message = await asyncio.wait_for(queue.get(), 0.3) - except asyncio.TimeoutError: - continue - except (asyncio.CancelledError, asyncio.exceptions.CancelledError): - break - - if task_id is not None and task_id != self.send_recv_task_id: - # Re-queue the message so the new task can pick it up - self._queue.put_nowait((future, message)) - log.trace( - "Task %s is no longer active after queue.get. Re-queued and exiting.", - task_id, - ) - break - - if future is None: - log.trace("Received send/recv shutdown sentinal") - send_recv_running = False - break + try: + send_recv_running = True + # Hold on to the socket so we'll still have a reference to it after the + # close method is called. This allows us to fail gracefully once it's + # been closed. + while send_recv_running: + if task_id is not None and task_id != self.send_recv_task_id: + break - try: - # Wait for socket to be ready for sending - if not await socket.poll(300, zmq.POLLOUT): - if not future.done(): - future.set_exception( - SaltReqTimeoutError("Socket not ready for sending") - ) - if not self._closing: - await self._reconnect() + try: + # Use a small timeout to allow periodic task_id checks + future, message = await asyncio.wait_for(queue.get(), 0.3) + except asyncio.TimeoutError: + continue + except (asyncio.CancelledError, asyncio.exceptions.CancelledError): break - await socket.send(message) - except (zmq.eventloop.future.CancelledError, asyncio.CancelledError) as exc: - send_recv_running = False - if not future.done(): - future.set_exception(exc) - break - except zmq.ZMQError as exc: - if exc.errno == zmq.EAGAIN: - # Re-queue and try again + if task_id is not None and task_id != self.send_recv_task_id: + # Re-queue the message so the new task can pick it up self._queue.put_nowait((future, message)) - continue - if not future.done(): - future.set_exception(exc) - # Add a small delay before reconnecting to prevent storms - await asyncio.sleep(0.1) - if not self._closing: - await self._reconnect() - break + log.trace( + "Task %s is no longer active after queue.get. Re-queued and exiting.", + task_id, + ) + break + + if future is None: + log.trace("Received send/recv shutdown sentinal") + send_recv_running = False + break - received = False - ready = False - while True: try: - # Time is in milliseconds. - ready = await socket.poll(300, zmq.POLLIN) + # Wait for socket to be ready for sending + if not await socket.poll(300, zmq.POLLOUT): + if not future.done(): + future.set_exception( + SaltReqTimeoutError("Socket not ready for sending") + ) + if not self._closing: + await self._reconnect() + break + + await socket.send(message) except ( - asyncio.CancelledError, zmq.eventloop.future.CancelledError, - asyncio.exceptions.CancelledError, + asyncio.CancelledError, ) as exc: send_recv_running = False if not future.done(): future.set_exception(exc) break except zmq.ZMQError as exc: - send_recv_running = False + if exc.errno == zmq.EAGAIN: + # Re-queue and try again + self._queue.put_nowait((future, message)) + continue if not future.done(): future.set_exception(exc) + # Add a small delay before reconnecting to prevent storms + await asyncio.sleep(0.1) if not self._closing: await self._reconnect() break - if ready: + received = False + ready = False + while True: try: - recv = await socket.recv() - received = True + # Time is in milliseconds. + ready = await socket.poll(300, zmq.POLLIN) except ( asyncio.CancelledError, zmq.eventloop.future.CancelledError, @@ -2280,6 +2347,7 @@ async def _send_recv( send_recv_running = False if not future.done(): future.set_exception(exc) + break except zmq.ZMQError as exc: send_recv_running = False if not future.done(): @@ -2287,41 +2355,69 @@ async def _send_recv( if not self._closing: await self._reconnect() break - break - elif future.done(): - break - if future.done(): - if future.cancelled(): - send_recv_running = False - break - exc = future.exception() - if exc is None: - continue - if isinstance( - exc, (asyncio.CancelledError, zmq.eventloop.future.CancelledError) - ): + if ready: + try: + recv = await socket.recv() + received = True + except ( + asyncio.CancelledError, + zmq.eventloop.future.CancelledError, + asyncio.exceptions.CancelledError, + ) as exc: + send_recv_running = False + if not future.done(): + future.set_exception(exc) + except zmq.ZMQError as exc: + send_recv_running = False + if not future.done(): + future.set_exception(exc) + if not self._closing: + await self._reconnect() + break + break + elif future.done(): + break + + if future.done(): + if future.cancelled(): + send_recv_running = False + break + exc = future.exception() + if exc is None: + continue + if isinstance( + exc, + (asyncio.CancelledError, zmq.eventloop.future.CancelledError), + ): + send_recv_running = False + break + if isinstance(exc, SaltReqTimeoutError): + log.error( + "Request timed out while waiting for a response. reconnecting." + ) + elif isinstance(exc, zmq.ZMQError) and exc.errno == zmq.EAGAIN: + # Resource temporarily unavailable is normal during reconnections + log.trace("Socket EAGAIN during send/recv loop. reconnecting.") + else: + log.error( + "The request ended with an error. reconnecting. %r", exc + ) + if not self._closing: + await self._reconnect() send_recv_running = False - break - if isinstance(exc, SaltReqTimeoutError): - log.error( - "Request timed out while waiting for a response. reconnecting." - ) - elif isinstance(exc, zmq.ZMQError) and exc.errno == zmq.EAGAIN: - # Resource temporarily unavailable is normal during reconnections - log.trace("Socket EAGAIN during send/recv loop. reconnecting.") - else: - log.error("The request ended with an error. reconnecting. %r", exc) - if not self._closing: - await self._reconnect() - send_recv_running = False - elif received: - try: - data = salt.payload.loads(recv) - if not future.done(): - future.set_result(data) - except Exception as exc: # pylint: disable=broad-except - log.error("Failed to deserialize response: %s", exc) - if not future.done(): - future.set_exception(exc) - log.trace("Send and receive coroutine ending %s", socket) + elif received: + try: + data = salt.payload.loads(recv) + if not future.done(): + future.set_result(data) + except Exception as exc: # pylint: disable=broad-except + log.error("Failed to deserialize response: %s", exc) + if not future.done(): + future.set_exception(exc) + log.trace("Send and receive coroutine ending %s", socket) + finally: + # PATCH: signal ``close()`` that the coroutine has exited + # and the socket/context are safe to tear down. + if exit_future is not None and not exit_future.done(): + exit_future.set_result(None) diff --git a/salt/utils/asynchronous.py b/salt/utils/asynchronous.py index 7001e0f27630..be07c1615a64 100644 --- a/salt/utils/asynchronous.py +++ b/salt/utils/asynchronous.py @@ -7,6 +7,7 @@ import logging import sys import threading +import warnings import tornado.concurrent import tornado.ioloop @@ -295,3 +296,49 @@ def __exit__(self, exc_type, exc_val, tb): if hasattr(self.obj, "__aexit__"): self._wrap("__aexit__")(exc_type, exc_val, tb) self.close() + + # pylint: disable=W1701 + def __del__(self): + # PATCH: mirror ``SaltEvent.__del__`` at ``salt/utils/event.py`` + # -- deliberately do NOT close the wrapped ``obj`` / io_loop / + # asyncio_loop from ``__del__``. ``__del__`` fires during GC + # (may be arbitrarily delayed, may skip on reference cycles) + # and during interpreter shutdown, when the world is already + # tearing down and touching a tornado/asyncio loop can raise + # from a partially-freed C extension. Instead, emit a + # ``ResourceWarning`` so callers that missed ``close()`` / + # context-manager surface loudly in tests / sentry / log + # aggregators. + # + # Motivation: ``SyncWrapper``-owned asyncio loops are the + # dominant leak surface on the minion under sustained + # ``saltutil.refresh_pillar`` / re-auth churn -- each abandoned + # wrapper holds a whole IOLoop, its ZMQ context, and the two + # socketpairs backing the master REQ channel. Observed ~451 + # leaked socketpairs (~902 fds) per minion, tripping the + # 1024-file ulimit critical threshold and the minion's own + # sock-throttle logic. + try: + unclosed = getattr(self, "obj", None) is not None or ( + getattr(self, "asyncio_loop", None) is not None + and not self.asyncio_loop.is_closed() + ) + except Exception: # pylint: disable=broad-except + return + if not unclosed: + return + try: + warnings.warn( + f"unclosed {type(self).__name__} for cls=" + f"{getattr(self, 'cls', None)!r}; call ``close()`` or " + f"use as a context manager", + ResourceWarning, + source=self, + ) + except Exception: # pylint: disable=broad-except + # ``warnings.warn`` can raise during interpreter shutdown + # when the ``warnings`` module has already been torn down. + # A finalizer must not propagate exceptions. + pass + + # pylint: enable=W1701 From ab80f9a8dabad7d0b18823a4c44af286ff5e899f Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 10 Aug 2026 18:29:19 -0700 Subject: [PATCH 244/469] RequestClient.close: don't rely on scheduled drain in same-thread case The graceful-drain patch scheduled ``_drain_and_close`` on the io_loop via ``call_soon_threadsafe`` and returned immediately. When the caller was already on the loop thread (async production code, or a sync fixture-teardown after an async test) and did not yield control to the loop before it was torn down, the drain task was destroyed while pending -- socket + context never got closed, and the ``zmq.Context`` finalizer later blocked in ``__del__`` -> ``term()`` under GC. All 20 functional zeromq CI jobs hung until the 3h workflow timeout after the first ``test_request_client_send_recv_socket_closed`` finished cleanly and pytest tried to run the next test (repro: 2h45m gap between last PASSED and cancellation in Debian 11 job 93340807019, py-spy dump showed the main thread stuck in ``zmq.sugar.context.term``). Split the teardown into three branches: 1. Same-thread + loop-running: the shutdown sentinel is already queued; fall through to the sync teardown so socket/context close deterministically before we return. ``_send_recv`` picks up the sentinel on the next iteration and drops its socket ref, matching base-branch behavior that the ``send_recv_socket_closed`` test asserts on. 2. Cross-thread + loop-running: schedule the drain and block on a ``threading.Event`` (6s cap, matching the 5s drain timeout) so the caller doesn't tear down its loop while our task is still pending. 3. Loop not running: sync teardown directly (unchanged fallback). Restores test_request_client.py to all-passing on the functional transport suite (16 passed + 1 xfailed, was hanging indefinitely after the third test). --- salt/transport/zeromq.py | 97 +++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 30 deletions(-) diff --git a/salt/transport/zeromq.py b/salt/transport/zeromq.py index e3fafdd848d5..44f1652953e4 100644 --- a/salt/transport/zeromq.py +++ b/salt/transport/zeromq.py @@ -2146,6 +2146,18 @@ def close(self): self.context = None self._send_recv_exit_future = None + def _sync_teardown(): + if socket is not None: + try: + socket.close() + except Exception: # pylint: disable=broad-except + pass + if context is not None and not context.closed: + try: + context.destroy(0) + except Exception: # pylint: disable=broad-except + pass + async def _drain_and_close(): if exit_future is not None: try: @@ -2157,16 +2169,7 @@ async def _drain_and_close(): "RequestClient graceful drain failed", exc_info=True, ) - if socket is not None: - try: - socket.close() - except Exception: # pylint: disable=broad-except - pass - if context is not None and not context.closed: - try: - context.destroy(0) - except Exception: # pylint: disable=broad-except - pass + _sync_teardown() asyncio_loop = getattr(self.io_loop, "asyncio_loop", None) if asyncio_loop is None: @@ -2177,28 +2180,62 @@ async def _drain_and_close(): loop_running = False if loop_running: - try: - asyncio_loop.call_soon_threadsafe( - lambda: asyncio_loop.create_task(_drain_and_close()) - ) + # Determine whether ``close()`` was called from the same thread + # that is currently running the io_loop. If so, we're inside + # async code (e.g. a coroutine finalising itself); scheduling + # is safe and the caller will drive the loop. Otherwise + # (cross-thread), we block until the drain completes so the + # caller doesn't tear down the loop while our task is pending. + loop_thread = getattr(asyncio_loop, "_thread_id", None) + same_thread = ( + loop_thread is not None and loop_thread == threading.get_ident() + ) + if same_thread: + # PATCH: same-thread + loop-running case. We cannot block + # (would deadlock the loop), but we also cannot rely on a + # scheduled task actually running before the loop is torn + # down (e.g. pytest-asyncio finishes the test coroutine + # and closes the loop without another iteration -- the + # ``_drain_and_close`` task is then destroyed while + # pending and the underlying socket/context leak). + # + # The shutdown sentinel has already been queued above; + # ``_send_recv`` will consume it and drop the socket + # reference from its coroutine locals on the next loop + # iteration (which the caller must yield to before the + # loop is closed -- matches base-branch behavior). Fall + # through to sync teardown so socket/context are closed + # deterministically before we return; do not cancel the + # send_recv task because functional tests assert on the + # sentinel log emitted by the graceful queue drain. + _sync_teardown() return - except RuntimeError: - # Loop already closed; fall through to sync path. - pass + else: + done_evt = threading.Event() - # Fallback: loop is not running. Best-effort sync teardown -- - # ``_send_recv`` is likewise not making progress, so nothing to - # drain; just close the resources directly. - if socket is not None: - try: - socket.close() - except Exception: # pylint: disable=broad-except - pass - if context is not None and not context.closed: - try: - context.destroy(0) - except Exception: # pylint: disable=broad-except - pass + async def _drain_and_signal(): + try: + await _drain_and_close() + finally: + done_evt.set() + + try: + asyncio_loop.call_soon_threadsafe( + lambda: asyncio_loop.create_task(_drain_and_signal()) + ) + # Wait for the drain to finish so we don't return with + # socket/context leaked. 5s matches the drain timeout. + if done_evt.wait(timeout=6): + return + except RuntimeError: + # Loop already closed; fall through to sync path. + pass + + # Fallback: loop is not running, or scheduling failed, or the + # cross-thread wait timed out. ``_send_recv`` is not going to + # make progress in any of those cases -- close the resources + # directly so we don't leak FDs (see #69991). + _sync_teardown() async def _reconnect(self): if self.socket is not None: From c82d3b8a6195ece60d6485941ad14e56267c2bb0 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 15:23:05 -0700 Subject: [PATCH 245/469] Cache OptsDict DictProxy/ListProxy wrappers to eliminate hot-path churn ``OptsDict.__getitem__`` was allocating a fresh ``DictProxy`` or ``ListProxy`` on every read of a mutable value. Master hot paths (``opts["file_roots"]``, ``opts["pillar_roots"]``) called this thousands of times per minute, causing continuous object allocation + GC churn. Add a per-instance ``_proxy_cache: dict[str, tuple[Any, int]]`` keyed on the config key, storing ``(proxy, id(underlying))``. Return the cached proxy if the underlying object hasn't been replaced; otherwise rebuild. Invalidate on ``__setitem__`` / ``__delitem__`` (pop key from cache). Fixes #69990 --- changelog/69990.fixed.md | 1 + salt/utils/optsdict.py | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 changelog/69990.fixed.md diff --git a/changelog/69990.fixed.md b/changelog/69990.fixed.md new file mode 100644 index 000000000000..a6f77b712837 --- /dev/null +++ b/changelog/69990.fixed.md @@ -0,0 +1 @@ +Cache ``DictProxy``/``ListProxy`` wrappers in ``OptsDict.__getitem__`` keyed on the underlying object's ``id()``. Prevents massive object churn on hot-path reads like ``opts["file_roots"]`` under sustained load. Cache is invalidated in ``__setitem__``/``__delitem__``. diff --git a/salt/utils/optsdict.py b/salt/utils/optsdict.py index 681b59d49469..e2dd8f584ed4 100644 --- a/salt/utils/optsdict.py +++ b/salt/utils/optsdict.py @@ -462,6 +462,10 @@ def __init__( self._base = base_dict if base_dict is not None else {} self._name = name or f"OptsDict@{id(self)}" self._lock = threading.RLock() + # Cache of {key: (proxy, id(underlying_value))} to avoid re-allocating + # a DictProxy/ListProxy on every read of the same mutable value. + # Invalidated on __setitem__/__delitem__/COW (id changes). + self._proxy_cache: dict[str, tuple[Any, int]] = {} # Mutation tracking if parent and parent._tracker: @@ -541,7 +545,8 @@ def __getitem__(self, key: str) -> Any: When accessing mutable values from parent/base, we return a proxy object that triggers copy-on-write on first mutation. This provides isolation - without copying until actually needed. + without copying until actually needed. Proxies are cached per key so + repeated reads of the same underlying value don't reallocate. """ with self._ensure_lock(): # Check local first - if already copied, return direct reference @@ -561,9 +566,9 @@ def __getitem__(self, key: str) -> Any: raise KeyError(key) # Wrap mutable values in proxies to catch mutations if isinstance(value, dict) and not isinstance(value, OptsDict): - return DictProxy(value, self, key) + return self._proxy_for(key, value, DictProxy) elif isinstance(value, list): - return ListProxy(value, self, key) + return self._proxy_for(key, value, ListProxy) # Immutable values can be returned directly return value @@ -573,13 +578,27 @@ def __getitem__(self, key: str) -> Any: # Even root instances need proxies to track when values are mutated # This allows us to know when a key has been accessed/modified if isinstance(value, dict) and not isinstance(value, OptsDict): - return DictProxy(value, self, key) + return self._proxy_for(key, value, DictProxy) elif isinstance(value, list): - return ListProxy(value, self, key) + return self._proxy_for(key, value, ListProxy) return value raise KeyError(key) + def _proxy_for(self, key: str, value: Any, cls: type) -> Any: + """ + Return a cached proxy for ``value`` at ``key``, allocating a new one + only when the underlying object identity has changed. + """ + entry = self._proxy_cache.get(key) + if entry is not None: + proxy, cached_id = entry + if cached_id == id(value): + return proxy + proxy = cls(value, self, key) + self._proxy_cache[key] = (proxy, id(value)) + return proxy + def __setitem__(self, key: str, value: Any): """ Set item with copy-on-write semantics. @@ -606,6 +625,10 @@ def __setitem__(self, key: str, value: Any): # Subsequent mutation of already-local key self._tracker.record_mutation(key, original_value, value) + # Invalidate any cached proxy for this key: the underlying value + # is changing, so a re-read must not hand back a proxy pointing + # at the stale target. + self._proxy_cache.pop(key, None) # Store the value locally self._local[key] = value @@ -635,6 +658,9 @@ def __delitem__(self, key: str): if key not in self: raise KeyError(key) + # Invalidate any cached proxy for this key. + self._proxy_cache.pop(key, None) + if key in self._local: # Key is in local - check if it's already deleted if self._local[key] is _DELETED: From 46d65c4c11a61ad780d2516bc8c5b3e174734315 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 3 Aug 2026 17:28:05 -0700 Subject: [PATCH 246/469] Cache RSA verifier/signer bridge objects and mtime-key PublicKey.from_file Under sustained load a busy MWorker rebuilds cryptography + libcrypto RSA state on every public-key operation. memray on a stressed 3008.x master showed ~5,000 RSAX931Verifier.__init__ calls per 60 seconds against a matching PublicKey.decrypt call count. Same pattern on the sign side. Three layers of caching: 1. Lazy per-instance _verifier / _signer on PublicKey / PrivateKey. self.key is immutable after __init__, so the derived libcrypto bridge object can be reused for the lifetime of the instance. 2. Path-level cache on PublicKey.from_file keyed on (path, mtime). A key rotation on disk bumps mtime and invalidates the cache automatically. 3. Retry-on-verify-fail in PublicKey.verify / .decrypt. Preserves the pre-cache "always fresh" behavior for edge cases where a rotation preserves mtime (cp -p, NFS mtime cache, atomic rename with preserved timestamps). On the first failure the cache entry is evicted and one reload-and-retry is attempted. Genuine bad signatures still return False / raise ValueError; the retry costs one extra file read + PEM parse per forged attempt. Fixes #69940 --- changelog/69940.fixed.md | 8 ++ salt/crypt.py | 132 +++++++++++++++++-- tests/pytests/unit/test_crypt.py | 220 +++++++++++++++++++++++++++++++ 3 files changed, 347 insertions(+), 13 deletions(-) create mode 100644 changelog/69940.fixed.md diff --git a/changelog/69940.fixed.md b/changelog/69940.fixed.md new file mode 100644 index 000000000000..5b9caa05ef8a --- /dev/null +++ b/changelog/69940.fixed.md @@ -0,0 +1,8 @@ +Cache the libcrypto-backed RSAX931 verifier / signer objects on +``salt.crypt.PublicKey`` and ``PrivateKey`` instances and route +``PublicKey.from_file`` through an mtime-keyed path cache. Eliminates +thousands of redundant PEM parses and libcrypto ``BIO``/``RSA`` allocations +per minute in a busy master's ``MWorker`` processes. ``PublicKey.verify`` +and ``PublicKey.decrypt`` fall back to a one-shot reload-and-retry when a +cached key doesn't validate, preserving the pre-cache behavior for on-disk +rotations that don't bump mtime. diff --git a/salt/crypt.py b/salt/crypt.py index ecac8cda3fba..1d674ca55b93 100644 --- a/salt/crypt.py +++ b/salt/crypt.py @@ -347,14 +347,22 @@ def __init__(self, key_bytes, passphrase=None): raise InvalidKeyError("Encountered bad RSA private key") except cryptography.exceptions.UnsupportedAlgorithm: raise InvalidKeyError("Unsupported key algorithm") + # Lazy cache of the libcrypto-backed X9.31 signer. ``self.key`` is + # immutable after __init__ so the derived signer can be reused for the + # lifetime of this instance. When PrivateKey instances are reused via + # the get_rsa_key path-level cache this eliminates repeated PEM + # serialization + libcrypto BIO/RSA allocation on every encrypt(). + self._signer = None def encrypt(self, data): - pem = self.key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - return salt.utils.rsax931.RSAX931Signer(pem).sign(data) + if self._signer is None: + pem = self.key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + self._signer = salt.utils.rsax931.RSAX931Signer(pem) + return self._signer.sign(data) def sign(self, data, algorithm=PKCS1v15_SHA1): _padding = self.parse_padding_for_signing(algorithm) @@ -397,6 +405,18 @@ def public_key(self): class PublicKey(BaseKey): + @classmethod + def from_file(cls, path, *args, **kwargs): + """ + Return a ``PublicKey`` for the on-disk public key at ``path``. + + Routes through the mtime-keyed cache so callers that repeatedly load + the same key file share a single ``PublicKey`` instance (and therefore + a single cached ``RSAX931Verifier``). A key rotation on disk bumps the + file's mtime and invalidates the cache automatically. + """ + return _get_pub_key_with_evict(path, str(os.path.getmtime(path))) + def __init__(self, key_bytes): log.debug("Loading public key") try: @@ -405,6 +425,12 @@ def __init__(self, key_bytes): raise InvalidKeyError("Encountered bad RSA public key") except cryptography.exceptions.UnsupportedAlgorithm: raise InvalidKeyError("Unsupported key algorithm") + # Lazy cache of the libcrypto-backed X9.31 verifier. ``self.key`` is + # immutable after __init__ so the derived verifier can be reused for + # the lifetime of this instance. When PublicKey instances are reused + # via the from_file() path-level cache this eliminates repeated PEM + # serialization + libcrypto BIO/RSA allocation on every decrypt(). + self._verifier = None def encrypt(self, data, algorithm=OAEP_SHA1): _padding = self.parse_padding_for_encryption(algorithm) @@ -426,7 +452,7 @@ def encrypt(self, data, algorithm=OAEP_SHA1): except cryptography.exceptions.UnsupportedAlgorithm: raise UnsupportedAlgorithm(f"Unsupported algorithm: {algorithm}") - def verify(self, data, signature, algorithm=PKCS1v15_SHA1): + def _verify(self, data, signature, algorithm): _padding = self.parse_padding_for_signing(algorithm) _hash = self.parse_hash(algorithm) if SHA1 in algorithm and fips_enabled(): @@ -447,13 +473,41 @@ def verify(self, data, signature, algorithm=PKCS1v15_SHA1): return False return True + def verify(self, data, signature, algorithm=PKCS1v15_SHA1): + result = self._verify(data, signature, algorithm) + if result: + return True + # Preserve the pre-cache "always fresh" behavior for edge cases where + # a key rotated on disk without bumping mtime (cp -p, NFS mtime cache, + # atomic rename that preserves timestamps). If we own an entry in the + # public-key cache for this instance, evict it and retry once with a + # freshly loaded key. Genuine bad signatures still return False and + # only cost one extra file read + PEM parse per forged attempt. + fresh = _reload_evicted_pub_key(self) + if fresh is None or fresh is self: + return False + return fresh._verify(data, signature, algorithm) + + def _decrypt(self, data): + if self._verifier is None: + pem = self.key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + self._verifier = salt.utils.rsax931.RSAX931Verifier(pem) + return self._verifier.verify(data) + def decrypt(self, data): - pem = self.key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - verifier = salt.utils.rsax931.RSAX931Verifier(pem) - return verifier.verify(data) + try: + return self._decrypt(data) + except ValueError: + # X9.31 verify failed. Mirror verify()'s retry-on-fail semantics + # so a rotated-on-disk key without an mtime bump doesn't wedge a + # cached instance. Genuine bad payloads re-raise after retry. + fresh = _reload_evicted_pub_key(self) + if fresh is None or fresh is self: + raise + return fresh._decrypt(data) class PrivateKeyString(PrivateKey): @@ -463,6 +517,7 @@ def __init__(self, data, password=None): data.encode(), password=password, ) + self._signer = None # pylint: enable=super-init-not-called @@ -474,6 +529,7 @@ def __init__(self, data): self.key = serialization.load_pem_public_key(data.encode()) except ValueError: raise InvalidKeyError("Invalid key") + self._verifier = None # pylint: enable=super-init-not-called @@ -505,6 +561,56 @@ def get_rsa_key(path, passphrase): return _get_key_with_evict(path, str(os.path.getmtime(path)), passphrase) +# Path-level cache for PublicKey instances. Keyed on (path, mtime_str) so a +# rotation on disk (which bumps mtime) transparently loads a fresh instance. +# A parallel index (path -> current key) supports the retry-on-verify-fail +# eviction path in PublicKey.verify()/decrypt() for the corner cases where a +# key is replaced on disk without an mtime change (cp -p, NFS mtime cache, +# atomic rename with preserved timestamps). +_pub_key_cache = {} +_pub_key_cache_path_index = {} + + +def _get_pub_key_with_evict(path, timestamp): + """ + Load a ``PublicKey`` from disk, caching it by (path, mtime). + + ``timestamp`` should be the file's mtime as a string so a key rotation on + disk (which bumps mtime) invalidates the cache. Callers should route + through ``PublicKey.from_file`` rather than call this directly. + """ + cache_key = (path, timestamp) + cached = _pub_key_cache.get(cache_key) + if cached is not None: + return cached + with salt.utils.files.fopen(path, "rb") as fp: + pub = PublicKey(fp.read()) + _pub_key_cache[cache_key] = pub + _pub_key_cache_path_index[path] = cache_key + return pub + + +def _reload_evicted_pub_key(instance): + """ + Evict ``instance`` from the public-key cache and return a freshly loaded + ``PublicKey`` for the same path, or ``None`` if the instance isn't cached + or the underlying file is no longer readable. + + Used by ``PublicKey.verify``/``decrypt`` to preserve the pre-cache + "always fresh" behavior when a key rotates on disk without an mtime bump. + """ + for path, cache_key in list(_pub_key_cache_path_index.items()): + cached = _pub_key_cache.get(cache_key) + if cached is instance: + _pub_key_cache.pop(cache_key, None) + _pub_key_cache_path_index.pop(path, None) + try: + return _get_pub_key_with_evict(path, str(os.path.getmtime(path))) + except OSError: + return None + return None + + def get_rsa_pub_key(path): """ Return a public key from bytes diff --git a/tests/pytests/unit/test_crypt.py b/tests/pytests/unit/test_crypt.py index 691f8970491d..274e913d6282 100644 --- a/tests/pytests/unit/test_crypt.py +++ b/tests/pytests/unit/test_crypt.py @@ -645,3 +645,223 @@ async def mock_sign_in(*args, **kwargs): assert isinstance(auth._creds, dict) assert auth._creds["aes"] == aes assert auth._creds["session"] == session + + +# --- PublicKey / PrivateKey caching regression tests -------------------------- + + +@pytest.fixture +def _clear_pub_key_cache(): + """ + Clear the module-level public-key cache before and after each test so + tests can make hard assertions about cache membership and identity. + """ + crypt._pub_key_cache.clear() + crypt._pub_key_cache_path_index.clear() + yield + crypt._pub_key_cache.clear() + crypt._pub_key_cache_path_index.clear() + + +@pytest.fixture +def _rsa_keypair(tmp_path): + """ + Generate an RSA keypair once per test and write both halves to disk so + tests exercise ``PublicKey.from_file`` / ``PrivateKey.from_file``. + """ + priv_pem, pub_pem = crypt.gen_keys(2048) + priv_path = tmp_path / "test.pem" + pub_path = tmp_path / "test.pub" + priv_path.write_text(priv_pem) + pub_path.write_text(pub_pem) + return { + "priv_pem": priv_pem, + "pub_pem": pub_pem, + "priv_path": str(priv_path), + "pub_path": str(pub_path), + } + + +def _count_class_init(cls): + """ + Return a context-manager-like helper that instruments ``cls.__init__`` to + count the number of calls it receives. Returns a ``dict`` whose ``count`` + key holds the running total; caller is responsible for restoring the + original ``__init__`` when done. + """ + counter = {"count": 0, "original": cls.__init__} + + def wrapper(self, *args, **kwargs): + counter["count"] += 1 + return counter["original"](self, *args, **kwargs) + + cls.__init__ = wrapper + return counter + + +def test_publickey_verifier_cached_across_decrypts(_rsa_keypair): + """ + Repeated ``PublicKey.decrypt`` calls on a single instance must build the + underlying ``RSAX931Verifier`` exactly once. Pre-fix behavior was one + verifier per decrypt() call. + """ + import salt.utils.rsax931 + + priv = crypt.PrivateKey.from_str(_rsa_keypair["priv_pem"]) + pub = crypt.PublicKey.from_str(_rsa_keypair["pub_pem"]) + signed = priv.encrypt(b"salt") + + counter = _count_class_init(salt.utils.rsax931.RSAX931Verifier) + try: + for _ in range(50): + assert pub.decrypt(signed) == b"salt" + finally: + salt.utils.rsax931.RSAX931Verifier.__init__ = counter["original"] + + assert counter["count"] == 1, ( + "PublicKey.decrypt should reuse a single RSAX931Verifier per " + f"instance; got {counter['count']} verifier constructions" + ) + + +def test_privatekey_signer_cached_across_encrypts(_rsa_keypair): + """ + Repeated ``PrivateKey.encrypt`` calls on a single instance must build the + underlying ``RSAX931Signer`` exactly once. Pre-fix behavior was one + signer per encrypt() call. + """ + import salt.utils.rsax931 + + priv = crypt.PrivateKey.from_str(_rsa_keypair["priv_pem"]) + + counter = _count_class_init(salt.utils.rsax931.RSAX931Signer) + try: + for _ in range(50): + priv.encrypt(b"salt") + finally: + salt.utils.rsax931.RSAX931Signer.__init__ = counter["original"] + + assert counter["count"] == 1, ( + "PrivateKey.encrypt should reuse a single RSAX931Signer per " + f"instance; got {counter['count']} signer constructions" + ) + + +def test_pubkey_from_file_returns_cached_instance(_rsa_keypair, _clear_pub_key_cache): + """ + ``PublicKey.from_file`` returns the *same* instance for repeated loads of + the same on-disk file, so downstream libcrypto state (verifiers) is + reused across the entire process. + """ + first = crypt.PublicKey.from_file(_rsa_keypair["pub_path"]) + second = crypt.PublicKey.from_file(_rsa_keypair["pub_path"]) + assert first is second + + +def test_pubkey_from_file_mtime_evicts(_rsa_keypair, _clear_pub_key_cache): + """ + A change to the file's mtime invalidates the cache entry and forces a + fresh ``PublicKey`` instance on the next load. + """ + pub_path = _rsa_keypair["pub_path"] + first = crypt.PublicKey.from_file(pub_path) + # Bump mtime one second into the future. Using an explicit stamp avoids + # relying on filesystem timestamp resolution. + old_mtime = os.path.getmtime(pub_path) + os.utime(pub_path, (old_mtime + 5, old_mtime + 5)) + second = crypt.PublicKey.from_file(pub_path) + assert first is not second + # Same key material -> same underlying cryptography public numbers. + from cryptography.hazmat.primitives.asymmetric import rsa + + assert isinstance(first.key, rsa.RSAPublicKey) + assert isinstance(second.key, rsa.RSAPublicKey) + assert first.key.public_numbers() == second.key.public_numbers() + + +def test_verify_retries_after_rotation_without_mtime_bump( + tmp_path, _clear_pub_key_cache +): + """ + Simulate an on-disk key rotation that preserves mtime (cp -p / NFS mtime + cache / atomic rename). ``PublicKey.verify`` must detect the mismatch, + evict the stale cache entry, and retry once with a freshly loaded key. + """ + stale_priv_pem, stale_pub_pem = crypt.gen_keys(2048) + fresh_priv_pem, fresh_pub_pem = crypt.gen_keys(2048) + + pub_path = tmp_path / "rotated.pub" + pub_path.write_text(stale_pub_pem) + mtime = os.path.getmtime(str(pub_path)) + + # Warm the cache with the stale key. + cached = crypt.PublicKey.from_file(str(pub_path)) + assert (str(pub_path), str(mtime)) in crypt._pub_key_cache + + # Rotate on disk without bumping mtime. A signature produced by the + # fresh key must NOT validate against the cached stale key on the first + # try, but the retry-on-fail path reloads and succeeds. + pub_path.write_text(fresh_pub_pem) + os.utime(str(pub_path), (mtime, mtime)) + + fresh_priv = crypt.PrivateKey.from_str(fresh_priv_pem) + message = b"rotation-safety-check" + signature = fresh_priv.sign(message) + + assert cached.verify(message, signature) is True + # The retry evicts the stale entry and reinstalls a fresh instance for + # the same (path, mtime) key. + assert crypt._pub_key_cache[(str(pub_path), str(mtime))] is not cached + + +def test_decrypt_retries_after_rotation_without_mtime_bump( + tmp_path, _clear_pub_key_cache +): + """ + Mirror of the verify retry, but for ``PublicKey.decrypt`` which drives the + X9.31 padding code path used by AsyncAuth. A payload signed by the + freshly rotated private key must decrypt successfully even though the + cache initially holds the stale public key. + """ + stale_priv_pem, stale_pub_pem = crypt.gen_keys(2048) + fresh_priv_pem, fresh_pub_pem = crypt.gen_keys(2048) + + pub_path = tmp_path / "rotated.pub" + pub_path.write_text(stale_pub_pem) + mtime = os.path.getmtime(str(pub_path)) + + cached = crypt.PublicKey.from_file(str(pub_path)) + + pub_path.write_text(fresh_pub_pem) + os.utime(str(pub_path), (mtime, mtime)) + + fresh_priv = crypt.PrivateKey.from_str(fresh_priv_pem) + signed = fresh_priv.encrypt(b"salt") + + assert cached.decrypt(signed) == b"salt" + + +def test_verify_genuine_bad_sig_returns_false_after_retry( + _rsa_keypair, _clear_pub_key_cache +): + """ + A genuinely invalid signature must still return ``False`` even though the + retry-on-fail path will attempt to reload the key from disk. The retry + is bounded (one extra attempt) and never papers over real failures. + """ + pub = crypt.PublicKey.from_file(_rsa_keypair["pub_path"]) + forged = b"\x00" * 256 + assert pub.verify(b"any message", forged) is False + + +def test_decrypt_genuine_bad_payload_raises_after_retry( + _rsa_keypair, _clear_pub_key_cache +): + """ + ``PublicKey.decrypt`` re-raises the underlying ``ValueError`` for genuine + decryption failures after exactly one retry. This preserves the + pre-cache contract callers rely on. + """ + pub = crypt.PublicKey.from_file(_rsa_keypair["pub_path"]) + with pytest.raises(ValueError): + pub.decrypt(b"\x00" * 256) From a2a0b91f25477dbdefd99af666129b3102f52224 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 15:24:25 -0700 Subject: [PATCH 247/469] Add changelog for #69989 (RSA verifier/signer cache on 3008.x) Sibling entry to the cherry-picked fix; complements the file-layer mtime-eviction fix (#69941) already on 3008.x. --- changelog/69989.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/69989.fixed.md diff --git a/changelog/69989.fixed.md b/changelog/69989.fixed.md new file mode 100644 index 000000000000..8a1bf5a7a371 --- /dev/null +++ b/changelog/69989.fixed.md @@ -0,0 +1 @@ +Cache libcrypto ``RSAX931Verifier``/``RSAX931Signer`` bridge objects on ``PublicKey``/``PrivateKey`` instances and cache ``PublicKey.from_file`` results keyed on file mtime. Under sustained master load ``memray`` showed ~5000 ``RSAX931Verifier.__init__`` calls per 60 s against a matching ``PublicKey.decrypt`` count -- fully eliminated. Complements upstream ``6cf49f5364e`` which caches at the private-key file layer. From 0e126315a4295e4369dcdc73ea1b1e7305596534 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 10 Aug 2026 17:17:13 -0700 Subject: [PATCH 248/469] Update tests for PublicKey.from_file cache indirection test_verify_signature was calling verify_signature() with a fake path `/keydir/keyname.pub` and patching fopen. PublicKey.from_file now takes os.path.getmtime(path) for the cache key, which raises FileNotFoundError on a fake path. Stub the mtime lookup and clear the pub-key cache so the mocked fopen is actually consulted. test_when_async_req_channel_with_syndic_role_... patched `salt.crypt.PublicKey` and asserted the class was invoked with the syndic master pubkey path. verify_signature now calls `PublicKey.from_file(path)` so the path lands on the from_file classmethod call rather than on the class itself. (cherry picked from commit 61f55fc8e24e8defd2bc21fb6a96e7aebe168745) --- tests/pytests/unit/crypt/test_crypt_cryptography.py | 12 +++++++++--- tests/pytests/unit/transport/test_tcp.py | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/pytests/unit/crypt/test_crypt_cryptography.py b/tests/pytests/unit/crypt/test_crypt_cryptography.py index dd1418c73941..fb24a444d04b 100644 --- a/tests/pytests/unit/crypt/test_crypt_cryptography.py +++ b/tests/pytests/unit/crypt/test_crypt_cryptography.py @@ -322,10 +322,16 @@ def test_sign_message_with_passphrase(signature, signing_algorithm): def test_verify_signature(signature, signing_algorithm): + # PublicKey.from_file caches by (path, mtime); stub the mtime lookup + # since the fake path is only backed by a mocked fopen. with patch("salt.utils.files.fopen", mock_open(read_data=PUBKEY_DATA.encode())): - assert salt.crypt.verify_signature( - "/keydir/keyname.pub", MSG, signature, algorithm=signing_algorithm - ) + with patch("salt.crypt.os.path.getmtime", return_value=0): + # Ensure a fresh cache entry so the mocked fopen is consulted. + salt.crypt._pub_key_cache.clear() + salt.crypt._pub_key_cache_path_index.clear() + assert salt.crypt.verify_signature( + "/keydir/keyname.pub", MSG, signature, algorithm=signing_algorithm + ) def test_loading_encrypted_openssl_format(openssl_encrypted_key, passphrase, tmp_path): diff --git a/tests/pytests/unit/transport/test_tcp.py b/tests/pytests/unit/transport/test_tcp.py index 0d588bfd87d9..b06dd10a92b6 100644 --- a/tests/pytests/unit/transport/test_tcp.py +++ b/tests/pytests/unit/transport/test_tcp.py @@ -715,6 +715,8 @@ async def test_when_async_req_channel_with_syndic_role_should_use_syndic_master_ } client = salt.channel.client.ReqChannel.factory(opts, io_loop=mockloop) assert client.master_pubkey_path == expected_pubkey_path + # verify_signature routes through PublicKey.from_file so the syndic + # master pubkey path shows up on the from_file classmethod call. with patch("salt.crypt.PublicKey.from_file", return_value=MagicMock()) as mock: client.verify_signature("mockdata", "mocksig") assert mock.call_args_list[0][0][0] == expected_pubkey_path From 8ce804c0014e15bbede1c27a31fb82ba066a9361 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 10 Aug 2026 18:03:20 -0700 Subject: [PATCH 249/469] Fix FIPS-mode retry test after PublicKey.from_file cache indirection test_verify_retries_after_rotation_without_mtime_bump was calling PrivateKey.sign() with the default PKCS1v15-SHA1 algorithm, which is rejected at the salt boundary in FIPS mode. Parameterize the test on FIPS_TESTRUN so it uses PKCS1v15-SHA224 under FIPS and still exercises the same retry-on-verify-fail code path on both toolchains. --- tests/pytests/unit/test_crypt.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/pytests/unit/test_crypt.py b/tests/pytests/unit/test_crypt.py index 274e913d6282..0c60cd4925d7 100644 --- a/tests/pytests/unit/test_crypt.py +++ b/tests/pytests/unit/test_crypt.py @@ -6,9 +6,14 @@ import salt.crypt as crypt import salt.exceptions +from tests.conftest import FIPS_TESTRUN from tests.support.mock import mock_open, patch +def _fips_safe_sig_algorithm(): + return crypt.PKCS1v15_SHA224 if FIPS_TESTRUN else crypt.PKCS1v15_SHA1 + + @pytest.fixture def key_data(): return [ @@ -804,11 +809,15 @@ def test_verify_retries_after_rotation_without_mtime_bump( pub_path.write_text(fresh_pub_pem) os.utime(str(pub_path), (mtime, mtime)) + # Use a FIPS-compatible signing algorithm so this test exercises the + # retry path under FIPS as well. PKCS1v15-SHA1 (the pre-cache default) + # is rejected at the salt boundary in FIPS mode. + algorithm = _fips_safe_sig_algorithm() fresh_priv = crypt.PrivateKey.from_str(fresh_priv_pem) message = b"rotation-safety-check" - signature = fresh_priv.sign(message) + signature = fresh_priv.sign(message, algorithm=algorithm) - assert cached.verify(message, signature) is True + assert cached.verify(message, signature, algorithm=algorithm) is True # The retry evicts the stale entry and reinstalls a fresh instance for # the same (path, mtime) key. assert crypt._pub_key_cache[(str(pub_path), str(mtime))] is not cached From 9654b6091b0858d3503f1ba7a2283f758a0e520f Mon Sep 17 00:00:00 2001 From: Brian Ha Date: Mon, 27 Jul 2026 21:59:53 -0700 Subject: [PATCH 250/469] Fix minion crash when grains config option is empty An empty 'grains:' config option parses to None instead of a dict, which crashed the minion during startup with "TypeError: 'NoneType' object is not iterable" when the loader tried to build the __grains__ NamespacedDictWrapper. Default the option to an empty dict in both places that read it: apply_minion_config, and salt.loader.grains(), which independently re-reads the raw config file off disk. Log a warning in each case pointing out that 'grains: {}' should be used instead. Fixes #61321 --- changelog/61321.fixed.md | 1 + salt/config/__init__.py | 9 +++++ salt/loader/__init__.py | 9 ++++- .../integration/cli/test_salt_minion.py | 38 +++++++++++++++++++ tests/pytests/unit/loader/test_loader.py | 28 ++++++++++++++ tests/unit/test_config.py | 20 ++++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 changelog/61321.fixed.md diff --git a/changelog/61321.fixed.md b/changelog/61321.fixed.md new file mode 100644 index 000000000000..8e7aba474812 --- /dev/null +++ b/changelog/61321.fixed.md @@ -0,0 +1 @@ +Fixed minion crashing on startup when the ``grains`` config option was present but empty (e.g. ``grains:`` with no value), which previously caused a ``TypeError: 'NoneType' object is not iterable``. diff --git a/salt/config/__init__.py b/salt/config/__init__.py index fc30c181836d..247a25b7c66f 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -3792,6 +3792,15 @@ def apply_minion_config( if overrides: opts.update(overrides) + if opts.get("grains") is None: + log.warning( + "Config option 'grains' is set to an empty value. An empty " + "'grains' config is invalid, a dict is required. To set an " + "empty grains config, use 'grains: {}' instead. Defaulting to " + "an empty dict." + ) + opts["grains"] = defaults.get("grains", {}) + if "environment" in opts: if opts["saltenv"] is not None: log.warning( diff --git a/salt/loader/__init__.py b/salt/loader/__init__.py index eff92aaa49a0..fb34a9dee5f3 100644 --- a/salt/loader/__init__.py +++ b/salt/loader/__init__.py @@ -1132,7 +1132,14 @@ def grains(opts, force_refresh=False, proxy=None, context=None, loaded_base_name pre_opts.update( salt.config.include_config(include, opts["conf_file"], verbose=True) ) - if "grains" in pre_opts: + if "grains" in pre_opts and pre_opts["grains"] is None: + log.warning( + "Config option 'grains' is set to an empty value. An empty " + "'grains' config is invalid, a dict is required. To set an " + "empty grains config, use 'grains: {}' instead. Defaulting " + "to an empty dict." + ) + if pre_opts.get("grains") is not None: opts["grains"] = pre_opts["grains"] else: opts["grains"] = {} diff --git a/tests/pytests/integration/cli/test_salt_minion.py b/tests/pytests/integration/cli/test_salt_minion.py index ad623bd30f50..cb9816009059 100644 --- a/tests/pytests/integration/cli/test_salt_minion.py +++ b/tests/pytests/integration/cli/test_salt_minion.py @@ -6,6 +6,7 @@ from saltfactories.utils import random_string import salt.defaults.exitcodes +import salt.utils.files from tests.conftest import FIPS_TESTRUN from tests.support.helpers import PRE_PYTEST_SKIP_REASON @@ -78,6 +79,43 @@ def test_exit_status_unknown_argument(salt_master, minion_id): assert "no such option: --unknown-argument" in exc.value.process_result.stderr +@pytest.mark.skip_on_windows(reason=PRE_PYTEST_SKIP_REASON) +def test_empty_grains_config_option(salt_master, minion_id, salt_cli): + """ + An empty 'grains' config option (i.e. 'grains:' with no value, which + parses to None) is invalid -- a dict is required. The minion should + not crash on startup because of it; it should log a warning, default + the option to an empty dict, and keep starting normally. + + See https://github.com/saltstack/salt/issues/61321 + """ + factory = salt_master.salt_minion_daemon( + minion_id, + overrides={ + "grains": None, + "fips_mode": FIPS_TESTRUN, + "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", + "signing_algorithm": ( + "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1" + ), + }, + ) + factory.start() + assert factory.is_running() + try: + ret = salt_cli.run("test.ping", minion_tgt=minion_id) + assert ret.returncode == 0 + assert ret.data is True + log_file = factory.config["log_file"] + finally: + factory.terminate() + + with salt.utils.files.fopen(log_file) as fp: + log_contents = fp.read() + assert "grains" in log_contents + assert "empty" in log_contents + + @pytest.mark.skip_on_windows(reason=PRE_PYTEST_SKIP_REASON) def test_exit_status_correct_usage(salt_master, minion_id, salt_cli): factory = salt_master.salt_minion_daemon( diff --git a/tests/pytests/unit/loader/test_loader.py b/tests/pytests/unit/loader/test_loader.py index ea0883b93889..dcadc20cf5d1 100644 --- a/tests/pytests/unit/loader/test_loader.py +++ b/tests/pytests/unit/loader/test_loader.py @@ -5,15 +5,18 @@ Unit tests for salt's loader """ +import logging import os import shutil import textwrap import pytest +import salt.config import salt.exceptions import salt.loader import salt.loader.lazy +import salt.utils.files @pytest.fixture @@ -46,6 +49,31 @@ def test_grains(minion_opts): assert "saltversion" in grains +def test_grains_with_empty_grains_config_option(tmp_path, caplog): + """ + salt.loader.grains() re-reads the raw minion config file off disk to + pick up any grains overrides. If 'grains:' is present in that file + with no value, the raw (un-normalized) value is None, and it must + not be propagated into opts['grains'] as-is, or building the + __grains__ NamespacedDictWrapper later crashes with + "TypeError: 'NoneType' object is not iterable". See issue #61321. + + A warning should also be logged pointing out that an empty 'grains' + config is invalid and that 'grains: {}' should be used instead. + """ + conf_file = str(tmp_path / "minion") + with salt.utils.files.fopen(conf_file, "w") as fp: + fp.write(f"root_dir: {tmp_path}\ngrains:\n") + with caplog.at_level(logging.WARNING): + opts = salt.config.minion_config(conf_file) + grains = salt.loader.grains(opts, force_refresh=True) + assert "saltversion" in grains + assert any( + "grains" in record.message and "empty" in record.message + for record in caplog.records + ) + + def test_custom_grain_with_annotations(minion_opts, grains_dir): """ Load custom grain with annotations. diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index a67ae52bbb9a..141fcc81aa87 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1915,6 +1915,26 @@ def test_mminion_config_cache_path_overrides(self, fpath): self.assertEqual(config["__role"], "master") self.assertEqual(config["cachedir"], cachedir) + @with_tempfile() + def test_minion_config_empty_grains_reverts_to_default(self, fpath): + """ + If 'grains:' is uncommented in the minion config file with no + grains defined, opts['grains'] will be None. This should be + reverted to the default empty dict instead of being left as + None, which previously caused a crash in the loader when it + tried to build the NamespacedDictWrapper for __grains__. + See issue #61321. + """ + with salt.utils.files.fopen(fpath, "w") as wfh: + wfh.write("root_dir: /\nkey_logfile: key\ngrains:\n") + with self.assertLogs("salt.config", level="WARNING") as cm: + config = salt.config.minion_config(fpath) + self.assertEqual(config["grains"], {}) + self.assertTrue( + any("grains" in msg and "empty" in msg for msg in cm.output), + cm.output, + ) + class APIConfigTestCase(DefaultConfigsBase, TestCase): """ From 11400b5be250b9630cc8d8b1cb85c685700b8cba Mon Sep 17 00:00:00 2001 From: Brian Ha Date: Tue, 28 Jul 2026 12:54:05 -0700 Subject: [PATCH 251/469] Narrow empty grains check and fix log_file ordering in test Only warn/default the 'grains' config option when it is explicitly present and None, not merely absent from opts. The looser opts.get("grains") is None check could not distinguish "grains: set to empty" from "grains key never set at all" (e.g. a sparse defaults dict passed into apply_minion_config without a 'grains' key), causing a false-positive warning in that case. This matches the equivalent check already used in salt.loader.grains(). Also move log_file = factory.config["log_file"] in the new integration test out of the try block, since it doesn't depend on the test.ping call succeeding and reads more clearly next to the log file assertions it's used for. --- salt/config/__init__.py | 2 +- tests/pytests/integration/cli/test_salt_minion.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 247a25b7c66f..211a8ac12b1f 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -3792,7 +3792,7 @@ def apply_minion_config( if overrides: opts.update(overrides) - if opts.get("grains") is None: + if "grains" in opts and opts["grains"] is None: log.warning( "Config option 'grains' is set to an empty value. An empty " "'grains' config is invalid, a dict is required. To set an " diff --git a/tests/pytests/integration/cli/test_salt_minion.py b/tests/pytests/integration/cli/test_salt_minion.py index cb9816009059..2147e2ea0215 100644 --- a/tests/pytests/integration/cli/test_salt_minion.py +++ b/tests/pytests/integration/cli/test_salt_minion.py @@ -100,13 +100,13 @@ def test_empty_grains_config_option(salt_master, minion_id, salt_cli): ), }, ) + log_file = factory.config["log_file"] factory.start() assert factory.is_running() try: ret = salt_cli.run("test.ping", minion_tgt=minion_id) assert ret.returncode == 0 assert ret.data is True - log_file = factory.config["log_file"] finally: factory.terminate() From 145539004ca7157738779b88b66cad3db5fbc0ac Mon Sep 17 00:00:00 2001 From: Brian Ha Date: Mon, 10 Aug 2026 16:17:17 -0700 Subject: [PATCH 252/469] Accept any non-dict grains value and document required shape Broaden the empty-grains guards in apply_minion_config and salt.loader.grains() from an explicit None check to isinstance(value, dict), so any non-mapping value (empty string, list, scalar, ...) is defaulted to an empty dict, not just an explicitly empty 'grains:' key. Drop the runtime warning in favor of documenting the required shape in conf/minion and doc/ref/configuration/minion.rst. Parametrize the existing tests over a range of non-dict grains values instead of just None, and drop the now-irrelevant log/warning assertions. --- changelog/61321.fixed.md | 2 +- conf/minion | 4 +++ doc/ref/configuration/minion.rst | 4 +++ salt/config/__init__.py | 10 ++----- salt/loader/__init__.py | 9 +------ .../integration/cli/test_salt_minion.py | 21 ++++++--------- tests/pytests/unit/loader/test_loader.py | 27 +++++++------------ tests/pytests/unit/test_config.py | 19 +++++++++++++ tests/unit/test_config.py | 20 -------------- 9 files changed, 49 insertions(+), 67 deletions(-) diff --git a/changelog/61321.fixed.md b/changelog/61321.fixed.md index 8e7aba474812..6fa2d9e89d90 100644 --- a/changelog/61321.fixed.md +++ b/changelog/61321.fixed.md @@ -1 +1 @@ -Fixed minion crashing on startup when the ``grains`` config option was present but empty (e.g. ``grains:`` with no value), which previously caused a ``TypeError: 'NoneType' object is not iterable``. +Fixed minion crashing on startup when the ``grains`` config option was present but not a mapping (e.g. ``grains:`` with no value, an empty string, or a scalar), which previously caused a ``TypeError: 'NoneType' object is not iterable`` and similar. Any non-dict value is now silently defaulted to an empty dict, and the required shape of the ``grains`` option is documented in the minion configuration reference. diff --git a/conf/minion b/conf/minion index 70cbe8934a45..56e0f534e80e 100644 --- a/conf/minion +++ b/conf/minion @@ -133,6 +133,10 @@ # Custom static grains for this minion can be specified here and used in SLS # files just like all other grains. This example sets 4 custom grains, with # the 'roles' grain having two values that can be matched against. +# +# The value of 'grains' must be a mapping. Use 'grains: {}' for an +# explicit empty section. Any non-dict value is silently defaulted +# to an empty dict. #grains: # roles: # - webserver diff --git a/doc/ref/configuration/minion.rst b/doc/ref/configuration/minion.rst index d0f075aca03a..ec934a1c0a65 100644 --- a/doc/ref/configuration/minion.rst +++ b/doc/ref/configuration/minion.rst @@ -822,6 +822,10 @@ Statically assigns grains to the minion. cabinet: 13 cab_u: 14-15 +The value of ``grains`` must be a mapping. Use ``grains: {}`` for an +explicit empty section. Any non-dict value is silently defaulted to +an empty dict. + .. conf_minion:: grains_blacklist ``grains_blacklist`` diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 211a8ac12b1f..5ad7eb0ae85b 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -3792,14 +3792,8 @@ def apply_minion_config( if overrides: opts.update(overrides) - if "grains" in opts and opts["grains"] is None: - log.warning( - "Config option 'grains' is set to an empty value. An empty " - "'grains' config is invalid, a dict is required. To set an " - "empty grains config, use 'grains: {}' instead. Defaulting to " - "an empty dict." - ) - opts["grains"] = defaults.get("grains", {}) + if "grains" in opts and not isinstance(opts["grains"], dict): + opts["grains"] = {} if "environment" in opts: if opts["saltenv"] is not None: diff --git a/salt/loader/__init__.py b/salt/loader/__init__.py index fb34a9dee5f3..59c3361c5bb8 100644 --- a/salt/loader/__init__.py +++ b/salt/loader/__init__.py @@ -1132,14 +1132,7 @@ def grains(opts, force_refresh=False, proxy=None, context=None, loaded_base_name pre_opts.update( salt.config.include_config(include, opts["conf_file"], verbose=True) ) - if "grains" in pre_opts and pre_opts["grains"] is None: - log.warning( - "Config option 'grains' is set to an empty value. An empty " - "'grains' config is invalid, a dict is required. To set an " - "empty grains config, use 'grains: {}' instead. Defaulting " - "to an empty dict." - ) - if pre_opts.get("grains") is not None: + if "grains" in pre_opts and isinstance(pre_opts["grains"], dict): opts["grains"] = pre_opts["grains"] else: opts["grains"] = {} diff --git a/tests/pytests/integration/cli/test_salt_minion.py b/tests/pytests/integration/cli/test_salt_minion.py index 2147e2ea0215..b312d3dc6712 100644 --- a/tests/pytests/integration/cli/test_salt_minion.py +++ b/tests/pytests/integration/cli/test_salt_minion.py @@ -6,7 +6,6 @@ from saltfactories.utils import random_string import salt.defaults.exitcodes -import salt.utils.files from tests.conftest import FIPS_TESTRUN from tests.support.helpers import PRE_PYTEST_SKIP_REASON @@ -80,19 +79,21 @@ def test_exit_status_unknown_argument(salt_master, minion_id): @pytest.mark.skip_on_windows(reason=PRE_PYTEST_SKIP_REASON) -def test_empty_grains_config_option(salt_master, minion_id, salt_cli): +@pytest.mark.parametrize("grains_value", [None, "", [], "foo", 42, [1, 2]]) +def test_non_dict_grains_config_option(salt_master, minion_id, salt_cli, grains_value): """ - An empty 'grains' config option (i.e. 'grains:' with no value, which - parses to None) is invalid -- a dict is required. The minion should - not crash on startup because of it; it should log a warning, default - the option to an empty dict, and keep starting normally. + A 'grains' config option that isn't a mapping (e.g. 'grains:' with no + value, which parses to None, or a string/number/list) is invalid -- a + dict is required. The minion should not crash on startup because of + it; it should default the option to an empty dict and keep starting + normally. See https://github.com/saltstack/salt/issues/61321 """ factory = salt_master.salt_minion_daemon( minion_id, overrides={ - "grains": None, + "grains": grains_value, "fips_mode": FIPS_TESTRUN, "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", "signing_algorithm": ( @@ -100,7 +101,6 @@ def test_empty_grains_config_option(salt_master, minion_id, salt_cli): ), }, ) - log_file = factory.config["log_file"] factory.start() assert factory.is_running() try: @@ -110,11 +110,6 @@ def test_empty_grains_config_option(salt_master, minion_id, salt_cli): finally: factory.terminate() - with salt.utils.files.fopen(log_file) as fp: - log_contents = fp.read() - assert "grains" in log_contents - assert "empty" in log_contents - @pytest.mark.skip_on_windows(reason=PRE_PYTEST_SKIP_REASON) def test_exit_status_correct_usage(salt_master, minion_id, salt_cli): diff --git a/tests/pytests/unit/loader/test_loader.py b/tests/pytests/unit/loader/test_loader.py index dcadc20cf5d1..c278fbe87db8 100644 --- a/tests/pytests/unit/loader/test_loader.py +++ b/tests/pytests/unit/loader/test_loader.py @@ -5,7 +5,6 @@ Unit tests for salt's loader """ -import logging import os import shutil import textwrap @@ -49,29 +48,23 @@ def test_grains(minion_opts): assert "saltversion" in grains -def test_grains_with_empty_grains_config_option(tmp_path, caplog): +@pytest.mark.parametrize("grains_value", ["", '""', "[]", "foo", "42", "[1, 2]"]) +def test_grains_with_non_dict_grains_config_option(tmp_path, grains_value): """ salt.loader.grains() re-reads the raw minion config file off disk to pick up any grains overrides. If 'grains:' is present in that file - with no value, the raw (un-normalized) value is None, and it must - not be propagated into opts['grains'] as-is, or building the - __grains__ NamespacedDictWrapper later crashes with - "TypeError: 'NoneType' object is not iterable". See issue #61321. - - A warning should also be logged pointing out that an empty 'grains' - config is invalid and that 'grains: {}' should be used instead. + with a non-dict value (empty, a string, a number, a list, ...), that + raw value must not be propagated into opts['grains'] as-is, or + building the __grains__ NamespacedDictWrapper later crashes with + "TypeError: 'NoneType' object is not iterable" or similar. See + issue #61321. """ conf_file = str(tmp_path / "minion") with salt.utils.files.fopen(conf_file, "w") as fp: - fp.write(f"root_dir: {tmp_path}\ngrains:\n") - with caplog.at_level(logging.WARNING): - opts = salt.config.minion_config(conf_file) - grains = salt.loader.grains(opts, force_refresh=True) + fp.write(f"root_dir: {tmp_path}\ngrains: {grains_value}\n") + opts = salt.config.minion_config(conf_file) + grains = salt.loader.grains(opts, force_refresh=True) assert "saltversion" in grains - assert any( - "grains" in record.message and "empty" in record.message - for record in caplog.records - ) def test_custom_grain_with_annotations(minion_opts, grains_dir): diff --git a/tests/pytests/unit/test_config.py b/tests/pytests/unit/test_config.py index 313c3cb0b0ec..0edc4380cacf 100644 --- a/tests/pytests/unit/test_config.py +++ b/tests/pytests/unit/test_config.py @@ -7,8 +7,11 @@ import pathlib +import pytest + import salt.config import salt.syspaths +import salt.utils.files def test_call_id_function(tmp_path): @@ -34,3 +37,19 @@ def test_prepend_root_dir(tmp_path): } salt.config.prepend_root_dir(opts, ["foo"]) assert opts["foo"] == str(root / "var" / "foo") + + +@pytest.mark.parametrize("grains_value", ["", '""', "[]", "foo", "42", "[1, 2]"]) +def test_minion_config_non_dict_grains_reverts_to_default(grains_value, tmp_path): + """ + The 'grains' minion config option must be a mapping. Any non-dict + value (an empty scalar, a string, a number, a list, ...) should be + silently defaulted to an empty dict instead of being left as-is, + which previously caused a crash in the loader when it tried to + build the NamespacedDictWrapper for __grains__. See issue #61321. + """ + conf_file = str(tmp_path / "minion") + with salt.utils.files.fopen(conf_file, "w") as wfh: + wfh.write(f"root_dir: /\nkey_logfile: key\ngrains: {grains_value}\n") + config = salt.config.minion_config(conf_file) + assert config["grains"] == {} diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 141fcc81aa87..a67ae52bbb9a 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1915,26 +1915,6 @@ def test_mminion_config_cache_path_overrides(self, fpath): self.assertEqual(config["__role"], "master") self.assertEqual(config["cachedir"], cachedir) - @with_tempfile() - def test_minion_config_empty_grains_reverts_to_default(self, fpath): - """ - If 'grains:' is uncommented in the minion config file with no - grains defined, opts['grains'] will be None. This should be - reverted to the default empty dict instead of being left as - None, which previously caused a crash in the loader when it - tried to build the NamespacedDictWrapper for __grains__. - See issue #61321. - """ - with salt.utils.files.fopen(fpath, "w") as wfh: - wfh.write("root_dir: /\nkey_logfile: key\ngrains:\n") - with self.assertLogs("salt.config", level="WARNING") as cm: - config = salt.config.minion_config(fpath) - self.assertEqual(config["grains"], {}) - self.assertTrue( - any("grains" in msg and "empty" in msg for msg in cm.output), - cm.output, - ) - class APIConfigTestCase(DefaultConfigsBase, TestCase): """ From 480f0f565c8f99825c1a5d3f45b42f6a275400e6 Mon Sep 17 00:00:00 2001 From: twangboy Date: Thu, 6 Aug 2026 15:14:18 -0600 Subject: [PATCH 253/469] Fix cmd.script bg=True deleting tempfile before child runs Keep the script available for background PowerShell/cmd/POSIX runs via a self-cleaning wrapper, then remove it after exit. Refs #69959 #50273 --- changelog/50273.fixed.md | 1 + changelog/69959.fixed.md | 1 + salt/modules/cmdmod.py | 133 +++++++++++++++++- .../functional/modules/cmd/test_script.py | 63 +++++++++ .../modules/cmd/test_script_powershell.py | 65 +++++++++ tests/pytests/unit/modules/test_cmdmod.py | 64 +++++++++ 6 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 changelog/50273.fixed.md create mode 100644 changelog/69959.fixed.md diff --git a/changelog/50273.fixed.md b/changelog/50273.fixed.md new file mode 100644 index 000000000000..55f21b18309e --- /dev/null +++ b/changelog/50273.fixed.md @@ -0,0 +1 @@ +Fixed ``cmd.script`` with ``bg=True`` deleting the temporary script before the background process could execute it, which caused ``No such file or directory`` on POSIX. Background runs now use a self-cleaning wrapper so the child removes the tempfile after exit. Refs #50273 #69959 diff --git a/changelog/69959.fixed.md b/changelog/69959.fixed.md new file mode 100644 index 000000000000..51ca78974368 --- /dev/null +++ b/changelog/69959.fixed.md @@ -0,0 +1 @@ +Fixed ``cmd.script`` deleting the temporary script before a background (``bg=True``) process could run it. This caused PowerShell ``-File`` "does not exist" errors on Windows and "No such file or directory" on POSIX. Background runs now use a self-cleaning wrapper so the child removes the tempfile after exit. Refs #69959 #50273 diff --git a/salt/modules/cmdmod.py b/salt/modules/cmdmod.py index 5fe699f375f8..cd0e731273f7 100644 --- a/salt/modules/cmdmod.py +++ b/salt/modules/cmdmod.py @@ -289,6 +289,121 @@ def _prep_powershell_cmd(win_shell, cmd, encoded_cmd): return new_cmd +def _ps_single_quote(value): + """Escape a string for use inside a PowerShell single-quoted literal.""" + return str(value).replace("'", "''") + + +def _is_powershell_shell(shell): + """Return True if shell names Windows PowerShell or PowerShell Core.""" + if not shell: + return False + shell_l = str(shell).lower().strip() + return shell_l in ("powershell", "pwsh") or shell_l.endswith( + ("powershell.exe", "pwsh.exe") + ) + + +def _is_cmd_shell(shell): + """Return True if shell names cmd.exe.""" + if not shell: + return False + shell_l = str(shell).lower().strip() + return shell_l in ("cmd", "cmd.exe") or shell_l.endswith("cmd.exe") + + +def _prepare_bg_script(path, args, shell=None, win_cwd=None, cwd=None): + """ + Build a self-cleaning command for ``cmd.script`` when ``bg=True``. + + The parent must not delete ``path`` (or ``win_cwd``) after spawning the + background process. Instead we invoke a wrapper that runs the real script + and removes the tempfile(s) when finished. Refs #69959 #50273. + """ + args = list(args) if args else [] + + if salt.utils.platform.is_windows() and _is_powershell_shell(shell): + wrapper_dir = cwd if cwd else None + wrapper_path = salt.utils.files.mkstemp(dir=wrapper_dir, suffix=".ps1") + script_q = _ps_single_quote(path) + win_cwd_block = "" + if win_cwd: + cwd_q = _ps_single_quote(win_cwd) + win_cwd_block = ( + f" Set-Location $env:TEMP\n" + f" Remove-Item -LiteralPath '{cwd_q}' -Recurse -Force " + f"-ErrorAction SilentlyContinue\n" + ) + content = ( + f"$script = '{script_q}'\n" + "try {\n" + " & $script @args\n" + " exit $LASTEXITCODE\n" + "} finally {\n" + " Remove-Item -LiteralPath $script -Force " + "-ErrorAction SilentlyContinue\n" + " $wrapper = $PSCommandPath\n" + f"{win_cwd_block}" + " Remove-Item -LiteralPath $wrapper -Force " + "-ErrorAction SilentlyContinue\n" + "}\n" + ) + with salt.utils.files.fopen(wrapper_path, "w") as fh_: + fh_.write(content) + log.debug( + "cmd.script: bg=True PowerShell wrapper %s for script %s", + wrapper_path, + path, + ) + return [wrapper_path, *args] + + if salt.utils.platform.is_windows() and ( + _is_cmd_shell(shell) or str(path).lower().endswith((".bat", ".cmd")) + ): + wrapper_dir = cwd if cwd else None + wrapper_path = salt.utils.files.mkstemp(dir=wrapper_dir, suffix=".cmd") + lines = [ + "@echo off", + f'set "SALT_BG_SCRIPT={path}"', + 'call "%SALT_BG_SCRIPT%" %*', + "set SALT_BG_EC=%ERRORLEVEL%", + 'del /f /q "%SALT_BG_SCRIPT%" >nul 2>&1', + ] + if win_cwd: + lines.append(f'set "SALT_BG_CWD={win_cwd}"') + lines.append("cd /d %TEMP%") + lines.append('rd /s /q "%SALT_BG_CWD%" >nul 2>&1') + lines.extend( + [ + 'del /f /q "%~f0" >nul 2>&1', + "exit /b %SALT_BG_EC%", + ] + ) + with salt.utils.files.fopen(wrapper_path, "w") as fh_: + fh_.write("\r\n".join(lines) + "\r\n") + log.debug( + "cmd.script: bg=True cmd wrapper %s for script %s", + wrapper_path, + path, + ) + return [wrapper_path, *args] + + # POSIX: wrap with /bin/sh so shebang scripts still exec directly. + # argv: sh -c BODY salt-cmd-script "$path" "$path" args... + # $1 is the tempfile to remove; "$@" after shift is the real exec argv. + sh_body = 'script="$1"; shift; trap \'rm -f -- "$script"\' EXIT; exec "$@"' + log.debug("cmd.script: bg=True POSIX /bin/sh wrapper for script %s", path) + return [ + "/bin/sh", + "-c", + sh_body, + "salt-cmd-script", + path, + path, + *args, + ] + + def _run( cmd, cwd=None, @@ -3091,7 +3206,12 @@ def _cleanup_tempfile(path): if isinstance(args, str): args = salt.utils.args.shlex_split(args) - new_cmd = [path, *args] if args else [path] + if bg: + new_cmd = _prepare_bg_script( + path, args, shell=shell, win_cwd=cwd if win_cwd else None, cwd=cwd + ) + else: + new_cmd = [path, *args] if args else [path] ret = {} try: @@ -3126,10 +3246,13 @@ def _cleanup_tempfile(path): exc, exc_info_on_loglevel=logging.DEBUG, ) - _cleanup_tempfile(path) - # If a temp working directory was created (Windows), let's remove that - if win_cwd: - _cleanup_tempfile(cwd) + # Background runs own tempfile cleanup via _prepare_bg_script wrappers. + # Deleting here races the child and causes missing-file errors (#69959). + if not bg: + _cleanup_tempfile(path) + # If a temp working directory was created (Windows), let's remove that + if win_cwd: + _cleanup_tempfile(cwd) if hide_output: ret["stdout"] = ret["stderr"] = "" diff --git a/tests/pytests/functional/modules/cmd/test_script.py b/tests/pytests/functional/modules/cmd/test_script.py index b8e1d0a48f91..9594bd3074b0 100644 --- a/tests/pytests/functional/modules/cmd/test_script.py +++ b/tests/pytests/functional/modules/cmd/test_script.py @@ -1,5 +1,7 @@ +import os import shlex import stat +import time from textwrap import dedent import pytest @@ -323,3 +325,64 @@ def test_script_pipe_spaces_runas(modules, pipe_script_with_space_runas, account password=account.password, ) assert result["stdout"] == "1" + + +@pytest.fixture +def bg_marker_script(state_tree, tmp_path): + """ + Script that writes a marker file (and its own path) for bg=True tests. + """ + marker = tmp_path / "bg_marker.txt" + if salt.utils.platform.is_windows(): + file_name = "bg_marker.bat" + # %~f0 is the full path to this bat file + contents = dedent( + f"""\ + @echo off + echo bg-ok^|%~f0>"{marker}" + """ + ) + else: + file_name = "bg_marker.sh" + contents = dedent( + f"""\ + #!/bin/sh + printf 'bg-ok|%s\\n' "$0" > "{marker}" + """ + ) + with pytest.helpers.temp_file(file_name, contents, state_tree) as script_path: + if not salt.utils.platform.is_windows(): + script_path.chmod(0o755) + yield file_name, marker + + +def _wait_for_marker(marker_path, timeout=30): + deadline = time.time() + timeout + while time.time() < deadline: + if marker_path.is_file() and marker_path.stat().st_size > 0: + return marker_path.read_text(encoding="utf-8").strip() + time.sleep(0.1) + raise AssertionError(f"Marker file not written within {timeout}s: {marker_path}") + + +def _wait_until_gone(path, timeout=30): + deadline = time.time() + timeout + while time.time() < deadline: + if not os.path.exists(path): + return + time.sleep(0.1) + raise AssertionError(f"Temp path still present after {timeout}s: {path}") + + +def test_script_bg_writes_marker_and_cleans_temp(modules, bg_marker_script): + """ + Regression for #69959 / #50273: cmd.script with bg=True must leave the + tempfile in place until the child runs, then clean it up. + """ + file_name, marker = bg_marker_script + ret = modules.cmd.script(f"salt://{file_name}", bg=True) + assert isinstance(ret["pid"], int) + contents = _wait_for_marker(marker) + payload, script_path = contents.split("|", 1) + assert payload == "bg-ok" + _wait_until_gone(script_path) diff --git a/tests/pytests/functional/modules/cmd/test_script_powershell.py b/tests/pytests/functional/modules/cmd/test_script_powershell.py index 08b792ac5294..89ed37eb0d49 100644 --- a/tests/pytests/functional/modules/cmd/test_script_powershell.py +++ b/tests/pytests/functional/modules/cmd/test_script_powershell.py @@ -1,3 +1,6 @@ +import os +import time +from pathlib import Path from textwrap import dedent import pytest @@ -51,6 +54,26 @@ def echo_script(state_tree): yield exit_code +@pytest.fixture(scope="module") +def marker_script(state_tree): + """ + Write a marker file so bg=True tests can observe that the real script ran. + Also records $PSCommandPath so we can assert tempfile cleanup. + """ + script_contents = dedent( + """\ + param ( + [Parameter(Mandatory=$true)] + [string]$OutFile, + [string]$Payload = "ok" + ) + Set-Content -LiteralPath $OutFile -Value "$Payload|$PSCommandPath" + """ + ) + with pytest.helpers.temp_file("marker.ps1", script_contents, state_tree): + yield + + @pytest.fixture(params=["powershell", "pwsh"]) def shell(request): """ @@ -115,3 +138,45 @@ def test_echo_runas(cmd, shell, account, echo_script, args, expected): assert ret["retcode"] == 0 assert ret["stderr"] == "" assert ret["stdout"] == expected + + +def _wait_for_marker(marker_path, timeout=30): + """Poll until the background script writes the marker file.""" + deadline = time.time() + timeout + while time.time() < deadline: + if marker_path.is_file() and marker_path.stat().st_size > 0: + return marker_path.read_text(encoding="utf-8").strip() + time.sleep(0.1) + raise AssertionError(f"Marker file not written within {timeout}s: {marker_path}") + + +def _wait_until_gone(path, timeout=30): + """Poll until path is removed by the bg self-cleanup wrapper.""" + deadline = time.time() + timeout + while time.time() < deadline: + if not os.path.exists(path): + return + time.sleep(0.1) + raise AssertionError(f"Temp path still present after {timeout}s: {path}") + + +def test_script_bg_writes_marker_and_cleans_temp(cmd, shell, marker_script, tmp_path): + """ + Regression for #69959 / #50273: cmd.script bg=True must not delete the + tempfile before PowerShell can open it, and must still clean up afterward. + """ + marker = tmp_path / "marker.txt" + ret = cmd.script( + "salt://marker.ps1", + args=["-OutFile", str(marker), "-Payload", "bg-ok"], + shell=shell, + saltenv="base", + bg=True, + ) + assert isinstance(ret["pid"], int) + # Background runs do not wait for the process; retcode is not meaningful. + contents = _wait_for_marker(marker) + payload, script_path = contents.split("|", 1) + assert payload == "bg-ok" + assert script_path.lower().endswith(".ps1") + _wait_until_gone(script_path) diff --git a/tests/pytests/unit/modules/test_cmdmod.py b/tests/pytests/unit/modules/test_cmdmod.py index a4eedfb9d80c..ce85c6422675 100644 --- a/tests/pytests/unit/modules/test_cmdmod.py +++ b/tests/pytests/unit/modules/test_cmdmod.py @@ -1497,3 +1497,67 @@ def test_prep_powershell_json(text, expected): """ result = cmdmod._prep_powershell_json(text) assert result == expected + + +def test_ps_single_quote(): + assert cmdmod._ps_single_quote(r"C:\temp\file.ps1") == r"C:\temp\file.ps1" + assert cmdmod._ps_single_quote("O'Brien") == "O''Brien" + + +@pytest.mark.parametrize( + "shell, expected", + [ + ("powershell", True), + ("pwsh", True), + (r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", True), + ("cmd", False), + (None, False), + ], +) +def test_is_powershell_shell(shell, expected): + assert cmdmod._is_powershell_shell(shell) is expected + + +def test_prepare_bg_script_posix(): + path = "/tmp/__salt.tmp.abc123.sh" + ret = cmdmod._prepare_bg_script(path, ["arg1", "arg two"], shell="/bin/sh") + assert ret[0] == "/bin/sh" + assert ret[1] == "-c" + assert "trap" in ret[2] + assert ret[3] == "salt-cmd-script" + assert ret[4] == path + assert ret[5] == path + assert ret[6:] == ["arg1", "arg two"] + + +@pytest.mark.skip_unless_on_windows +def test_prepare_bg_script_powershell(tmp_path): + script = tmp_path / "__salt.tmp.real.ps1" + script.write_text("Write-Output hi\n", encoding="utf-8") + ret = cmdmod._prepare_bg_script( + str(script), ["-OutFile", "x"], shell="powershell", cwd=str(tmp_path) + ) + assert len(ret) == 3 + wrapper = ret[0] + assert wrapper.endswith(".ps1") + assert ret[1:] == ["-OutFile", "x"] + content = open(wrapper, encoding="utf-8").read() + assert str(script) in content + assert "& $script @args" in content + assert "Remove-Item -LiteralPath $script" in content + os.remove(wrapper) + + +@pytest.mark.skip_unless_on_windows +def test_prepare_bg_script_cmd(tmp_path): + script = tmp_path / "__salt.tmp.real.bat" + script.write_text("@echo off\necho hi\n", encoding="utf-8") + ret = cmdmod._prepare_bg_script( + str(script), ["a", "b"], shell="cmd", cwd=str(tmp_path) + ) + assert ret[0].endswith(".cmd") + assert ret[1:] == ["a", "b"] + content = open(ret[0], encoding="utf-8").read() + assert str(script) in content + assert "SALT_BG_SCRIPT" in content + os.remove(ret[0]) From 95f745d164bf61b9565028edff9c9e170d024da4 Mon Sep 17 00:00:00 2001 From: twangboy Date: Thu, 6 Aug 2026 15:30:19 -0600 Subject: [PATCH 254/469] Fix POSIX cmd.script bg wrapper skipping tempfile cleanup Do not exec the real script from the /bin/sh wrapper; exec replaces the shell and skips the EXIT trap, leaving the tempfile behind. Refs #69959 #50273 --- salt/modules/cmdmod.py | 7 ++++--- tests/pytests/unit/modules/test_cmdmod.py | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/salt/modules/cmdmod.py b/salt/modules/cmdmod.py index cd0e731273f7..c69344fd76d7 100644 --- a/salt/modules/cmdmod.py +++ b/salt/modules/cmdmod.py @@ -388,10 +388,11 @@ def _prepare_bg_script(path, args, shell=None, win_cwd=None, cwd=None): ) return [wrapper_path, *args] - # POSIX: wrap with /bin/sh so shebang scripts still exec directly. + # POSIX: wrap with /bin/sh. Do not use exec — replacing the shell would + # skip the EXIT trap and leave the tempfile behind. # argv: sh -c BODY salt-cmd-script "$path" "$path" args... - # $1 is the tempfile to remove; "$@" after shift is the real exec argv. - sh_body = 'script="$1"; shift; trap \'rm -f -- "$script"\' EXIT; exec "$@"' + # $1 is the tempfile to remove; "$@" after shift is the real script argv. + sh_body = 'script="$1"; shift; trap \'rm -f -- "$script"\' EXIT; "$@"' log.debug("cmd.script: bg=True POSIX /bin/sh wrapper for script %s", path) return [ "/bin/sh", diff --git a/tests/pytests/unit/modules/test_cmdmod.py b/tests/pytests/unit/modules/test_cmdmod.py index ce85c6422675..6fc7b5e98ed5 100644 --- a/tests/pytests/unit/modules/test_cmdmod.py +++ b/tests/pytests/unit/modules/test_cmdmod.py @@ -1524,6 +1524,8 @@ def test_prepare_bg_script_posix(): assert ret[0] == "/bin/sh" assert ret[1] == "-c" assert "trap" in ret[2] + # exec would replace /bin/sh and skip the EXIT trap (tempfile leak). + assert "exec" not in ret[2] assert ret[3] == "salt-cmd-script" assert ret[4] == path assert ret[5] == path From ab743c2f6fb9dff1eba1986b04a5dda54a9daaa8 Mon Sep 17 00:00:00 2001 From: twangboy Date: Fri, 7 Aug 2026 09:31:09 -0600 Subject: [PATCH 255/469] Fix some lint Use salt.utils.files.fopen in cmdmod bg wrapper unit tests --- .../functional/modules/cmd/test_script_powershell.py | 1 - tests/pytests/unit/modules/test_cmdmod.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/pytests/functional/modules/cmd/test_script_powershell.py b/tests/pytests/functional/modules/cmd/test_script_powershell.py index 89ed37eb0d49..6332f7db595e 100644 --- a/tests/pytests/functional/modules/cmd/test_script_powershell.py +++ b/tests/pytests/functional/modules/cmd/test_script_powershell.py @@ -1,6 +1,5 @@ import os import time -from pathlib import Path from textwrap import dedent import pytest diff --git a/tests/pytests/unit/modules/test_cmdmod.py b/tests/pytests/unit/modules/test_cmdmod.py index 6fc7b5e98ed5..76cde261e1f7 100644 --- a/tests/pytests/unit/modules/test_cmdmod.py +++ b/tests/pytests/unit/modules/test_cmdmod.py @@ -1543,7 +1543,8 @@ def test_prepare_bg_script_powershell(tmp_path): wrapper = ret[0] assert wrapper.endswith(".ps1") assert ret[1:] == ["-OutFile", "x"] - content = open(wrapper, encoding="utf-8").read() + with salt.utils.files.fopen(wrapper) as fh_: + content = fh_.read() assert str(script) in content assert "& $script @args" in content assert "Remove-Item -LiteralPath $script" in content @@ -1559,7 +1560,8 @@ def test_prepare_bg_script_cmd(tmp_path): ) assert ret[0].endswith(".cmd") assert ret[1:] == ["a", "b"] - content = open(ret[0], encoding="utf-8").read() + with salt.utils.files.fopen(ret[0]) as fh_: + content = fh_.read() assert str(script) in content assert "SALT_BG_SCRIPT" in content os.remove(ret[0]) From 23178f04b8f049e24edfb4153deb82dace1171c9 Mon Sep 17 00:00:00 2001 From: twangboy Date: Mon, 10 Aug 2026 09:49:23 -0600 Subject: [PATCH 256/469] Fix silent truncation of large HTTP downloads in cp/fileclient Tornado's HTTPClient enforces a default max_buffer_size of 100MiB independently of max_body_size. When a server doesn't send a Content-Length header (as some winrepo_ng HTTP servers don't), Salt read the response until the connection closed and silently truncated downloads over 100MiB instead of raising an error. Pass max_buffer_size alongside max_body_size so both track http_max_body. Also harden fileclient.get_url() to compare bytes received against any advertised Content-Length and raise MinionError on mismatch instead of caching a partial file, and fix the requests backend to stream via iter_content() and catch RequestException so connection failures surface as clean errors instead of unhandled exceptions. Fixes #69916 --- changelog/69916.fixed.md | 17 ++ salt/fileclient.py | 28 +++ salt/utils/http.py | 111 ++++++++---- .../test_fileclient_get_url_large_file.py | 167 ++++++++++++++++++ tests/pytests/integration/modules/test_cp.py | 95 ++++++++++ .../unit/fileclient/test_fileclient.py | 29 +++ tests/pytests/unit/utils/test_http.py | 147 +++++++++++++++ 7 files changed, 556 insertions(+), 38 deletions(-) create mode 100644 changelog/69916.fixed.md create mode 100644 tests/pytests/functional/test_fileclient_get_url_large_file.py diff --git a/changelog/69916.fixed.md b/changelog/69916.fixed.md new file mode 100644 index 000000000000..ddc44cf0e4de --- /dev/null +++ b/changelog/69916.fixed.md @@ -0,0 +1,17 @@ +Fixed large HTTP(S) downloads (over 100MiB) via `cp.cache_file`/ +`fileclient.get_url` being silently truncated, which could leave +`winrepo_ng` installers (and other large `salt://`-adjacent HTTP +downloads) incomplete without raising an error. Tornado's HTTPClient +enforces a default `max_buffer_size` of 100MiB independently of +`max_body_size`; when a server doesn't send a `Content-Length` header, +Salt read the response until the connection closed, hitting that limit +and truncating the download. `max_buffer_size` is now passed alongside +`max_body_size` so both track the `http_max_body` option. + +`fileclient.get_url` now also compares the number of bytes received +against any advertised `Content-Length` and raises a clear error +instead of caching a partial file if they don't match, and the +`requests` backend now streams responses via `iter_content` and +catches `requests.exceptions.RequestException`, so a connection +dropped mid-download is reported the same way as other HTTP errors +instead of crashing with an unhandled exception. diff --git a/salt/fileclient.py b/salt/fileclient.py index b466ad7ff1f5..764826ce67b7 100644 --- a/salt/fileclient.py +++ b/salt/fileclient.py @@ -685,7 +685,21 @@ def swift_opt(key, default): # both content encoding and etag are found. write_body = [None, False, None, None] + # Content-Length of the final (non-redirect) response, used to + # detect truncated downloads. See #69916: some HTTP client + # configurations can silently stop reading a streamed response + # partway through without raising an error. + content_length = [None] + bytes_received = [0] + def on_header(hdr): + if write_body[0] and content_length[0] is None: + header_name, _, header_value = hdr.partition(":") + if header_name.strip().lower() == "content-length": + try: + content_length[0] = int(header_value.strip()) + except ValueError: + pass if write_body[1] is not False and ( write_body[2] is None or (use_etag and write_body[3] is None) ): @@ -760,6 +774,7 @@ def on_header(hdr): def on_chunk(chunk): if write_body[0]: + bytes_received[0] += len(chunk) if write_body[2]: chunk = chunk.decode(write_body[2]) result.append(chunk) @@ -774,6 +789,7 @@ def on_chunk(chunk): def on_chunk(chunk): if write_body[0]: + bytes_received[0] += len(chunk) destfp.write(chunk) # ETag is only used for refetch. Cached file and previous ETag @@ -809,6 +825,18 @@ def on_chunk(chunk): raise MinionError( "Error: {} reading {}".format(query["error"], url_data.path) ) + if content_length[0] is not None and bytes_received[0] != content_length[0]: + if not no_cache and destfp is not None: + destfp.close() + destfp = None + with contextlib.suppress(OSError): + os.remove(dest_tmp) + raise MinionError( + "Failed to download {}: expected {} bytes but received " + "{} bytes (truncated download)".format( + url, content_length[0], bytes_received[0] + ) + ) if no_cache: if write_body[2]: return "".join(result) diff --git a/salt/utils/http.py b/salt/utils/http.py index 0636e5c5222e..84dbfb30d09c 100644 --- a/salt/utils/http.py +++ b/salt/utils/http.py @@ -78,6 +78,9 @@ log = logging.getLogger(__name__) USERAGENT = f"Salt/{salt.version.__version__}" +# Chunk size used when streaming a response body through the ``requests`` +# backend (see #69916). +REQUESTS_CHUNK_SIZE = 1024 * 1024 def __decompressContent(coding, pgctnt): @@ -423,44 +426,65 @@ def query( cert, ) - if formdata: - if not formdata_fieldname: - ret["error"] = "formdata_fieldname is required when formdata=True" - log.error(ret["error"]) - return ret - result = sess.request( - method, - url, - params=params, - files={formdata_fieldname: (formdata_filename, io.StringIO(data))}, - **req_kwargs, - ) - else: - result = sess.request(method, url, params=params, data=data, **req_kwargs) - result.raise_for_status() - if stream is True: - # fake a HTTP response header - header_callback(f"HTTP/1.0 {result.status_code} MESSAGE") - # fake streaming the content - streaming_callback(result.content) - return { - "handle": result, - } - - if handle is True: - return { - "handle": result, - "body": result.content, - } + if formdata and not formdata_fieldname: + ret["error"] = "formdata_fieldname is required when formdata=True" + log.error(ret["error"]) + return ret - log.debug( - "Final URL location of Response: %s", sanitize_url(result.url, hide_fields) - ) + try: + if formdata: + result = sess.request( + method, + url, + params=params, + files={formdata_fieldname: (formdata_filename, io.StringIO(data))}, + **req_kwargs, + ) + else: + result = sess.request( + method, url, params=params, data=data, **req_kwargs + ) + result.raise_for_status() + if stream is True: + # fake a HTTP response header + header_callback(f"HTTP/1.0 {result.status_code} MESSAGE") + # Stream the response in chunks instead of buffering the + # entire body in memory at once via result.content, so + # large downloads (e.g. winrepo installers) don't need to + # fit in RAM. See #69916. + for chunk in result.iter_content(chunk_size=REQUESTS_CHUNK_SIZE): + if chunk: + streaming_callback(chunk) + return { + "handle": result, + } + + if handle is True: + return { + "handle": result, + "body": result.content, + } + + log.debug( + "Final URL location of Response: %s", + sanitize_url(result.url, hide_fields), + ) - result_status_code = result.status_code - result_headers = result.headers - result_text = result.content - result_cookies = result.cookies + result_status_code = result.status_code + result_headers = result.headers + result_text = result.content + result_cookies = result.cookies + except requests.exceptions.RequestException as exc: + # Surface connection-level failures (e.g. a server closing the + # connection before delivering the full response, as can + # happen with large downloads) the same way the tornado + # backend surfaces HTTP errors, instead of letting the + # exception propagate unhandled out of http.query(). See + # #69916. + ret["status"] = getattr(getattr(exc, "response", None), "status_code", None) + ret["error"] = str(exc) + log.debug("Cannot perform 'http.query': %s - %s", url_full, ret["error"]) + return ret result_text = _decode_result_text( result_text, backend, decode_body=decode_body, result=result ) @@ -627,11 +651,22 @@ def query( req_kwargs = salt.utils.data.decode(req_kwargs, to_str=True) try: + # < --- START do not merge these settings to other branches START ---> # + # 3006.x uses vendored salt.ext.tornado + a blocking HTTPClient. + # 3007.x+ uses system Tornado + SyncWrapper(AsyncHTTPClient), so + # the equivalent max_buffer_size fix must be applied there + # separately (see #69916). Tornado's IOStream defaults + # max_buffer_size to 100MiB independently of max_body_size; with + # a streaming_callback and no Content-Length response header, + # Tornado silently truncates the download at that limit instead + # of raising an error. Pass max_buffer_size alongside + # max_body_size so both track the http_max_body opt. download_client = ( - HTTPClient(max_body_size=max_body) + HTTPClient(max_body_size=max_body, max_buffer_size=max_body) if supports_max_body_size - else HTTPClient() + else HTTPClient(max_buffer_size=max_body) ) + # < --- END do not merge these settings to other branches END ---> # result = download_client.fetch(url_full, **req_kwargs) except salt.ext.tornado.httpclient.HTTPError as exc: ret["status"] = exc.code diff --git a/tests/pytests/functional/test_fileclient_get_url_large_file.py b/tests/pytests/functional/test_fileclient_get_url_large_file.py new file mode 100644 index 000000000000..0b2bf07e0f96 --- /dev/null +++ b/tests/pytests/functional/test_fileclient_get_url_large_file.py @@ -0,0 +1,167 @@ +""" +Integration-style regression tests for +https://github.com/saltstack/salt/issues/69916. + +Unlike the unit tests in ``tests/pytests/unit/utils/test_http.py`` and +``tests/pytests/unit/fileclient/test_fileclient.py`` (which exercise +``salt.utils.http.query`` and ``salt.fileclient.Client.get_url`` in +isolation, mocking the other), these tests drive +``salt.fileclient.Client.get_url`` end-to-end against a real HTTP +server with nothing mocked, to confirm the whole download pipeline +(fileclient -> salt.utils.http.query -> a real socket -> the minion's +file cache on disk) actually delivers large files intact for both the +``tornado`` and ``requests`` backends. +""" + +import hashlib +import os +import socketserver +import threading +from http.server import BaseHTTPRequestHandler + +import pytest + +import salt.fileclient as fileclient +import salt.utils.files +from salt.exceptions import MinionError + +# This bug (#69916) is specifically about winrepo_ng downloads on +# Windows, so make sure these tests actually run there too. +pytestmark = [pytest.mark.windows_whitelisted] + + +class _CloseWithoutContentLengthHandler(BaseHTTPRequestHandler): + """ + Serves ``body`` with no ``Content-Length`` header, forcing the + client to read until the connection is closed. This is the framing + that triggered Tornado's silent truncation at its default + ``max_buffer_size`` (100MiB) prior to the fix for #69916. + """ + + body = b"" + + def do_GET(self): # noqa: N802 + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() + try: + self.wfile.write(self.body) + except OSError: + # Client gave up; nothing left to do. + pass + + def log_message(self, *args): # pylint: disable=arguments-differ + pass + + +class _TruncatedContentLengthHandler(BaseHTTPRequestHandler): + """ + Advertises the full size of ``body`` via ``Content-Length``, but + only sends half of it before dropping the connection, simulating a + server-side failure partway through a download. + """ + + body = b"" + + def do_GET(self): # noqa: N802 + truncated_at = len(self.body) // 2 + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(self.body))) + self.end_headers() + try: + self.wfile.write(self.body[:truncated_at]) + except OSError: + pass + self.connection.close() + + def log_message(self, *args): # pylint: disable=arguments-differ + pass + + +@pytest.fixture(scope="module") +def large_body(): + # Comfortably larger than Tornado's 100MiB default max_buffer_size + # so a truncation would be reliably detected. Built from a + # repeating, non-constant pattern (rather than all-zero/all-'x' + # bytes) so that a regression which corrupts data without changing + # its length wouldn't slip past a naive size-only check. + pattern = bytes(range(256)) + size = 101 * 1024 * 1024 + reps, remainder = divmod(size, len(pattern)) + return pattern * reps + pattern[:remainder] + + +def _start_server(handler_cls, body): + handler = type("Handler", (handler_cls,), {"body": body}) + httpd = socketserver.TCPServer(("127.0.0.1", 0), handler) + port = httpd.server_address[1] + server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) + server_thread.start() + return httpd, server_thread, f"http://127.0.0.1:{port}/largefile.bin" + + +@pytest.mark.slow_test +@pytest.mark.parametrize("backend", ["tornado", "requests"]) +def test_get_url_large_file_no_content_length_not_truncated( + tmp_path, large_body, backend +): + """ + A minion downloading a large (>100MiB) file from a server that + doesn't send a Content-Length header (as can happen with + winrepo_ng HTTP servers) must receive and cache the entire file + instead of having it silently truncated. + """ + httpd, server_thread, url = _start_server( + _CloseWithoutContentLengthHandler, large_body + ) + try: + dest = str(tmp_path / "downloaded.bin") + client = fileclient.Client( + {"cachedir": str(tmp_path / "cache"), "backend": backend} + ) + + result = client.get_url(url, dest) + + assert result == dest + assert os.path.getsize(dest) == len(large_body) + with salt.utils.files.fopen(dest, "rb") as fp_: + downloaded = fp_.read() + assert ( + hashlib.sha256(downloaded).digest() == hashlib.sha256(large_body).digest() + ) + finally: + httpd.shutdown() + server_thread.join(timeout=5) + + +@pytest.mark.slow_test +@pytest.mark.parametrize("backend", ["tornado", "requests"]) +def test_get_url_truncated_content_length_raises_cleanly(tmp_path, large_body, backend): + """ + If a server advertises a Content-Length but the connection drops + before delivering that many bytes, both backends detect the broken + connection themselves (before get_url's own Content-Length check + ever runs) and get_url must surface that as a clean MinionError + instead of the underlying tornado/requests exception propagating + unhandled -- notably for the ``requests`` backend, which used to + crash here with an unhandled ``ChunkedEncodingError``/ + ``IncompleteRead`` prior to the fix for #69916. Either way, the + partial download must not be promoted to ``dest``. + """ + httpd, server_thread, url = _start_server( + _TruncatedContentLengthHandler, large_body + ) + try: + dest = str(tmp_path / "downloaded.bin") + client = fileclient.Client( + {"cachedir": str(tmp_path / "cache"), "backend": backend} + ) + + with pytest.raises(MinionError): + client.get_url(url, dest) + + assert not os.path.exists(dest) + finally: + httpd.shutdown() + server_thread.join(timeout=5) diff --git a/tests/pytests/integration/modules/test_cp.py b/tests/pytests/integration/modules/test_cp.py index 470ce3e383c7..04a0cdd1ad49 100644 --- a/tests/pytests/integration/modules/test_cp.py +++ b/tests/pytests/integration/modules/test_cp.py @@ -2,6 +2,12 @@ Integration tests for the cp execution module. """ +import hashlib +import os +import socketserver +import threading +from http.server import BaseHTTPRequestHandler + import pytest import salt.utils.files @@ -48,3 +54,92 @@ def test_get_template_with_imported_context( with salt.utils.files.fopen(str(dest), "r") as fp_: rendered = salt.utils.stringutils.to_unicode(fp_.read()) assert "bar" in rendered + + +# Comfortably larger than Tornado's 100MiB (104857600 byte) default +# max_buffer_size, so a truncated download would be reliably detected. +_LARGE_DOWNLOAD_SIZE = 101 * 1024 * 1024 +# Deterministic, cheap-to-regenerate payload so the expected hash can be +# computed independently of the server that streams it. +_LARGE_DOWNLOAD_PATTERN = bytes(range(256)) + + +def _expected_large_download_sha256(): + digest = hashlib.sha256() + remaining = _LARGE_DOWNLOAD_SIZE + while remaining: + chunk = _LARGE_DOWNLOAD_PATTERN[: min(len(_LARGE_DOWNLOAD_PATTERN), remaining)] + digest.update(chunk) + remaining -= len(chunk) + return digest.hexdigest() + + +class _NoContentLengthHandler(BaseHTTPRequestHandler): + """ + Serves ``_LARGE_DOWNLOAD_SIZE`` bytes of a deterministic pattern with no + ``Content-Length`` header, forcing the client to read until the + connection is closed, same as e.g. a winrepo installer served without + that header. + """ + + def do_GET(self): # noqa: N802 + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() + remaining = _LARGE_DOWNLOAD_SIZE + while remaining: + chunk = _LARGE_DOWNLOAD_PATTERN[ + : min(len(_LARGE_DOWNLOAD_PATTERN), remaining) + ] + try: + self.wfile.write(chunk) + except OSError: + return + remaining -= len(chunk) + + def log_message(self, *args): # pylint: disable=arguments-differ + pass + + +@pytest.fixture +def no_content_length_webserver(): + httpd = socketserver.TCPServer(("127.0.0.1", 0), _NoContentLengthHandler) + port = httpd.server_address[1] + server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) + server_thread.start() + try: + yield f"http://127.0.0.1:{port}/large-file" + finally: + httpd.shutdown() + server_thread.join(timeout=5) + + +@pytest.mark.slow_test +@pytest.mark.windows_whitelisted +def test_cache_file_large_http_download_without_content_length_not_truncated( + salt_call_cli, no_content_length_webserver +): + """ + Regression test for https://github.com/saltstack/salt/issues/69916 + + ``cp.cache_file`` (used by ``win_pkg`` to download winrepo installers, + among others) must not silently truncate downloads at Tornado's 100MiB + default ``max_buffer_size`` when the server doesn't send a + ``Content-Length`` header. + """ + ret = salt_call_cli.run("cp.cache_file", no_content_length_webserver, _timeout=120) + assert ret.returncode == 0, ret + cached_path = ret.data + assert cached_path, ret + + assert os.path.getsize(cached_path) == _LARGE_DOWNLOAD_SIZE, ( + f"Expected {_LARGE_DOWNLOAD_SIZE} bytes but got " + f"{os.path.getsize(cached_path)}; the download was truncated " + "(see #69916)" + ) + + digest = hashlib.sha256() + with salt.utils.files.fopen(cached_path, "rb") as fp_: + for chunk in iter(lambda: fp_.read(1024 * 1024), b""): + digest.update(chunk) + assert digest.hexdigest() == _expected_large_download_sha256() diff --git a/tests/pytests/unit/fileclient/test_fileclient.py b/tests/pytests/unit/fileclient/test_fileclient.py index ac0181437f9d..a2b909675cd0 100644 --- a/tests/pytests/unit/fileclient/test_fileclient.py +++ b/tests/pytests/unit/fileclient/test_fileclient.py @@ -10,6 +10,7 @@ import salt.utils.files from salt import fileclient +from salt.exceptions import MinionError from tests.support.mock import AsyncMock, MagicMock, Mock, patch log = logging.getLogger(__name__) @@ -265,6 +266,34 @@ def test_setstate(file_client, mocked_opts): assert file_client.opts == mocked_opts +def test_get_url_raises_on_truncated_content_length(tmp_path): + """ + Regression test for https://github.com/saltstack/salt/issues/69916 + + If the server advertises a ``Content-Length`` but delivers fewer bytes + than that, ``get_url`` must not silently write the truncated data to + the minion's file cache; it should clean up the partial download and + raise a clear error instead. + """ + dest = os.path.join(tmp_path, "downloaded_file") + + def fake_query(url, stream, streaming_callback, header_callback, **kwargs): + header_callback("HTTP/1.1 200 OK") + header_callback("Content-Length: 1000") + header_callback("") + streaming_callback(b"x" * 500) + return {"handle": object()} + + client = fileclient.Client({"cachedir": str(tmp_path)}) + + with patch("salt.utils.http.query", side_effect=fake_query): + with pytest.raises(MinionError, match="truncated"): + client.get_url("http://example.com/file", dest) + + assert not os.path.exists(dest) + assert not os.path.exists(f"{dest}.part") + + def test_get_url_with_hash(client_opts): """ Test get_url function with a URL containing a hash character. diff --git a/tests/pytests/unit/utils/test_http.py b/tests/pytests/unit/utils/test_http.py index 62a169ff9a47..8b695429b9e6 100644 --- a/tests/pytests/unit/utils/test_http.py +++ b/tests/pytests/unit/utils/test_http.py @@ -1,4 +1,7 @@ +import socketserver +import threading import urllib +from http.server import BaseHTTPRequestHandler import pytest import requests @@ -189,6 +192,150 @@ def test_query_tornado_httperror_no_response(): assert "body" not in ret +class _CloseWithoutContentLengthHandler(BaseHTTPRequestHandler): + """ + Serves a response of a given size with no Content-Length header, + forcing the client to read until the connection is closed. + """ + + response_size = 0 + + def do_GET(self): # noqa: N802 + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() + remaining = self.response_size + chunk = b"x" * (1024 * 1024) + while remaining: + to_write = chunk[: min(len(chunk), remaining)] + try: + self.wfile.write(to_write) + except OSError: + # Client gave up (e.g. it hit a read buffer limit); nothing + # left to do but stop serving this request. + return + remaining -= len(to_write) + + def log_message(self, *args): # pylint: disable=arguments-differ + pass + + +@pytest.mark.slow_test +def test_query_tornado_no_content_length_large_body_not_truncated(): + """ + Regression test for https://github.com/saltstack/salt/issues/69916 + + Tornado's ``SimpleAsyncHTTPClient`` enforces a default ``max_buffer_size`` + of 100MiB independently of ``max_body_size``/``http_max_body``. When a + server does not send a ``Content-Length`` header (forcing Salt to read + until the connection closes), downloads larger than 100MiB were silently + truncated at ~100MiB instead of completing or raising an error. + """ + # Comfortably larger than Tornado's 100MiB (104857600 byte) default + # max_buffer_size, so a truncation would be reliably detected. + response_size = 101 * 1024 * 1024 + + handler = type( + "Handler", + (_CloseWithoutContentLengthHandler,), + {"response_size": response_size}, + ) + httpd = socketserver.TCPServer(("127.0.0.1", 0), handler) + port = httpd.server_address[1] + server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) + server_thread.start() + + received = [] + + def on_chunk(chunk): + received.append(chunk) + + try: + ret = http.query( + f"http://127.0.0.1:{port}/", + backend="tornado", + stream=True, + streaming_callback=on_chunk, + opts={}, + ) + finally: + httpd.shutdown() + server_thread.join(timeout=5) + + assert "error" not in ret, ret.get("error") + total_received = sum(len(chunk) for chunk in received) + assert total_received == response_size, ( + f"Expected {response_size} bytes but received {total_received}; " + "the download was truncated (see #69916)" + ) + + +class _TruncatedContentLengthHandler(BaseHTTPRequestHandler): + """ + Serves half of the advertised Content-Length and then drops the + connection, simulating a mid-download failure. + """ + + body = b"" + + def do_GET(self): # noqa: N802 + truncated_at = len(self.body) // 2 + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(self.body))) + self.end_headers() + self.wfile.write(self.body[:truncated_at]) + self.connection.close() + + def log_message(self, *args): # pylint: disable=arguments-differ + pass + + +def test_query_requests_connection_error_returns_error_dict(): + """ + Regression test for https://github.com/saltstack/salt/issues/69916 + + The ``requests`` backend is the documented workaround for the Tornado + ``max_buffer_size`` truncation bug, but unlike the ``tornado`` backend it + did not catch connection errors: when a server closed the connection + before delivering the full ``Content-Length``, the underlying + ``requests`` exception propagated unhandled out of ``http.query()`` + instead of being reported the same way the ``tornado`` backend reports + HTTP errors (via the returned dict's ``error``/``status`` keys). + """ + handler = type( + "Handler", (_TruncatedContentLengthHandler,), {"body": b"x" * (256 * 1024)} + ) + httpd = socketserver.TCPServer(("127.0.0.1", 0), handler) + port = httpd.server_address[1] + server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) + server_thread.start() + + received = [] + + def on_chunk(chunk): + received.append(chunk) + + def on_header(hdr): + pass + + try: + ret = http.query( + f"http://127.0.0.1:{port}/", + backend="requests", + stream=True, + streaming_callback=on_chunk, + header_callback=on_header, + opts={}, + ) + finally: + httpd.shutdown() + server_thread.join(timeout=5) + + assert "handle" not in ret + assert ret.get("error") + + def test_parse_cookie_header(): header = "; ".join( [ From 54e478d21332cc2a62d99dc8ca9e69dda858079b Mon Sep 17 00:00:00 2001 From: twangboy Date: Mon, 3 Aug 2026 16:31:11 -0600 Subject: [PATCH 257/469] Reset event-bus pusher on failed fire_event send (#69914) SaltEvent.fire_event() re-raised send failures without resetting self.pusher/self.cpush, so once an MWorker's IPC pusher stream broke (e.g. a stale epoll fd after EventPublisher restarts), every subsequent job return on that worker hit the same exception forever, silently dropping the return before it reached the job cache and burning memory/CPU on repeated thread+IOLoop churn. Close the pusher on failure, mirroring the existing reconnect pattern on the subscribe side, so the next fire_event() call reconnects instead. --- changelog/69914.fixed.md | 1 + salt/utils/event.py | 1 + tests/pytests/unit/utils/event/test_event.py | 16 ++++++++++++++++ 3 files changed, 18 insertions(+) create mode 100644 changelog/69914.fixed.md diff --git a/changelog/69914.fixed.md b/changelog/69914.fixed.md new file mode 100644 index 000000000000..8d6d76334e45 --- /dev/null +++ b/changelog/69914.fixed.md @@ -0,0 +1 @@ +Fixed the master event bus keeping a broken pusher connection after a failed send, which caused every subsequent job return on that worker to fail and silently drop the job return instead of reconnecting. diff --git a/salt/utils/event.py b/salt/utils/event.py index 93a3d0518ea0..956caacbfcf7 100644 --- a/salt/utils/event.py +++ b/salt/utils/event.py @@ -887,6 +887,7 @@ def fire_event(self, data, tag, timeout=1000): exc, exc_info_on_loglevel=logging.DEBUG, ) + self.close_pull() raise else: self.io_loop.spawn_callback(self.pusher.send, msg) diff --git a/tests/pytests/unit/utils/event/test_event.py b/tests/pytests/unit/utils/event/test_event.py index 3b3c2944bde6..e7f2ce97a887 100644 --- a/tests/pytests/unit/utils/event/test_event.py +++ b/tests/pytests/unit/utils/event/test_event.py @@ -333,6 +333,22 @@ def test_connect_pull_should_error_log_on_other_errors(error): ) +def test_fire_event_closes_pusher_on_send_failure(): + """ + A failed pusher.send() must drop the broken pusher (close_pull) so the + next fire_event() reconnects instead of hammering the same dead stream + forever. See https://github.com/saltstack/salt/issues/69914 + """ + event = SaltEvent(node=None) + with patch.object(event, "pusher") as mock_pusher: + event.cpush = True + mock_pusher.send.side_effect = FileNotFoundError(2, "No such file or directory") + with pytest.raises(FileNotFoundError): + event.fire_event({"data": "foo1"}, "evt1") + assert event.cpush is False + assert event.pusher is None + + @pytest.mark.slow_test def test_master_pub_permissions(sock_dir): with eventpublisher_process(str(sock_dir)): From 0563a552975f4cbc05228f41d0754f30cbe5eb0d Mon Sep 17 00:00:00 2001 From: twangboy Date: Mon, 3 Aug 2026 21:46:22 -0600 Subject: [PATCH 258/469] Add functional test for event-bus pusher recovery (#69914) dwoz requested a functional/integration test on PR #69937 since the existing unit test only exercised a mocked pusher. Add a test that spins up a real EventPublisher and a real SaltEvent pusher, fakes a send() failure at the IPCMessageClient boundary to reproduce the reported FileNotFoundError deterministically, and asserts the pusher is dropped and a subsequent fire_event() reconnects and actually delivers the event to a live listener. --- .../functional/master/test_event_publisher.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/pytests/functional/master/test_event_publisher.py b/tests/pytests/functional/master/test_event_publisher.py index ba3f30a4d7ac..e27f01ca5de3 100644 --- a/tests/pytests/functional/master/test_event_publisher.py +++ b/tests/pytests/functional/master/test_event_publisher.py @@ -8,6 +8,7 @@ import salt.config import salt.utils.event +from tests.support.mock import patch log = logging.getLogger() # __name__) @@ -154,6 +155,57 @@ def listeners(opts, stop_event): thread.join() +def test_fire_event_recovers_after_pusher_send_failure(publisher, opts): + """ + Regression test for https://github.com/saltstack/salt/issues/69914 + + A failed ``pusher.send()`` (e.g. the ``FileNotFoundError`` raised by a + stale IPC stream after ``EventPublisher`` is restarted) must not + permanently wedge the event bus for the lifetime of the ``SaltEvent`` + instance. Before the fix, every subsequent ``fire_event()`` call kept + hitting the same dead pusher forever; after the fix, the broken pusher + is dropped and the next call transparently reconnects and delivers. + + This exercises the real ``SyncWrapper`` + threaded ``IOLoop`` + + ``IPCMessageClient`` stack used by master worker processes (only the + innermost ``send()`` call is faked, to deterministically reproduce the + failure without racing the actual epoll bug), against a real, running + ``EventPublisher`` process. + """ + event = salt.utils.event.get_event("master", opts=opts, listen=False) + try: + # Establish a real, connected pusher against the live EventPublisher. + assert event.fire_event({"data": "foo1"}, "evt1") is True + assert event.cpush is True + + # Simulate the real-world failure: the underlying IPCMessageClient's + # send() raises inside the SyncWrapper's worker thread. + with patch.object( + event.pusher.obj, + "send", + side_effect=FileNotFoundError(2, "No such file or directory"), + ): + with pytest.raises(FileNotFoundError): + event.fire_event({"data": "foo2"}, "evt2") + + # The broken pusher must be dropped, not reused. + assert event.cpush is False + assert event.pusher is None + + # The next fire_event() call must reconnect and actually deliver, + # instead of raising the same exception forever. + listener = salt.utils.event.get_event("master", opts=opts, listen=True) + try: + assert event.fire_event({"data": "foo3"}, "evt3") is True + evt = listener.get_event(tag="evt3", wait=10, match_type="startswith") + assert evt is not None + assert evt["data"] == "foo3" + finally: + listener.destroy() + finally: + event.destroy() + + def test_publisher_mem(publisher, publish, listeners, stop_event): """ Test event publisher memory consumption. From e8c94d7b995bc24e2043d5f8ebf7e4e69992391e Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 11 Jul 2026 20:09:44 -0400 Subject: [PATCH 259/469] Give each deltaproxy sub-proxy its own schedule and beacon storage (#65088) subproxy_post_master_init builds each sub-proxy's opts with a shallow opts.copy(), so proxyopts["schedule"] and proxyopts["beacons"] were the same dict objects as the control minion's. The schedule and beacon helpers mutate those dicts in place -- Schedule.add_job does opts["schedule"].update(...) -- so every sub-proxy's add_job("__proxy_keepalive", ...) overwrote the same key in the one shared dict. Only the last sub-proxy kept a keepalive job, so only one of N sub-proxies got a __proxy_keepalive (the reported symptom); per sub-proxy beacons collided the same way. Give each sub-proxy its own schedule and beacon storage. Their jobs and beacons come from their own pillar plus the per-sub-proxy keepalive added below, so they were never meant to share the control minion's dicts (the sharing was an accident of the shallow copy). --- changelog/65088.fixed.md | 1 + salt/metaproxy/deltaproxy.py | 13 ++++ .../pytests/unit/metaproxy/test_deltaproxy.py | 71 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 changelog/65088.fixed.md diff --git a/changelog/65088.fixed.md b/changelog/65088.fixed.md new file mode 100644 index 000000000000..7417043f70f5 --- /dev/null +++ b/changelog/65088.fixed.md @@ -0,0 +1 @@ +Fixed deltaproxy sub-proxies sharing the control minion's ``schedule`` and ``beacons`` dicts. ``subproxy_post_master_init`` builds each sub-proxy's opts with a shallow ``opts.copy()``, so every sub-proxy's ``opts["schedule"]`` (and ``opts["beacons"]``) was the same dict object as the control minion's. The schedule/beacon helpers mutate those dicts in place, so each sub-proxy's ``add_job("__proxy_keepalive", ...)`` overwrote the same key and only one of N sub-proxies kept a keepalive job (per-sub-proxy beacons collided the same way). Each sub-proxy now gets its own schedule and beacon storage. diff --git a/salt/metaproxy/deltaproxy.py b/salt/metaproxy/deltaproxy.py index 7cae077faf67..e30d4fb13cb9 100644 --- a/salt/metaproxy/deltaproxy.py +++ b/salt/metaproxy/deltaproxy.py @@ -425,6 +425,19 @@ def subproxy_post_master_init(minion_id, uid, opts, main_proxy, main_utils): ) proxyopts.update({"id": minion_id, "proxyid": minion_id, "subproxy": True}) + # ``opts.copy()`` above is a shallow copy, so ``proxyopts["schedule"]`` and + # ``proxyopts["beacons"]`` are still the control minion's dicts, shared by + # reference with every sub-proxy. The schedule/beacon management helpers + # mutate those dicts in place (e.g. ``Schedule.add_job`` does + # ``opts["schedule"].update(...)``), so each sub-proxy's + # ``add_job("__proxy_keepalive", ...)`` overwrites the same key and only the + # last sub-proxy keeps a keepalive job (#65088); per-sub-proxy beacons + # collide the same way. Give each sub-proxy its own storage. Their jobs and + # beacons come from their own pillar plus the per-sub-proxy keepalive added + # below. + proxyopts["schedule"] = {} + proxyopts["beacons"] = {} + proxy_context = {"proxy_id": minion_id} # We need grains first to be able to load pillar, which is where we keep the proxy diff --git a/tests/pytests/unit/metaproxy/test_deltaproxy.py b/tests/pytests/unit/metaproxy/test_deltaproxy.py index 96efc80fda9b..5893279896ca 100644 --- a/tests/pytests/unit/metaproxy/test_deltaproxy.py +++ b/tests/pytests/unit/metaproxy/test_deltaproxy.py @@ -211,3 +211,74 @@ def test_subproxy_post_master_init_packs_per_minion_grains( # control proxy stores the right grains in ``self.deltaproxy_opts``. assert result1["proxy_opts"]["grains"]["serial_number"] == "SN-AAA-001" assert result2["proxy_opts"]["grains"]["serial_number"] == "SN-BBB-002" + + +def test_subproxy_post_master_init_isolates_schedule( + proxy_opts, fake_main_proxy, fake_main_utils +): + """ + Regression test for #65088. + + ``subproxy_post_master_init`` builds each sub-proxy's opts with + ``opts.copy()`` -- a shallow copy -- so every sub-proxy would otherwise + share the control minion's ``opts["schedule"]`` (and ``opts["beacons"]``) + dict by reference. Each sub-proxy's ``add_job("__proxy_keepalive", ...)`` + writes the same key into that one dict, so only the last sub-proxy keeps a + keepalive job and only one of N sub-proxies gets a keepalive; per-sub-proxy + beacons collide the same way. Each sub-proxy must instead get its own + schedule and beacon storage. + """ + # Simulate the control minion already having populated schedule/beacon + # dicts, as it does by the time sub-proxies are set up. + shared_control_schedule = {"__mine_interval": {"function": "mine.update"}} + shared_control_beacons = {"__control_beacon": [{"interval": 60}]} + proxy_opts["schedule"] = shared_control_schedule + proxy_opts["beacons"] = shared_control_beacons + + per_minion_grains = {"minion1": {"id": "minion1"}, "minion2": {"id": "minion2"}} + p = _make_subproxy_patches(per_minion_grains) + + with patch.object( + deltaproxy.salt.config, "proxy_config", p["proxy_config"] + ), patch.object( + deltaproxy.salt.pillar, "get_pillar", p["get_pillar"] + ), patch.object( + deltaproxy.salt.loader, "grains", p["grains"] + ), patch.object( + deltaproxy.salt.loader, "proxy", p["proxy_loader"] + ), patch.object( + deltaproxy.salt.loader, "utils", p["utils_loader"] + ), patch.object( + deltaproxy, "ProxyMinion", p["proxy_minion_cls"] + ), patch.object( + deltaproxy.salt.minion, "get_proc_dir", p["get_proc_dir"] + ), patch.object( + deltaproxy.salt.utils.schedule, "Schedule", p["schedule"] + ): + result1 = deltaproxy.subproxy_post_master_init( + "minion1", 0, proxy_opts, fake_main_proxy, fake_main_utils + ) + result2 = deltaproxy.subproxy_post_master_init( + "minion2", 0, proxy_opts, fake_main_proxy, fake_main_utils + ) + + sched1 = result1["proxy_opts"]["schedule"] + sched2 = result2["proxy_opts"]["schedule"] + beac1 = result1["proxy_opts"]["beacons"] + beac2 = result2["proxy_opts"]["beacons"] + + # Each sub-proxy gets its own schedule dict -- not the control minion's and + # not each other's. Without the fix all three are the same object, so a + # per-sub-proxy ``add_job("__proxy_keepalive")`` collides on the one key. + assert sched1 is not shared_control_schedule + assert sched2 is not shared_control_schedule + assert sched1 is not sched2 + + # ...and its own beacon dict, which the shallow copy shared the same way. + assert beac1 is not shared_control_beacons + assert beac2 is not shared_control_beacons + assert beac1 is not beac2 + + # The control minion's own schedule/beacons must be left untouched. + assert shared_control_schedule == {"__mine_interval": {"function": "mine.update"}} + assert shared_control_beacons == {"__control_beacon": [{"interval": 60}]} From 92e71e7152a4e9b2b149e631aa1a4ad8796cc3b5 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Tue, 30 Jun 2026 20:51:32 -0400 Subject: [PATCH 260/469] test: migrate rh_ip unit tests from TestCase to pytest Move tests/unit/modules/test_rh_ip.py to tests/pytests/unit/modules/test_rh_ip.py, converting the legacy unittest.TestCase style (LoaderModuleMockMixin, self.assertX) to modern pytest (configure_loader_modules fixture, plain asserts, pytest.raises). Faithful style-only migration: all 26 tests preserved and passing; the legacy file (which no longer runs under the current pytest/py3.12 test env) is removed. This lets the rh_ip test suite run in the maintained pytests tree, ahead of adding NetworkManager-aware provider selection for #54791. --- tests/pytests/unit/modules/test_rh_ip.py | 916 +++++++++++++++++++++++ tests/unit/modules/test_rh_ip.py | 912 ---------------------- 2 files changed, 916 insertions(+), 912 deletions(-) create mode 100644 tests/pytests/unit/modules/test_rh_ip.py delete mode 100644 tests/unit/modules/test_rh_ip.py diff --git a/tests/pytests/unit/modules/test_rh_ip.py b/tests/pytests/unit/modules/test_rh_ip.py new file mode 100644 index 000000000000..7dc458eccd2f --- /dev/null +++ b/tests/pytests/unit/modules/test_rh_ip.py @@ -0,0 +1,916 @@ +""" + :codeauthor: Jayesh Kariya +""" + +import copy +import os + +import jinja2.exceptions +import pytest + +import salt.modules.rh_ip as rh_ip +import salt.modules.systemd_service as service_mod +from tests.support.mock import MagicMock, create_autospec, patch + + +@pytest.fixture +def configure_loader_modules(): + return {rh_ip: {"__grains__": {"os": "CentOS"}}} + + +def _check_common_opts_bond(lines): + """ + Reduce code duplication by making sure that the expected options are + present in the config file. Note that this assumes that duplex="full" + was passed in the kwargs. If it wasn't, then there would be no + ETHTOOL_OPTS line. + """ + assert 'DEVICE="bond0"' in lines + assert 'ETHTOOL_OPTS="duplex full"' in lines + assert 'NM_CONTROLLED="no"' in lines + assert 'ONBOOT="yes"' in lines + assert 'TYPE="Bond"' in lines + assert 'USERCTL="no"' in lines + + +def _validate_miimon_downdelay(kwargs): + """ + Validate that downdelay that is not a multiple of miimon raises an error + """ + # Make copy of kwargs so we don't modify what was passed in + kwargs = copy.copy(kwargs) + + # Remove miimon and downdelay (if present) to test invalid input + for key in ("miimon", "downdelay"): + kwargs.pop(key, None) + + kwargs["miimon"] = 100 + kwargs["downdelay"] = 201 + try: + rh_ip.build_interface( + "bond0", + "bond", + enabled=True, + **kwargs, + ) + except AttributeError as exc: + assert "multiple of miimon" in str(exc) + else: + raise Exception("AttributeError was not raised") + + +def _validate_miimon_conf(kwargs, required=True): + """ + Validate miimon configuration + """ + # Make copy of kwargs so we don't modify what was passed in + kwargs = copy.copy(kwargs) + + # Remove miimon and downdelay (if present) to test invalid input + for key in ("miimon", "downdelay"): + kwargs.pop(key, None) + + if required: + # Leaving out miimon should raise an error + try: + rh_ip.build_interface( + "bond0", + "bond", + enabled=True, + **kwargs, + ) + except AttributeError as exc: + assert "miimon" in str(exc) + else: + raise Exception("AttributeError was not raised") + + _validate_miimon_downdelay(kwargs) + + +def _get_bonding_opts(kwargs): + results = rh_ip.build_interface( + "bond0", + "bond", + enabled=True, + **kwargs, + ) + _check_common_opts_bond(results) + + for line in results: + if line.startswith("BONDING_OPTS="): + return sorted(line.split("=", 1)[-1].strip('"').split()) + raise Exception("BONDING_OPTS not found") + + +def _test_mode_0_or_2(mode_num=0): + """ + Modes 0 and 2 share the majority of code, with mode 2 being a superset + of mode 0. This function will do the proper asserts for the common code + in these two modes. + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + } + + if mode_num == 0: + modes = ("balance-rr", mode_num, str(mode_num)) + else: + modes = ("balance-xor", mode_num, str(mode_num)) + + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} + ): + for mode in modes: + kwargs["mode"] = mode + # Remove all miimon/arp settings to test invalid config + for key in ( + "miimon", + "downdelay", + "arp_interval", + "arp_ip_targets", + ): + kwargs.pop(key, None) + + # Check that invalid downdelay is handled correctly + _validate_miimon_downdelay(kwargs) + + # Leaving out miimon and arp_interval should raise an error + try: + bonding_opts = _get_bonding_opts(kwargs) + except AttributeError as exc: + assert "miimon or arp_interval" in str(exc) + else: + raise Exception("AttributeError was not raised") + + kwargs["miimon"] = 100 + kwargs["downdelay"] = 200 + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + f"mode={mode_num}", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + # Add arp settings, and test again + kwargs["arp_interval"] = 300 + kwargs["arp_ip_target"] = ["1.2.3.4", "5.6.7.8"] + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "arp_interval=300", + "arp_ip_target=1.2.3.4,5.6.7.8", + "downdelay=200", + "miimon=100", + f"mode={mode_num}", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + # Remove miimon and downdelay and test again + del kwargs["miimon"] + del kwargs["downdelay"] + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "arp_interval=300", + "arp_ip_target=1.2.3.4,5.6.7.8", + f"mode={mode_num}", + ] + assert bonding_opts == expected, bonding_opts + + +def test_error_message_iface_should_process_non_str_expected(): + values = [1, True, False, "no-kaboom"] + iface = "ethtest" + option = "test" + msg = rh_ip._error_msg_iface(iface, option, values) + assert msg.endswith("[1|True|False|no-kaboom]"), msg + + +def test_error_message_network_should_process_non_str_expected(): + values = [1, True, False, "no-kaboom"] + msg = rh_ip._error_msg_network("fnord", values) + assert msg.endswith("[1|True|False|no-kaboom]"), msg + + +def test_build_interface(): + """ + Test to build an interface script for a network interface. + """ + with patch.dict(rh_ip.__grains__, {"os": "Fedora", "osmajorrelease": 26}): + with patch.object(rh_ip, "_raise_error_iface", return_value=None): + with pytest.raises(AttributeError): + rh_ip.build_interface("iface", "slave", True) + + with patch.dict( + rh_ip.__salt__, {"network.interfaces": lambda: {"eth": True}} + ): + with pytest.raises(AttributeError): + rh_ip.build_interface( + "iface", + "eth", + True, + netmask="255.255.255.255", + prefix=32, + test=True, + ) + with pytest.raises(AttributeError): + rh_ip.build_interface( + "iface", + "eth", + True, + ipaddrs=["A"], + test=True, + ) + with pytest.raises(AttributeError): + rh_ip.build_interface( + "iface", + "eth", + True, + ipv6addrs=["A"], + test=True, + ) + + for osrelease in range(7, 8): + with patch.dict( + rh_ip.__grains__, + {"os": "RedHat", "osrelease": str(osrelease)}, + ): + with patch.object(rh_ip, "_raise_error_iface", return_value=None): + with patch.object(rh_ip, "_parse_settings_bond", MagicMock()): + mock = jinja2.exceptions.TemplateNotFound("foo") + with patch.object( + jinja2.Environment, + "get_template", + MagicMock(side_effect=mock), + ): + assert rh_ip.build_interface("iface", "vlan", True) == "" + + with patch.object(rh_ip, "_read_temp", return_value="A"): + with patch.object( + jinja2.Environment, "get_template", MagicMock() + ): + assert ( + rh_ip.build_interface("iface", "vlan", True, test="A") + == "A" + ) + + with patch.object( + rh_ip, "_write_file_iface", return_value=None + ): + with patch.object(os.path, "join", return_value="A"): + with patch.object( + rh_ip, "_read_file", return_value="A" + ): + assert ( + rh_ip.build_interface("iface", "vlan", True) + == "A" + ) + if osrelease > 6: + with patch.dict( + rh_ip.__salt__, + { + "network.interfaces": lambda: { + "eth": True + } + }, + ): + assert ( + rh_ip.build_interface( + "iface", + "eth", + True, + ipaddrs=["127.0.0.1/8"], + ) + == "A" + ) + assert ( + rh_ip.build_interface( + "iface", + "eth", + True, + ipv6addrs=["fc00::1/128"], + ) + == "A" + ) + + +def test_build_routes(): + """ + Test to build a route script for a network interface. + """ + with patch.dict(rh_ip.__grains__, {"osrelease": "5.0"}): + with patch.object(rh_ip, "_parse_routes", MagicMock()): + mock = jinja2.exceptions.TemplateNotFound("foo") + with patch.object( + jinja2.Environment, "get_template", MagicMock(side_effect=mock) + ): + assert rh_ip.build_routes("iface") == "" + + with patch.object(jinja2.Environment, "get_template", MagicMock()): + with patch.object(rh_ip, "_read_temp", return_value=["A"]): + assert rh_ip.build_routes("i", test="t") == ["A", "A"] + + with patch.object(rh_ip, "_read_file", return_value=["A"]): + with patch.object(os.path, "join", return_value="A"): + with patch.object( + rh_ip, "_write_file_iface", return_value=None + ): + assert rh_ip.build_routes("i", test=None) == ["A", "A"] + + +def test_down(): + """ + Test to shutdown a network interface + """ + with patch.dict(rh_ip.__salt__, {"cmd.run": MagicMock(return_value="A")}): + assert rh_ip.down("iface", "iface_type") == "A" + + assert rh_ip.down("iface", "slave") is None + + +def test_get_interface(): + """ + Test to return the contents of an interface script + """ + with patch.object(os.path, "join", return_value="A"): + with patch.object(rh_ip, "_read_file", return_value="A"): + assert rh_ip.get_interface("iface") == "A" + + +def test__parse_settings_eth_hwaddr_and_macaddr(): + """ + Test that an AttributeError is thrown when hwaddr and macaddr are + passed together. They cannot be used together + """ + opts = {"hwaddr": 1, "macaddr": 2} + + with pytest.raises(AttributeError): + rh_ip._parse_settings_eth( + opts=opts, + iface_type="eth", + enabled=True, + iface="eth0", + ) + + +def test__parse_settings_eth_hwaddr(): + """ + Make sure hwaddr gets added when parsing opts + """ + opts = {"hwaddr": "AA:BB:CC:11:22:33"} + with patch.dict(rh_ip.__salt__, {"network.interfaces": MagicMock()}): + results = rh_ip._parse_settings_eth( + opts=opts, iface_type="eth", enabled=True, iface="eth0" + ) + assert "hwaddr" in results + assert results["hwaddr"] == opts["hwaddr"] + + +def test__parse_settings_eth_macaddr(): + """ + Make sure macaddr gets added when parsing opts + """ + opts = {"macaddr": "AA:BB:CC:11:22:33"} + with patch.dict(rh_ip.__salt__, {"network.interfaces": MagicMock()}): + results = rh_ip._parse_settings_eth( + opts=opts, iface_type="eth", enabled=True, iface="eth0" + ) + assert "macaddr" in results + assert results["macaddr"] == opts["macaddr"] + + +def test__parse_settings_eth_ethtool_channels(): + """ + Make sure channels gets added when parsing opts + """ + opts = {"channels": {"rx": 4, "tx": 4, "combined": 4, "other": 4}} + with patch.dict(rh_ip.__grains__, {"num_cpus": 4}), patch.dict( + rh_ip.__salt__, {"network.interfaces": MagicMock()} + ): + results = rh_ip._parse_settings_eth( + opts=opts, iface_type="eth", enabled=True, iface="eth0" + ) + assert "ethtool" in results + assert results["ethtool"] == "-L eth0 rx 4 tx 4 other 4 combined 4" + + +def test_up(): + """ + Test to start up a network interface + """ + with patch.dict(rh_ip.__salt__, {"cmd.run": MagicMock(return_value="A")}): + assert rh_ip.up("iface", "iface_type") == "A" + + assert rh_ip.up("iface", "slave") is None + + +def test_get_routes(): + """ + Test to return the contents of the interface routes script. + """ + with patch.object(os.path, "join", return_value="A"): + with patch.object(rh_ip, "_read_file", return_value=["A"]): + assert rh_ip.get_routes("iface") == ["A", "A"] + + +def test_get_network_settings(): + """ + Test to return the contents of the global network script. + """ + with patch.object(rh_ip, "_read_file", return_value="A"): + assert rh_ip.get_network_settings() == "A" + + +def test_apply_network_settings(): + """ + Test to apply global network configuration. + """ + # This should be pytest.mark.parametrize, when this gets ported to + # pytest approach. This is just following previous patterns here. + # Edge cases are 7 & 8 + mock_service = create_autospec(service_mod.restart, return_value=True) + for majorrelease, expected_service_name in ( + (3, "network"), + (7, "network"), + (8, "NetworkManager"), + (42, "NetworkManager"), + ): + with patch.dict(rh_ip.__salt__, {"service.restart": mock_service}), patch.dict( + rh_ip.__grains__, + {"osmajorrelease": majorrelease}, + ): + assert rh_ip.apply_network_settings() + mock_service.assert_called_with(expected_service_name) + + +def test_build_network_settings(): + """ + Test to build the global network script. + """ + with patch.object(rh_ip, "_parse_rh_config", MagicMock()): + with patch.object(rh_ip, "_parse_network_settings", MagicMock()): + + mock = jinja2.exceptions.TemplateNotFound("foo") + with patch.object( + jinja2.Environment, "get_template", MagicMock(side_effect=mock) + ): + assert rh_ip.build_network_settings() == "" + + with patch.object(jinja2.Environment, "get_template", MagicMock()): + with patch.object(rh_ip, "_read_temp", return_value="A"): + assert rh_ip.build_network_settings(test="t") == "A" + + with patch.object(rh_ip, "_write_file_network", return_value=None): + with patch.object(rh_ip, "_read_file", return_value="A"): + assert rh_ip.build_network_settings(test=None) == "A" + + +def test_build_interface_teamport(): + """ + Test that teamport interfaces are properly built + """ + ifaces = MagicMock(return_value={"eth1": {"hwaddr": "02:42:ac:11:00:02"}}) + dunder_salt = {"network.interfaces": ifaces} + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} + ), patch.dict(rh_ip.__salt__, dunder_salt): + ret = sorted( + rh_ip.build_interface( + "eth1", + "teamport", + enabled=True, + test=True, + team_port_config={"prio": 100}, + team_master="team0", + ) + ) + + expected = [ + 'DEVICE="eth1"', + 'DEVICETYPE="TeamPort"', + 'HWADDR="02:42:ac:11:00:02"', + 'NM_CONTROLLED="no"', + 'ONBOOT="yes"', + 'TEAM_MASTER="team0"', + "TEAM_PORT_CONFIG='{\"prio\": 100}'", + 'USERCTL="no"', + ] + assert ret == expected, ret + + +def test_build_interface_team(): + """ + Test that team interfaces are properly built + """ + dunder_salt = {"pkg.version": MagicMock(return_value="1.29-1.el7")} + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} + ), patch.dict(rh_ip.__salt__, dunder_salt): + ret = sorted( + rh_ip.build_interface( + "team0", + "team", + enabled=True, + test=True, + ipaddr="1.2.3.4", + team_config={"foo": "bar"}, + ) + ) + + expected = [ + 'DEVICE="team0"', + 'DEVICETYPE="Team"', + 'IPADDR="1.2.3.4"', + 'NM_CONTROLLED="no"', + 'ONBOOT="yes"', + 'TEAM_CONFIG=\'{"foo": "bar"}\'', + 'USERCTL="no"', + ] + assert ret == expected + + +def test_build_interface_bond_mode_0(): + """ + Test that mode 0 bond interfaces are properly built + """ + _test_mode_0_or_2(0) + + +def test_build_interface_bond_mode_1(): + """ + Test that mode 1 bond interfaces are properly built + """ + kwargs = { + "test": True, + "mode": "active-backup", + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} + ): + for mode in ("active-backup", 1, "1"): + kwargs.pop("primary", None) + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=1", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + # Add a "primary" option and confirm that it shows up in + # the bonding opts. + kwargs["primary"] = "foo" + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=1", + "primary=foo", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_2(): + """ + Test that mode 2 bond interfaces are properly built + """ + _test_mode_0_or_2(2) + + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} + ): + for mode in ("balance-xor", 2, "2"): + # Using an invalid hashing algorithm should cause an error + # to be raised. + kwargs["mode"] = mode + kwargs["hashing-algorithm"] = "layer42" + try: + bonding_opts = _get_bonding_opts(kwargs) + except AttributeError as exc: + assert "hashing-algorithm" in str(exc) + else: + raise Exception("AttributeError was not raised") + + # Correct the hashing algorithm and re-run + kwargs["hashing-algorithm"] = "layer2" + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=2", + "use_carrier=0", + "xmit_hash_policy=layer2", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_3(): + """ + Test that mode 3 bond interfaces are properly built + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} + ): + for mode in ("broadcast", 3, "3"): + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=3", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_4_xmit(): + """ + Test that mode 4 bond interfaces are properly built + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + valid_lacp_rate = ("fast", "slow", "1", "0") + valid_ad_select = ("0",) + + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, + { + "osmajorrelease": version, + "osrelease": str(version), + "os_family": "RedHat", + }, + ): + for mode in ("802.3ad", 4, "4"): + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + + for version in range(7, 8): + with patch.dict(rh_ip.__grains__, {"osmajorrelease": version}): + # Using an invalid hashing algorithm should cause an error + # to be raised. + kwargs["hashing-algorithm"] = "layer42" + try: + bonding_opts = _get_bonding_opts(kwargs) + except AttributeError as exc: + assert "hashing-algorithm" in str(exc) + else: + raise Exception("AttributeError was not raised") + + hash_alg = "vlan+srcmac" + if version == 7: + # Using an invalid hashing algorithm should cause an error + # to be raised. + kwargs["hashing-algorithm"] = hash_alg + try: + bonding_opts = _get_bonding_opts(kwargs) + except AttributeError as exc: + assert "hashing-algorithm" in str(exc) + else: + raise Exception("AttributeError was not raised") + else: + # Correct the hashing algorithm and re-run + kwargs["hashing-algorithm"] = hash_alg + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "ad_select=0", + "downdelay=200", + "lacp_rate=0", + "miimon=100", + "mode=4", + "use_carrier=0", + f"xmit_hash_policy={hash_alg}", + ] + assert bonding_opts == expected, bonding_opts + + for hash_alg in [ + "layer2", + "layer2+3", + "layer3+4", + "encap2+3", + "encap3+4", + ]: + # Correct the hashing algorithm and re-run + kwargs["hashing-algorithm"] = hash_alg + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "ad_select=0", + "downdelay=200", + "lacp_rate=0", + "miimon=100", + "mode=4", + "use_carrier=0", + f"xmit_hash_policy={hash_alg}", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_4_lacp(): + """ + Test that mode 4 bond interfaces are properly built + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + valid_lacp_rate = ("fast", "slow", "1", "0") + valid_ad_select = ("0",) + + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} + ): + for mode in ("802.3ad", 4, "4"): + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + + for lacp_rate in valid_lacp_rate + ("2", "speedy"): + for ad_select in valid_ad_select + ("foo",): + kwargs["lacp_rate"] = lacp_rate + kwargs["ad_select"] = ad_select + try: + bonding_opts = _get_bonding_opts(kwargs) + except AttributeError as exc: + error = str(exc) + # Re-raise the exception only if it was + # unexpected. It should not be expected when + # the lacp_rate or ad_select is valid. + if "lacp_rate" in error: + if lacp_rate in valid_lacp_rate: + raise + elif "ad_select" in error: + if ad_select in valid_ad_select: + raise + else: + raise + else: + expected = [ + f"ad_select={ad_select}", + "downdelay=200", + "lacp_rate={}".format( + "1" + if lacp_rate == "fast" + else "0" if lacp_rate == "slow" else lacp_rate + ), + "miimon=100", + "mode=4", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_5(): + """ + Test that mode 5 bond interfaces are properly built + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} + ): + for mode in ("balance-tlb", 5, "5"): + kwargs.pop("primary", None) + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=5", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + # Add a "primary" option and confirm that it shows up in + # the bonding opts. + kwargs["primary"] = "foo" + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=5", + "primary=foo", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_6(): + """ + Test that mode 6 bond interfaces are properly built + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} + ): + for mode in ("balance-alb", 6, "6"): + kwargs.pop("primary", None) + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=6", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + # Add a "primary" option and confirm that it shows up in + # the bonding opts. + kwargs["primary"] = "foo" + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=6", + "primary=foo", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_slave(): + """ + Test that bond slave interfaces are properly built + """ + for version in range(7, 8): + with patch.dict( + rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} + ): + results = sorted( + rh_ip.build_interface( + "eth1", + "slave", + enabled=True, + test=True, + master="bond0", + ) + ) + expected = [ + 'BOOTPROTO="none"', + 'DEVICE="eth1"', + 'MASTER="bond0"', + 'NM_CONTROLLED="no"', + 'ONBOOT="yes"', + 'SLAVE="yes"', + 'USERCTL="no"', + ] + assert results == expected, results diff --git a/tests/unit/modules/test_rh_ip.py b/tests/unit/modules/test_rh_ip.py deleted file mode 100644 index b868144fb916..000000000000 --- a/tests/unit/modules/test_rh_ip.py +++ /dev/null @@ -1,912 +0,0 @@ -""" - :codeauthor: Jayesh Kariya -""" - -import copy -import os - -import jinja2.exceptions - -import salt.modules.rh_ip as rh_ip -import salt.modules.systemd_service as service_mod -from tests.support.mixins import LoaderModuleMockMixin -from tests.support.mock import MagicMock, create_autospec, patch -from tests.support.unit import TestCase - - -class RhipTestCase(TestCase, LoaderModuleMockMixin): - """ - Test cases for salt.modules.rh_ip - """ - - def setup_loader_modules(self): - return {rh_ip: {"__grains__": {"os": "CentOS"}}} - - def test_error_message_iface_should_process_non_str_expected(self): - values = [1, True, False, "no-kaboom"] - iface = "ethtest" - option = "test" - msg = rh_ip._error_msg_iface(iface, option, values) - self.assertTrue(msg.endswith("[1|True|False|no-kaboom]"), msg) - - def test_error_message_network_should_process_non_str_expected(self): - values = [1, True, False, "no-kaboom"] - msg = rh_ip._error_msg_network("fnord", values) - self.assertTrue(msg.endswith("[1|True|False|no-kaboom]"), msg) - - def test_build_interface(self): - """ - Test to build an interface script for a network interface. - """ - with patch.dict(rh_ip.__grains__, {"os": "Fedora", "osmajorrelease": 26}): - with patch.object(rh_ip, "_raise_error_iface", return_value=None): - self.assertRaises( - AttributeError, rh_ip.build_interface, "iface", "slave", True - ) - - with patch.dict( - rh_ip.__salt__, {"network.interfaces": lambda: {"eth": True}} - ): - self.assertRaises( - AttributeError, - rh_ip.build_interface, - "iface", - "eth", - True, - netmask="255.255.255.255", - prefix=32, - test=True, - ) - self.assertRaises( - AttributeError, - rh_ip.build_interface, - "iface", - "eth", - True, - ipaddrs=["A"], - test=True, - ) - self.assertRaises( - AttributeError, - rh_ip.build_interface, - "iface", - "eth", - True, - ipv6addrs=["A"], - test=True, - ) - - for osrelease in range(7, 8): - with patch.dict( - rh_ip.__grains__, - {"os": "RedHat", "osrelease": str(osrelease)}, - ): - with patch.object(rh_ip, "_raise_error_iface", return_value=None): - with patch.object(rh_ip, "_parse_settings_bond", MagicMock()): - mock = jinja2.exceptions.TemplateNotFound("foo") - with patch.object( - jinja2.Environment, - "get_template", - MagicMock(side_effect=mock), - ): - self.assertEqual( - rh_ip.build_interface("iface", "vlan", True), "" - ) - - with patch.object(rh_ip, "_read_temp", return_value="A"): - with patch.object( - jinja2.Environment, "get_template", MagicMock() - ): - self.assertEqual( - rh_ip.build_interface( - "iface", "vlan", True, test="A" - ), - "A", - ) - - with patch.object( - rh_ip, "_write_file_iface", return_value=None - ): - with patch.object( - os.path, "join", return_value="A" - ): - with patch.object( - rh_ip, "_read_file", return_value="A" - ): - self.assertEqual( - rh_ip.build_interface( - "iface", "vlan", True - ), - "A", - ) - if osrelease > 6: - with patch.dict( - rh_ip.__salt__, - { - "network.interfaces": lambda: { - "eth": True - } - }, - ): - self.assertEqual( - rh_ip.build_interface( - "iface", - "eth", - True, - ipaddrs=["127.0.0.1/8"], - ), - "A", - ) - self.assertEqual( - rh_ip.build_interface( - "iface", - "eth", - True, - ipv6addrs=["fc00::1/128"], - ), - "A", - ) - - def test_build_routes(self): - """ - Test to build a route script for a network interface. - """ - with patch.dict(rh_ip.__grains__, {"osrelease": "5.0"}): - with patch.object(rh_ip, "_parse_routes", MagicMock()): - mock = jinja2.exceptions.TemplateNotFound("foo") - with patch.object( - jinja2.Environment, "get_template", MagicMock(side_effect=mock) - ): - self.assertEqual(rh_ip.build_routes("iface"), "") - - with patch.object(jinja2.Environment, "get_template", MagicMock()): - with patch.object(rh_ip, "_read_temp", return_value=["A"]): - self.assertEqual(rh_ip.build_routes("i", test="t"), ["A", "A"]) - - with patch.object(rh_ip, "_read_file", return_value=["A"]): - with patch.object(os.path, "join", return_value="A"): - with patch.object( - rh_ip, "_write_file_iface", return_value=None - ): - self.assertEqual( - rh_ip.build_routes("i", test=None), ["A", "A"] - ) - - def test_down(self): - """ - Test to shutdown a network interface - """ - with patch.dict(rh_ip.__salt__, {"cmd.run": MagicMock(return_value="A")}): - self.assertEqual(rh_ip.down("iface", "iface_type"), "A") - - self.assertEqual(rh_ip.down("iface", "slave"), None) - - def test_get_interface(self): - """ - Test to return the contents of an interface script - """ - with patch.object(os.path, "join", return_value="A"): - with patch.object(rh_ip, "_read_file", return_value="A"): - self.assertEqual(rh_ip.get_interface("iface"), "A") - - def test__parse_settings_eth_hwaddr_and_macaddr(self): - """ - Test that an AttributeError is thrown when hwaddr and macaddr are - passed together. They cannot be used together - """ - opts = {"hwaddr": 1, "macaddr": 2} - - self.assertRaises( - AttributeError, - rh_ip._parse_settings_eth, - opts=opts, - iface_type="eth", - enabled=True, - iface="eth0", - ) - - def test__parse_settings_eth_hwaddr(self): - """ - Make sure hwaddr gets added when parsing opts - """ - opts = {"hwaddr": "AA:BB:CC:11:22:33"} - with patch.dict(rh_ip.__salt__, {"network.interfaces": MagicMock()}): - results = rh_ip._parse_settings_eth( - opts=opts, iface_type="eth", enabled=True, iface="eth0" - ) - self.assertIn("hwaddr", results) - self.assertEqual(results["hwaddr"], opts["hwaddr"]) - - def test__parse_settings_eth_macaddr(self): - """ - Make sure macaddr gets added when parsing opts - """ - opts = {"macaddr": "AA:BB:CC:11:22:33"} - with patch.dict(rh_ip.__salt__, {"network.interfaces": MagicMock()}): - results = rh_ip._parse_settings_eth( - opts=opts, iface_type="eth", enabled=True, iface="eth0" - ) - self.assertIn("macaddr", results) - self.assertEqual(results["macaddr"], opts["macaddr"]) - - def test__parse_settings_eth_ethtool_channels(self): - """ - Make sure channels gets added when parsing opts - """ - opts = {"channels": {"rx": 4, "tx": 4, "combined": 4, "other": 4}} - with patch.dict(rh_ip.__grains__, {"num_cpus": 4}), patch.dict( - rh_ip.__salt__, {"network.interfaces": MagicMock()} - ): - results = rh_ip._parse_settings_eth( - opts=opts, iface_type="eth", enabled=True, iface="eth0" - ) - self.assertIn("ethtool", results) - self.assertEqual(results["ethtool"], "-L eth0 rx 4 tx 4 other 4 combined 4") - - def test_up(self): - """ - Test to start up a network interface - """ - with patch.dict(rh_ip.__salt__, {"cmd.run": MagicMock(return_value="A")}): - self.assertEqual(rh_ip.up("iface", "iface_type"), "A") - - self.assertEqual(rh_ip.up("iface", "slave"), None) - - def test_get_routes(self): - """ - Test to return the contents of the interface routes script. - """ - with patch.object(os.path, "join", return_value="A"): - with patch.object(rh_ip, "_read_file", return_value=["A"]): - self.assertEqual(rh_ip.get_routes("iface"), ["A", "A"]) - - def test_get_network_settings(self): - """ - Test to return the contents of the global network script. - """ - with patch.object(rh_ip, "_read_file", return_value="A"): - self.assertEqual(rh_ip.get_network_settings(), "A") - - def test_apply_network_settings(self): - """ - Test to apply global network configuration. - """ - # This should be pytest.mark.parametrize, when this gets ported to - # pytest approach. This is just following previous patterns here. - # Edge cases are 7 & 8 - mock_service = create_autospec(service_mod.restart, return_value=True) - for majorrelease, expected_service_name in ( - (3, "network"), - (7, "network"), - (8, "NetworkManager"), - (42, "NetworkManager"), - ): - with patch.dict( - rh_ip.__salt__, {"service.restart": mock_service} - ), patch.dict( - rh_ip.__grains__, - {"osmajorrelease": majorrelease}, - ): - self.assertTrue(rh_ip.apply_network_settings()) - mock_service.assert_called_with(expected_service_name) - - def test_build_network_settings(self): - """ - Test to build the global network script. - """ - with patch.object(rh_ip, "_parse_rh_config", MagicMock()): - with patch.object(rh_ip, "_parse_network_settings", MagicMock()): - - mock = jinja2.exceptions.TemplateNotFound("foo") - with patch.object( - jinja2.Environment, "get_template", MagicMock(side_effect=mock) - ): - self.assertEqual(rh_ip.build_network_settings(), "") - - with patch.object(jinja2.Environment, "get_template", MagicMock()): - with patch.object(rh_ip, "_read_temp", return_value="A"): - self.assertEqual(rh_ip.build_network_settings(test="t"), "A") - - with patch.object( - rh_ip, "_write_file_network", return_value=None - ): - with patch.object(rh_ip, "_read_file", return_value="A"): - self.assertEqual( - rh_ip.build_network_settings(test=None), "A" - ) - - def test_build_interface_teamport(self): - """ - Test that teamport interfaces are properly built - """ - ifaces = MagicMock(return_value={"eth1": {"hwaddr": "02:42:ac:11:00:02"}}) - dunder_salt = {"network.interfaces": ifaces} - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} - ), patch.dict(rh_ip.__salt__, dunder_salt): - ret = sorted( - rh_ip.build_interface( - "eth1", - "teamport", - enabled=True, - test=True, - team_port_config={"prio": 100}, - team_master="team0", - ) - ) - - expected = [ - 'DEVICE="eth1"', - 'DEVICETYPE="TeamPort"', - 'HWADDR="02:42:ac:11:00:02"', - 'NM_CONTROLLED="no"', - 'ONBOOT="yes"', - 'TEAM_MASTER="team0"', - "TEAM_PORT_CONFIG='{\"prio\": 100}'", - 'USERCTL="no"', - ] - assert ret == expected, ret - - def test_build_interface_team(self): - """ - Test that team interfaces are properly built - """ - dunder_salt = {"pkg.version": MagicMock(return_value="1.29-1.el7")} - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} - ), patch.dict(rh_ip.__salt__, dunder_salt): - ret = sorted( - rh_ip.build_interface( - "team0", - "team", - enabled=True, - test=True, - ipaddr="1.2.3.4", - team_config={"foo": "bar"}, - ) - ) - - expected = [ - 'DEVICE="team0"', - 'DEVICETYPE="Team"', - 'IPADDR="1.2.3.4"', - 'NM_CONTROLLED="no"', - 'ONBOOT="yes"', - 'TEAM_CONFIG=\'{"foo": "bar"}\'', - 'USERCTL="no"', - ] - assert ret == expected - - @staticmethod - def _check_common_opts_bond(lines): - """ - Reduce code duplication by making sure that the expected options are - present in the config file. Note that this assumes that duplex="full" - was passed in the kwargs. If it wasn't, then there would be no - ETHTOOL_OPTS line. - """ - assert 'DEVICE="bond0"' in lines - assert 'ETHTOOL_OPTS="duplex full"' in lines - assert 'NM_CONTROLLED="no"' in lines - assert 'ONBOOT="yes"' in lines - assert 'TYPE="Bond"' in lines - assert 'USERCTL="no"' in lines - - def _validate_miimon_downdelay(self, kwargs): - """ - Validate that downdelay that is not a multiple of miimon raises an error - """ - # Make copy of kwargs so we don't modify what was passed in - kwargs = copy.copy(kwargs) - - # Remove miimon and downdelay (if present) to test invalid input - for key in ("miimon", "downdelay"): - kwargs.pop(key, None) - - kwargs["miimon"] = 100 - kwargs["downdelay"] = 201 - try: - rh_ip.build_interface( - "bond0", - "bond", - enabled=True, - **kwargs, - ) - except AttributeError as exc: - assert "multiple of miimon" in str(exc) - else: - raise Exception("AttributeError was not raised") - - def _validate_miimon_conf(self, kwargs, required=True): - """ - Validate miimon configuration - """ - # Make copy of kwargs so we don't modify what was passed in - kwargs = copy.copy(kwargs) - - # Remove miimon and downdelay (if present) to test invalid input - for key in ("miimon", "downdelay"): - kwargs.pop(key, None) - - if required: - # Leaving out miimon should raise an error - try: - rh_ip.build_interface( - "bond0", - "bond", - enabled=True, - **kwargs, - ) - except AttributeError as exc: - assert "miimon" in str(exc) - else: - raise Exception("AttributeError was not raised") - - self._validate_miimon_downdelay(kwargs) - - def _get_bonding_opts(self, kwargs): - results = rh_ip.build_interface( - "bond0", - "bond", - enabled=True, - **kwargs, - ) - self._check_common_opts_bond(results) - - for line in results: - if line.startswith("BONDING_OPTS="): - return sorted(line.split("=", 1)[-1].strip('"').split()) - raise Exception("BONDING_OPTS not found") - - def _test_mode_0_or_2(self, mode_num=0): - """ - Modes 0 and 2 share the majority of code, with mode 2 being a superset - of mode 0. This function will do the proper asserts for the common code - in these two modes. - """ - kwargs = { - "test": True, - "duplex": "full", - "slaves": "eth1 eth2", - } - - if mode_num == 0: - modes = ("balance-rr", mode_num, str(mode_num)) - else: - modes = ("balance-xor", mode_num, str(mode_num)) - - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} - ): - for mode in modes: - kwargs["mode"] = mode - # Remove all miimon/arp settings to test invalid config - for key in ( - "miimon", - "downdelay", - "arp_interval", - "arp_ip_targets", - ): - kwargs.pop(key, None) - - # Check that invalid downdelay is handled correctly - self._validate_miimon_downdelay(kwargs) - - # Leaving out miimon and arp_interval should raise an error - try: - bonding_opts = self._get_bonding_opts(kwargs) - except AttributeError as exc: - assert "miimon or arp_interval" in str(exc) - else: - raise Exception("AttributeError was not raised") - - kwargs["miimon"] = 100 - kwargs["downdelay"] = 200 - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "downdelay=200", - "miimon=100", - f"mode={mode_num}", - "use_carrier=0", - ] - assert bonding_opts == expected, bonding_opts - - # Add arp settings, and test again - kwargs["arp_interval"] = 300 - kwargs["arp_ip_target"] = ["1.2.3.4", "5.6.7.8"] - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "arp_interval=300", - "arp_ip_target=1.2.3.4,5.6.7.8", - "downdelay=200", - "miimon=100", - f"mode={mode_num}", - "use_carrier=0", - ] - assert bonding_opts == expected, bonding_opts - - # Remove miimon and downdelay and test again - del kwargs["miimon"] - del kwargs["downdelay"] - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "arp_interval=300", - "arp_ip_target=1.2.3.4,5.6.7.8", - f"mode={mode_num}", - ] - assert bonding_opts == expected, bonding_opts - - def test_build_interface_bond_mode_0(self): - """ - Test that mode 0 bond interfaces are properly built - """ - self._test_mode_0_or_2(0) - - def test_build_interface_bond_mode_1(self): - """ - Test that mode 1 bond interfaces are properly built - """ - kwargs = { - "test": True, - "mode": "active-backup", - "duplex": "full", - "slaves": "eth1 eth2", - "miimon": 100, - "downdelay": 200, - } - - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} - ): - for mode in ("active-backup", 1, "1"): - kwargs.pop("primary", None) - kwargs["mode"] = mode - self._validate_miimon_conf(kwargs) - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "downdelay=200", - "miimon=100", - "mode=1", - "use_carrier=0", - ] - assert bonding_opts == expected, bonding_opts - - # Add a "primary" option and confirm that it shows up in - # the bonding opts. - kwargs["primary"] = "foo" - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "downdelay=200", - "miimon=100", - "mode=1", - "primary=foo", - "use_carrier=0", - ] - assert bonding_opts == expected, bonding_opts - - def test_build_interface_bond_mode_2(self): - """ - Test that mode 2 bond interfaces are properly built - """ - self._test_mode_0_or_2(2) - - kwargs = { - "test": True, - "duplex": "full", - "slaves": "eth1 eth2", - "miimon": 100, - "downdelay": 200, - } - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} - ): - for mode in ("balance-xor", 2, "2"): - # Using an invalid hashing algorithm should cause an error - # to be raised. - kwargs["mode"] = mode - kwargs["hashing-algorithm"] = "layer42" - try: - bonding_opts = self._get_bonding_opts(kwargs) - except AttributeError as exc: - assert "hashing-algorithm" in str(exc) - else: - raise Exception("AttributeError was not raised") - - # Correct the hashing algorithm and re-run - kwargs["hashing-algorithm"] = "layer2" - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "downdelay=200", - "miimon=100", - "mode=2", - "use_carrier=0", - "xmit_hash_policy=layer2", - ] - assert bonding_opts == expected, bonding_opts - - def test_build_interface_bond_mode_3(self): - """ - Test that mode 3 bond interfaces are properly built - """ - kwargs = { - "test": True, - "duplex": "full", - "slaves": "eth1 eth2", - "miimon": 100, - "downdelay": 200, - } - - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} - ): - for mode in ("broadcast", 3, "3"): - kwargs["mode"] = mode - self._validate_miimon_conf(kwargs) - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "downdelay=200", - "miimon=100", - "mode=3", - "use_carrier=0", - ] - assert bonding_opts == expected, bonding_opts - - def test_build_interface_bond_mode_4_xmit(self): - """ - Test that mode 4 bond interfaces are properly built - """ - kwargs = { - "test": True, - "duplex": "full", - "slaves": "eth1 eth2", - "miimon": 100, - "downdelay": 200, - } - valid_lacp_rate = ("fast", "slow", "1", "0") - valid_ad_select = ("0",) - - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, - { - "osmajorrelease": version, - "osrelease": str(version), - "os_family": "RedHat", - }, - ): - for mode in ("802.3ad", 4, "4"): - kwargs["mode"] = mode - self._validate_miimon_conf(kwargs) - - for version in range(7, 8): - with patch.dict(rh_ip.__grains__, {"osmajorrelease": version}): - # Using an invalid hashing algorithm should cause an error - # to be raised. - kwargs["hashing-algorithm"] = "layer42" - try: - bonding_opts = self._get_bonding_opts(kwargs) - except AttributeError as exc: - assert "hashing-algorithm" in str(exc) - else: - raise Exception("AttributeError was not raised") - - hash_alg = "vlan+srcmac" - if version == 7: - # Using an invalid hashing algorithm should cause an error - # to be raised. - kwargs["hashing-algorithm"] = hash_alg - try: - bonding_opts = self._get_bonding_opts(kwargs) - except AttributeError as exc: - assert "hashing-algorithm" in str(exc) - else: - raise Exception("AttributeError was not raised") - else: - # Correct the hashing algorithm and re-run - kwargs["hashing-algorithm"] = hash_alg - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "ad_select=0", - "downdelay=200", - "lacp_rate=0", - "miimon=100", - "mode=4", - "use_carrier=0", - f"xmit_hash_policy={hash_alg}", - ] - assert bonding_opts == expected, bonding_opts - - for hash_alg in [ - "layer2", - "layer2+3", - "layer3+4", - "encap2+3", - "encap3+4", - ]: - # Correct the hashing algorithm and re-run - kwargs["hashing-algorithm"] = hash_alg - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "ad_select=0", - "downdelay=200", - "lacp_rate=0", - "miimon=100", - "mode=4", - "use_carrier=0", - f"xmit_hash_policy={hash_alg}", - ] - assert bonding_opts == expected, bonding_opts - - def test_build_interface_bond_mode_4_lacp(self): - """ - Test that mode 4 bond interfaces are properly built - """ - kwargs = { - "test": True, - "duplex": "full", - "slaves": "eth1 eth2", - "miimon": 100, - "downdelay": 200, - } - valid_lacp_rate = ("fast", "slow", "1", "0") - valid_ad_select = ("0",) - - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} - ): - for mode in ("802.3ad", 4, "4"): - kwargs["mode"] = mode - self._validate_miimon_conf(kwargs) - - for lacp_rate in valid_lacp_rate + ("2", "speedy"): - for ad_select in valid_ad_select + ("foo",): - kwargs["lacp_rate"] = lacp_rate - kwargs["ad_select"] = ad_select - try: - bonding_opts = self._get_bonding_opts(kwargs) - except AttributeError as exc: - error = str(exc) - # Re-raise the exception only if it was - # unexpected. It should not be expected when - # the lacp_rate or ad_select is valid. - if "lacp_rate" in error: - if lacp_rate in valid_lacp_rate: - raise - elif "ad_select" in error: - if ad_select in valid_ad_select: - raise - else: - raise - else: - expected = [ - f"ad_select={ad_select}", - "downdelay=200", - "lacp_rate={}".format( - "1" - if lacp_rate == "fast" - else "0" if lacp_rate == "slow" else lacp_rate - ), - "miimon=100", - "mode=4", - "use_carrier=0", - ] - assert bonding_opts == expected, bonding_opts - - def test_build_interface_bond_mode_5(self): - """ - Test that mode 5 bond interfaces are properly built - """ - kwargs = { - "test": True, - "duplex": "full", - "slaves": "eth1 eth2", - "miimon": 100, - "downdelay": 200, - } - - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} - ): - for mode in ("balance-tlb", 5, "5"): - kwargs.pop("primary", None) - kwargs["mode"] = mode - self._validate_miimon_conf(kwargs) - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "downdelay=200", - "miimon=100", - "mode=5", - "use_carrier=0", - ] - assert bonding_opts == expected, bonding_opts - - # Add a "primary" option and confirm that it shows up in - # the bonding opts. - kwargs["primary"] = "foo" - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "downdelay=200", - "miimon=100", - "mode=5", - "primary=foo", - "use_carrier=0", - ] - assert bonding_opts == expected, bonding_opts - - def test_build_interface_bond_mode_6(self): - """ - Test that mode 6 bond interfaces are properly built - """ - kwargs = { - "test": True, - "duplex": "full", - "slaves": "eth1 eth2", - "miimon": 100, - "downdelay": 200, - } - - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} - ): - for mode in ("balance-alb", 6, "6"): - kwargs.pop("primary", None) - kwargs["mode"] = mode - self._validate_miimon_conf(kwargs) - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "downdelay=200", - "miimon=100", - "mode=6", - "use_carrier=0", - ] - assert bonding_opts == expected, bonding_opts - - # Add a "primary" option and confirm that it shows up in - # the bonding opts. - kwargs["primary"] = "foo" - bonding_opts = self._get_bonding_opts(kwargs) - expected = [ - "downdelay=200", - "miimon=100", - "mode=6", - "primary=foo", - "use_carrier=0", - ] - assert bonding_opts == expected, bonding_opts - - def test_build_interface_bond_slave(self): - """ - Test that bond slave interfaces are properly built - """ - for version in range(7, 8): - with patch.dict( - rh_ip.__grains__, {"osmajorrelease": version, "osrelease": str(version)} - ): - results = sorted( - rh_ip.build_interface( - "eth1", - "slave", - enabled=True, - test=True, - master="bond0", - ) - ) - expected = [ - 'BOOTPROTO="none"', - 'DEVICE="eth1"', - 'MASTER="bond0"', - 'NM_CONTROLLED="no"', - 'ONBOOT="yes"', - 'SLAVE="yes"', - 'USERCTL="no"', - ] - assert results == expected, results From f55cfe0e4e581ee844fec31bc8a603bccc6bd942 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Tue, 30 Jun 2026 22:39:54 -0400 Subject: [PATCH 261/469] Add nm_ip NetworkManager provider for network.managed (#54791) network.managed has been broken on RedHat-family systems since EL8. The rh_ip provider writes /etc/sysconfig/network-scripts/ifcfg-* files and brings interfaces up with ifup/ifdown from the network-scripts package. That package is not installed by default on EL8+ and is removed entirely on EL10, so the state fails with Unable to run command '['ifdown', 'eth1']' ... No such file or directory: 'ifdown' and configures nothing. Verified on AlmaLinux 8, 9 and 10. nm_ip writes NetworkManager keyfiles under /etc/NetworkManager/system-connections/ and applies them with nmcli, the supported way to manage networking on modern RedHat systems. It supports ethernet (static/dhcp/disabled, dual-stack), bond, vlan and bridge, with bond/bridge members written as their own port keyfiles. A deterministic per-interface connection uuid keeps build_interface output identical to the keyfile NetworkManager reads back, so the state stays idempotent. Provider selection is a single condition both modules test: nmcli present, /run/NetworkManager exists, and no ifup/ifdown on PATH. nm_ip claims the ip virtual when that holds; rh_ip defers to it. Hosts that still have network-scripts installed keep the legacy rh_ip behavior. Also addresses #68252 and #62844, which share this root cause. Validated end to end on AlmaLinux 8, 9 and 10 VMs: nm_ip is selected, network.managed brings the interface up, a second apply is a no-op, and the address persists across reboot. --- changelog/54791.fixed.md | 1 + doc/ref/modules/all/index.rst | 1 + doc/ref/modules/all/salt.modules.nm_ip.rst | 5 + salt/modules/nm_ip.py | 695 +++++++++++++++++++++ salt/modules/rh_ip.py | 38 +- tests/pytests/unit/modules/test_nm_ip.py | 416 ++++++++++++ tests/pytests/unit/modules/test_rh_ip.py | 38 ++ 7 files changed, 1188 insertions(+), 6 deletions(-) create mode 100644 changelog/54791.fixed.md create mode 100644 doc/ref/modules/all/salt.modules.nm_ip.rst create mode 100644 salt/modules/nm_ip.py create mode 100644 tests/pytests/unit/modules/test_nm_ip.py diff --git a/changelog/54791.fixed.md b/changelog/54791.fixed.md new file mode 100644 index 000000000000..1652a1b39395 --- /dev/null +++ b/changelog/54791.fixed.md @@ -0,0 +1 @@ +Added a NetworkManager provider for ``network.managed`` so it works on RedHat-family systems that use NetworkManager (RHEL/CentOS/Alma/Rocky 8+, Fedora). The legacy ``rh_ip`` provider writes ``ifcfg-*`` files and brings interfaces up with ``ifup``/``ifdown`` from the ``network-scripts`` package, which is not installed by default on EL8+ (and removed on EL10), so ``network.managed`` failed with ``No such file or directory: 'ifdown'`` and configured nothing. The new ``nm_ip`` module writes NetworkManager keyfiles under ``/etc/NetworkManager/system-connections/`` and applies them with ``nmcli``. It claims the ``ip`` virtual when NetworkManager is managing the system without the legacy ifup/ifdown tooling, and ``rh_ip`` defers to it in that case (hosts that still have ``network-scripts`` installed keep the legacy behavior). Also addresses #68252 and #62844. diff --git a/doc/ref/modules/all/index.rst b/doc/ref/modules/all/index.rst index e22ebfe30268..ee38d3cd70d1 100644 --- a/doc/ref/modules/all/index.rst +++ b/doc/ref/modules/all/index.rst @@ -329,6 +329,7 @@ execution modules nginx nilrt_ip nix + nm_ip nova npm nspawn diff --git a/doc/ref/modules/all/salt.modules.nm_ip.rst b/doc/ref/modules/all/salt.modules.nm_ip.rst new file mode 100644 index 000000000000..64b37499ecd0 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.nm_ip.rst @@ -0,0 +1,5 @@ +salt.modules.nm_ip +================== + +.. automodule:: salt.modules.nm_ip + :members: diff --git a/salt/modules/nm_ip.py b/salt/modules/nm_ip.py new file mode 100644 index 000000000000..203dbef06969 --- /dev/null +++ b/salt/modules/nm_ip.py @@ -0,0 +1,695 @@ +""" +The networking module for RedHat-family distributions managed by +NetworkManager (RHEL/CentOS/Alma/Rocky 8+, Fedora). + +This is the ``ip`` execution-module provider behind :py:func:`network.managed +` on NetworkManager systems. The legacy +:py:mod:`rh_ip ` provider writes +``/etc/sysconfig/network-scripts/ifcfg-*`` and brings interfaces up with +``ifup``/``ifdown`` from the ``network-scripts`` package. On EL8+ that package +is not installed by default (and is removed entirely on EL10), so ``rh_ip`` +fails with ``No such file or directory: 'ifdown'`` and no interface is +configured -- see issues #54791, #68252 and #62844. + +This provider instead writes NetworkManager keyfiles under +``/etc/NetworkManager/system-connections/`` and applies them with ``nmcli``, +which is the supported way to manage networking on modern RedHat systems. + +.. versionadded:: 3006.27 + +.. note:: + NetworkManager is the source of truth here, so only the subset of the + ``network.managed`` schema that maps cleanly onto NM connection keyfiles is + supported (addresses, gateway, nameservers, mtu, dhcp, bond/vlan/bridge). + ifcfg/ifupdown-only options such as ethtool offload settings and up/down + hook scripts have no keyfile equivalent and raise an informative error + rather than being silently dropped. +""" + +import logging +import os +import uuid + +import salt.utils.files +import salt.utils.path +import salt.utils.stringutils +from salt.exceptions import CommandExecutionError + +try: + import ipaddress +except ImportError: # pragma: no cover + ipaddress = None + +log = logging.getLogger(__name__) + +__virtualname__ = "ip" + +_NM_DIR = "/etc/NetworkManager/system-connections" +# Deterministic namespace so a given interface always maps to the same +# connection uuid; that keeps build_interface output byte-identical to the +# keyfile NetworkManager reads back, so the state's diff is stable/idempotent. +_UUID_NS = uuid.UUID("6f7a2c1e-3b4d-5e6f-8a9b-0c1d2e3f4a5b") + +# network.managed interface type -> NetworkManager connection type. +_NM_TYPE = { + "eth": "ethernet", + "slave": "ethernet", + "bond": "bond", + "vlan": "vlan", + "bridge": "bridge", +} + +# ifcfg/ethtool-era settings with no keyfile equivalent. +_UNSUPPORTED = ( + "up_cmds", + "down_cmds", + "pre_up_cmds", + "post_up_cmds", + "pre_down_cmds", + "post_down_cmds", + "ethtool", +) + +# salt bond option -> NM [bond] key. NM stores bond options with the kernel +# option names, same as the sysfs bonding interface. +_BOND_OPT_MAP = { + "mode": "mode", + "miimon": "miimon", + "lacp_rate": "lacp_rate", + "xmit_hash_policy": "xmit_hash_policy", + "downdelay": "downdelay", + "updelay": "updelay", + "arp_interval": "arp_interval", + "arp_ip_target": "arp_ip_target", + "primary": "primary", + "use_carrier": "use_carrier", +} + +# salt bridge option -> NM [bridge] key. +_BRIDGE_OPT_MAP = { + "fd": "forward-delay", + "forward_delay": "forward-delay", + "ageing": "ageing-time", + "maxage": "max-age", + "hello": "hello-time", + "priority": "priority", +} + + +def __virtual__(): + """ + Confine to RedHat-family systems where NetworkManager is the active network + service and the legacy ``ifup``/``ifdown`` tooling is unavailable. + + That combination is exactly where :py:mod:`rh_ip` breaks, so ``rh_ip`` + defers under the same condition and precisely one provider claims ``ip``. + Hosts that still have ``network-scripts`` installed keep the legacy + ``rh_ip`` behavior untouched. + """ + if __grains__.get("os_family") != "RedHat": + return (False, "nm_ip: only applicable to the RedHat os_family") + if not nm_managed(): + return ( + False, + "nm_ip: NetworkManager is not managing this system, or the legacy " + "ifup/ifdown tooling is present (rh_ip handles it)", + ) + return __virtualname__ + + +def _has_legacy_ifupdown(): + """True if both ``ifup`` and ``ifdown`` are on PATH (network-scripts).""" + return bool(salt.utils.path.which("ifup")) and bool(salt.utils.path.which("ifdown")) + + +def nm_managed(): + """ + Return True if this system is managed by NetworkManager without the legacy + network-scripts tooling: ``nmcli`` is available, NetworkManager is running + (``/run/NetworkManager`` exists) and neither ``ifup`` nor ``ifdown`` is on + PATH. + + This is the deterministic, load-time-safe condition that decides whether + ``nm_ip`` or ``rh_ip`` owns the ``ip`` provider -- both modules test it, so + exactly one claims it and no runtime service call is needed during + ``__virtual__`` resolution. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.nm_managed + """ + return ( + bool(salt.utils.path.which("nmcli")) + and os.path.isdir("/run/NetworkManager") + and not _has_legacy_ifupdown() + ) + + +def _keyfile(iface): + """Path of the salt-managed NM keyfile for ``iface``.""" + return os.path.join(_NM_DIR, f"{iface}.nmconnection") + + +def _conn_uuid(iface): + """Deterministic connection uuid for ``iface``.""" + return str(uuid.uuid5(_UUID_NS, f"salt-{iface}")) + + +def _check_unsupported(settings): + bad = sorted(k for k in _UNSUPPORTED if settings.get(k)) + if bad: + raise CommandExecutionError( + "NetworkManager keyfiles do not support these network.managed " + "options: {}. Manage them outside network.managed on NetworkManager " + "systems.".format(", ".join(bad)) + ) + + +def _listify(value): + if value is None: + return [] + if isinstance(value, (list, tuple)): + return list(value) + # space- or comma-separated string + return [v for v in str(value).replace(",", " ").split() if v] + + +def _as_bool(value): + if isinstance(value, bool): + return value + return str(value).lower() in ("true", "yes", "on", "1") + + +def _to_cidr(addr, netmask): + """Combine an address + dotted/prefix netmask into ``addr/prefix``.""" + if "/" in str(addr): + return str(addr) + if netmask is None: + raise CommandExecutionError(f"No netmask supplied for address {addr}") + if ipaddress is None: + raise CommandExecutionError("ipaddress module unavailable; cannot build CIDR") + try: + return str(ipaddress.ip_interface(f"{addr}/{netmask}").with_prefixlen) + except ValueError as exc: + raise CommandExecutionError(f"Invalid address/netmask {addr}/{netmask}: {exc}") + + +def _ipv4_section(settings): + """Build the ordered ``[ipv4]`` key/value list for the connection.""" + proto = str(settings.get("proto", "")).lower() + addresses = [] + if str(settings.get("ipaddr", "")): + addresses.append(_to_cidr(settings["ipaddr"], settings.get("netmask"))) + for addr in _listify(settings.get("ipaddrs") or settings.get("addresses")): + addresses.append( + addr if "/" in str(addr) else _to_cidr(addr, settings.get("netmask")) + ) + + kvs = [] + if proto in ("dhcp", "dhcp4", "bootp"): + kvs.append(("method", "auto")) + elif addresses: + kvs.append(("method", "manual")) + gateway = settings.get("gateway") + for idx, addr in enumerate(addresses, start=1): + if idx == 1 and gateway: + kvs.append((f"address{idx}", f"{addr},{gateway}")) + else: + kvs.append((f"address{idx}", addr)) + elif proto in ("none", "disabled", "off"): + kvs.append(("method", "disabled")) + else: + # Nothing about IPv4 was specified; leave it on automatic like NM's + # own default so a lone IPv6 config doesn't strand v4. + kvs.append(("method", "auto")) + + dns = _listify(settings.get("dns") or settings.get("nameservers")) + v4dns = [d for d in dns if ":" not in str(d)] + if v4dns: + kvs.append(("dns", ";".join(v4dns) + ";")) + search = _listify(settings.get("dns_search") or settings.get("domain")) + if search: + kvs.append(("dns-search", ";".join(search) + ";")) + return kvs + + +def _ipv6_section(settings): + """Build the ordered ``[ipv6]`` key/value list for the connection.""" + proto = str(settings.get("ipv6proto", "")).lower() + addresses = [] + if str(settings.get("ipv6ipaddr", "")): + addresses.append(_to_cidr(settings["ipv6ipaddr"], settings.get("ipv6netmask"))) + for addr in _listify(settings.get("ipv6addrs")): + addresses.append(addr) + + kvs = [] + if proto in ("disabled", "off", "none"): + kvs.append(("method", "disabled")) + elif proto in ("dhcp", "dhcp6"): + kvs.append(("method", "dhcp")) + elif addresses: + kvs.append(("method", "manual")) + gateway = settings.get("ipv6gateway") + for idx, addr in enumerate(addresses, start=1): + if idx == 1 and gateway: + kvs.append((f"address{idx}", f"{addr},{gateway}")) + else: + kvs.append((f"address{idx}", addr)) + else: + # NM default: SLAAC. Keeps interfaces dual-stack unless told otherwise. + kvs.append(("method", "auto")) + + dns = _listify(settings.get("dns") or settings.get("nameservers")) + v6dns = [d for d in dns if ":" in str(d)] + if v6dns: + kvs.append(("dns", ";".join(v6dns) + ";")) + return kvs + + +def _vlan_id_parent(iface, settings): + """Resolve a vlan's tag id and parent link (parse ``eth0.100`` as fallback).""" + vid = settings.get("vlan_id") or settings.get("id") + parent = ( + settings.get("vlan-raw-device") + or settings.get("vlan_raw_device") + or settings.get("parent") + or settings.get("link") + ) + if (vid is None or parent is None) and "." in iface: + base, _, tag = iface.rpartition(".") + if parent is None: + parent = base + if vid is None and tag.isdigit(): + vid = tag + return vid, parent + + +def _bond_options(settings): + opts = {} + for salt_key, nm_key in _BOND_OPT_MAP.items(): + if settings.get(salt_key) is not None: + opts[nm_key] = settings[salt_key] + return opts + + +def _bridge_options(settings): + kvs = [] + if settings.get("stp") is not None: + kvs.append(("stp", "true" if _as_bool(settings["stp"]) else "false")) + for salt_key, nm_key in _BRIDGE_OPT_MAP.items(): + if settings.get(salt_key) is not None: + kvs.append((nm_key, settings[salt_key])) + return kvs + + +def _member_interfaces(iface, iface_type, settings): + """ + Physical NICs a bond/bridge enslaves. NetworkManager models each as its own + port connection, so build_interface writes one keyfile per member. + """ + itype = iface_type.lower() + if itype == "bond": + return _listify(settings.get("slaves") or settings.get("interfaces")) + if itype == "bridge": + return _listify( + settings.get("ports") + or settings.get("bridge_ports") + or settings.get("interfaces") + ) + return [] + + +def _connection_sections(iface, iface_type, enabled, settings, master=None): + """ + Build the ordered list of ``(section, [(key, value), ...])`` tuples for one + NetworkManager connection keyfile. + + ``master`` (a ``(master_iface, slave_type)`` tuple) marks this connection as + a bond/bridge port: it carries no IP config and is controlled by its master. + """ + _check_unsupported(settings) + itype = iface_type.lower() + nm_type = _NM_TYPE.get(itype) + if nm_type is None: + raise CommandExecutionError( + "nm_ip supports interface types {}; got '{}'".format( + ", ".join(sorted(_NM_TYPE)), iface_type + ) + ) + + conn = [ + ("id", iface), + ("uuid", _conn_uuid(iface)), + ("type", nm_type), + ("interface-name", iface), + ("autoconnect", "true" if enabled else "false"), + ] + + if itype == "slave": + master = master or (settings.get("master"), settings.get("slave_type", "bond")) + + if master and master[0]: + conn.append(("master", master[0])) + conn.append(("slave-type", master[1])) + # A port has no L3 config; the master owns it. + return [("connection", conn)] + + sections = [("connection", conn)] + + # One device section per connection, named after the NM connection type. + # mtu folds into it so a connection never emits a duplicate section. + device_section = nm_type + device_kvs = [] + if itype == "bond": + if "mode" not in settings: + raise CommandExecutionError( + f"Missing required option 'mode' for bond interface '{iface}'" + ) + opts = _bond_options(settings) + device_kvs = [(k, opts[k]) for k in sorted(opts)] + elif itype == "bridge": + device_kvs = _bridge_options(settings) + elif itype == "vlan": + vid, parent = _vlan_id_parent(iface, settings) + if vid is None or not parent: + raise CommandExecutionError( + f"vlan interface '{iface}' needs both a vlan id and a parent " + "(set vlan_id/id and parent, or name it like eth0.100)" + ) + device_kvs = [("id", int(vid)), ("parent", parent)] + + # mtu is a property of the wired (ethernet) setting; NM's bond/bridge/vlan + # settings have no mtu key, so only fold it into an ethernet section. + if settings.get("mtu") and nm_type == "ethernet": + device_kvs.append(("mtu", int(settings["mtu"]))) + + if device_kvs: + sections.append((device_section, device_kvs)) + + sections.append(("ipv4", _ipv4_section(settings))) + sections.append(("ipv6", _ipv6_section(settings))) + return sections + + +def _dump_lines(sections): + """Serialize ordered keyfile sections to a deterministic list of lines.""" + lines = [] + for name, kvs in sections: + lines.append(f"[{name}]\n") + for key, value in kvs: + lines.append(f"{key}={value}\n") + lines.append("\n") + return lines + + +def _write_keyfile(iface, lines): + """Write ``lines`` to ``iface``'s keyfile with the 0600 NM requires.""" + path = _keyfile(iface) + with salt.utils.files.fopen(path, "w") as fp_: + fp_.write(salt.utils.stringutils.to_str("".join(lines))) + try: + os.chmod(path, 0o600) + except OSError: # pragma: no cover + log.debug("Could not chmod %s to 0600", path) + + +def build_interface(iface, iface_type, enabled, **settings): + """ + Build (and, unless ``test=True``, write) the NetworkManager keyfile for a + network interface. Returns the rendered keyfile as a list of lines. + + For bond and bridge interfaces the enslaved members (``slaves`` / ``ports``) + are written out as their own port keyfiles as a side effect. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.build_interface eth0 eth True ipaddr=10.0.0.5 netmask=255.255.255.0 gateway=10.0.0.1 + """ + itype = iface_type.lower() + if itype not in _NM_TYPE: + raise CommandExecutionError( + "nm_ip supports interface types {}; got '{}'".format( + ", ".join(sorted(_NM_TYPE)), iface_type + ) + ) + + sections = _connection_sections(iface, itype, enabled, settings) + lines = _dump_lines(sections) + + if settings.get("test"): + return lines + + _write_keyfile(iface, lines) + + # Write port keyfiles for any enslaved members. slave-type follows the + # master's device type (bond/bridge). + slave_type = "bond" if itype == "bond" else "bridge" + for member in _member_interfaces(iface, itype, settings): + if member == iface: + continue + member_lines = _dump_lines( + _connection_sections( + member, "slave", enabled, {}, master=(iface, slave_type) + ) + ) + _write_keyfile(member, member_lines) + + return lines + + +def get_interface(iface): + """ + Return the salt-managed NetworkManager keyfile for ``iface`` as a list of + lines, or an empty list if salt does not manage it yet. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.get_interface eth0 + """ + path = _keyfile(iface) + if not os.path.isfile(path): + return [] + with salt.utils.files.fopen(path) as fp_: + return [salt.utils.stringutils.to_unicode(line) for line in fp_.readlines()] + + +def build_routes(iface, **settings): + """ + Fold static routes into ``iface``'s salt-managed keyfile as NM + ``routeN=,`` entries in the matching ipv4/ipv6 section. + Returns the rendered route lines. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.build_routes eth0 routes='[{"ipaddr": "10.1.0.0", "netmask": "255.255.0.0", "gateway": "10.0.0.1"}]' + """ + v4, v6 = [], [] + for route in settings.get("routes", []): + dest = route.get("ipaddr") or route.get("destination") or route.get("name") + gateway = route.get("gateway") + if not dest or str(dest) in ("default", "0.0.0.0", "::"): + dest = "0.0.0.0/0" if gateway and ":" not in str(gateway) else "::/0" + else: + netmask = route.get("netmask") + dest = ( + str(dest) + if "/" in str(dest) or not netmask + else _to_cidr(dest, netmask) + ) + entry = dest if not gateway else f"{dest},{gateway}" + if ":" in dest or (gateway and ":" in str(gateway)): + v6.append(entry) + else: + v4.append(entry) + + lines = [] + for family, entries in (("ipv4", v4), ("ipv6", v6)): + if entries: + kvs = [(f"route{i}", e) for i, e in enumerate(entries, start=1)] + lines.extend(_dump_lines([(family, kvs)])) + + if lines and not settings.get("test"): + _merge_routes(iface, v4, v6) + return lines + + +def _merge_routes(iface, v4, v6): + """Inject route entries into the existing keyfile's ipv4/ipv6 sections.""" + path = _keyfile(iface) + if not os.path.isfile(path): + return + with salt.utils.files.fopen(path) as fp_: + existing = [salt.utils.stringutils.to_unicode(x) for x in fp_.readlines()] + + out, current = [], None + injected = {"ipv4": False, "ipv6": False} + routes = {"ipv4": v4, "ipv6": v6} + + def _emit(section): + for idx, entry in enumerate(routes[section], start=1): + out.append(f"route{idx}={entry}\n") + + for line in existing: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + # Leaving a section: flush routes into it before the section break. + if current in routes and routes[current] and not injected[current]: + # remove trailing blank line, add routes, restore blank + while out and out[-1].strip() == "": + out.pop() + _emit(current) + out.append("\n") + injected[current] = True + current = stripped[1:-1] + # Drop any pre-existing route entries so re-runs stay idempotent. + if current in routes and stripped.startswith("route") and "=" in stripped: + continue + out.append(line) + + if current in routes and routes[current] and not injected[current]: + while out and out[-1].strip() == "": + out.pop() + _emit(current) + out.append("\n") + + _write_keyfile(iface, out) + + +def get_routes(iface): + """ + Return the static routes currently declared for ``iface`` in the + salt-managed keyfile, as a list of lines. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.get_routes eth0 + """ + path = _keyfile(iface) + if not os.path.isfile(path): + return [] + with salt.utils.files.fopen(path) as fp_: + existing = [salt.utils.stringutils.to_unicode(x) for x in fp_.readlines()] + + current, out = None, {"ipv4": [], "ipv6": []} + for line in existing: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + current = stripped[1:-1] + elif current in out and stripped.startswith("route") and "=" in stripped: + out[current].append(stripped.split("=", 1)[1]) + + lines = [] + for family in ("ipv4", "ipv6"): + if out[family]: + kvs = [(f"route{i}", e) for i, e in enumerate(out[family], start=1)] + lines.extend(_dump_lines([(family, kvs)])) + return lines + + +def get_network_settings(): + """ + NetworkManager has no separate global network-settings file (each + connection keyfile is self-contained). Returns an empty list. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.get_network_settings + """ + return [] + + +def build_network_settings(**settings): + """ + No-op on NetworkManager: there is no global ``/etc/sysconfig/network`` + equivalent that this provider manages; settings are expressed per + connection. Returns an empty list. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.build_network_settings + """ + return [] + + +def _nmcli(): + nmcli = salt.utils.path.which("nmcli") + if not nmcli: + raise CommandExecutionError("nmcli command not found") + return nmcli + + +def apply_network_settings(**settings): + """ + Reload NetworkManager so it picks up the keyfiles written by + build_interface (``nmcli connection reload``). + + CLI Example: + + .. code-block:: bash + + salt '*' ip.apply_network_settings + """ + if settings.get("test"): + return True + out = __salt__["cmd.run_all"]( + [_nmcli(), "connection", "reload"], python_shell=False + ) + if out["retcode"] != 0: + raise CommandExecutionError( + "nmcli connection reload failed: {}".format( + out.get("stderr") or out.get("stdout") + ) + ) + return True + + +def down(iface, iface_type=None): + """ + Deactivate ``iface``'s NetworkManager connection. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.down eth0 + """ + # Ports are controlled by their master. + if iface_type and iface_type.lower() in ("slave", "teamport"): + return None + return __salt__["cmd.run"]( + [_nmcli(), "connection", "down", iface], python_shell=False + ) + + +def up(iface, iface_type=None): # pylint: disable=invalid-name + """ + Reload keyfiles and (re)activate ``iface``'s NetworkManager connection. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.up eth0 + """ + # Ports are controlled by their master. + if iface_type and iface_type.lower() in ("slave", "teamport"): + return None + nmcli = _nmcli() + # Reload first so a freshly written keyfile is known to NM before we bring + # the connection up. + __salt__["cmd.run_all"]([nmcli, "connection", "reload"], python_shell=False) + return __salt__["cmd.run"]([nmcli, "connection", "up", iface], python_shell=False) diff --git a/salt/modules/rh_ip.py b/salt/modules/rh_ip.py index f1d823b1a822..a0699d77db0b 100644 --- a/salt/modules/rh_ip.py +++ b/salt/modules/rh_ip.py @@ -10,6 +10,7 @@ import salt.utils.files import salt.utils.json +import salt.utils.path import salt.utils.stringutils import salt.utils.templates import salt.utils.validate.net @@ -70,16 +71,41 @@ ) +def _nm_managed(): + """ + True when NetworkManager manages the system and the legacy ifup/ifdown + tooling is gone. This provider brings interfaces up/down with ifup/ifdown + (from the network-scripts package), so under this condition it cannot work + and the :py:mod:`nm_ip ` provider takes over. Kept in + sync with ``nm_ip.nm_managed`` so exactly one provider claims ``ip``. + """ + return ( + bool(salt.utils.path.which("nmcli")) + and os.path.isdir("/run/NetworkManager") + and not (salt.utils.path.which("ifup") and salt.utils.path.which("ifdown")) + ) + + def __virtual__(): """ - Confine this module to RHEL/Fedora based distros + Confine this module to RHEL/Fedora based distros, but defer to + :py:mod:`nm_ip ` on NetworkManager systems that lack the + legacy ``ifup``/``ifdown`` tooling this module relies on (EL8+ by default). """ if __grains__["os_family"] == "RedHat": - if __grains__["os"] == "Amazon": - if __grains__["osmajorrelease"] >= 2: - return __virtualname__ - else: - return __virtualname__ + if __grains__["os"] == "Amazon" and __grains__["osmajorrelease"] < 2: + return ( + False, + "The rh_ip execution module cannot be loaded: unsupported Amazon" + " Linux release.", + ) + if _nm_managed(): + return ( + False, + "The rh_ip execution module is deferring to nm_ip: this system is" + " managed by NetworkManager without the legacy ifup/ifdown tooling.", + ) + return __virtualname__ return ( False, "The rh_ip execution module cannot be loaded: this module is only available on" diff --git a/tests/pytests/unit/modules/test_nm_ip.py b/tests/pytests/unit/modules/test_nm_ip.py new file mode 100644 index 000000000000..6447ff80bea1 --- /dev/null +++ b/tests/pytests/unit/modules/test_nm_ip.py @@ -0,0 +1,416 @@ +""" +Unit tests for salt.modules.nm_ip (the NetworkManager 'ip' provider, #54791). +""" + +import pytest + +import salt.modules.nm_ip as nm_ip +from salt.exceptions import CommandExecutionError +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return { + nm_ip: { + "__grains__": {"os_family": "RedHat"}, + "__salt__": {}, + } + } + + +def _parse(lines): + """Parse keyfile lines into {section: {key: value}} (last value wins).""" + out = {} + current = None + for line in lines: + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("[") and stripped.endswith("]"): + current = stripped[1:-1] + out.setdefault(current, {}) + elif "=" in stripped and current is not None: + key, _, value = stripped.partition("=") + out[current][key] = value + return out + + +# ---- __virtual__ / provider selection ---- + + +def test_virtual_loads_when_nm_managed(): + with patch.dict(nm_ip.__grains__, {"os_family": "RedHat"}), patch.object( + nm_ip, "nm_managed", MagicMock(return_value=True) + ): + assert nm_ip.__virtual__() == "ip" + + +def test_virtual_declines_when_not_nm_managed(): + with patch.dict(nm_ip.__grains__, {"os_family": "RedHat"}), patch.object( + nm_ip, "nm_managed", MagicMock(return_value=False) + ): + ret = nm_ip.__virtual__() + assert ret[0] is False + + +def test_virtual_declines_off_redhat(): + with patch.dict(nm_ip.__grains__, {"os_family": "Debian"}), patch.object( + nm_ip, "nm_managed", MagicMock(return_value=True) + ): + ret = nm_ip.__virtual__() + assert ret[0] is False + + +def test_nm_managed_true_on_modern_el(): + # nmcli present, NM running, no ifup/ifdown -> nm_ip owns it. + def _which(cmd): + return "/usr/bin/nmcli" if cmd == "nmcli" else None + + with patch("salt.utils.path.which", MagicMock(side_effect=_which)), patch( + "os.path.isdir", MagicMock(return_value=True) + ): + assert nm_ip.nm_managed() is True + + +def test_nm_managed_false_with_legacy_ifupdown(): + # network-scripts installed (ifup/ifdown present) -> defer to rh_ip. + with patch( + "salt.utils.path.which", MagicMock(return_value="/usr/sbin/ifup") + ), patch("os.path.isdir", MagicMock(return_value=True)): + assert nm_ip.nm_managed() is False + + +def test_nm_managed_false_without_nmcli(): + with patch("salt.utils.path.which", MagicMock(return_value=None)), patch( + "os.path.isdir", MagicMock(return_value=True) + ): + assert nm_ip.nm_managed() is False + + +def test_nm_managed_false_when_nm_not_running(): + def _which(cmd): + return "/usr/bin/nmcli" if cmd == "nmcli" else None + + with patch("salt.utils.path.which", MagicMock(side_effect=_which)), patch( + "os.path.isdir", MagicMock(return_value=False) + ): + assert nm_ip.nm_managed() is False + + +# ---- build_interface: ethernet ---- + + +def test_build_interface_static(): + lines = nm_ip.build_interface( + "eth1", + "eth", + True, + proto="none", + ipaddr="192.168.99.10", + netmask="255.255.255.0", + gateway="192.168.99.1", + dns=["8.8.8.8", "2001:4860:4860::8888"], + mtu=1500, + test=True, + ) + doc = _parse(lines) + assert doc["connection"]["type"] == "ethernet" + assert doc["connection"]["interface-name"] == "eth1" + assert doc["connection"]["autoconnect"] == "true" + assert doc["ipv4"]["method"] == "manual" + assert doc["ipv4"]["address1"] == "192.168.99.10/24,192.168.99.1" + # IPv4 nameserver on ipv4, IPv6 nameserver split onto ipv6. + assert doc["ipv4"]["dns"] == "8.8.8.8;" + assert doc["ipv6"]["dns"] == "2001:4860:4860::8888;" + assert doc["ethernet"]["mtu"] == "1500" + + +def test_build_interface_dhcp(): + lines = nm_ip.build_interface("eth1", "eth", True, proto="dhcp", test=True) + doc = _parse(lines) + assert doc["ipv4"]["method"] == "auto" + assert doc["ipv6"]["method"] == "auto" + + +def test_build_interface_disabled_ipv4(): + lines = nm_ip.build_interface("eth1", "eth", True, proto="none", test=True) + doc = _parse(lines) + assert doc["ipv4"]["method"] == "disabled" + + +def test_build_interface_ipv6_static_and_disabled(): + lines = nm_ip.build_interface( + "eth1", + "eth", + True, + proto="dhcp", + ipv6proto="static", + ipv6ipaddr="2001:db8::10", + ipv6netmask="64", + ipv6gateway="2001:db8::1", + test=True, + ) + doc = _parse(lines) + assert doc["ipv6"]["method"] == "manual" + assert doc["ipv6"]["address1"] == "2001:db8::10/64,2001:db8::1" + + lines = nm_ip.build_interface( + "eth1", "eth", True, proto="dhcp", ipv6proto="disabled", test=True + ) + assert _parse(lines)["ipv6"]["method"] == "disabled" + + +def test_build_interface_disabled_when_not_enabled(): + lines = nm_ip.build_interface("eth1", "eth", False, proto="dhcp", test=True) + assert _parse(lines)["connection"]["autoconnect"] == "false" + + +def test_build_interface_rejects_unknown_type(): + with pytest.raises(CommandExecutionError): + nm_ip.build_interface("eth1", "infiniband", True, test=True) + + +def test_build_interface_rejects_unsupported_options(): + with pytest.raises(CommandExecutionError): + nm_ip.build_interface( + "eth1", "eth", True, proto="dhcp", ethtool="autoneg on", test=True + ) + + +def test_deterministic_uuid(): + a = _parse(nm_ip.build_interface("eth1", "eth", True, proto="dhcp", test=True)) + b = _parse(nm_ip.build_interface("eth1", "eth", True, proto="dhcp", test=True)) + assert a["connection"]["uuid"] == b["connection"]["uuid"] + c = _parse(nm_ip.build_interface("eth2", "eth", True, proto="dhcp", test=True)) + assert c["connection"]["uuid"] != a["connection"]["uuid"] + + +# ---- build_interface: bond / vlan / bridge / slave ---- + + +def test_build_interface_bond(): + lines = nm_ip.build_interface( + "bond0", + "bond", + True, + mode="active-backup", + miimon="100", + slaves="eth1 eth2", + ipaddr="10.0.0.5", + netmask="255.255.255.0", + test=True, + ) + doc = _parse(lines) + assert doc["connection"]["type"] == "bond" + assert doc["bond"]["mode"] == "active-backup" + assert doc["bond"]["miimon"] == "100" + assert doc["ipv4"]["address1"] == "10.0.0.5/24" + + +def test_build_interface_bond_requires_mode(): + with pytest.raises(CommandExecutionError): + nm_ip.build_interface("bond0", "bond", True, slaves="eth1", test=True) + + +def test_build_interface_bond_writes_slave_keyfiles(tmp_path): + with patch.object(nm_ip, "_NM_DIR", str(tmp_path)): + nm_ip.build_interface( + "bond0", "bond", True, mode="802.3ad", miimon="100", slaves="eth1 eth2" + ) + slave = _parse(nm_ip.get_interface("eth1")) + assert slave["connection"]["master"] == "bond0" + assert slave["connection"]["slave-type"] == "bond" + # A port carries no L3 config. + assert "ipv4" not in slave + assert "ipv6" not in slave + + +def test_build_interface_vlan_dotted_name(): + lines = nm_ip.build_interface( + "eth0.100", "vlan", True, ipaddr="10.1.0.5", netmask="255.255.255.0", test=True + ) + doc = _parse(lines) + assert doc["connection"]["type"] == "vlan" + assert doc["vlan"]["id"] == "100" + assert doc["vlan"]["parent"] == "eth0" + + +def test_build_interface_vlan_explicit(): + lines = nm_ip.build_interface( + "myvlan", "vlan", True, vlan_id=42, parent="eth3", test=True + ) + doc = _parse(lines) + assert doc["vlan"]["id"] == "42" + assert doc["vlan"]["parent"] == "eth3" + + +def test_build_interface_vlan_requires_id_and_parent(): + with pytest.raises(CommandExecutionError): + nm_ip.build_interface("badvlan", "vlan", True, test=True) + + +def test_build_interface_bridge_writes_port_keyfiles(tmp_path): + with patch.object(nm_ip, "_NM_DIR", str(tmp_path)): + lines = nm_ip.build_interface( + "br0", + "bridge", + True, + ports="eth1 eth2", + stp="yes", + ipaddr="10.2.0.5", + netmask="255.255.255.0", + ) + port = _parse(nm_ip.get_interface("eth1")) + doc = _parse(lines) + assert doc["connection"]["type"] == "bridge" + assert doc["bridge"]["stp"] == "true" + assert port["connection"]["master"] == "br0" + assert port["connection"]["slave-type"] == "bridge" + + +def test_build_interface_slave_has_no_l3(): + lines = nm_ip.build_interface("eth1", "slave", True, master="bond0", test=True) + doc = _parse(lines) + assert doc["connection"]["master"] == "bond0" + assert doc["connection"]["slave-type"] == "bond" + assert "ipv4" not in doc + + +# ---- get_interface / write / idempotency ---- + + +def test_get_interface_empty_when_absent(tmp_path): + with patch.object(nm_ip, "_NM_DIR", str(tmp_path)): + assert nm_ip.get_interface("nope") == [] + + +def test_build_interface_writes_and_roundtrips(tmp_path): + with patch.object(nm_ip, "_NM_DIR", str(tmp_path)): + written = nm_ip.build_interface( + "eth1", + "eth", + True, + proto="none", + ipaddr="10.9.0.5", + netmask="255.255.255.0", + gateway="10.9.0.1", + ) + assert nm_ip.get_interface("eth1") == written + # Second build is byte-identical -> state sees no change. + again = nm_ip.build_interface( + "eth1", + "eth", + True, + proto="none", + ipaddr="10.9.0.5", + netmask="255.255.255.0", + gateway="10.9.0.1", + ) + assert again == written + + +def test_keyfile_is_chmod_600(tmp_path): + import os + import stat + + with patch.object(nm_ip, "_NM_DIR", str(tmp_path)): + nm_ip.build_interface("eth1", "eth", True, proto="dhcp") + mode = stat.S_IMODE(os.stat(nm_ip._keyfile("eth1")).st_mode) + assert mode == 0o600 + + +# ---- routes ---- + + +def test_build_and_get_routes_roundtrip(tmp_path): + with patch.object(nm_ip, "_NM_DIR", str(tmp_path)): + nm_ip.build_interface( + "eth1", + "eth", + True, + proto="none", + ipaddr="10.9.0.5", + netmask="255.255.255.0", + ) + nm_ip.build_routes( + "eth1", + routes=[ + { + "ipaddr": "172.16.0.0", + "netmask": "255.255.0.0", + "gateway": "10.9.0.1", + }, + {"ipaddr": "default", "gateway": "10.9.0.254"}, + ], + ) + doc = _parse(nm_ip.get_interface("eth1")) + assert doc["ipv4"]["route1"] == "172.16.0.0/16,10.9.0.1" + assert doc["ipv4"]["route2"] == "0.0.0.0/0,10.9.0.254" + # Re-applying the same routes stays idempotent (no duplicate routeN). + before = nm_ip.get_interface("eth1") + nm_ip.build_routes( + "eth1", + routes=[ + { + "ipaddr": "172.16.0.0", + "netmask": "255.255.0.0", + "gateway": "10.9.0.1", + }, + {"ipaddr": "default", "gateway": "10.9.0.254"}, + ], + ) + assert nm_ip.get_interface("eth1") == before + assert nm_ip.get_routes("eth1") + + +# ---- global network settings (no-op) ---- + + +def test_network_settings_are_noops(): + assert nm_ip.get_network_settings() == [] + assert nm_ip.build_network_settings() == [] + + +# ---- up / down / apply ---- + + +def test_up_calls_nmcli_up(): + run = MagicMock(return_value="ok") + run_all = MagicMock(return_value={"retcode": 0}) + with patch("salt.utils.path.which", MagicMock(return_value="/usr/bin/nmcli")): + with patch.dict(nm_ip.__salt__, {"cmd.run": run, "cmd.run_all": run_all}): + nm_ip.up("eth1", "eth") + assert run.call_args[0][0] == ["/usr/bin/nmcli", "connection", "up", "eth1"] + + +def test_down_calls_nmcli_down(): + run = MagicMock(return_value="ok") + with patch("salt.utils.path.which", MagicMock(return_value="/usr/bin/nmcli")): + with patch.dict(nm_ip.__salt__, {"cmd.run": run}): + nm_ip.down("eth1", "eth") + assert run.call_args[0][0] == ["/usr/bin/nmcli", "connection", "down", "eth1"] + + +def test_up_down_skip_slaves(): + run = MagicMock() + with patch.dict(nm_ip.__salt__, {"cmd.run": run}): + assert nm_ip.up("eth1", "slave") is None + assert nm_ip.down("eth1", "slave") is None + run.assert_not_called() + + +def test_apply_network_settings_reloads(): + run_all = MagicMock(return_value={"retcode": 0}) + with patch("salt.utils.path.which", MagicMock(return_value="/usr/bin/nmcli")): + with patch.dict(nm_ip.__salt__, {"cmd.run_all": run_all}): + assert nm_ip.apply_network_settings() is True + assert run_all.call_args[0][0] == ["/usr/bin/nmcli", "connection", "reload"] + + +def test_apply_network_settings_raises_on_failure(): + run_all = MagicMock(return_value={"retcode": 1, "stderr": "boom"}) + with patch("salt.utils.path.which", MagicMock(return_value="/usr/bin/nmcli")): + with patch.dict(nm_ip.__salt__, {"cmd.run_all": run_all}): + with pytest.raises(CommandExecutionError): + nm_ip.apply_network_settings() diff --git a/tests/pytests/unit/modules/test_rh_ip.py b/tests/pytests/unit/modules/test_rh_ip.py index 7dc458eccd2f..3004060cc706 100644 --- a/tests/pytests/unit/modules/test_rh_ip.py +++ b/tests/pytests/unit/modules/test_rh_ip.py @@ -914,3 +914,41 @@ def test_build_interface_bond_slave(): 'USERCTL="no"', ] assert results == expected, results + + +# ---- provider selection / deferral to nm_ip (#54791) ---- + + +def test_virtual_claims_ip_on_legacy_box(): + # network-scripts present (ifup/ifdown on PATH) -> rh_ip still owns "ip". + with patch.dict(rh_ip.__grains__, {"os_family": "RedHat", "os": "CentOS"}): + with patch.object(rh_ip, "_nm_managed", MagicMock(return_value=False)): + assert rh_ip.__virtual__() == "ip" + + +def test_virtual_defers_to_nm_ip_on_networkmanager(): + # NetworkManager-managed, no ifup/ifdown -> defer so nm_ip claims "ip". + with patch.dict(rh_ip.__grains__, {"os_family": "RedHat", "os": "CentOS"}): + with patch.object(rh_ip, "_nm_managed", MagicMock(return_value=True)): + ret = rh_ip.__virtual__() + assert ret[0] is False + assert "nm_ip" in ret[1] + + +def test_virtual_declines_off_redhat(): + with patch.dict(rh_ip.__grains__, {"os_family": "Debian", "os": "Debian"}): + ret = rh_ip.__virtual__() + assert ret[0] is False + + +def test_nm_managed_gate_matches_nm_ip(): + # Mirrors nm_ip.nm_managed: nmcli + /run/NetworkManager + no ifup/ifdown. + def _which(cmd): + return "/usr/bin/nmcli" if cmd == "nmcli" else None + + with patch("salt.utils.path.which", MagicMock(side_effect=_which)): + with patch("os.path.isdir", MagicMock(return_value=True)): + assert rh_ip._nm_managed() is True + with patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/ifup")): + with patch("os.path.isdir", MagicMock(return_value=True)): + assert rh_ip._nm_managed() is False From 2fc318a992ae0fc93809f8b53c91dff670b8b9a4 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 2 Jul 2026 02:20:04 -0400 Subject: [PATCH 262/469] Bump nm_ip versionadded to 3006.28 3006.27 was released 2026-07-01 without this provider, so the next available 3006.x release is 3006.28. --- salt/modules/nm_ip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/modules/nm_ip.py b/salt/modules/nm_ip.py index 203dbef06969..12d3e5cda538 100644 --- a/salt/modules/nm_ip.py +++ b/salt/modules/nm_ip.py @@ -15,7 +15,7 @@ ``/etc/NetworkManager/system-connections/`` and applies them with ``nmcli``, which is the supported way to manage networking on modern RedHat systems. -.. versionadded:: 3006.27 +.. versionadded:: 3006.28 .. note:: NetworkManager is the source of truth here, so only the subset of the From ee9736b5ff187516471733bad909baae9b9bc79f Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 5 Jul 2026 23:20:06 -0400 Subject: [PATCH 263/469] Add direct and inverse regression tests for nm_ip test-flag and rh_ip Amazon deferral The direct tests call nm_ip.build_interface at the exact altitude network.managed uses (name, iface_type, enabled, **kwargs with the state-injected test flag): test=True must return the rendered keyfile lines for the diff without writing anything (including bond port keyfiles), and the same call with test=False must write those exact lines. The inverse tests guard the refactored rh_ip.__virtual__ against overcorrection: Amazon Linux 2 with network-scripts must still claim ip and Amazon Linux 1 must still decline, verified against the base branch module as well. --- tests/pytests/unit/modules/test_nm_ip.py | 54 ++++++++++++++++++++++++ tests/pytests/unit/modules/test_rh_ip.py | 25 +++++++++++ 2 files changed, 79 insertions(+) diff --git a/tests/pytests/unit/modules/test_nm_ip.py b/tests/pytests/unit/modules/test_nm_ip.py index 6447ff80bea1..91dc04076ff6 100644 --- a/tests/pytests/unit/modules/test_nm_ip.py +++ b/tests/pytests/unit/modules/test_nm_ip.py @@ -277,6 +277,60 @@ def test_build_interface_slave_has_no_l3(): assert "ipv4" not in doc +def test_build_interface_test_flag_skips_write_54791(tmp_path): + """ + Direct call at the altitude network.managed uses: the state always injects + kwargs["test"] (from __opts__) before calling + ip.build_interface(name, iface_type, enabled, **kwargs). test is the + decisive flag -- with test=True the rendered lines must come back for the + state's diff without anything hitting disk, and the same call with + test=False (a real run) must write exactly those lines. + """ + with patch.object(nm_ip, "_NM_DIR", str(tmp_path)): + lines = nm_ip.build_interface( + "eth1", + "eth", + True, + proto="none", + ipaddr="10.9.0.5", + netmask="255.255.255.0", + test=True, + ) + assert lines + assert list(tmp_path.iterdir()) == [] + written = nm_ip.build_interface( + "eth1", + "eth", + True, + proto="none", + ipaddr="10.9.0.5", + netmask="255.255.255.0", + test=False, + ) + assert written == lines + assert nm_ip.get_interface("eth1") == lines + + +def test_build_interface_bond_test_flag_skips_port_keyfiles_54791(tmp_path): + """ + Guards against overcorrection: writing member port keyfiles is a side + effect of a real bond build, and it must not start happening under + test=True -- a state test run may not touch disk at all. + """ + with patch.object(nm_ip, "_NM_DIR", str(tmp_path)): + lines = nm_ip.build_interface( + "bond0", + "bond", + True, + mode="active-backup", + miimon="100", + slaves="eth1 eth2", + test=True, + ) + assert lines + assert list(tmp_path.iterdir()) == [] + + # ---- get_interface / write / idempotency ---- diff --git a/tests/pytests/unit/modules/test_rh_ip.py b/tests/pytests/unit/modules/test_rh_ip.py index 3004060cc706..9fde18b02f2a 100644 --- a/tests/pytests/unit/modules/test_rh_ip.py +++ b/tests/pytests/unit/modules/test_rh_ip.py @@ -952,3 +952,28 @@ def _which(cmd): with patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/ifup")): with patch("os.path.isdir", MagicMock(return_value=True)): assert rh_ip._nm_managed() is False + + +def test_virtual_amazon2_legacy_still_claims_ip_54791(): + """ + Guards against overcorrection: the nm_ip deferral added to __virtual__ + must not stop rh_ip from loading on Amazon Linux 2 boxes that still ship + network-scripts (ifup/ifdown present, so _nm_managed is False). + """ + grains = {"os_family": "RedHat", "os": "Amazon", "osmajorrelease": 2} + with patch.dict(rh_ip.__grains__, grains): + # ifup on PATH = network-scripts installed = _nm_managed() is False. + with patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/ifup")): + assert rh_ip.__virtual__() == "ip" + + +def test_virtual_amazon1_still_declines_54791(): + """ + Guards against overcorrection: Amazon Linux 1 must keep declining exactly + as it did before the __virtual__ refactor inverted the release check. + """ + grains = {"os_family": "RedHat", "os": "Amazon", "osmajorrelease": 1} + with patch.dict(rh_ip.__grains__, grains): + with patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/ifup")): + ret = rh_ip.__virtual__() + assert ret[0] is False From 149f3439762594a29114d4dd349682403e4ecbf6 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 20:11:56 -0400 Subject: [PATCH 264/469] Expand nm_ip NetworkManager keyfile coverage and harden keyfile write Close a set of coverage gaps in the nm_ip (NetworkManager keyfile) ip provider so it maps more of the network.managed schema and moves closer to rh_ip parity, and address review feedback on keyfile permissions and code duplication. Coverage: - mtu on bond/bridge/vlan is now emitted via a separate [ethernet] (802-3-ethernet) section attached to the connection, matching how NM sets MTU on virtual devices. Previously it was accepted and silently dropped because the native [bond]/[bridge]/[vlan] settings have no mtu key. Ethernet interfaces keep folding mtu into their own [ethernet] section. - hwaddr pins a connection to a NIC's permanent MAC (802-3-ethernet mac-address, or bridge.mac-address for bridges), honouring the auto/none sentinels. macaddr sets the in-use MAC (802-3-ethernet cloned-mac-address) and is mutually exclusive with hwaddr, matching rh_ip. - The autoneg/speed/duplex ethtool link parameters map to [ethernet] auto-negotiate/speed/duplex (speed and duplex must be set together) instead of being rejected. Offload/channel/advertise ethtool knobs, which have no keyfile equivalent, are still refused. - Bond options are passed through to [bond] from the full kernel bonding set (ad_select, fail_over_mac, primary_reselect, arp_validate, all_slaves_active, min_links, ...) rather than a fixed ten-key allow-list. Option names are validated and mode stays required. - dns-search is written under [ipv6] as well as [ipv4], so search domains survive on IPv6-only hosts; a disabled family no longer carries a dead dns-search line. - vlan reorder_hdr/gvrp/loose_binding fold into the [vlan] flags bitmask (emitted only when it diverges from NM's default), and wol maps to [ethernet] wake-on-lan. Hardening / cleanup: - Write keyfiles with salt.utils.files.fpopen(mode=0o600) so the connection file is created with 0600 permissions before any content is written, instead of chmod'ing an already-populated file. - Extract the shared NetworkManager provider-selection check into salt.utils.network.nm_managed; nm_ip.nm_managed and rh_ip._nm_managed now both call it so exactly one provider claims the ip module. Add direct and inverse unit tests for each gap under tests/pytests/unit/modules/test_nm_ip.py. --- changelog/5479.added.md | 25 ++ salt/modules/nm_ip.py | 437 +++++++++++++++++++---- salt/modules/rh_ip.py | 12 +- salt/utils/network.py | 21 ++ tests/pytests/unit/modules/test_nm_ip.py | 329 +++++++++++++++++ 5 files changed, 753 insertions(+), 71 deletions(-) create mode 100644 changelog/5479.added.md diff --git a/changelog/5479.added.md b/changelog/5479.added.md new file mode 100644 index 000000000000..ed5861dde800 --- /dev/null +++ b/changelog/5479.added.md @@ -0,0 +1,25 @@ +Expanded the NetworkManager keyfile provider (`nm_ip`) so it covers more of the +`network.managed` schema and reaches closer parity with `rh_ip`: + +- `mtu` is now emitted for bond, bridge and vlan interfaces (via a separate + `[ethernet]` / 802-3-ethernet section on the connection), not just ethernet. + Previously it was silently dropped on those types. +- `hwaddr` now pins a connection to a NIC's permanent MAC + (`[ethernet] mac-address`, or `[bridge] mac-address` for bridges), honouring + the `auto`/`none` sentinels. `macaddr` sets the in-use MAC + (`[ethernet] cloned-mac-address`) and is mutually exclusive with `hwaddr`. +- The `autoneg`, `speed` and `duplex` ethtool link parameters now map to + `[ethernet] auto-negotiate`/`speed`/`duplex` instead of being rejected; + offload/channel/advertise ethtool knobs (which have no keyfile equivalent) + are still refused. +- Bond options are now passed through to `[bond]` from the full kernel bonding + set (`ad_select`, `fail_over_mac`, `primary_reselect`, `arp_validate`, + `all_slaves_active`, `min_links`, ...) rather than a fixed ten-key list. +- `dns_search` is now written under `[ipv6]` as well as `[ipv4]`, so search + domains are no longer lost on IPv6-only hosts. +- vlan `reorder_hdr`/`gvrp`/`loose_binding` are folded into the `[vlan] flags` + bitmask, and `wol` maps to `[ethernet] wake-on-lan`. + +The keyfile is now created with 0600 permissions before any content is written, +and the NetworkManager provider-selection check is shared with `rh_ip` via a +single `salt.utils.network.nm_managed` helper. diff --git a/salt/modules/nm_ip.py b/salt/modules/nm_ip.py index 12d3e5cda538..30817f82f936 100644 --- a/salt/modules/nm_ip.py +++ b/salt/modules/nm_ip.py @@ -20,17 +20,23 @@ .. note:: NetworkManager is the source of truth here, so only the subset of the ``network.managed`` schema that maps cleanly onto NM connection keyfiles is - supported (addresses, gateway, nameservers, mtu, dhcp, bond/vlan/bridge). - ifcfg/ifupdown-only options such as ethtool offload settings and up/down - hook scripts have no keyfile equivalent and raise an informative error - rather than being silently dropped. + supported: addresses, gateway, nameservers, dns search domains, mtu (on + ethernet, bond, bridge and vlan), dhcp, hwaddr/macaddr, the + autoneg/speed/duplex link parameters, wake-on-lan, the full bond option set, + bridge/vlan attributes and static routes. + + ifcfg/ifupdown-only options such as ethtool offload/channel settings and + up/down hook scripts have no keyfile equivalent and raise an informative + error rather than being silently dropped. """ import logging import os +import re import uuid import salt.utils.files +import salt.utils.network import salt.utils.path import salt.utils.stringutils from salt.exceptions import CommandExecutionError @@ -59,7 +65,11 @@ "bridge": "bridge", } -# ifcfg/ethtool-era settings with no keyfile equivalent. +# ifcfg/ethtool-era settings with no NetworkManager keyfile equivalent. The +# autoneg/speed/duplex link parameters ARE mappable (onto the [ethernet] +# section) and are handled separately; the offload/channel/advertise ethtool +# knobs below have no keyfile analogue, so they are rejected rather than +# silently dropped. _UNSUPPORTED = ( "up_cmds", "down_cmds", @@ -68,8 +78,30 @@ "pre_down_cmds", "post_down_cmds", "ethtool", + "advertise", + "channels", + "rx", + "tx", + "sg", + "tso", + "ufo", + "gso", + "gro", + "lro", ) +# NetworkManager wake-on-lan (802-3-ethernet.wake-on-lan) flag mask bits. +_WOL_FLAGS = { + "default": 0x1, + "phy": 0x2, + "unicast": 0x4, + "multicast": 0x8, + "broadcast": 0x10, + "arp": 0x20, + "magic": 0x40, + "ignore": 0x8000, +} + # salt bond option -> NM [bond] key. NM stores bond options with the kernel # option names, same as the sysfs bonding interface. _BOND_OPT_MAP = { @@ -85,6 +117,120 @@ "use_carrier": "use_carrier", } +# Connection-level, IP and device keys that must never be treated as [bond] +# options. Any OTHER key on a bond interface is passed straight through to the +# [bond] section, because NetworkManager's bond.options is an arbitrary +# kernel-bonding dict rather than a fixed allow-list. +_BOND_RESERVED = frozenset( + { + # provider / state control + "type", + "test", + "enabled", + "onboot", + "name", + "noifupdown", + "addr", + # members and port enslavement + "slaves", + "interfaces", + "ports", + "bridge_ports", + "master", + "slave_type", + # ipv4 addressing + "proto", + "ipaddr", + "ipaddrs", + "addresses", + "netmask", + "prefix", + "gateway", + "broadcast", + "metric", + "pointopoint", + "scope", + "srcaddr", + # dns + "dns", + "nameservers", + "dns_search", + "domain", + "search", + "peerdns", + # ipv6 addressing / control + "enable_ipv6", + "ipv6proto", + "ipv6addr", + "ipv6ipaddr", + "ipv6addrs", + "ipv6gateway", + "ipv6netmask", + "ipv6_autoconf", + "ipv6_peerdns", + "ipv6_defroute", + "ipv6_peerroutes", + "dhcpv6c", + # link / ethernet-family + "mtu", + "hwaddr", + "macaddr", + "autoneg", + "speed", + "duplex", + "wol", + # ethtool offload and hook keys (rejected by _check_unsupported) + "ethtool", + "advertise", + "channels", + "rx", + "tx", + "sg", + "tso", + "ufo", + "gso", + "gro", + "lro", + "up_cmds", + "down_cmds", + "pre_up_cmds", + "post_up_cmds", + "pre_down_cmds", + "post_down_cmds", + # bridge / vlan device keys (never bond options) + "stp", + "fd", + "forward_delay", + "ageing", + "maxage", + "hello", + "priority", + "id", + "vlan_id", + "parent", + "link", + "vlan-raw-device", + "vlan_raw_device", + "reorder_hdr", + "gvrp", + "loose_binding", + # misc pass-through / control flags + "zone", + "uuid", + "nickname", + "userctl", + "nm_controlled", + "defroute", + "ipv4_failure_fatal", + "peerroutes", + "arpcheck", + "routes", + } +) + +# Valid NetworkManager bond.options key spelling. +_BOND_OPT_NAME_RE = re.compile(r"^[a-zA-Z0-9_]+$") + # salt bridge option -> NM [bridge] key. _BRIDGE_OPT_MAP = { "fd": "forward-delay", @@ -117,11 +263,6 @@ def __virtual__(): return __virtualname__ -def _has_legacy_ifupdown(): - """True if both ``ifup`` and ``ifdown`` are on PATH (network-scripts).""" - return bool(salt.utils.path.which("ifup")) and bool(salt.utils.path.which("ifdown")) - - def nm_managed(): """ Return True if this system is managed by NetworkManager without the legacy @@ -130,9 +271,10 @@ def nm_managed(): PATH. This is the deterministic, load-time-safe condition that decides whether - ``nm_ip`` or ``rh_ip`` owns the ``ip`` provider -- both modules test it, so - exactly one claims it and no runtime service call is needed during - ``__virtual__`` resolution. + ``nm_ip`` or ``rh_ip`` owns the ``ip`` provider. The check itself lives in + :py:func:`salt.utils.network.nm_managed` so both providers share a single + definition, exactly one claims ``ip`` and no runtime service call is needed + during ``__virtual__`` resolution. CLI Example: @@ -140,11 +282,7 @@ def nm_managed(): salt '*' ip.nm_managed """ - return ( - bool(salt.utils.path.which("nmcli")) - and os.path.isdir("/run/NetworkManager") - and not _has_legacy_ifupdown() - ) + return salt.utils.network.nm_managed() def _keyfile(iface): @@ -229,8 +367,11 @@ def _ipv4_section(settings): v4dns = [d for d in dns if ":" not in str(d)] if v4dns: kvs.append(("dns", ";".join(v4dns) + ";")) + # Skip search domains when IPv4 is disabled; they are carried by [ipv6] + # instead (see _ipv6_section) so an ipv6-only host does not lose them. + disabled = not addresses and proto in ("none", "disabled", "off") search = _listify(settings.get("dns_search") or settings.get("domain")) - if search: + if search and not disabled: kvs.append(("dns-search", ";".join(search) + ";")) return kvs @@ -244,27 +385,35 @@ def _ipv6_section(settings): for addr in _listify(settings.get("ipv6addrs")): addresses.append(addr) - kvs = [] if proto in ("disabled", "off", "none"): - kvs.append(("method", "disabled")) + method = "disabled" elif proto in ("dhcp", "dhcp6"): - kvs.append(("method", "dhcp")) + method = "dhcp" elif addresses: - kvs.append(("method", "manual")) + method = "manual" + else: + # NM default: SLAAC. Keeps interfaces dual-stack unless told otherwise. + method = "auto" + + kvs = [("method", method)] + if method == "manual": gateway = settings.get("ipv6gateway") for idx, addr in enumerate(addresses, start=1): if idx == 1 and gateway: kvs.append((f"address{idx}", f"{addr},{gateway}")) else: kvs.append((f"address{idx}", addr)) - else: - # NM default: SLAAC. Keeps interfaces dual-stack unless told otherwise. - kvs.append(("method", "auto")) dns = _listify(settings.get("dns") or settings.get("nameservers")) v6dns = [d for d in dns if ":" in str(d)] if v6dns: kvs.append(("dns", ";".join(v6dns) + ";")) + # dns-search is per-address-family; emit it under [ipv6] too (not just + # [ipv4]) so search domains survive on ipv6-only hosts. Pointless when IPv6 + # is disabled. + search = _listify(settings.get("dns_search") or settings.get("domain")) + if search and method != "disabled": + kvs.append(("dns-search", ";".join(search) + ";")) return kvs @@ -287,10 +436,28 @@ def _vlan_id_parent(iface, settings): def _bond_options(settings): + """ + Flatten the bond options from ``settings`` into an ``nm_key -> value`` dict. + + NetworkManager's ``bond.options`` is an arbitrary kernel-bonding option dict + rendered one key per line under ``[bond]``, so any option the user supplies + (``ad_select``, ``fail_over_mac``, ``primary_reselect``, ``arp_validate``, + ``all_slaves_active``, ``min_links``, ...) passes through rather than being + limited to a fixed allow-list. Connection/IP/device keys are excluded via + :data:`_BOND_RESERVED`; the historical name map is still applied so any + renamed option keeps its behaviour. + """ opts = {} - for salt_key, nm_key in _BOND_OPT_MAP.items(): - if settings.get(salt_key) is not None: - opts[nm_key] = settings[salt_key] + for key, value in settings.items(): + if value is None or key in _BOND_RESERVED: + continue + nm_key = _BOND_OPT_MAP.get(key, key) + if not _BOND_OPT_NAME_RE.match(nm_key): + raise CommandExecutionError( + f"Invalid bond option name '{nm_key}'; NetworkManager bond " + "option names must match [a-zA-Z0-9_]" + ) + opts[nm_key] = value return opts @@ -301,6 +468,130 @@ def _bridge_options(settings): for salt_key, nm_key in _BRIDGE_OPT_MAP.items(): if settings.get(salt_key) is not None: kvs.append((nm_key, settings[salt_key])) + # A bridge device's own MAC is set via bridge.mac-address, not the + # 802-3-ethernet mac-address used for physical NICs. + pin = _mac_pin(settings) + if pin: + kvs.append(("mac-address", pin)) + return kvs + + +def _mac_pin(settings): + """ + hwaddr as a permanent-MAC match, or ``None`` for the ``auto``/``none`` + sentinels (and when unset). Mirrors rh_ip, where ``auto``/``none`` mean "do + not pin to a specific NIC". + """ + hwaddr = settings.get("hwaddr") + if not hwaddr or str(hwaddr).strip().lower() in ("auto", "none"): + return None + return hwaddr + + +def _link_options(settings): + """ + Physical-link ethtool settings that map onto the [ethernet] section: + ``autoneg`` -> auto-negotiate, ``speed``/``duplex`` -> speed/duplex. NM + requires speed and duplex to be configured together. + """ + kvs = [] + if settings.get("autoneg") is not None: + kvs.append( + ("auto-negotiate", "true" if _as_bool(settings["autoneg"]) else "false") + ) + speed = settings.get("speed") + duplex = settings.get("duplex") + if (speed is None) != (duplex is None): + raise CommandExecutionError("ethtool 'speed' and 'duplex' must be set together") + if speed is not None: + dup = str(duplex).lower() + if dup not in ("half", "full"): + raise CommandExecutionError( + f"Invalid duplex '{duplex}'; expected 'half' or 'full'" + ) + kvs.append(("speed", int(speed))) + kvs.append(("duplex", dup)) + return kvs + + +def _wol_mask(value): + """ + Translate a ``wol`` setting into NetworkManager's wake-on-lan uint32 flag + mask. Accepts an integer mask, one or more NM flag names + (``phy``/``unicast``/``multicast``/``broadcast``/``arp``/``magic``/ + ``default``/``ignore``), or a bool (True -> magic, False -> ignore). + """ + if value is None: + return None + if isinstance(value, bool): + return _WOL_FLAGS["magic"] if value else _WOL_FLAGS["ignore"] + text = str(value).strip().lower() + if not text: + return None + if text.lstrip("-").isdigit(): + return int(text) + mask = 0 + for token in text.replace(",", " ").split(): + if token not in _WOL_FLAGS: + raise CommandExecutionError( + "Invalid wol value '{}'; expected an integer flag mask or one " + "or more of: {}".format(value, ", ".join(sorted(_WOL_FLAGS))) + ) + mask |= _WOL_FLAGS[token] + return mask + + +def _vlan_flags(settings): + """ + NM ``[vlan]`` flags bitmask from ``reorder_hdr``/``gvrp``/``loose_binding`` + (NMVlanFlags: 0x1 reorder-headers, 0x2 gvrp, 0x4 loose-binding, 0x8 mvrp). + NM's default is ``1`` (reorder-headers on), so this returns ``None`` -- i.e. + emit no ``flags=`` line -- when no flag option is given or the computed + value equals that default. + """ + keys = ("reorder_hdr", "gvrp", "loose_binding") + if not any(k in settings for k in keys): + return None + reorder = _as_bool(settings["reorder_hdr"]) if "reorder_hdr" in settings else True + flags = 0 + if reorder: + flags |= 0x1 + if _as_bool(settings.get("gvrp", False)): + flags |= 0x2 + if _as_bool(settings.get("loose_binding", False)): + flags |= 0x4 + if flags == 0x1: + return None + return flags + + +def _ethernet_section(iface_type, settings): + """ + Build the ordered ``[ethernet]`` (802-3-ethernet) key/value list for a + connection. NetworkManager attaches this setting to bond/bridge/vlan + connections too -- e.g. to carry ``mtu`` -- not just physical ethernet, so + it is emitted as a section separate from the ``[bond]``/``[bridge]``/ + ``[vlan]`` device section for those types. + """ + kvs = [] + if settings.get("mtu"): + kvs.append(("mtu", int(settings["mtu"]))) + # mac-address pins the connection to the NIC with this permanent MAC; on a + # vlan it doubles as the parent selector. A bridge uses bridge.mac-address + # instead (handled in _bridge_options). + if iface_type in ("eth", "vlan"): + pin = _mac_pin(settings) + if pin: + kvs.append(("mac-address", pin)) + # The remaining 802-3-ethernet properties are physical-link only. + if iface_type == "eth": + cloned = settings.get("macaddr") + if cloned: + kvs.append(("cloned-mac-address", cloned)) + kvs.extend(_link_options(settings)) + wol = _wol_mask(settings.get("wol")) + if wol is not None: + kvs.append(("wake-on-lan", wol)) return kvs @@ -356,37 +647,54 @@ def _connection_sections(iface, iface_type, enabled, settings, master=None): # A port has no L3 config; the master owns it. return [("connection", conn)] - sections = [("connection", conn)] - - # One device section per connection, named after the NM connection type. - # mtu folds into it so a connection never emits a duplicate section. - device_section = nm_type - device_kvs = [] - if itype == "bond": - if "mode" not in settings: - raise CommandExecutionError( - f"Missing required option 'mode' for bond interface '{iface}'" - ) - opts = _bond_options(settings) - device_kvs = [(k, opts[k]) for k in sorted(opts)] - elif itype == "bridge": - device_kvs = _bridge_options(settings) - elif itype == "vlan": - vid, parent = _vlan_id_parent(iface, settings) - if vid is None or not parent: - raise CommandExecutionError( - f"vlan interface '{iface}' needs both a vlan id and a parent " - "(set vlan_id/id and parent, or name it like eth0.100)" - ) - device_kvs = [("id", int(vid)), ("parent", parent)] + if settings.get("hwaddr") and settings.get("macaddr"): + raise CommandExecutionError( + f"interface '{iface}': use either hwaddr or macaddr, not both" + ) - # mtu is a property of the wired (ethernet) setting; NM's bond/bridge/vlan - # settings have no mtu key, so only fold it into an ethernet section. - if settings.get("mtu") and nm_type == "ethernet": - device_kvs.append(("mtu", int(settings["mtu"]))) + sections = [("connection", conn)] - if device_kvs: - sections.append((device_section, device_kvs)) + if nm_type == "ethernet": + # An ethernet connection's own device section IS [ethernet], so its + # mtu/mac/link settings fold straight into it. + eth_kvs = _ethernet_section(itype, settings) + if eth_kvs: + sections.append(("ethernet", eth_kvs)) + else: + # bond/bridge/vlan carry a [bond]/[bridge]/[vlan] device section whose + # keys are type-specific. mtu (and a vlan's parent-selector mac) is an + # 802-3-ethernet property, so NM sets it via a SEPARATE [ethernet] + # section attached to the same connection -- the native bond/bridge/vlan + # settings have no mtu key of their own. + device_section = nm_type + device_kvs = [] + if itype == "bond": + if "mode" not in settings: + raise CommandExecutionError( + f"Missing required option 'mode' for bond interface '{iface}'" + ) + opts = _bond_options(settings) + device_kvs = [(k, opts[k]) for k in sorted(opts)] + elif itype == "bridge": + device_kvs = _bridge_options(settings) + elif itype == "vlan": + vid, parent = _vlan_id_parent(iface, settings) + if vid is None or not parent: + raise CommandExecutionError( + f"vlan interface '{iface}' needs both a vlan id and a parent " + "(set vlan_id/id and parent, or name it like eth0.100)" + ) + device_kvs = [("id", int(vid)), ("parent", parent)] + flags = _vlan_flags(settings) + if flags is not None: + device_kvs.append(("flags", flags)) + + if device_kvs: + sections.append((device_section, device_kvs)) + + eth_kvs = _ethernet_section(itype, settings) + if eth_kvs: + sections.append(("ethernet", eth_kvs)) sections.append(("ipv4", _ipv4_section(settings))) sections.append(("ipv6", _ipv6_section(settings))) @@ -405,14 +713,15 @@ def _dump_lines(sections): def _write_keyfile(iface, lines): - """Write ``lines`` to ``iface``'s keyfile with the 0600 NM requires.""" + """ + Write ``lines`` to ``iface``'s keyfile. The connection may carry secrets, so + fpopen applies the 0600 NetworkManager requires before any content is + written, rather than chmod'ing an already-populated, briefly world-readable + file. + """ path = _keyfile(iface) - with salt.utils.files.fopen(path, "w") as fp_: + with salt.utils.files.fpopen(path, "w", mode=0o600) as fp_: fp_.write(salt.utils.stringutils.to_str("".join(lines))) - try: - os.chmod(path, 0o600) - except OSError: # pragma: no cover - log.debug("Could not chmod %s to 0600", path) def build_interface(iface, iface_type, enabled, **settings): diff --git a/salt/modules/rh_ip.py b/salt/modules/rh_ip.py index a0699d77db0b..a73140a7298f 100644 --- a/salt/modules/rh_ip.py +++ b/salt/modules/rh_ip.py @@ -10,6 +10,7 @@ import salt.utils.files import salt.utils.json +import salt.utils.network import salt.utils.path import salt.utils.stringutils import salt.utils.templates @@ -76,14 +77,11 @@ def _nm_managed(): True when NetworkManager manages the system and the legacy ifup/ifdown tooling is gone. This provider brings interfaces up/down with ifup/ifdown (from the network-scripts package), so under this condition it cannot work - and the :py:mod:`nm_ip ` provider takes over. Kept in - sync with ``nm_ip.nm_managed`` so exactly one provider claims ``ip``. + and the :py:mod:`nm_ip ` provider takes over. The shared + check lives in :py:func:`salt.utils.network.nm_managed` so exactly one + provider claims ``ip``. """ - return ( - bool(salt.utils.path.which("nmcli")) - and os.path.isdir("/run/NetworkManager") - and not (salt.utils.path.which("ifup") and salt.utils.path.which("ifdown")) - ) + return salt.utils.network.nm_managed() def __virtual__(): diff --git a/salt/utils/network.py b/salt/utils/network.py index fa63a8058860..2302c58bc3d8 100644 --- a/salt/utils/network.py +++ b/salt/utils/network.py @@ -2379,3 +2379,24 @@ def ip_bracket(addr, strip=False): addr = addr.rstrip("]") addr = ipaddress.ip_address(addr) return ("[{}]" if addr.version == 6 and not strip else "{}").format(addr) + + +def nm_managed(): + """ + Return ``True`` when this host is managed by NetworkManager without the + legacy ``network-scripts`` tooling: ``nmcli`` is on ``PATH``, NetworkManager + is running (``/run/NetworkManager`` exists) and neither ``ifup`` nor + ``ifdown`` is available. + + This is the load-time-safe condition that decides whether the + :py:mod:`nm_ip ` or :py:mod:`rh_ip + ` execution module owns the ``ip`` provider on the + RedHat os_family. Both modules call this single helper from their + ``__virtual__`` so exactly one of them claims ``ip`` and no runtime service + call is needed. + """ + return ( + bool(salt.utils.path.which("nmcli")) + and os.path.isdir("/run/NetworkManager") + and not (salt.utils.path.which("ifup") and salt.utils.path.which("ifdown")) + ) diff --git a/tests/pytests/unit/modules/test_nm_ip.py b/tests/pytests/unit/modules/test_nm_ip.py index 91dc04076ff6..ba1c689b9668 100644 --- a/tests/pytests/unit/modules/test_nm_ip.py +++ b/tests/pytests/unit/modules/test_nm_ip.py @@ -468,3 +468,332 @@ def test_apply_network_settings_raises_on_failure(): with patch.dict(nm_ip.__salt__, {"cmd.run_all": run_all}): with pytest.raises(CommandExecutionError): nm_ip.apply_network_settings() + + +# ---- nm_managed is shared with rh_ip via salt.utils.network (#5479) ---- + + +def test_nm_managed_delegates_to_utils_network(): + # The gate lives in salt.utils.network so rh_ip and nm_ip share one copy; + # nm_ip.nm_managed must simply return it. + with patch("salt.utils.network.nm_managed", MagicMock(return_value=True)): + assert nm_ip.nm_managed() is True + with patch("salt.utils.network.nm_managed", MagicMock(return_value=False)): + assert nm_ip.nm_managed() is False + + +# ---- mtu on bond / bridge / vlan -> separate [ethernet] section (#5479) ---- + + +def test_bond_mtu_emits_separate_ethernet_section(): + # NM has no [bond] mtu key; mtu is carried by an 802-3-ethernet setting + # attached to the bond master connection. + lines = nm_ip.build_interface( + "bond0", + "bond", + True, + mode="active-backup", + miimon="100", + mtu=9000, + slaves="eth1 eth2", + test=True, + ) + doc = _parse(lines) + assert doc["connection"]["type"] == "bond" + assert doc["ethernet"]["mtu"] == "9000" + # mtu must NOT leak into the [bond] section. + assert "mtu" not in doc["bond"] + assert doc["bond"]["mode"] == "active-backup" + + +def test_bridge_mtu_emits_separate_ethernet_section(): + lines = nm_ip.build_interface("br0", "bridge", True, stp="yes", mtu=9000, test=True) + doc = _parse(lines) + assert doc["connection"]["type"] == "bridge" + assert doc["ethernet"]["mtu"] == "9000" + assert doc["bridge"]["stp"] == "true" + + +def test_vlan_mtu_emits_separate_ethernet_section(): + lines = nm_ip.build_interface( + "eth0.100", "vlan", True, mtu=9000, ipaddr="10.1.0.5", netmask="24", test=True + ) + doc = _parse(lines) + assert doc["connection"]["type"] == "vlan" + assert doc["ethernet"]["mtu"] == "9000" + assert doc["vlan"]["id"] == "100" + + +def test_bond_without_mtu_has_no_ethernet_section(): + # Inverse/no-regress: a bond with no ethernet-family option must not emit an + # empty [ethernet] section. + lines = nm_ip.build_interface( + "bond0", "bond", True, mode="active-backup", slaves="eth1", test=True + ) + doc = _parse(lines) + assert "ethernet" not in doc + + +def test_ethernet_mtu_stays_in_ethernet_device_section(): + # For a plain ethernet interface, mtu still folds into its own [ethernet] + # device section (one section, not two). + lines = nm_ip.build_interface( + "eth1", "eth", True, proto="dhcp", mtu=1500, test=True + ) + doc = _parse(lines) + assert doc["ethernet"]["mtu"] == "1500" + assert list(doc).count("ethernet") == 1 + + +# ---- hwaddr / macaddr (#5479) ---- + + +def test_hwaddr_emits_ethernet_mac_address(): + lines = nm_ip.build_interface( + "eth1", "eth", True, proto="dhcp", hwaddr="AA:BB:CC:DD:EE:FF", test=True + ) + doc = _parse(lines) + assert doc["ethernet"]["mac-address"] == "AA:BB:CC:DD:EE:FF" + + +def test_hwaddr_auto_and_none_sentinels_skip_emit(): + for sentinel in ("auto", "none"): + lines = nm_ip.build_interface( + "eth1", "eth", True, proto="dhcp", hwaddr=sentinel, test=True + ) + doc = _parse(lines) + assert "ethernet" not in doc + + +def test_hwaddr_on_bridge_uses_bridge_mac_address(): + # A bridge sets its own device MAC via bridge.mac-address, not [ethernet]. + lines = nm_ip.build_interface( + "br0", "bridge", True, hwaddr="AA:BB:CC:DD:EE:FF", test=True + ) + doc = _parse(lines) + assert doc["bridge"]["mac-address"] == "AA:BB:CC:DD:EE:FF" + assert "ethernet" not in doc + + +def test_hwaddr_on_vlan_uses_ethernet_mac_address(): + lines = nm_ip.build_interface( + "myvlan", + "vlan", + True, + vlan_id=10, + parent="eth0", + hwaddr="AA:BB:CC:DD:EE:FF", + test=True, + ) + doc = _parse(lines) + assert doc["ethernet"]["mac-address"] == "AA:BB:CC:DD:EE:FF" + + +def test_macaddr_emits_cloned_mac_address(): + lines = nm_ip.build_interface( + "eth1", "eth", True, proto="dhcp", macaddr="52:54:00:12:34:56", test=True + ) + doc = _parse(lines) + assert doc["ethernet"]["cloned-mac-address"] == "52:54:00:12:34:56" + + +def test_macaddr_accepts_special_value(): + lines = nm_ip.build_interface( + "eth1", "eth", True, proto="dhcp", macaddr="random", test=True + ) + doc = _parse(lines) + assert doc["ethernet"]["cloned-mac-address"] == "random" + + +def test_hwaddr_and_macaddr_are_mutually_exclusive(): + with pytest.raises(CommandExecutionError): + nm_ip.build_interface( + "eth1", + "eth", + True, + proto="dhcp", + hwaddr="AA:BB:CC:DD:EE:FF", + macaddr="random", + test=True, + ) + + +# ---- ethtool autoneg / speed / duplex (#5479) ---- + + +def test_ethtool_speed_and_duplex_map_to_ethernet(): + lines = nm_ip.build_interface( + "eth1", + "eth", + True, + proto="dhcp", + autoneg="off", + speed=1000, + duplex="full", + test=True, + ) + doc = _parse(lines) + assert doc["ethernet"]["auto-negotiate"] == "false" + assert doc["ethernet"]["speed"] == "1000" + assert doc["ethernet"]["duplex"] == "full" + + +def test_ethtool_speed_requires_duplex(): + with pytest.raises(CommandExecutionError): + nm_ip.build_interface("eth1", "eth", True, proto="dhcp", speed=1000, test=True) + + +def test_ethtool_offload_key_still_rejected(): + # autoneg/speed/duplex are carved out, but offload knobs stay unsupported. + with pytest.raises(CommandExecutionError): + nm_ip.build_interface("eth1", "eth", True, proto="dhcp", gro="on", test=True) + + +# ---- bond option pass-through beyond the old allow-list (#5479) ---- + + +def test_bond_passes_through_unmapped_option(): + # ad_select was absent from the fixed _BOND_OPT_MAP; it must now reach [bond]. + lines = nm_ip.build_interface( + "bond0", + "bond", + True, + mode="802.3ad", + ad_select="bandwidth", + fail_over_mac="active", + min_links=2, + test=True, + ) + doc = _parse(lines) + assert doc["bond"]["mode"] == "802.3ad" + assert doc["bond"]["ad_select"] == "bandwidth" + assert doc["bond"]["fail_over_mac"] == "active" + assert doc["bond"]["min_links"] == "2" + + +def test_bond_does_not_treat_connection_keys_as_options(): + # Non-bond keys (ipaddr, mtu, slaves, ...) must never land in [bond]. + lines = nm_ip.build_interface( + "bond0", + "bond", + True, + mode="active-backup", + miimon="100", + ipaddr="10.0.0.5", + netmask="24", + mtu=9000, + slaves="eth1 eth2", + zone="public", + test=True, + ) + doc = _parse(lines) + assert set(doc["bond"]) == {"mode", "miimon"} + + +def test_bond_rejects_invalid_option_name(): + with pytest.raises(CommandExecutionError): + nm_ip.build_interface( + "bond0", "bond", True, **{"mode": "active-backup", "bad-opt": "x"} + ) + + +# ---- dns-search on IPv6 (#5479) ---- + + +def test_dns_search_emitted_on_ipv6_only_host(): + # ipv4 disabled, ipv6 static: search domains must survive under [ipv6]. + lines = nm_ip.build_interface( + "eth1", + "eth", + True, + proto="none", + ipv6proto="static", + ipv6ipaddr="2001:db8::10", + ipv6netmask="64", + dns_search=["example.com", "corp.example.com"], + test=True, + ) + doc = _parse(lines) + assert doc["ipv4"]["method"] == "disabled" + assert doc["ipv6"]["dns-search"] == "example.com;corp.example.com;" + # A disabled [ipv4] must not carry a dead dns-search line. + assert "dns-search" not in doc["ipv4"] + + +def test_dns_search_still_emitted_on_ipv4(): + lines = nm_ip.build_interface( + "eth1", + "eth", + True, + proto="none", + ipaddr="10.0.0.5", + netmask="24", + dns_search="example.com", + test=True, + ) + doc = _parse(lines) + assert doc["ipv4"]["dns-search"] == "example.com;" + + +def test_dns_search_not_emitted_when_ipv6_disabled(): + # Inverse: a disabled ipv6 stack must not carry a pointless dns-search. + lines = nm_ip.build_interface( + "eth1", + "eth", + True, + proto="none", + ipaddr="10.0.0.5", + netmask="24", + ipv6proto="disabled", + dns_search="example.com", + test=True, + ) + doc = _parse(lines) + assert doc["ipv6"]["method"] == "disabled" + assert "dns-search" not in doc["ipv6"] + + +# ---- vlan flags (#5479) ---- + + +def test_vlan_reorder_hdr_off_emits_flags_zero(): + lines = nm_ip.build_interface( + "eth0.100", "vlan", True, reorder_hdr=False, test=True + ) + doc = _parse(lines) + assert doc["vlan"]["flags"] == "0" + + +def test_vlan_gvrp_sets_flag_bit_over_default(): + # reorder-headers stays on by default (0x1); gvrp adds 0x2 -> 3. + lines = nm_ip.build_interface("eth0.100", "vlan", True, gvrp="yes", test=True) + doc = _parse(lines) + assert doc["vlan"]["flags"] == "3" + + +def test_vlan_default_flags_not_emitted(): + # Inverse: no flag option, or reorder_hdr left at its default, emits no + # flags= line. + doc = _parse(nm_ip.build_interface("eth0.100", "vlan", True, test=True)) + assert "flags" not in doc["vlan"] + doc = _parse( + nm_ip.build_interface("eth0.100", "vlan", True, reorder_hdr=True, test=True) + ) + assert "flags" not in doc["vlan"] + + +# ---- wake-on-lan (#5479) ---- + + +def test_wol_named_flag_maps_to_mask(): + lines = nm_ip.build_interface( + "eth1", "eth", True, proto="dhcp", wol="magic", test=True + ) + doc = _parse(lines) + assert doc["ethernet"]["wake-on-lan"] == "64" + + +def test_wol_integer_mask_passthrough(): + lines = nm_ip.build_interface("eth1", "eth", True, proto="dhcp", wol=66, test=True) + doc = _parse(lines) + assert doc["ethernet"]["wake-on-lan"] == "66" From 0e99738695ca0568b542eb57d2f9467a152cc2ca Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Tue, 14 Jul 2026 07:46:49 -0400 Subject: [PATCH 265/469] Address review: atomic keyfile write, semicolon _listify, clearer bond-mode error - _write_keyfile writes to a 0600 mkstemp temp in the keyfile's own directory and os.replace()s it onto the target, so NetworkManager (which watches these files via inotify) never sees a half-written or briefly world-readable keyfile. Replaces the in-place fpopen write; a copy that preserves the destination mode (salt.utils.files.copyfile) would let an existing 0644 keyfile stay world-readable, so it is deliberately not used here. - _listify also splits on ';', NetworkManager's on-disk array delimiter, so a pillar value pre-formatted that way parses into individual entries. - The missing-bond-mode error names example modes and explains why mode is required rather than defaulted (rh_ip requires it too). Adds direct tests for the atomic 0600 write (including rewriting an existing 0644 keyfile) and for semicolon _listify. --- salt/modules/nm_ip.py | 37 ++++++++++++++++++------ tests/pytests/unit/modules/test_nm_ip.py | 37 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/salt/modules/nm_ip.py b/salt/modules/nm_ip.py index 30817f82f936..e65012cb8f3d 100644 --- a/salt/modules/nm_ip.py +++ b/salt/modules/nm_ip.py @@ -33,6 +33,7 @@ import logging import os import re +import tempfile import uuid import salt.utils.files @@ -310,8 +311,10 @@ def _listify(value): return [] if isinstance(value, (list, tuple)): return list(value) - # space- or comma-separated string - return [v for v in str(value).replace(",", " ").split() if v] + # space-, comma-, or semicolon-separated string. NetworkManager uses ``;`` + # as its on-disk array delimiter (e.g. ``dns=10.0.0.1;10.0.0.2;``), so a + # value pre-formatted that way in pillar splits correctly too. + return [v for v in str(value).replace(",", " ").replace(";", " ").split() if v] def _as_bool(value): @@ -671,7 +674,10 @@ def _connection_sections(iface, iface_type, enabled, settings, master=None): if itype == "bond": if "mode" not in settings: raise CommandExecutionError( - f"Missing required option 'mode' for bond interface '{iface}'" + f"Missing required option 'mode' for bond interface '{iface}' " + "(e.g. active-backup, 802.3ad, balance-rr). The kernel would " + "otherwise silently fall back to balance-rr, which is rarely " + "intended; set it explicitly." ) opts = _bond_options(settings) device_kvs = [(k, opts[k]) for k in sorted(opts)] @@ -714,14 +720,27 @@ def _dump_lines(sections): def _write_keyfile(iface, lines): """ - Write ``lines`` to ``iface``'s keyfile. The connection may carry secrets, so - fpopen applies the 0600 NetworkManager requires before any content is - written, rather than chmod'ing an already-populated, briefly world-readable - file. + Atomically write ``lines`` to ``iface``'s keyfile. + + The connection may carry secrets and NetworkManager watches these files via + inotify, so the content is written to a temporary file in the same directory + -- created ``0600`` by ``mkstemp`` -- and then ``os.replace``'d onto the + target. NM only ever sees the finished file at its final ``0600`` mode: the + keyfile never passes through a world-readable or half-written state, both of + which an in-place ``open(path, "w")`` (truncate then write) would expose to + NM's directory watcher. """ path = _keyfile(iface) - with salt.utils.files.fpopen(path, "w", mode=0o600) as fp_: - fp_.write(salt.utils.stringutils.to_str("".join(lines))) + fd, tmp = tempfile.mkstemp( + prefix=f"{os.path.basename(path)}.", dir=os.path.dirname(path) + ) + try: + with os.fdopen(fd, "w", encoding=__salt_system_encoding__) as fp_: + fp_.write(salt.utils.stringutils.to_str("".join(lines))) + os.replace(tmp, path) + except Exception: # pylint: disable=broad-except + os.unlink(tmp) + raise def build_interface(iface, iface_type, enabled, **settings): diff --git a/tests/pytests/unit/modules/test_nm_ip.py b/tests/pytests/unit/modules/test_nm_ip.py index ba1c689b9668..a8a135bb551e 100644 --- a/tests/pytests/unit/modules/test_nm_ip.py +++ b/tests/pytests/unit/modules/test_nm_ip.py @@ -2,6 +2,9 @@ Unit tests for salt.modules.nm_ip (the NetworkManager 'ip' provider, #54791). """ +import os +import stat + import pytest import salt.modules.nm_ip as nm_ip @@ -797,3 +800,37 @@ def test_wol_integer_mask_passthrough(): lines = nm_ip.build_interface("eth1", "eth", True, proto="dhcp", wol=66, test=True) doc = _parse(lines) assert doc["ethernet"]["wake-on-lan"] == "66" + + +def test_listify_splits_on_semicolons(): + # NetworkManager uses ';' as its on-disk array delimiter, so a pillar value + # pre-formatted that way (e.g. copied from an existing keyfile) must split + # into individual entries rather than one malformed element. + assert nm_ip._listify("10.0.0.1;10.0.0.2;") == ["10.0.0.1", "10.0.0.2"] + assert nm_ip._listify("a, b;c d") == ["a", "b", "c", "d"] + # Lists still pass through untouched. + assert nm_ip._listify(["10.0.0.1", "10.0.0.2"]) == ["10.0.0.1", "10.0.0.2"] + + +def test_write_keyfile_atomic_forces_0600_even_over_existing_0644(tmp_path): + # The keyfile write must land at 0600 regardless of any pre-existing mode. + # A copy-that-preserves-dest-mode (salt.utils.files.copyfile does exactly + # that) would leave an existing 0644 keyfile world-readable, which NM + # rejects and which can leak connection secrets. Also guards against a + # non-atomic in-place write leaving a stray temp file behind. + with patch.object(nm_ip, "_NM_DIR", str(tmp_path)): + path = nm_ip._keyfile("eth0") + nm_ip._write_keyfile("eth0", ["[connection]\n", "id=eth0\n"]) + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + with open(path, encoding="utf-8") as fh: + assert "id=eth0" in fh.read() + + # Rewriting an over-permissive existing keyfile still yields 0600. + os.chmod(path, 0o644) + nm_ip._write_keyfile("eth0", ["[connection]\n", "id=eth0-v2\n"]) + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + with open(path, encoding="utf-8") as fh: + assert "id=eth0-v2" in fh.read() + + # No stray temporary files left in the keyfile directory. + assert [p.name for p in tmp_path.iterdir()] == [os.path.basename(path)] From 15e78f4e49ccfed0fb9f485cc643e608004978b5 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 15 Jul 2026 14:06:55 -0400 Subject: [PATCH 266/469] Use salt.utils.files.fopen in the keyfile write test (test-lint W8470) The atomic-write test read the file back with a bare open(), which the test suite's pylint checker flags as W8470 (resource-leakage). Use salt.utils.files.fopen instead, per the salt convention. This is what broke Lint / Lint Salt's Test Suite on the previous commit. --- tests/pytests/unit/modules/test_nm_ip.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/pytests/unit/modules/test_nm_ip.py b/tests/pytests/unit/modules/test_nm_ip.py index a8a135bb551e..f0207d47fd09 100644 --- a/tests/pytests/unit/modules/test_nm_ip.py +++ b/tests/pytests/unit/modules/test_nm_ip.py @@ -8,6 +8,7 @@ import pytest import salt.modules.nm_ip as nm_ip +import salt.utils.files from salt.exceptions import CommandExecutionError from tests.support.mock import MagicMock, patch @@ -822,14 +823,14 @@ def test_write_keyfile_atomic_forces_0600_even_over_existing_0644(tmp_path): path = nm_ip._keyfile("eth0") nm_ip._write_keyfile("eth0", ["[connection]\n", "id=eth0\n"]) assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 - with open(path, encoding="utf-8") as fh: + with salt.utils.files.fopen(path) as fh: assert "id=eth0" in fh.read() # Rewriting an over-permissive existing keyfile still yields 0600. os.chmod(path, 0o644) nm_ip._write_keyfile("eth0", ["[connection]\n", "id=eth0-v2\n"]) assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 - with open(path, encoding="utf-8") as fh: + with salt.utils.files.fopen(path) as fh: assert "id=eth0-v2" in fh.read() # No stray temporary files left in the keyfile directory. From 2ed6766c7ded82115cd93c5ec489dde33d2e64a4 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 14:57:33 -0700 Subject: [PATCH 267/469] Make whitelist_modules only apply to remote dispatch Split minion_mods() into a two-loader model: an outer whitelist-filtered loader for wire dispatch and an inner unfiltered loader packed as __salt__ inside every loaded module. This lets a minion whitelist a narrow surface (e.g. [test, mycompany, saltutil]) without breaking internal module composition -- mycompany.deploy can still call __salt__["cmd.run"](...) even when cmd is off the whitelist. Also fix salt/modules/saltcheck.py: move __context__["global_scheck"] initialisation out of module-level code (where it re-ran on every exec_module and clobbered state a running function had set) into an __init__(opts) hook with setdefault, matching the check-then-populate pattern the developing-modules docs prescribe. Fixes #69983 Refs #52592, #25854, #35609 --- changelog/69983.fixed.md | 1 + salt/loader/__init__.py | 64 ++++++-- salt/modules/saltcheck.py | 18 ++- .../loader/test_module_whitelist_dunder.py | 139 ++++++++++++++++++ .../renderers/test_renderer_whitelist.py | 99 +++++++++++++ .../unit/states/test_boto_cloudtrail.py | 8 +- .../unit/states/test_boto_cloudwatch_event.py | 8 +- .../states/test_boto_elasticsearch_domain.py | 8 +- tests/pytests/unit/states/test_boto_iot.py | 8 +- tests/pytests/unit/states/test_boto_lambda.py | 8 +- .../unit/states/test_boto_s3_bucket.py | 8 +- tests/unit/states/test_boto_apigateway.py | 8 +- .../unit/states/test_boto_cognitoidentity.py | 15 +- tests/unit/states/test_boto_vpc.py | 6 + 14 files changed, 375 insertions(+), 23 deletions(-) create mode 100644 changelog/69983.fixed.md create mode 100644 tests/pytests/integration/loader/test_module_whitelist_dunder.py create mode 100644 tests/pytests/integration/renderers/test_renderer_whitelist.py diff --git a/changelog/69983.fixed.md b/changelog/69983.fixed.md new file mode 100644 index 000000000000..766e9198a1f8 --- /dev/null +++ b/changelog/69983.fixed.md @@ -0,0 +1 @@ +Fixed `whitelist_modules` so it only restricts what remote callers can invoke. Whitelisted modules can now compose with non-whitelisted modules via `__salt__[...]`, so a minion configured with `whitelist_modules: [test, mycompany, saltutil]` refuses `salt '*' cmd.run 'rm -rf /'` from the master while `mycompany.deploy` (which internally calls `__salt__["cmd.run"](...)`) still works. diff --git a/salt/loader/__init__.py b/salt/loader/__init__.py index 59c3361c5bb8..171a0ae6bbb4 100644 --- a/salt/loader/__init__.py +++ b/salt/loader/__init__.py @@ -316,27 +316,73 @@ def minion_mods( # TODO Publish documentation for module whitelisting if not whitelist: whitelist = opts.get("whitelist_modules", None) + # Both loaders must share the same ``__context__`` dict. If we leave it + # as ``None`` LazyLoader.__init__ replaces it with a fresh ``{}`` in each + # loader's ``self.pack``, so writes made via one loader's + # NamedLoaderContext never reach reads made via the other's. + if context is None: + context = {} + pack = { + "__context__": context, + "__utils__": utils, + "__proxy__": proxy, + "__opts__": opts, + "__file_client__": file_client, + } + # Two-loader model: outer loader is whitelist-filtered for wire dispatch; + # inner ``salt_dunder`` is unfiltered and packed as ``__salt__`` inside + # every loaded module, so a whitelisted module can still compose with + # non-whitelisted modules via ``__salt__[...]``. + salt_dunder = LazyLoader( + _module_dirs(opts, "modules", "module"), + opts, + tag="module", + pack=pack, + loaded_base_name=loaded_base_name, + static_modules=static_modules, + extra_module_dirs=utils.module_dirs if utils else None, + pack_self="__salt__", + ) + pack = dict(pack) + pack["__salt__"] = salt_dunder ret = LazyLoader( _module_dirs(opts, "modules", "module"), opts, tag="module", - pack={ - "__context__": context, - "__utils__": utils, - "__proxy__": proxy, - "__opts__": opts, - "__file_client__": file_client, - }, + pack=pack, whitelist=whitelist, loaded_base_name=loaded_base_name, static_modules=static_modules, extra_module_dirs=utils.module_dirs if utils else None, - pack_self="__salt__", ) + # Test / callsite compatibility: ``patch.dict(ret, {...})`` was the way + # pre-split-loader tests injected mocks that both the wire-dispatch path + # AND internal ``__salt__[...]`` composition would see, because there was + # only one loader. With the split, exec modules' ``__salt__`` is now the + # unfiltered inner ``salt_dunder`` and writes to ``ret`` don't reach it. + # Mirror writes made on ``ret`` into ``salt_dunder._dict`` so the classic + # ``patch.dict(ret, ...)`` idiom still works; reads through ``ret`` still + # go through ``_load()`` (which enforces the whitelist) so the security + # boundary at wire dispatch is preserved. + _salt_dunder = salt_dunder + + class _WriteThroughLoader(type(ret)): # noqa: N801 + __module__ = type(ret).__module__ + + def __setitem__(self, key, val): + LazyLoader.__setitem__(self, key, val) + _salt_dunder._dict[key] = val + + def __delitem__(self, key): + LazyLoader.__delitem__(self, key) + _salt_dunder._dict.pop(key, None) + + ret.__class__ = _WriteThroughLoader + # Allow the usage of salt dunder in utils modules. if utils and isinstance(utils, LazyLoader): - utils.pack["__salt__"] = ret + utils.pack["__salt__"] = salt_dunder # Load any provider overrides from the configuration file providers option # Note: Providers can be pkg, service, user or group - not to be confused diff --git a/salt/modules/saltcheck.py b/salt/modules/saltcheck.py index e959e42e9874..2abff72d2c59 100644 --- a/salt/modules/saltcheck.py +++ b/salt/modules/saltcheck.py @@ -290,15 +290,21 @@ log = logging.getLogger(__name__) -try: - __context__ -except NameError: - __context__ = {} -__context__["global_scheck"] = None - __virtualname__ = "saltcheck" +def __init__(opts): + # Initialise ``global_scheck`` in the loader's ``__context__`` on every + # load, but only if no previous load has already populated it. Doing + # this at module top-level would be unsafe: module-level code runs + # *before* the loader's pack loop binds ``__context__`` to the loader's + # ``NamedLoaderContext``, so a fresh dict created there is orphaned when + # the pack loop rewires ``__context__``. It would also unconditionally + # reset the entry on every ``exec_module``, clobbering the ``SaltCheck`` + # instance a running call has already stored. + __context__.setdefault("global_scheck", None) + + def __virtual__(): """ Set the virtual pkg module if not running as a proxy diff --git a/tests/pytests/integration/loader/test_module_whitelist_dunder.py b/tests/pytests/integration/loader/test_module_whitelist_dunder.py new file mode 100644 index 000000000000..c4c015f1b2b7 --- /dev/null +++ b/tests/pytests/integration/loader/test_module_whitelist_dunder.py @@ -0,0 +1,139 @@ +""" +Integration tests for the split-loader behavior in ``salt.loader.minion_mods``. + +``minion_mods()`` returns a whitelist-filtered LazyLoader for remote +dispatch, but packs an *unfiltered* loader as ``__salt__`` inside every +loaded module. + +Effect on a whitelisted minion: + - Remote publishers can only invoke functions from whitelisted modules. + - A whitelisted module can still compose with non-whitelisted modules + via ``__salt__[...]``. +""" + +import pytest + +from tests.conftest import FIPS_TESTRUN + +SECTEST_MODULE = """ +def run(cmd): + return __salt__["cmd.run"](cmd) +""" + + +@pytest.fixture +def whitelisted_minion(salt_master): + """ + A minion configured with ``whitelist_modules: [test, sectest, saltutil]``. + ``cmd`` is *deliberately absent* from the whitelist. + """ + minion = salt_master.salt_minion_daemon( + "test-whitelist-dunder-minion", + overrides={ + "whitelist_modules": [ + "test", + "sectest", + "saltutil", + # Needed for the SLS-render tests below (state.template_str + # touches config/grains/pillar/slsutil during compilation). + "state", + "config", + "grains", + "pillar", + "slsutil", + ], + "fips_mode": FIPS_TESTRUN, + "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", + "signing_algorithm": ( + "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1" + ), + }, + ) + minion.after_terminate( + pytest.helpers.remove_stale_minion_key, salt_master, minion.id + ) + with salt_master.state_tree.base.temp_file("_modules/sectest.py", SECTEST_MODULE): + with minion.started(): + salt_cli = salt_master.salt_cli() + salt_cli.run("saltutil.sync_modules", minion_tgt=minion.id) + yield minion + + +def test_whitelisted_function_returns(salt_cli, whitelisted_minion): + """ + ``test.ping`` is on the whitelist and must return normally. + """ + ret = salt_cli.run("test.ping", minion_tgt=whitelisted_minion.id) + assert ret.data is True + + +def test_nonwhitelisted_function_is_blocked(salt_cli, whitelisted_minion): + """ + ``cmd.run`` is *not* on the whitelist. Remote publish must not + execute it: the minion's outer (filtered) loader has no ``cmd`` + entry, so the function is unavailable and the CLI reports either + "'cmd.run' is not available." or "Minion did not return" -- both + prove the whitelist rejected the call. + """ + ret = salt_cli.run( + "cmd.run", "echo blocked", minion_tgt=whitelisted_minion.id, _timeout=15 + ) + data = str(ret.data or "") + assert "not available" in data or "did not return" in data + + +def test_whitelisted_module_reaches_nonwhitelisted_via_dunder( + salt_cli, whitelisted_minion +): + """ + ``sectest`` is whitelisted; its ``run()`` internally calls + ``__salt__['cmd.run']``. Because the packed ``__salt__`` is the + *unfiltered* loader, the call succeeds even though direct remote + dispatch of ``cmd.run`` is blocked (previous test). + """ + ret = salt_cli.run( + "sectest.run", "echo hello-from-dunder", minion_tgt=whitelisted_minion.id + ) + assert ret.data == "hello-from-dunder" + + +def test_sls_render_can_call_whitelisted_module(salt_cli, whitelisted_minion): + """ + SLS files render on the minion with the whitelist-filtered loader + exposed as ``salt`` / ``__salt__``. A whitelisted module call inside + the template must render normally and the resulting state must run. + """ + template = ( + "{% set r = salt['test.echo']('hi-from-sls') %}\n" + "probe:\n" + " test.nop:\n" + " - name: {{ r }}\n" + ) + ret = salt_cli.run("state.template_str", template, minion_tgt=whitelisted_minion.id) + # state.template_str returns a dict keyed by state chunk id. + assert isinstance(ret.data, dict) + key = next(iter(ret.data)) + assert ret.data[key]["result"] is True + assert ret.data[key]["name"] == "hi-from-sls" + + +def test_sls_render_cannot_call_nonwhitelisted_module(salt_cli, whitelisted_minion): + """ + ``cmd`` is not on ``whitelist_modules``. A template that tries + ``salt['cmd.run'](...)`` must fail *at render time* -- the render + pipeline receives the same filtered loader that the wire dispatch + uses, not the unfiltered ``salt_dunder`` that execution modules see. + + Jinja surfaces the missing key as ``UndefinedError: '...AliasedLoader + object' has no attribute 'cmd.run'``. + """ + template = ( + "{% set r = salt['cmd.run']('id') %}\n" + "probe:\n" + " test.nop:\n" + " - name: {{ r }}\n" + ) + ret = salt_cli.run("state.template_str", template, minion_tgt=whitelisted_minion.id) + text = str(ret.data or ret.stdout) + assert "cmd.run" in text + assert "UndefinedError" in text or "no attribute" in text diff --git a/tests/pytests/integration/renderers/test_renderer_whitelist.py b/tests/pytests/integration/renderers/test_renderer_whitelist.py new file mode 100644 index 000000000000..b27d31d44fe1 --- /dev/null +++ b/tests/pytests/integration/renderers/test_renderer_whitelist.py @@ -0,0 +1,99 @@ +""" +Integration tests for the minion-side ``renderer_whitelist`` opt. + +Setting ``renderer_whitelist: [jinja, yaml]`` on a minion must prevent +SLS files that request other renderers (``#!py``, ``#!pyobjects``, +``#!pydsl``, ``#!mako``, ``#!wempy``) from rendering. Without the +whitelist, a ``#!py`` SLS executes arbitrary Python on the minion +during render -- so this is a real defense-in-depth boundary. +""" + +import pytest + +from tests.conftest import FIPS_TESTRUN + +PY_SLS = """#!py +def run(): + return {"probe": {"test.nop": [{"name": "hi-from-py-sls"}]}} +""" + +JINJA_SLS = ( + "{% set r = salt['test.echo']('hi-from-jinja') %}\n" + "probe:\n" + " test.nop:\n" + " - name: {{ r }}\n" +) + + +@pytest.fixture +def renderer_whitelisted_minion(salt_master): + """ + Minion with ``renderer_whitelist: [jinja, yaml]``. Also whitelists + the execution modules that ``state.template_str`` needs internally + so we can drive rendering through a single top-level call. + """ + minion = salt_master.salt_minion_daemon( + "test-renderer-whitelist-minion", + overrides={ + "renderer_whitelist": ["jinja", "yaml"], + "whitelist_modules": [ + "test", + "state", + "saltutil", + "config", + "grains", + "pillar", + "slsutil", + ], + "fips_mode": FIPS_TESTRUN, + "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", + "signing_algorithm": ( + "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1" + ), + }, + ) + minion.after_terminate( + pytest.helpers.remove_stale_minion_key, salt_master, minion.id + ) + with minion.started(): + yield minion + + +def test_default_pipeline_still_renders(salt_cli, renderer_whitelisted_minion): + """ + A plain SLS (no shebang) uses the default ``jinja|yaml`` pipe -- both + are on the whitelist, so rendering must succeed. + """ + ret = salt_cli.run( + "state.template_str", + JINJA_SLS, + minion_tgt=renderer_whitelisted_minion.id, + ) + assert isinstance(ret.data, dict), f"unexpected return: {ret.data!r}" + key = next(iter(ret.data)) + assert ret.data[key]["result"] is True + assert ret.data[key]["name"] == "hi-from-jinja" + + +def test_shebang_py_renderer_is_rejected(salt_cli, renderer_whitelisted_minion): + """ + An SLS starting with ``#!py`` requests the ``py`` renderer, which is + NOT on the whitelist. ``check_render_pipe_str`` drops it, the render + pipe becomes empty, and ``state.template_str`` reports no data -- + the arbitrary-Python-in-SLS attack surface is closed. + + Also verifies via the minion log that the renderer was rejected + with the standard ``The renderer "..." is not available`` warning. + """ + ret = salt_cli.run( + "state.template_str", + PY_SLS, + minion_tgt=renderer_whitelisted_minion.id, + ) + # A rejected render returns falsy data (empty dict / empty list / + # error string). Positively assert the Python body did NOT execute: + # a successful #!py render would produce a ``probe`` state chunk + # named ``hi-from-py-sls``. + text = str(ret.data or "") + assert "hi-from-py-sls" not in text + assert "test.nop" not in text diff --git a/tests/pytests/unit/states/test_boto_cloudtrail.py b/tests/pytests/unit/states/test_boto_cloudtrail.py index b267e63a17a6..3b2d061481a0 100644 --- a/tests/pytests/unit/states/test_boto_cloudtrail.py +++ b/tests/pytests/unit/states/test_boto_cloudtrail.py @@ -5,6 +5,7 @@ import pytest import salt.loader +import salt.modules.boto_cloudtrail as boto_cloudtrail_module import salt.states.boto_cloudtrail as boto_cloudtrail from tests.support.mock import MagicMock, patch @@ -98,7 +99,12 @@ def configure_loader_modules(minion_opts): "__utils__": utils, "__states__": salt_states, "__serializers__": serializers, - } + }, + boto_cloudtrail_module: { + "__opts__": minion_opts, + "__salt__": funcs, + "__utils__": utils, + }, } diff --git a/tests/pytests/unit/states/test_boto_cloudwatch_event.py b/tests/pytests/unit/states/test_boto_cloudwatch_event.py index 49a8a769d572..0275c57a56c4 100644 --- a/tests/pytests/unit/states/test_boto_cloudwatch_event.py +++ b/tests/pytests/unit/states/test_boto_cloudwatch_event.py @@ -5,6 +5,7 @@ import pytest import salt.loader +import salt.modules.boto_cloudwatch_event as boto_cloudwatch_event_module import salt.states.boto_cloudwatch_event as boto_cloudwatch_event from tests.support.mock import MagicMock, patch @@ -92,7 +93,12 @@ def configure_loader_modules(minion_opts): "__utils__": utils, "__states__": salt_states, "__serializers__": serializers, - } + }, + boto_cloudwatch_event_module: { + "__opts__": minion_opts, + "__salt__": funcs, + "__utils__": utils, + }, } diff --git a/tests/pytests/unit/states/test_boto_elasticsearch_domain.py b/tests/pytests/unit/states/test_boto_elasticsearch_domain.py index ab9e1b7bc727..c01471a2b355 100644 --- a/tests/pytests/unit/states/test_boto_elasticsearch_domain.py +++ b/tests/pytests/unit/states/test_boto_elasticsearch_domain.py @@ -6,6 +6,7 @@ import salt.config import salt.loader +import salt.modules.boto_elasticsearch_domain as boto_elasticsearch_domain_module import salt.states.boto_elasticsearch_domain as boto_elasticsearch_domain from tests.support.mock import MagicMock, patch @@ -86,7 +87,12 @@ def configure_loader_modules(minion_opts): "__utils__": utils, "__states__": salt_states, "__serializers__": serializers, - } + }, + boto_elasticsearch_domain_module: { + "__opts__": minion_opts, + "__salt__": funcs, + "__utils__": utils, + }, } diff --git a/tests/pytests/unit/states/test_boto_iot.py b/tests/pytests/unit/states/test_boto_iot.py index ba5f0e522b26..c486c60d13ac 100644 --- a/tests/pytests/unit/states/test_boto_iot.py +++ b/tests/pytests/unit/states/test_boto_iot.py @@ -6,6 +6,7 @@ import salt.config import salt.loader +import salt.modules.boto_iot as boto_iot_module import salt.states.boto_iot as boto_iot from tests.support.mock import MagicMock, patch @@ -136,7 +137,12 @@ def configure_loader_modules(minion_opts): "__utils__": utils, "__states__": salt_states, "__serializers__": serializers, - } + }, + boto_iot_module: { + "__opts__": minion_opts, + "__salt__": funcs, + "__utils__": utils, + }, } diff --git a/tests/pytests/unit/states/test_boto_lambda.py b/tests/pytests/unit/states/test_boto_lambda.py index 400af9b23c8b..418a050f6858 100644 --- a/tests/pytests/unit/states/test_boto_lambda.py +++ b/tests/pytests/unit/states/test_boto_lambda.py @@ -6,6 +6,7 @@ import salt.config import salt.loader +import salt.modules.boto_lambda as boto_lambda_module import salt.states.boto_lambda as boto_lambda import salt.utils.json from tests.support.mock import MagicMock, patch @@ -113,7 +114,12 @@ def configure_loader_modules(minion_opts): "__utils__": utils, "__states__": salt_states, "__serializers__": serializers, - } + }, + boto_lambda_module: { + "__opts__": minion_opts, + "__salt__": funcs, + "__utils__": utils, + }, } diff --git a/tests/pytests/unit/states/test_boto_s3_bucket.py b/tests/pytests/unit/states/test_boto_s3_bucket.py index 340faa733cf2..3d2c99606397 100644 --- a/tests/pytests/unit/states/test_boto_s3_bucket.py +++ b/tests/pytests/unit/states/test_boto_s3_bucket.py @@ -6,6 +6,7 @@ import pytest import salt.loader +import salt.modules.boto_s3_bucket as boto_s3_bucket_module import salt.states.boto_s3_bucket as boto_s3_bucket from tests.support.mock import MagicMock, patch @@ -214,7 +215,12 @@ def configure_loader_modules(minion_opts): "__utils__": utils, "__states__": salt_states, "__serializers__": serializers, - } + }, + boto_s3_bucket_module: { + "__opts__": minion_opts, + "__salt__": funcs, + "__utils__": utils, + }, } diff --git a/tests/unit/states/test_boto_apigateway.py b/tests/unit/states/test_boto_apigateway.py index a00514aeff04..f916d61a2530 100644 --- a/tests/unit/states/test_boto_apigateway.py +++ b/tests/unit/states/test_boto_apigateway.py @@ -8,6 +8,7 @@ import salt.config import salt.loader +import salt.modules.boto_apigateway as boto_apigateway_module import salt.states.boto_apigateway as boto_apigateway import salt.utils.files import salt.utils.yaml @@ -520,7 +521,12 @@ def setup_loader_modules(self): "__salt__": self.funcs, "__states__": self.salt_states, "__serializers__": serializers, - } + }, + boto_apigateway_module: { + "__opts__": self.opts, + "__utils__": utils, + "__salt__": self.funcs, + }, } # Set up MagicMock to replace the boto3 session diff --git a/tests/unit/states/test_boto_cognitoidentity.py b/tests/unit/states/test_boto_cognitoidentity.py index 8354b50d13fe..648e9d9975c8 100644 --- a/tests/unit/states/test_boto_cognitoidentity.py +++ b/tests/unit/states/test_boto_cognitoidentity.py @@ -6,6 +6,7 @@ import salt.config import salt.loader +import salt.modules.boto_cognitoidentity as boto_cognitoidentity_module import salt.states.boto_cognitoidentity as boto_cognitoidentity from salt.utils.versions import Version from tests.support.mixins import LoaderModuleMockMixin @@ -170,7 +171,19 @@ def setup_loader_modules(self): "__utils__": utils, "__states__": self.salt_states, "__serializers__": serializers, - } + }, + # Also override the exec module's ``__salt__`` with ``funcs``. + # ``whitelist_modules`` now only restricts what remote callers + # can invoke -- modules loaded through the whitelisted loader + # receive an *unfiltered* ``__salt__`` (see #69983), so mocks + # patched into ``self.funcs`` are otherwise invisible when the + # exec module reaches back through ``__salt__[...]`` (e.g. + # ``_get_role_arn`` calls ``__salt__["boto_iam.describe_role"]``). + boto_cognitoidentity_module: { + "__opts__": self.opts, + "__salt__": funcs, + "__utils__": utils, + }, } @classmethod diff --git a/tests/unit/states/test_boto_vpc.py b/tests/unit/states/test_boto_vpc.py index 32305d1a5891..24d526724cd8 100644 --- a/tests/unit/states/test_boto_vpc.py +++ b/tests/unit/states/test_boto_vpc.py @@ -6,6 +6,7 @@ import pytest import salt.config +import salt.modules.boto_vpc as boto_vpc_module import salt.states.boto_vpc as boto_vpc import salt.utils.botomod as botomod from salt.utils.versions import Version @@ -119,6 +120,11 @@ def setup_loader_modules(self): "__states__": self.salt_states, "__serializers__": serializers, }, + boto_vpc_module: { + "__opts__": self.opts, + "__salt__": self.funcs, + "__utils__": utils, + }, botomod: {}, } From ccecccf8d9b17714e2a38852fb99437f9ac74f3f Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sat, 8 Aug 2026 01:20:27 -0700 Subject: [PATCH 268/469] wip --- salt/loader/__init__.py | 55 ++++++- salt/modules/saltcheck.py | 19 ++- .../loader/test_module_whitelist_dunder.py | 139 ++++++++++++++++++ .../renderers/test_renderer_whitelist.py | 99 +++++++++++++ 4 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 tests/pytests/integration/loader/test_module_whitelist_dunder.py create mode 100644 tests/pytests/integration/renderers/test_renderer_whitelist.py diff --git a/salt/loader/__init__.py b/salt/loader/__init__.py index ec1033ff599b..4990f6f1d809 100644 --- a/salt/loader/__init__.py +++ b/salt/loader/__init__.py @@ -358,6 +358,14 @@ def minion_mods( # TODO Publish documentation for module whitelisting if not whitelist: whitelist = opts.get("whitelist_modules", None) + # Both loaders must share the same ``__context__`` dict. If we + # leave it as ``None`` LazyLoader.__init__ replaces it with a fresh + # ``{}`` in each loader's ``self.pack``, so writes made via one + # loader's NamedLoaderContext never reach reads made via the other's. + # Materialising the dict here keeps both packs pointing at the same + # object. + if context is None: + context = {} pack = { "__context__": context, "__utils__": utils, @@ -365,6 +373,26 @@ def minion_mods( "__opts__": opts, "__file_client__": file_client, } + # Two-loader model: outer loader is whitelist-filtered for wire + # dispatch; inner ``salt_dunder`` is unfiltered and packed as + # ``__salt__`` inside every loaded module, so a whitelisted module + # can still compose with non-whitelisted modules via ``__salt__[...]``. + # When no whitelist is set both loaders load the same set of modules; + # LazyLoader reuses an existing per-module ``LoaderContext`` when it + # encounters one, so both loaders share the same NamedLoaderContext + # bindings and per-module ``__context__`` state stays consistent. + salt_dunder = LazyLoader( + _module_dirs(opts, "modules", "module"), + opts, + tag="module", + pack=pack, + loaded_base_name=loaded_base_name, + static_modules=static_modules, + extra_module_dirs=utils.module_dirs if utils else None, + pack_self="__salt__", + ) + pack = dict(pack) + pack["__salt__"] = salt_dunder if pillar is not None: pack["__pillar__"] = pillar ret = LazyLoader( @@ -376,12 +404,35 @@ def minion_mods( loaded_base_name=loaded_base_name, static_modules=static_modules, extra_module_dirs=utils.module_dirs if utils else None, - pack_self="__salt__", ) + # Test / callsite compatibility: ``patch.dict(ret, {...})`` was the way + # pre-split-loader tests injected mocks that both the wire-dispatch path + # AND internal ``__salt__[...]`` composition would see, because there was + # only one loader. With the split, exec modules' ``__salt__`` is now the + # unfiltered inner ``salt_dunder`` and writes to ``ret`` don't reach it. + # Mirror writes made on ``ret`` into ``salt_dunder._dict`` so the classic + # ``patch.dict(ret, ...)`` idiom still works; reads through ``ret`` still + # go through ``_load()`` (which enforces the whitelist) so the security + # boundary at wire dispatch is preserved. + _salt_dunder = salt_dunder + + class _WriteThroughLoader(type(ret)): # noqa: N801 + __module__ = type(ret).__module__ + + def __setitem__(self, key, val): + LazyLoader.__setitem__(self, key, val) + _salt_dunder._dict[key] = val + + def __delitem__(self, key): + LazyLoader.__delitem__(self, key) + _salt_dunder._dict.pop(key, None) + + ret.__class__ = _WriteThroughLoader + # Allow the usage of salt dunder in utils modules. if utils and isinstance(utils, LazyLoader): - utils.pack["__salt__"] = ret + utils.pack["__salt__"] = salt_dunder # Load any provider overrides from the configuration file providers option # Note: Providers can be pkg, service, user or group - not to be confused diff --git a/salt/modules/saltcheck.py b/salt/modules/saltcheck.py index fa0e549192e1..a7a189c37edb 100644 --- a/salt/modules/saltcheck.py +++ b/salt/modules/saltcheck.py @@ -298,15 +298,22 @@ log = logging.getLogger(__name__) -try: - __context__ -except NameError: - __context__ = {} -__context__["global_scheck"] = None - __virtualname__ = "saltcheck" +def __init__(opts): + # Initialise ``global_scheck`` in the loader's ``__context__`` on + # every load, but only if no previous load has already populated it. + # Doing this at module top-level would be unsafe: module-level code + # runs *before* the loader's pack loop binds ``__context__`` to the + # loader's ``NamedLoaderContext``, so a fresh dict created there is + # orphaned when the pack loop rewires ``__context__``. It would + # also unconditionally reset the entry on every ``exec_module``, + # clobbering the ``SaltCheck`` instance a running call has already + # stored. + __context__.setdefault("global_scheck", None) + + def __virtual__(): """ Set the virtual pkg module if not running as a proxy diff --git a/tests/pytests/integration/loader/test_module_whitelist_dunder.py b/tests/pytests/integration/loader/test_module_whitelist_dunder.py new file mode 100644 index 000000000000..c4c015f1b2b7 --- /dev/null +++ b/tests/pytests/integration/loader/test_module_whitelist_dunder.py @@ -0,0 +1,139 @@ +""" +Integration tests for the split-loader behavior in ``salt.loader.minion_mods``. + +``minion_mods()`` returns a whitelist-filtered LazyLoader for remote +dispatch, but packs an *unfiltered* loader as ``__salt__`` inside every +loaded module. + +Effect on a whitelisted minion: + - Remote publishers can only invoke functions from whitelisted modules. + - A whitelisted module can still compose with non-whitelisted modules + via ``__salt__[...]``. +""" + +import pytest + +from tests.conftest import FIPS_TESTRUN + +SECTEST_MODULE = """ +def run(cmd): + return __salt__["cmd.run"](cmd) +""" + + +@pytest.fixture +def whitelisted_minion(salt_master): + """ + A minion configured with ``whitelist_modules: [test, sectest, saltutil]``. + ``cmd`` is *deliberately absent* from the whitelist. + """ + minion = salt_master.salt_minion_daemon( + "test-whitelist-dunder-minion", + overrides={ + "whitelist_modules": [ + "test", + "sectest", + "saltutil", + # Needed for the SLS-render tests below (state.template_str + # touches config/grains/pillar/slsutil during compilation). + "state", + "config", + "grains", + "pillar", + "slsutil", + ], + "fips_mode": FIPS_TESTRUN, + "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", + "signing_algorithm": ( + "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1" + ), + }, + ) + minion.after_terminate( + pytest.helpers.remove_stale_minion_key, salt_master, minion.id + ) + with salt_master.state_tree.base.temp_file("_modules/sectest.py", SECTEST_MODULE): + with minion.started(): + salt_cli = salt_master.salt_cli() + salt_cli.run("saltutil.sync_modules", minion_tgt=minion.id) + yield minion + + +def test_whitelisted_function_returns(salt_cli, whitelisted_minion): + """ + ``test.ping`` is on the whitelist and must return normally. + """ + ret = salt_cli.run("test.ping", minion_tgt=whitelisted_minion.id) + assert ret.data is True + + +def test_nonwhitelisted_function_is_blocked(salt_cli, whitelisted_minion): + """ + ``cmd.run`` is *not* on the whitelist. Remote publish must not + execute it: the minion's outer (filtered) loader has no ``cmd`` + entry, so the function is unavailable and the CLI reports either + "'cmd.run' is not available." or "Minion did not return" -- both + prove the whitelist rejected the call. + """ + ret = salt_cli.run( + "cmd.run", "echo blocked", minion_tgt=whitelisted_minion.id, _timeout=15 + ) + data = str(ret.data or "") + assert "not available" in data or "did not return" in data + + +def test_whitelisted_module_reaches_nonwhitelisted_via_dunder( + salt_cli, whitelisted_minion +): + """ + ``sectest`` is whitelisted; its ``run()`` internally calls + ``__salt__['cmd.run']``. Because the packed ``__salt__`` is the + *unfiltered* loader, the call succeeds even though direct remote + dispatch of ``cmd.run`` is blocked (previous test). + """ + ret = salt_cli.run( + "sectest.run", "echo hello-from-dunder", minion_tgt=whitelisted_minion.id + ) + assert ret.data == "hello-from-dunder" + + +def test_sls_render_can_call_whitelisted_module(salt_cli, whitelisted_minion): + """ + SLS files render on the minion with the whitelist-filtered loader + exposed as ``salt`` / ``__salt__``. A whitelisted module call inside + the template must render normally and the resulting state must run. + """ + template = ( + "{% set r = salt['test.echo']('hi-from-sls') %}\n" + "probe:\n" + " test.nop:\n" + " - name: {{ r }}\n" + ) + ret = salt_cli.run("state.template_str", template, minion_tgt=whitelisted_minion.id) + # state.template_str returns a dict keyed by state chunk id. + assert isinstance(ret.data, dict) + key = next(iter(ret.data)) + assert ret.data[key]["result"] is True + assert ret.data[key]["name"] == "hi-from-sls" + + +def test_sls_render_cannot_call_nonwhitelisted_module(salt_cli, whitelisted_minion): + """ + ``cmd`` is not on ``whitelist_modules``. A template that tries + ``salt['cmd.run'](...)`` must fail *at render time* -- the render + pipeline receives the same filtered loader that the wire dispatch + uses, not the unfiltered ``salt_dunder`` that execution modules see. + + Jinja surfaces the missing key as ``UndefinedError: '...AliasedLoader + object' has no attribute 'cmd.run'``. + """ + template = ( + "{% set r = salt['cmd.run']('id') %}\n" + "probe:\n" + " test.nop:\n" + " - name: {{ r }}\n" + ) + ret = salt_cli.run("state.template_str", template, minion_tgt=whitelisted_minion.id) + text = str(ret.data or ret.stdout) + assert "cmd.run" in text + assert "UndefinedError" in text or "no attribute" in text diff --git a/tests/pytests/integration/renderers/test_renderer_whitelist.py b/tests/pytests/integration/renderers/test_renderer_whitelist.py new file mode 100644 index 000000000000..b27d31d44fe1 --- /dev/null +++ b/tests/pytests/integration/renderers/test_renderer_whitelist.py @@ -0,0 +1,99 @@ +""" +Integration tests for the minion-side ``renderer_whitelist`` opt. + +Setting ``renderer_whitelist: [jinja, yaml]`` on a minion must prevent +SLS files that request other renderers (``#!py``, ``#!pyobjects``, +``#!pydsl``, ``#!mako``, ``#!wempy``) from rendering. Without the +whitelist, a ``#!py`` SLS executes arbitrary Python on the minion +during render -- so this is a real defense-in-depth boundary. +""" + +import pytest + +from tests.conftest import FIPS_TESTRUN + +PY_SLS = """#!py +def run(): + return {"probe": {"test.nop": [{"name": "hi-from-py-sls"}]}} +""" + +JINJA_SLS = ( + "{% set r = salt['test.echo']('hi-from-jinja') %}\n" + "probe:\n" + " test.nop:\n" + " - name: {{ r }}\n" +) + + +@pytest.fixture +def renderer_whitelisted_minion(salt_master): + """ + Minion with ``renderer_whitelist: [jinja, yaml]``. Also whitelists + the execution modules that ``state.template_str`` needs internally + so we can drive rendering through a single top-level call. + """ + minion = salt_master.salt_minion_daemon( + "test-renderer-whitelist-minion", + overrides={ + "renderer_whitelist": ["jinja", "yaml"], + "whitelist_modules": [ + "test", + "state", + "saltutil", + "config", + "grains", + "pillar", + "slsutil", + ], + "fips_mode": FIPS_TESTRUN, + "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", + "signing_algorithm": ( + "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1" + ), + }, + ) + minion.after_terminate( + pytest.helpers.remove_stale_minion_key, salt_master, minion.id + ) + with minion.started(): + yield minion + + +def test_default_pipeline_still_renders(salt_cli, renderer_whitelisted_minion): + """ + A plain SLS (no shebang) uses the default ``jinja|yaml`` pipe -- both + are on the whitelist, so rendering must succeed. + """ + ret = salt_cli.run( + "state.template_str", + JINJA_SLS, + minion_tgt=renderer_whitelisted_minion.id, + ) + assert isinstance(ret.data, dict), f"unexpected return: {ret.data!r}" + key = next(iter(ret.data)) + assert ret.data[key]["result"] is True + assert ret.data[key]["name"] == "hi-from-jinja" + + +def test_shebang_py_renderer_is_rejected(salt_cli, renderer_whitelisted_minion): + """ + An SLS starting with ``#!py`` requests the ``py`` renderer, which is + NOT on the whitelist. ``check_render_pipe_str`` drops it, the render + pipe becomes empty, and ``state.template_str`` reports no data -- + the arbitrary-Python-in-SLS attack surface is closed. + + Also verifies via the minion log that the renderer was rejected + with the standard ``The renderer "..." is not available`` warning. + """ + ret = salt_cli.run( + "state.template_str", + PY_SLS, + minion_tgt=renderer_whitelisted_minion.id, + ) + # A rejected render returns falsy data (empty dict / empty list / + # error string). Positively assert the Python body did NOT execute: + # a successful #!py render would produce a ``probe`` state chunk + # named ``hi-from-py-sls``. + text = str(ret.data or "") + assert "hi-from-py-sls" not in text + assert "test.nop" not in text From 9fe3a3b800d89e2e3a679b351d50f4853bad32e7 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 5 Aug 2026 14:55:38 -0700 Subject: [PATCH 269/469] Wire ipc_write_buffer through to TCP transport per-stream cap (#69930) The ipc_write_buffer master option was left in the config schema after the legacy salt.transport.ipc module was removed in 3008.x, but was no longer read by any code path -- so setting it in master.conf was a silent no-op. Without a per-stream cap, a slow/blocked event-bus subscriber lets Tornado's per-connection outbound IOStream write buffer grow without bound, driving RSS growth on masters under sustained event churn. Apply the configured value as ``max_write_buffer_size`` on the Tornado IOStream in: - PubServer.handle_stream (plaintext subscribers) - PubServer._validate_ssl_and_add_client (SSL-delayed subscribers) - SaltMessageServer.handle_stream (request/reply clients) The default (unset / 0) preserves the existing unlimited-buffer behavior; operators opt in by setting an explicit byte value. --- changelog/69930.fixed.md | 1 + salt/transport/tcp.py | 36 +++++ tests/pytests/unit/transport/test_tcp.py | 167 +++++++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 changelog/69930.fixed.md diff --git a/changelog/69930.fixed.md b/changelog/69930.fixed.md new file mode 100644 index 000000000000..2646422699b3 --- /dev/null +++ b/changelog/69930.fixed.md @@ -0,0 +1 @@ +Wired the ``ipc_write_buffer`` master option through to the TCP transport in 3008.x. The option remained in the config schema after the legacy ``salt.transport.ipc`` module was removed but was no longer read by any code path, so setting it in ``master.conf`` had no effect. It now caps the per-stream Tornado outbound ``max_write_buffer_size`` on both ``PubServer`` (event-bus subscribers, plaintext and SSL-delayed paths) and ``SaltMessageServer`` (request/reply clients), matching the semantics of the legacy IPC module's per-connection cap. The default (unset / ``0``) preserves the existing unlimited-buffer behavior; operators opt in by setting an explicit byte value. diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index c17c21cd667c..167ef0c0fd7b 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -737,11 +737,17 @@ def post_fork(self, message_handler, io_loop, **kwargs): ctx = None if self.ssl is not None: ctx = salt.transport.base.ssl_context(self.ssl, server_side=True) + # See issue #69930: pass the configured cap through to the + # per-stream Tornado outbound write buffer. ``ipc_write_buffer`` + # is the legacy option name kept for master.conf compatibility; + # it was a no-op on 3008.x until this wiring was added. + max_write_buffer_size = self.opts.get("ipc_write_buffer") or None if USE_LOAD_BALANCER: self.req_server = LoadBalancerWorker( self.socket_queue, self.handle_message, ssl_options=ctx, + max_write_buffer_size=max_write_buffer_size, ) else: if salt.utils.platform.is_windows(): @@ -754,6 +760,7 @@ def post_fork(self, message_handler, io_loop, **kwargs): self.handle_message, ssl_options=ctx, io_loop=io_loop, + max_write_buffer_size=max_write_buffer_size, ) self.req_server.add_socket(self._socket) self._socket.listen(self.backlog) @@ -806,6 +813,11 @@ class SaltMessageServer(tornado.tcpserver.TCPServer): def __init__(self, message_handler, *args, **kwargs): io_loop = kwargs.pop("io_loop", None) or tornado.ioloop.IOLoop.current() + # ``ipc_write_buffer`` (the legacy option name preserved for + # backwards-compat with ``master.conf``) caps the per-stream + # Tornado outbound write buffer. ``0`` / ``None`` == unlimited + # (Tornado default), matching prior behavior. + self.max_write_buffer_size = kwargs.pop("max_write_buffer_size", None) or None self._closing = False super().__init__(*args, **kwargs) self.io_loop = io_loop @@ -823,6 +835,12 @@ async def handle_stream( # pylint: disable=arguments-differ,invalid-overridden- Handle incoming streams and add messages to the incoming queue """ log.trace("Req client %s connected", address) + if self.max_write_buffer_size: + # See issue #69930: cap the outbound IOStream buffer per accepted + # request/reply client so a slow consumer can't grow it without + # bound. Tornado's ``TCPServer`` builds the ``IOStream`` before + # dispatching to ``handle_stream``, so we set the attribute here. + stream.max_write_buffer_size = self.max_write_buffer_size self.clients.append((stream, address)) unpacker = salt.utils.msgpack.Unpacker() try: @@ -1409,11 +1427,28 @@ def handle_stream(self, stream, address): self._validate_ssl_and_add_client(stream, address) ) return + self._apply_write_buffer_cap(stream) client = Subscriber(stream, address) self.clients.add(client) stream.set_close_callback(self._discard_on_close(client)) self.io_loop.create_task(self._stream_read(client)) + def _apply_write_buffer_cap(self, stream): + """ + Cap the accepted stream's outbound write buffer per ``ipc_write_buffer``. + + See issue #69930: the legacy ``salt.transport.ipc`` module was + removed in 3008.x but the ``ipc_write_buffer`` opt remained in + the config schema. Without this cap, Tornado defaults the + per-stream write buffer to unlimited, so a slow / blocked + event-bus subscriber lets the master's outbound bytearray grow + without bound (RSS growth observed on prod masters under event + burst). ``0`` / falsy preserves prior behavior (unlimited). + """ + cap = self.opts.get("ipc_write_buffer") or None + if cap: + stream.max_write_buffer_size = cap + async def _validate_ssl_and_add_client(self, stream, address): """ Validate SSL handshake completed successfully before accepting client. @@ -1434,6 +1469,7 @@ async def _validate_ssl_and_add_client(self, stream, address): return # Successfully got cert - add client + self._apply_write_buffer_cap(stream) client = Subscriber(stream, address) self.clients.add(client) stream.set_close_callback(self._discard_on_close(client)) diff --git a/tests/pytests/unit/transport/test_tcp.py b/tests/pytests/unit/transport/test_tcp.py index b06dd10a92b6..59285195163f 100644 --- a/tests/pytests/unit/transport/test_tcp.py +++ b/tests/pytests/unit/transport/test_tcp.py @@ -1509,3 +1509,170 @@ def closed(self): # The "boom" was dropped by the except-log-and-continue guard; the # other two got through. assert handled == ["ok1", "ok2"] + + +# --------------------------------------------------------------------------- +# issue #69930: ipc_write_buffer wired through to per-stream cap. +# --------------------------------------------------------------------------- + + +async def test_salt_message_server_applies_ipc_write_buffer(master_opts): + """ + ``SaltMessageServer.handle_stream`` must set the accepted stream's + ``max_write_buffer_size`` to the ``ipc_write_buffer`` value passed + in. Without this wiring (regression on 3008.x after the legacy + ``salt.transport.ipc`` module was dropped), setting + ``ipc_write_buffer`` in ``master.conf`` was a no-op and the + outbound IOStream buffer grew without bound under slow-consumer + conditions. See issue #69930. + """ + + def handler(stream, body, header): # pylint: disable=unused-argument + return None + + cap = 12345 + server = salt.transport.tcp.SaltMessageServer(handler, max_write_buffer_size=cap) + + class Stream: + def __init__(self): + self.max_write_buffer_size = None + + def read_bytes(self, *args, **kwargs): + raise tornado.iostream.StreamClosedError() + + stream = Stream() + await server.handle_stream(stream, "client-cap") + + assert stream.max_write_buffer_size == cap + + +async def test_salt_message_server_no_cap_by_default(master_opts): + """ + Not passing ``max_write_buffer_size`` (or passing 0) must leave the + stream untouched -- preserves Tornado's default (unlimited) and + matches prior behavior when ``ipc_write_buffer`` is not set in + ``master.conf``. + """ + + def handler(stream, body, header): # pylint: disable=unused-argument + return None + + server = salt.transport.tcp.SaltMessageServer(handler) + assert server.max_write_buffer_size is None + + server_zero = salt.transport.tcp.SaltMessageServer(handler, max_write_buffer_size=0) + assert server_zero.max_write_buffer_size is None + + class Stream: + def __init__(self): + self.max_write_buffer_size = "sentinel" + + def read_bytes(self, *args, **kwargs): + raise tornado.iostream.StreamClosedError() + + stream = Stream() + await server.handle_stream(stream, "client-nocap") + # Untouched -- the sentinel is still there. + assert stream.max_write_buffer_size == "sentinel" + + +def test_pub_server_applies_ipc_write_buffer(master_opts, io_loop): + """ + ``PubServer.handle_stream`` must set the accepted stream's + ``max_write_buffer_size`` to ``opts['ipc_write_buffer']`` when set. + See issue #69930. + """ + master_opts["ipc_write_buffer"] = 54321 + server = salt.transport.tcp.PubServer(master_opts, io_loop=io_loop) + + class Stream: + def __init__(self): + self.max_write_buffer_size = None + self.socket = MagicMock() + self.socket.getpeercert.return_value = None + self._closed = False + + def set_close_callback(self, cb): + pass + + def close(self): + self._closed = True + + def closed(self): + return self._closed + + stream = Stream() + try: + with patch.object( + server, "_stream_read", MagicMock(return_value=None) + ), patch.object(server.io_loop, "create_task"): + server.handle_stream(stream, ("127.0.0.1", 12345)) + finally: + server.close() + + assert stream.max_write_buffer_size == 54321 + + +def test_pub_server_no_cap_when_ipc_write_buffer_zero(master_opts, io_loop): + """ + ``ipc_write_buffer == 0`` (the default when the operator hasn't + opted in) must leave the stream's ``max_write_buffer_size`` + untouched -- preserving Tornado's unlimited-write-buffer default. + """ + master_opts["ipc_write_buffer"] = 0 + server = salt.transport.tcp.PubServer(master_opts, io_loop=io_loop) + + class Stream: + def __init__(self): + self.max_write_buffer_size = "sentinel" + self.socket = MagicMock() + self.socket.getpeercert.return_value = None + self._closed = False + + def set_close_callback(self, cb): + pass + + def close(self): + self._closed = True + + def closed(self): + return self._closed + + stream = Stream() + try: + with patch.object( + server, "_stream_read", MagicMock(return_value=None) + ), patch.object(server.io_loop, "create_task"): + server.handle_stream(stream, ("127.0.0.1", 12345)) + finally: + server.close() + + assert stream.max_write_buffer_size == "sentinel" + + +def test_pub_server_apply_write_buffer_cap_helper(master_opts, io_loop): + """ + ``_apply_write_buffer_cap`` is the shared helper used by both the + plaintext ``handle_stream`` path and the SSL-delayed + ``_validate_ssl_and_add_client`` path. Verify the helper's contract + directly so both call sites are covered. + """ + master_opts["ipc_write_buffer"] = 99999 + server = salt.transport.tcp.PubServer(master_opts, io_loop=io_loop) + + class Stream: + max_write_buffer_size = None + + stream = Stream() + server._apply_write_buffer_cap(stream) + assert stream.max_write_buffer_size == 99999 + + master_opts["ipc_write_buffer"] = 0 + server2 = salt.transport.tcp.PubServer(master_opts, io_loop=io_loop) + + class Stream2: + max_write_buffer_size = "sentinel" + + stream2 = Stream2() + server2._apply_write_buffer_cap(stream2) + assert stream2.max_write_buffer_size == "sentinel" From 46fc3b1fb4dec23f3213352cbee62c8d3d862bb0 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 5 Aug 2026 14:53:11 -0700 Subject: [PATCH 270/469] Fix localfs cache tmp-file leak when key contains a path separator Since 3008.0 the pillar cache key is `:`. When `pillarenv` contains `/` (e.g. `pillar_roots` names like `someenv/beta`), `localfs.store()` computed an ``outfile`` inside a subdirectory that did not exist. The atomic rename then failed and the `tempfile.mkstemp` tmp file was never cleaned up, producing millions of orphan `tmp*` files under `/var/cache/salt/master/pillar/` and a repeating traceback in the master log every pillar compile. Make `localfs.store()`: * create the parent directory of the target file so keys with `/` are stored at the natural nested path, * always remove its `tempfile.mkstemp` scratch file when the write or rename raises. Fixes #69741 --- changelog/69741.fixed.md | 1 + salt/cache/localfs.py | 28 +++++++ .../pytests/functional/cache/test_localfs.py | 79 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 changelog/69741.fixed.md diff --git a/changelog/69741.fixed.md b/changelog/69741.fixed.md new file mode 100644 index 000000000000..29d1a1e6ccdb --- /dev/null +++ b/changelog/69741.fixed.md @@ -0,0 +1 @@ +Fixed `localfs` cache leaking temporary files and raising `FileNotFoundError` when the cache key contained a path separator (e.g. a `pillarenv` with `/` in it). `localfs.store()` now creates the parent directory of the target file and always removes its `tempfile.mkstemp` scratch file on failure. diff --git a/salt/cache/localfs.py b/salt/cache/localfs.py index 855598e7952e..8ba547ca703c 100644 --- a/salt/cache/localfs.py +++ b/salt/cache/localfs.py @@ -56,6 +56,21 @@ def store(bank, key, data, cachedir): ) outfile = salt.utils.path.join(base, f"{key}.p") + # A ``key`` may legitimately contain path separators (e.g. the pillar + # cache uses ``:`` as its key, and ``pillarenv`` + # may itself contain ``/`` when pillar_roots use hierarchical names). + # In that case ``outfile`` lands in a subdirectory that may not exist + # yet -- create it so the atomic rename below can succeed. See + # issue #69741. + outdir = os.path.dirname(outfile) + if outdir and outdir != base: + try: + os.makedirs(outdir, exist_ok=True) + except OSError as exc: + raise SaltCacheError( + f"The cache directory, {outdir}, could not be created: {exc}" + ) + tmpfh, tmpfname = tempfile.mkstemp(dir=base) os.close(tmpfh) try: @@ -67,6 +82,19 @@ def store(bank, key, data, cachedir): raise SaltCacheError( f"There was an error writing the cache file, {base}: {exc}" ) + finally: + # ``atomic_rename`` moves ``tmpfname`` to ``outfile`` on success, so + # the tmp file is only left behind when the write or rename failed. + # Not cleaning this up caused the pillar cache to accumulate + # millions of leaked ``tmp*`` files (issue #69741). + if os.path.exists(tmpfname): + try: + os.remove(tmpfname) + except OSError: + log.debug( + "Could not remove leftover localfs cache tmp file %s", + tmpfname, + ) def fetch(bank, key, cachedir): diff --git a/tests/pytests/functional/cache/test_localfs.py b/tests/pytests/functional/cache/test_localfs.py index 6cddff4e026d..bf7432fe3b65 100644 --- a/tests/pytests/functional/cache/test_localfs.py +++ b/tests/pytests/functional/cache/test_localfs.py @@ -85,6 +85,85 @@ def test_contains_is_constrained_to_cachedir(cache, tmp_path, key): assert not cache.contains(str(tmp_path), key) +def test_store_key_with_path_separator_does_not_leak_tmp_files_69741(cache): + """ + Regression test for issue #69741. + + Since 3008.0 the pillar cache uses ``:`` as its + cache key. When ``pillarenv`` contains ``/`` (e.g. a + hierarchical ``pillar_roots`` name like ``someenv/beta``) the key + contains a path separator, so ``outfile`` in ``localfs.store()`` + lands in a subdirectory that did not exist yet. The atomic rename + then failed with ``FileNotFoundError`` and the tmp file created by + ``tempfile.mkstemp`` was left behind. Reporters saw millions of + leaked ``tmp*`` files under ``/var/cache/salt/master/pillar/``. + + Storing a key that contains ``/`` must: + * succeed without raising, + * write the value to the expected nested path, + * be readable back via ``fetch``, + * and leave no ``tmp*`` files behind in the bank directory. + """ + bank = "pillar" + key = "minion.example:someenv/beta" + + cache.store(bank, key, {"hello": "world"}) + + assert cache.fetch(bank, key) == {"hello": "world"} + + bank_dir = Path(cache.cachedir) / bank + leftover = [ + entry.name + for entry in bank_dir.iterdir() + if entry.name.startswith("tmp") and entry.is_file() + ] + assert not leftover, ( + f"localfs.store() leaked tmp files into {bank_dir}: {leftover} " + "(issue #69741)" + ) + + +def test_store_tmp_file_cleaned_up_on_write_failure_69741(cache, monkeypatch): + """ + Regression test for issue #69741 (defensive). + + Even when the atomic rename fails for reasons unrelated to the key + path (e.g. a lower-level ``OSError``), ``localfs.store()`` must not + leave the ``tempfile.mkstemp`` tmp file behind. Prior to the fix, + every failed store leaked one ``tmp*`` file into the bank + directory; over time this produced millions of orphan files. + """ + import salt.utils.atomicfile + + def _boom(src, dst): + raise OSError(2, "boom", src) + + monkeypatch.setattr(salt.utils.atomicfile, "atomic_rename", _boom) + # localfs.py binds ``salt.utils.atomicfile`` at import time via + # ``salt.utils.atomicfile.atomic_rename``; patching the attribute on + # the module object is sufficient because the lookup happens at call + # time. + + bank = "pillar" + key = "some-minion" + + from salt.exceptions import SaltCacheError + + with pytest.raises(SaltCacheError): + cache.store(bank, key, {"hello": "world"}) + + bank_dir = Path(cache.cachedir) / bank + leftover = [ + entry.name + for entry in bank_dir.iterdir() + if entry.name.startswith("tmp") and entry.is_file() + ] + assert not leftover, ( + f"localfs.store() leaked tmp files into {bank_dir}: {leftover} " + "(issue #69741)" + ) + + def test_clean_expired_does_not_drop_unexpired_entries_69307(cache): """ Regression test for issue #69307. From 14eb2dbd3bf1d276436586a17a03b39e7ee69b0a Mon Sep 17 00:00:00 2001 From: darkdi Date: Thu, 6 Aug 2026 20:37:06 +0300 Subject: [PATCH 271/469] Fix docstring :param: names that do not match the signatures --- changelog/69966.fixed.md | 1 + salt/client/__init__.py | 2 -- salt/loader/__init__.py | 2 -- salt/loader/lazy.py | 2 +- salt/master.py | 4 ++-- salt/modules/baredoc.py | 4 ++-- salt/modules/bcache.py | 1 - salt/modules/cassandra_cql.py | 2 -- salt/modules/napalm_network.py | 2 +- salt/modules/rpm_lowpkg.py | 2 +- salt/returners/local_cache.py | 2 +- salt/returners/pgjsonb.py | 2 +- salt/utils/dns.py | 8 ++++---- salt/utils/event.py | 2 -- salt/utils/extend.py | 4 ---- salt/utils/network.py | 2 +- salt/utils/thin.py | 3 --- 17 files changed, 15 insertions(+), 30 deletions(-) create mode 100644 changelog/69966.fixed.md diff --git a/changelog/69966.fixed.md b/changelog/69966.fixed.md new file mode 100644 index 000000000000..a98694fae9ee --- /dev/null +++ b/changelog/69966.fixed.md @@ -0,0 +1 @@ +Corrected 25 docstring `:param:` fields that named an argument the callable does not take. diff --git a/salt/client/__init__.py b/salt/client/__init__.py index cbe561543226..fda144323024 100644 --- a/salt/client/__init__.py +++ b/salt/client/__init__.py @@ -98,8 +98,6 @@ def get_local_client( set_event_handler() API. Otherwise, operation will be synchronous. - :param bool keep_loop: Do not destroy the event loop when closing the event - subsriber. :param bool auto_reconnect: When True the event subscriber will reconnect automatically if a disconnect error is raised. diff --git a/salt/loader/__init__.py b/salt/loader/__init__.py index 171a0ae6bbb4..114c2854816e 100644 --- a/salt/loader/__init__.py +++ b/salt/loader/__init__.py @@ -739,7 +739,6 @@ def auth(opts, whitelist=None, loaded_base_name=None): :param dict opts: The Salt options dictionary - :param LazyLoader functions: A LazyLoader instance returned from ``minion_mods``. :param list whitelist: A list of modules which should be whitelisted. :param str loaded_base_name: The imported modules namespace when imported by the salt loader. @@ -854,7 +853,6 @@ def states( :param dict opts: The Salt options dictionary :param LazyLoader functions: A LazyLoader instance returned from ``minion_mods``. - :param LazyLoader runners: A LazyLoader instance returned from ``runner``. :param LazyLoader utils: A LazyLoader instance returned from ``utils``. :param LazyLoader serializers: An optional LazyLoader instance returned from ``serializers``. :param LazyLoader proxy: An optional LazyLoader instance returned from ``proxy``. diff --git a/salt/loader/lazy.py b/salt/loader/lazy.py index 372b375776d1..7e2027980dde 100644 --- a/salt/loader/lazy.py +++ b/salt/loader/lazy.py @@ -129,7 +129,7 @@ class LoadedFunc: - Makes sure functions are called with the correct loader's context. - Provides access to a wrapped func's __global__ attribute - :param func str: The function name to wrap + :param str name: The function name to wrap :param LazyLoader loader: The loader instance to use in the context when the wrapped callable is called. """ diff --git a/salt/master.py b/salt/master.py index d0c5fc4dae85..4df40ab83a9f 100644 --- a/salt/master.py +++ b/salt/master.py @@ -605,7 +605,7 @@ def __init__(self, opts): """ Create a salt master server instance - :param dict: The salt options + :param dict opts: The salt options """ if zmq and ZMQ_VERSION_INFO < (3, 2): log.warning( @@ -1146,7 +1146,7 @@ def _handle_aes(self, data): """ Process a command sent via an AES key - :param str load: Encrypted payload + :param str data: Encrypted payload :return: The result of passing the load to a function in AESFuncs corresponding to the command specified in the load's 'cmd' key. """ diff --git a/salt/modules/baredoc.py b/salt/modules/baredoc.py index c3d048d21ce2..92f36b6c630d 100644 --- a/salt/modules/baredoc.py +++ b/salt/modules/baredoc.py @@ -304,7 +304,7 @@ def state_docs(*names): Return the docstrings for all state modules. Optionally, specify a state module or a function to narrow the selection. - :param name: specify a specific module to list. + :param names: specify a specific module to list. CLI Example: @@ -332,7 +332,7 @@ def module_docs(*names): Return the docstrings for all modules. Optionally, specify a module or a function to narrow the selection. - :param name: specify a specific module to list. + :param names: specify a specific module to list. CLI Example: diff --git a/salt/modules/bcache.py b/salt/modules/bcache.py index acd4fdbcd56e..4f88411861f5 100644 --- a/salt/modules/bcache.py +++ b/salt/modules/bcache.py @@ -505,7 +505,6 @@ def device(dev, stats=False, config=False, internals=False, superblock=False): salt '*' bcache.device /dev/sdc stats=True :param stats: include statistics - :param settings: include all settings :param internals: include all internals :param superblock: include superblock info """ diff --git a/salt/modules/cassandra_cql.py b/salt/modules/cassandra_cql.py index 38e42f42bd99..7891208e6de9 100644 --- a/salt/modules/cassandra_cql.py +++ b/salt/modules/cassandra_cql.py @@ -444,8 +444,6 @@ def cql_query( :type cql_pass: str :param port: The Cassandra cluster port, defaults to None. :type port: int - :param params: The parameters for the query, optional. - :type params: str :param protocol_version: Cassandra protocol version to use. :type protocol_version: int :param load_balancing_policy: cassandra.policy class name to use diff --git a/salt/modules/napalm_network.py b/salt/modules/napalm_network.py index fb35b1eb0879..d54d5768ac0f 100644 --- a/salt/modules/napalm_network.py +++ b/salt/modules/napalm_network.py @@ -88,7 +88,7 @@ def _filter_dict(input_dict, search_key, search_value): :param input_dict: is a dictionary whose values are lists of dictionaries :param search_key: is the key in the leaf dictionaries - :param search_values: is the value in the leaf dictionaries + :param search_value: is the value in the leaf dictionaries :return: filtered dictionary """ diff --git a/salt/modules/rpm_lowpkg.py b/salt/modules/rpm_lowpkg.py index cfd0c650bfd0..55f2a242d8d1 100644 --- a/salt/modules/rpm_lowpkg.py +++ b/salt/modules/rpm_lowpkg.py @@ -462,7 +462,7 @@ def diff(package_path, path): NOTE: this function includes all files (configuration and not), but does not work on binary content. - :param package: Full pack of the RPM file + :param package_path: Full pack of the RPM file :param path: Full path to the installed file :return: Difference or empty string. For binary files only a notification. diff --git a/salt/returners/local_cache.py b/salt/returners/local_cache.py index cdbda0b2322b..286e67188b84 100644 --- a/salt/returners/local_cache.py +++ b/salt/returners/local_cache.py @@ -489,7 +489,7 @@ def get_jids_filter(count, filter_find_job=True): """ Return a list of all jobs information filtered by the given criteria. :param int count: show not more than the count of most recent jobs - :param bool filter_find_jobs: filter out 'saltutil.find_job' jobs + :param bool filter_find_job: filter out 'saltutil.find_job' jobs """ keys = [] ret = [] diff --git a/salt/returners/pgjsonb.py b/salt/returners/pgjsonb.py index a9345547e5b5..6da42d27bc14 100644 --- a/salt/returners/pgjsonb.py +++ b/salt/returners/pgjsonb.py @@ -501,7 +501,7 @@ def prep_jid(nocache=False, passed_jid=None): # pylint: disable=unused-argument def _purge_jobs(timestamp): """ Purge records from the returner tables. - :param job_age_in_seconds: Purge jobs older than this + :param timestamp: Purge jobs older than this :return: """ with _get_serv() as cursor: diff --git a/salt/utils/dns.py b/salt/utils/dns.py index 07086a494e78..7bba4c36ae55 100644 --- a/salt/utils/dns.py +++ b/salt/utils/dns.py @@ -453,7 +453,7 @@ def _lookup_dnspython(name, rdtype, timeout=None, servers=None, secure=None): :param name: Name of record to search :param rdtype: DNS record type :param timeout: query timeout - :param server: [] of server(s) to try in order + :param servers: [] of server(s) to try in order :return: [] of records or False if error """ resolver = dns.resolver.Resolver() @@ -793,7 +793,7 @@ def aaaa_rec(rdata): def caa_rec(rdatas): """ Validate and parse DNS record data for a CAA record - :param rdata: DNS record data + :param rdatas: DNS record data :return: dict w/fields """ rschema = OrderedDict( @@ -833,7 +833,7 @@ def mx_data(target, preference=10): def mx_rec(rdatas): """ Validate and parse DNS record data for MX record(s) - :param rdata: DNS record data + :param rdatas: DNS record data :return: dict w/fields """ rschema = OrderedDict( @@ -965,7 +965,7 @@ def srv_name(svc, proto="tcp", domain=None): def srv_rec(rdatas): """ Validate and parse DNS record data for SRV record(s) - :param rdata: DNS record data + :param rdatas: DNS record data :return: dict w/fields """ rschema = OrderedDict( diff --git a/salt/utils/event.py b/salt/utils/event.py index 956caacbfcf7..d11008be4341 100644 --- a/salt/utils/event.py +++ b/salt/utils/event.py @@ -510,8 +510,6 @@ def _check_pending(self, tag, match_func=None): :param tag: The tag to search for :type tag: str - :param tags_regex: List of re expressions to search for also - :type tags_regex: list[re.compile()] :return: """ if match_func is None: diff --git a/salt/utils/extend.py b/salt/utils/extend.py index 449793fb4c07..de3370051b21 100644 --- a/salt/utils/extend.py +++ b/salt/utils/extend.py @@ -197,11 +197,7 @@ def apply_template(template_dir, output_dir, context): Apply the template from the template directory to the output using the supplied context dict. - :param src: The source path - :type src: ``str`` - :param dst: The destination path - :type dst: ``str`` :param context: The dictionary to inject into the Jinja template as context :type context: ``dict`` diff --git a/salt/utils/network.py b/salt/utils/network.py index 2302c58bc3d8..a44558b41186 100644 --- a/salt/utils/network.py +++ b/salt/utils/network.py @@ -296,7 +296,7 @@ def ip_to_host(ip): def is_reachable_host(entity_name): """ Returns a bool telling if the entity name is a reachable host (IPv4/IPv6/FQDN/etc). - :param hostname: + :param entity_name: :return: """ try: diff --git a/salt/utils/thin.py b/salt/utils/thin.py index 045a51cfa089..a600e4881b9d 100644 --- a/salt/utils/thin.py +++ b/salt/utils/thin.py @@ -235,7 +235,6 @@ def _add_dependency(container, obj): Add a dependency to the top list. :param obj: - :param is_file: :return: """ if os.path.basename(obj.__file__).split(".")[0] == "__init__": @@ -249,8 +248,6 @@ def gte(): This function is called externally from the alternative Python interpreter from within _get_tops function. - :param extra_mods: - :param so_mods: :return: """ extra = salt.utils.json.loads(sys.argv[1]) From ca283a93b4daa620d7b9ed73022dc684b6577280 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 15:28:38 -0700 Subject: [PATCH 272/469] Fix nested SyncWrapper deadlock in tcp.TCPPublishServer.publish (3006.x) When ``TCPPublishServer.publish`` was invoked from a running asyncio loop (e.g. via ``MWorker._return -> store_job -> fire_event``), the outer ``SaltEvent.pusher`` SyncWrapper's worker thread ran this coroutine, then ``self.pub_sock.send`` invoked SyncWrapper *again* -- it detected the inner thread's running io_loop, spawned yet another thread, and both deadlocked on ``threading.Thread.join()``. Detect the async context via ``asyncio.get_running_loop()`` and bypass the outer SyncWrapper. Since 3006.x's ``publish`` is sync (no ``async def``), dispatch to ``loop.create_task(...)`` as a fire-and-forget (matches the ``fire_event`` / ``spawn_callback`` precedent in ``salt/utils/event.py``). Cache a raw ``IPCMessageClient`` per running loop via ``WeakKeyDictionary`` so a fresh SyncWrapper asyncio_loop can't inherit a dead client via id() recycling. Invalidate proactively (pre-flight ``stream.closed()``) and reactively (retry once on ``salt.ext.tornado.iostream.StreamClosedError``). 3006.x-specific counterpart to 3008.x PR #69992. Fixes #69986 --- changelog/69986.fixed.md | 1 + salt/transport/tcp.py | 112 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 changelog/69986.fixed.md diff --git a/changelog/69986.fixed.md b/changelog/69986.fixed.md new file mode 100644 index 000000000000..33aa2cb96a08 --- /dev/null +++ b/changelog/69986.fixed.md @@ -0,0 +1 @@ +Fix MWorker deadlock caused by nested ``SyncWrapper`` recursion in ``tcp.TCPPublishServer.publish``. When ``fire_event`` invoked ``publish`` from inside a running asyncio loop, the outer ``SaltEvent.pusher`` SyncWrapper's thread spawned another SyncWrapper which deadlocked on ``threading.Thread.join()``, wedging all MWorkers. On 3006.x the fix uses a fire-and-forget dispatch via ``loop.create_task`` (``publish`` remains sync on 3006.x) with a per-loop ``IPCMessageClient`` cache. diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index a21321b0f65f..12d5522b3e98 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -6,6 +6,7 @@ """ +import asyncio import errno import logging import multiprocessing @@ -16,6 +17,7 @@ import urllib import uuid import warnings +import weakref import salt.ext.tornado import salt.ext.tornado.concurrent @@ -1109,6 +1111,116 @@ def publish(self, payload, **kwargs): pull_uri = int(self.opts.get("tcp_master_publish_pull", 4514)) else: pull_uri = os.path.join(self.opts["sock_dir"], "publish_pull.ipc") + + # PATCH: avoid the nested-SyncWrapper deadlock in the + # ``fire_event`` -> ``TCPPublishServer.publish`` -> ``pub_sock.send`` + # chain. ``self.pub_sock`` is a ``SyncWrapper(IPCMessageClient)``. + # When ``publish`` is invoked while an asyncio/Tornado io_loop is + # running on the current thread (as is the case in every + # ``MWorker._handle_payload`` coroutine -> ``_handle_clear`` -> + # ``_send_pub`` -> ``chan.publish`` path), the outer SyncWrapper + # around ``SaltEvent.pusher`` (or the MWorker's own io_loop) has + # already spawned a worker thread that runs this method, then + # ``self.pub_sock.send`` invokes SyncWrapper *again* -- it detects + # the running io_loop, spawns yet another thread, and the two + # threads can deadlock on ``threading.Thread.join()``. All + # MWorkers wedge, MWQ's DEALER send() blocks (queue backlog), + # minions time out and reconnect, dead-peer TCP conns pile up. + # + # Fix: when we're already in an async context, bypass the outer + # SyncWrapper entirely and schedule an ``IPCMessageClient.send`` + # coroutine directly on the running loop (fire-and-forget, which + # matches the ``fire_event`` precedent at + # ``salt/utils/event.py``: ``self.io_loop.spawn_callback( + # self.pusher.send, msg)``). Cache the raw + # ``IPCMessageClient`` per running loop because Tornado + # ``IOStream`` instances (and any locks bound to a specific loop) + # cannot be safely shared across loops -- this + # ``TCPPublishServer`` is used by both the sync-mode SyncWrapper + # thread's io_loop and any coroutine-mode io_loop. A per-loop + # ``asyncio.Lock`` serializes concurrent ``fire_event`` tasks so + # their length-prefixed frames don't interleave on the shared + # stream (framing corruption would otherwise surface as bogus + # ~GB length prefixes on the puller side). + try: + loop = asyncio.get_running_loop() + in_async = True + except RuntimeError: + in_async = False + + if in_async: + per_loop = getattr(self, "_async_pub_by_loop", None) + if per_loop is None: + # PATCH: WeakKeyDictionary so entries drop when the loop + # is GC'd. Keying on ``id(loop)`` would be unsafe -- + # CPython recycles integer ids after GC and a fresh + # ``SyncWrapper.asyncio_loop`` could land on the same id + # as a dead one and inherit that dead loop's cached + # (dead) publisher. + per_loop = self._async_pub_by_loop = weakref.WeakKeyDictionary() + + entry = per_loop.get(loop) + if entry is not None: + pub, _lock = entry + # PATCH: invalidate on a dead stream. If the puller + # side went away (slow-subscriber discard, subscriber + # process restart) the stream is closed but the entry + # is still cached -- next ``send`` raises + # ``StreamClosedError`` forever until we rebuild. A + # closed stream is unrecoverable in tornado's + # ``IOStream``; drop the entry so we reconnect below. + stream = getattr(pub, "stream", None) + if stream is None or stream.closed(): + del per_loop[loop] + entry = None + + if entry is None: + # ``IPCMessageClient`` expects a Tornado ``IOLoop`` for + # ``add_callback``/``add_future`` scheduling. In Tornado + # 6.x ``IOLoop.current()`` is a thin wrapper around the + # currently-running asyncio loop, so constructing it + # here (on the loop's own thread) binds the client to + # the correct loop. + tio_loop = salt.ext.tornado.ioloop.IOLoop.current() + pub = salt.transport.ipc.IPCMessageClient(pull_uri, io_loop=tio_loop) + lock = asyncio.Lock() + entry = (pub, lock) + per_loop[loop] = entry + pub, lock = entry + + async def _send_async(): + async with lock: + try: + if not pub.connected(): + await pub.connect() + await pub.send(payload) + except salt.ext.tornado.iostream.StreamClosedError: + # PATCH: puller closed on us mid-send. Drop the + # cached publisher and rebuild once so the next + # call can succeed. We do a single retry inside + # the lock to preserve message ordering for + # concurrent callers on this loop. + per_loop.pop(loop, None) + tio_loop = salt.ext.tornado.ioloop.IOLoop.current() + new_pub = salt.transport.ipc.IPCMessageClient( + pull_uri, io_loop=tio_loop + ) + per_loop[loop] = (new_pub, lock) + try: + await new_pub.connect() + await new_pub.send(payload) + except Exception: # pylint: disable=broad-except + log.exception("TCPPublishServer async publish retry failed") + except Exception: # pylint: disable=broad-except + log.exception("TCPPublishServer async publish failed") + + # Schedule on the running loop. Fire-and-forget matches the + # sync-branch semantics (SyncWrapper.send returns after the + # local IPC write completes; callers do not observe an ack + # from the puller side). + loop.create_task(_send_async()) + return + if not self.pub_sock: self.pub_sock = salt.utils.asynchronous.SyncWrapper( salt.transport.ipc.IPCMessageClient, From b668a8a3751a2fd577a04602b09679ca752e621c Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 11 Aug 2026 17:11:22 -0700 Subject: [PATCH 273/469] Address twangboy review: re-resolve active publisher inside lock Before: concurrent tasks captured pub from the outer scope before taking the per-loop lock. If task A hit StreamClosedError, replaced per_loop[loop] with a healthy pub2, and released the lock, task B then acquired the lock still holding its captured pub1, tried to send on that already-closed publisher, and evicted the healthy pub2 from the cache -- cascading unnecessary reconnects under sustained concurrent publish. Re-resolve the active publisher from per_loop inside the lock so waiting tasks pick up the newly reconnected instance. Also guard the eviction path so we only pop the cache entry when it still points at the publisher we tried; a peer task's successful replacement must not be dropped. Refs review comment on PR #69998. --- salt/transport/tcp.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index 12d5522b3e98..8412a00d977f 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -1190,17 +1190,28 @@ def publish(self, payload, **kwargs): async def _send_async(): async with lock: + # Re-resolve the active publisher inside the lock so + # tasks that captured a stale ``pub`` from the outer + # scope before a concurrent StreamClosedError retry + # replaced ``per_loop[loop]`` don't cascade evict + # the healthy replacement. Falls back to the + # outer-scope ``pub`` if the cache is empty (e.g. + # close() ran between capture and lock acquisition). + current_entry = per_loop.get(loop) + active_pub = current_entry[0] if current_entry else pub try: - if not pub.connected(): - await pub.connect() - await pub.send(payload) + if not active_pub.connected(): + await active_pub.connect() + await active_pub.send(payload) except salt.ext.tornado.iostream.StreamClosedError: - # PATCH: puller closed on us mid-send. Drop the - # cached publisher and rebuild once so the next - # call can succeed. We do a single retry inside - # the lock to preserve message ordering for - # concurrent callers on this loop. - per_loop.pop(loop, None) + # PATCH: puller closed on us mid-send. Only + # drop the cache entry if it still points at + # the publisher we tried -- otherwise a peer + # task already replaced it with a healthy new + # one and we must not evict that. + current_entry = per_loop.get(loop) + if current_entry is not None and current_entry[0] is active_pub: + per_loop.pop(loop, None) tio_loop = salt.ext.tornado.ioloop.IOLoop.current() new_pub = salt.transport.ipc.IPCMessageClient( pull_uri, io_loop=tio_loop From 1b3b09f4c501514cdf06f3f68c5527b1eebe3a1f Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 9 Aug 2026 15:33:44 -0700 Subject: [PATCH 274/469] Add onedir package test for libyaml-linked PyYAML Regression cover for #69907 / PR #69950 (3006.x) / #69949 (3008.x). Spawns the onedir python and asserts yaml.CSafeLoader/CSafeDumper and the _yaml C extension are present, plus salt.utils.yamlloader.SafeLoader resolves to yaml.CSafeLoader. Linux-only; Windows/macOS onedirs already pick libyaml-linked wheels because they do not pass --no-binary=:all: to pip. --- tests/pytests/pkg/integration/test_libyaml.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/pytests/pkg/integration/test_libyaml.py diff --git a/tests/pytests/pkg/integration/test_libyaml.py b/tests/pytests/pkg/integration/test_libyaml.py new file mode 100644 index 000000000000..038424a18cbc --- /dev/null +++ b/tests/pytests/pkg/integration/test_libyaml.py @@ -0,0 +1,96 @@ +""" +Verify the onedir bundle ships a libyaml-linked PyYAML. + +Regression cover for #69907 / PR #69950 (3006.x) and #69949 (3008.x): +the Linux onedir build was source-compiling PyYAML under a relenv toolchain +that has no libyaml, so `yaml.CSafeLoader`/`yaml.CSafeDumper` were absent +and every YAML load fell back to the ~10-20x slower pure-Python parser. +""" + +import subprocess +import sys +import textwrap + +import pytest + + +@pytest.fixture +def python_script_bin(install_salt): + return install_salt.binary_paths["python"] + + +@pytest.fixture +def check_libyaml_file(tmp_path): + script_path = tmp_path / "check_libyaml.py" + script_path.write_text( + textwrap.dedent( + """ + import sys + import yaml + + assert hasattr(yaml, "CSafeLoader"), "yaml.CSafeLoader missing" + assert hasattr(yaml, "CSafeDumper"), "yaml.CSafeDumper missing" + assert hasattr(yaml, "CLoader"), "yaml.CLoader missing" + assert hasattr(yaml, "CDumper"), "yaml.CDumper missing" + + import _yaml # noqa: F401 # PyYAML C extension + + loader = yaml.CSafeLoader("key: value\\n") + try: + data = loader.get_single_data() + finally: + loader.dispose() + assert data == {"key": "value"}, data + sys.exit(0) + """ + ) + ) + return script_path + + +@pytest.mark.skipif( + not sys.platform.startswith("linux"), + reason="Only the Linux onedir build passes --no-binary=:all:; " + "Windows/macOS already pick libyaml-linked wheels.", +) +def test_libyaml_bundled_in_onedir(install_salt, python_script_bin, check_libyaml_file): + ret = install_salt.proc.run( + *(python_script_bin + [str(check_libyaml_file)]), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + universal_newlines=True, + ) + assert ret.returncode == 0, ret.stderr + + +@pytest.mark.skipif( + not sys.platform.startswith("linux"), + reason="Only the Linux onedir build passes --no-binary=:all:; " + "Windows/macOS already pick libyaml-linked wheels.", +) +def test_salt_yamlloader_uses_libyaml(install_salt, python_script_bin, tmp_path): + script_path = tmp_path / "check_yamlloader.py" + script_path.write_text( + textwrap.dedent( + """ + import sys + import yaml + import salt.utils.yamlloader + + assert salt.utils.yamlloader.SafeLoader is yaml.CSafeLoader, ( + "salt.utils.yamlloader.SafeLoader fell back to pure-Python " + "SafeLoader (libyaml not linked)" + ) + sys.exit(0) + """ + ) + ) + ret = install_salt.proc.run( + *(python_script_bin + [str(script_path)]), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + universal_newlines=True, + ) + assert ret.returncode == 0, ret.stderr From 7011905cba08bbe9ec61f837462866dc54fb4945 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 10 Aug 2026 16:53:00 -0700 Subject: [PATCH 275/469] Fix yamlloader attribute name in libyaml onedir test salt.utils.yamlloader exports BaseLoader (which resolves to yaml.CSafeLoader when libyaml is linked), not SafeLoader. The initial test used the wrong name and hit AttributeError on every Linux runner. Assert against BaseLoader instead. --- tests/pytests/pkg/integration/test_libyaml.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/pytests/pkg/integration/test_libyaml.py b/tests/pytests/pkg/integration/test_libyaml.py index 038424a18cbc..c136c5490ec7 100644 --- a/tests/pytests/pkg/integration/test_libyaml.py +++ b/tests/pytests/pkg/integration/test_libyaml.py @@ -78,9 +78,9 @@ def test_salt_yamlloader_uses_libyaml(install_salt, python_script_bin, tmp_path) import yaml import salt.utils.yamlloader - assert salt.utils.yamlloader.SafeLoader is yaml.CSafeLoader, ( - "salt.utils.yamlloader.SafeLoader fell back to pure-Python " - "SafeLoader (libyaml not linked)" + assert salt.utils.yamlloader.BaseLoader is yaml.CSafeLoader, ( + "salt.utils.yamlloader.BaseLoader fell back to pure-Python " + "yaml.SafeLoader (libyaml not linked)" ) sys.exit(0) """ From 12a638cfc575b18275efbc46c0b6febef79f18d6 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 11 Aug 2026 00:40:25 -0700 Subject: [PATCH 276/469] Skip libyaml onedir test on downgrade flavor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pkg-test downgrade matrix installs current salt, then rolls back to the previous release before running pytest. That leaves the pre-#69950 onedir on disk, which correctly lacks libyaml-linked PyYAML — asserting its presence there produces a false positive. Gate both libyaml tests on install_salt.downgrade so the downgrade matrix skips them; install/upgrade flavors continue to exercise the fix. --- tests/pytests/pkg/integration/test_libyaml.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/pytests/pkg/integration/test_libyaml.py b/tests/pytests/pkg/integration/test_libyaml.py index c136c5490ec7..8a0bb4a9a638 100644 --- a/tests/pytests/pkg/integration/test_libyaml.py +++ b/tests/pytests/pkg/integration/test_libyaml.py @@ -54,6 +54,11 @@ def check_libyaml_file(tmp_path): "Windows/macOS already pick libyaml-linked wheels.", ) def test_libyaml_bundled_in_onedir(install_salt, python_script_bin, check_libyaml_file): + if install_salt.downgrade: + pytest.skip( + "Downgrade flavor tests against the pre-#69950 onedir; " + "libyaml is expected to be absent there." + ) ret = install_salt.proc.run( *(python_script_bin + [str(check_libyaml_file)]), stdout=subprocess.PIPE, @@ -70,6 +75,11 @@ def test_libyaml_bundled_in_onedir(install_salt, python_script_bin, check_libyam "Windows/macOS already pick libyaml-linked wheels.", ) def test_salt_yamlloader_uses_libyaml(install_salt, python_script_bin, tmp_path): + if install_salt.downgrade: + pytest.skip( + "Downgrade flavor tests against the pre-#69950 onedir; " + "libyaml is expected to be absent there." + ) script_path = tmp_path / "check_yamlloader.py" script_path.write_text( textwrap.dedent( From ddf673fa2594ef12144717cb6810dd181dda8f72 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 11 Aug 2026 17:23:30 -0700 Subject: [PATCH 277/469] test_libyaml: version-conditional assertions across install/upgrade/downgrade Post-downgrade pytest re-enters the pkg/integration suite with the previous salt onedir on disk and no --downgrade flag, so the earlier install_salt.downgrade guard didn't trigger and the test still failed. Rewritten to key on install_salt.version: - >= 3006.28 -> assert libyaml present (guards the fix) - < 3006.28 -> assert libyaml absent (documents the pre-fix baseline so a silent regression on the old branch is also caught) Works uniformly across the three pkg-test flavors without skips. --- tests/pytests/pkg/integration/test_libyaml.py | 70 ++++++++++++++----- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/tests/pytests/pkg/integration/test_libyaml.py b/tests/pytests/pkg/integration/test_libyaml.py index 8a0bb4a9a638..d1a8590ffbcd 100644 --- a/tests/pytests/pkg/integration/test_libyaml.py +++ b/tests/pytests/pkg/integration/test_libyaml.py @@ -5,20 +5,40 @@ the Linux onedir build was source-compiling PyYAML under a relenv toolchain that has no libyaml, so `yaml.CSafeLoader`/`yaml.CSafeDumper` were absent and every YAML load fell back to the ~10-20x slower pure-Python parser. + +The test asserts the invariant that matches whatever salt is installed at +run time, so it works uniformly across the install / upgrade / downgrade +package-test flavors: + +- install / post-upgrade: current onedir is on disk, expect libyaml present +- post-downgrade: previous onedir is on disk. If that release predates the + fix, expect libyaml absent (documenting the pre-fix state so a silent + regression on the previous branch is still caught). """ import subprocess import sys import textwrap +import packaging.version import pytest +# First release that ships the libyaml-linked PyYAML wheel on Linux onedir. +# Update if the fix is ever backported earlier. +LIBYAML_FIX_LANDED_IN = packaging.version.Version("3006.28") + @pytest.fixture def python_script_bin(install_salt): return install_salt.binary_paths["python"] +@pytest.fixture +def libyaml_expected(install_salt): + """True if the onedir currently on disk is expected to ship libyaml.""" + return packaging.version.Version(install_salt.version) >= LIBYAML_FIX_LANDED_IN + + @pytest.fixture def check_libyaml_file(tmp_path): script_path = tmp_path / "check_libyaml.py" @@ -53,12 +73,9 @@ def check_libyaml_file(tmp_path): reason="Only the Linux onedir build passes --no-binary=:all:; " "Windows/macOS already pick libyaml-linked wheels.", ) -def test_libyaml_bundled_in_onedir(install_salt, python_script_bin, check_libyaml_file): - if install_salt.downgrade: - pytest.skip( - "Downgrade flavor tests against the pre-#69950 onedir; " - "libyaml is expected to be absent there." - ) +def test_libyaml_matches_installed_version( + install_salt, python_script_bin, check_libyaml_file, libyaml_expected +): ret = install_salt.proc.run( *(python_script_bin + [str(check_libyaml_file)]), stdout=subprocess.PIPE, @@ -66,7 +83,17 @@ def test_libyaml_bundled_in_onedir(install_salt, python_script_bin, check_libyam check=False, universal_newlines=True, ) - assert ret.returncode == 0, ret.stderr + if libyaml_expected: + assert ret.returncode == 0, ( + f"libyaml expected present in salt {install_salt.version} " + f"(>= {LIBYAML_FIX_LANDED_IN}) but the probe failed:\n{ret.stderr}" + ) + else: + assert ret.returncode != 0, ( + f"libyaml unexpectedly present in salt {install_salt.version} " + f"(pre-{LIBYAML_FIX_LANDED_IN}). If the fix was backported " + f"earlier, lower LIBYAML_FIX_LANDED_IN in this test." + ) @pytest.mark.skipif( @@ -74,12 +101,9 @@ def test_libyaml_bundled_in_onedir(install_salt, python_script_bin, check_libyam reason="Only the Linux onedir build passes --no-binary=:all:; " "Windows/macOS already pick libyaml-linked wheels.", ) -def test_salt_yamlloader_uses_libyaml(install_salt, python_script_bin, tmp_path): - if install_salt.downgrade: - pytest.skip( - "Downgrade flavor tests against the pre-#69950 onedir; " - "libyaml is expected to be absent there." - ) +def test_salt_yamlloader_matches_installed_version( + install_salt, python_script_bin, tmp_path, libyaml_expected +): script_path = tmp_path / "check_yamlloader.py" script_path.write_text( textwrap.dedent( @@ -88,11 +112,7 @@ def test_salt_yamlloader_uses_libyaml(install_salt, python_script_bin, tmp_path) import yaml import salt.utils.yamlloader - assert salt.utils.yamlloader.BaseLoader is yaml.CSafeLoader, ( - "salt.utils.yamlloader.BaseLoader fell back to pure-Python " - "yaml.SafeLoader (libyaml not linked)" - ) - sys.exit(0) + sys.exit(0 if salt.utils.yamlloader.BaseLoader is getattr(yaml, "CSafeLoader", None) else 1) """ ) ) @@ -103,4 +123,16 @@ def test_salt_yamlloader_uses_libyaml(install_salt, python_script_bin, tmp_path) check=False, universal_newlines=True, ) - assert ret.returncode == 0, ret.stderr + if libyaml_expected: + assert ret.returncode == 0, ( + f"salt.utils.yamlloader.BaseLoader should be yaml.CSafeLoader in " + f"salt {install_salt.version} (>= {LIBYAML_FIX_LANDED_IN}); " + f"it resolved to the pure-Python loader instead." + ) + else: + assert ret.returncode != 0, ( + f"salt.utils.yamlloader.BaseLoader unexpectedly resolves to " + f"yaml.CSafeLoader in pre-{LIBYAML_FIX_LANDED_IN} " + f"salt {install_salt.version}. If the fix was backported " + f"earlier, lower LIBYAML_FIX_LANDED_IN in this test." + ) From 6b373eb2b22e68ce7f548e52b5996b1168b1cd6a Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 12 Aug 2026 00:37:31 -0700 Subject: [PATCH 278/469] test_libyaml: key on install_salt.use_prev_version, not artifact version The prior design compared install_salt.version >= Version("3006.28") to predict whether libyaml should be present, but dev builds report '3006.27+NNN.gSHA' which packaging.version orders BEFORE '3006.27' let alone '3006.28' (PEP 440 local-version segment). That flipped the expected-libyaml boolean to False on install jobs and turned every Linux install matrix row red. install_salt.use_prev_version is True iff the pytest run is executing against the downgraded-to previous release (set by --use-prev-version in the post-downgrade validation stage). That's the only flavor where the onedir predates PR #69950 and libyaml is legitimately absent. Key the expectation off that flag and drop the version-comparison plumbing. --- tests/pytests/pkg/integration/test_libyaml.py | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/tests/pytests/pkg/integration/test_libyaml.py b/tests/pytests/pkg/integration/test_libyaml.py index d1a8590ffbcd..900d9b39fb71 100644 --- a/tests/pytests/pkg/integration/test_libyaml.py +++ b/tests/pytests/pkg/integration/test_libyaml.py @@ -11,8 +11,8 @@ package-test flavors: - install / post-upgrade: current onedir is on disk, expect libyaml present -- post-downgrade: previous onedir is on disk. If that release predates the - fix, expect libyaml absent (documenting the pre-fix state so a silent +- post-downgrade: previous onedir is on disk. That release predates the + fix, so expect libyaml absent (documenting the pre-fix state so a silent regression on the previous branch is still caught). """ @@ -20,13 +20,8 @@ import sys import textwrap -import packaging.version import pytest -# First release that ships the libyaml-linked PyYAML wheel on Linux onedir. -# Update if the fix is ever backported earlier. -LIBYAML_FIX_LANDED_IN = packaging.version.Version("3006.28") - @pytest.fixture def python_script_bin(install_salt): @@ -35,8 +30,9 @@ def python_script_bin(install_salt): @pytest.fixture def libyaml_expected(install_salt): - """True if the onedir currently on disk is expected to ship libyaml.""" - return packaging.version.Version(install_salt.version) >= LIBYAML_FIX_LANDED_IN + """Current onedir (install/upgrade) ships libyaml; the previous release + (post-downgrade validation) predates PR #69950 and does not.""" + return not install_salt.use_prev_version @pytest.fixture @@ -85,14 +81,14 @@ def test_libyaml_matches_installed_version( ) if libyaml_expected: assert ret.returncode == 0, ( - f"libyaml expected present in salt {install_salt.version} " - f"(>= {LIBYAML_FIX_LANDED_IN}) but the probe failed:\n{ret.stderr}" + f"libyaml expected present in the current onedir but the probe " + f"failed:\n{ret.stderr}" ) else: assert ret.returncode != 0, ( - f"libyaml unexpectedly present in salt {install_salt.version} " - f"(pre-{LIBYAML_FIX_LANDED_IN}). If the fix was backported " - f"earlier, lower LIBYAML_FIX_LANDED_IN in this test." + "libyaml unexpectedly present in the previous-release onedir. " + "If PR #69950 was backported earlier than 3006.28, drop this " + "test's downgrade branch." ) @@ -125,14 +121,14 @@ def test_salt_yamlloader_matches_installed_version( ) if libyaml_expected: assert ret.returncode == 0, ( - f"salt.utils.yamlloader.BaseLoader should be yaml.CSafeLoader in " - f"salt {install_salt.version} (>= {LIBYAML_FIX_LANDED_IN}); " - f"it resolved to the pure-Python loader instead." + "salt.utils.yamlloader.BaseLoader should be yaml.CSafeLoader in " + "the current onedir; it resolved to the pure-Python loader " + "instead." ) else: assert ret.returncode != 0, ( - f"salt.utils.yamlloader.BaseLoader unexpectedly resolves to " - f"yaml.CSafeLoader in pre-{LIBYAML_FIX_LANDED_IN} " - f"salt {install_salt.version}. If the fix was backported " - f"earlier, lower LIBYAML_FIX_LANDED_IN in this test." + "salt.utils.yamlloader.BaseLoader unexpectedly resolves to " + "yaml.CSafeLoader in the previous-release onedir. If PR #69950 " + "was backported earlier than 3006.28, drop this test's downgrade " + "branch." ) From 098e4bee00a240b8c4cbb0fbf41358c39139a026 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 13 Aug 2026 04:45:22 -0700 Subject: [PATCH 279/469] zeromq: per-instance UUID IDENTITY for daemon AsyncReqMessageClient (#69920) Give each daemon AsyncReqMessageClient a per-instance uuid.uuid4().hex as its ZMQ IDENTITY, replacing the earlier process-wide _REQ_IDENTITY_SLOT counter and SALT_REQ_IDENTITY_SLOT_MAX cap. Each RequestClient is opened and closed by Salt itself, so a per-instance UUID matches the object's lifetime and gives the master ROUTER's routing-id table a 1:1 mapping to a client we control. Fork inheritance of the earlier counter -- root cause of #69753 -- is impossible by construction, since each child draws a fresh UUID. --- changelog/69920.fixed.md | 1 + salt/transport/zeromq.py | 41 ++---- tests/pytests/unit/transport/test_zeromq.py | 26 ++-- .../transport/test_zeromq_identity_uuid.py | 131 ++++++++++++++++++ 4 files changed, 151 insertions(+), 48 deletions(-) create mode 100644 changelog/69920.fixed.md create mode 100644 tests/pytests/unit/transport/test_zeromq_identity_uuid.py diff --git a/changelog/69920.fixed.md b/changelog/69920.fixed.md new file mode 100644 index 000000000000..8dc4b5406f27 --- /dev/null +++ b/changelog/69920.fixed.md @@ -0,0 +1 @@ +Give each daemon ``AsyncReqMessageClient`` a per-instance UUID as its ZMQ ``IDENTITY``, so the master ROUTER's routing-id entry maps 1:1 to a client whose lifecycle Salt itself owns. Replaces the earlier process-wide ``_REQ_IDENTITY_SLOT`` counter whose state was inherited across ``fork()`` and produced colliding identities in forked minion children (root cause of #69753). diff --git a/salt/transport/zeromq.py b/salt/transport/zeromq.py index 44f1652953e4..9fbd3c98cfa2 100644 --- a/salt/transport/zeromq.py +++ b/salt/transport/zeromq.py @@ -7,7 +7,6 @@ import datetime import errno import hashlib -import itertools import logging import multiprocessing import os @@ -17,6 +16,7 @@ import stat import sys import threading +import uuid import zlib from random import randint @@ -56,17 +56,6 @@ # Payload marker for AsyncReqMessageClient queue: stop _send_recv gracefully. _REQ_QUEUE_SHUTDOWN = object() -# Per-process counter used to give each AsyncReqMessageClient instance a -# stable, unique routing-id slot. Long-lived daemons (minions, syndics) -# multiplex multiple concurrent REQ sockets over one process, so each -# socket must claim a distinct identity -- otherwise the master's -# ROUTER_HANDOVER=1 would drop in-flight replies when a sibling socket -# reconnected with the same identity. Within a single socket instance the -# identity is reused across ZMQ-level reconnects, which is what lets the -# master's ROUTER replace the previous peer table entry instead of -# leaking one per reconnect. -_REQ_IDENTITY_SLOT = itertools.count() - # Per-process 24-bit random slot used to disambiguate concurrent salt CLI # processes claiming the same host/uid/role IDENTITY on the master's ROUTER. # ``os.getpid() % 256`` -- previously used here -- collides with probability @@ -1199,30 +1188,16 @@ def _init_socket(self): ) self.socket.setsockopt(zmq.IDENTITY, identity.encode("utf-8")) elif _role in ("minion", "syndic") and _minion_id: - # Long-lived minion / syndic daemon. Each AsyncReqMessageClient - # instance gets its own slot from a process-lifetime counter so - # concurrent siblings differ (avoiding the ROUTER_HANDOVER drop - # that caused the earlier syndic regression), while the slot is - # reused across ZMQ-level reconnects so the master's ROUTER - # replaces the prior peer entry instead of leaking one per - # reconnect. Without this, ``MWorkerQueue`` was observed - # leaking ~23 GB / 2 days under sustained stress as libzmq - # never reclaims routing-id table entries. On daemon restart - # slots replay in construction order and overwrite the prior - # master-side entries cleanly. - # - # Include ``os.getpid()`` so forked minion children (scheduled - # jobs, published-command handlers) each have a distinct - # IDENTITY. Without the pid, two concurrent children inherit - # the parent's ``_REQ_IDENTITY_SLOT`` state and both draw the - # same slot value after fork -- with ``ROUTER_HANDOVER=1`` on - # the master, in-flight replies queued for one child get re- - # routed to the sibling and fail nonce verification (#69753). - identity = "salt-req/{role}/{minion_id}/{pid}/{slot}".format( + # Per-RequestClient UUID: one IDENTITY per instance lifetime, so the + # master ROUTER's routing-id entry maps 1:1 to a client we open and + # close ourselves. Naturally distinct across fork boundaries (each + # child draws a fresh UUID) so the identity-collision retry class + # that motivated #69753 is impossible by construction. + identity = "salt-req/{role}/{minion_id}/{pid}/{uuid}".format( role=_role, minion_id=_minion_id, pid=os.getpid(), - slot=next(_REQ_IDENTITY_SLOT), + uuid=uuid.uuid4().hex, ) self.socket.setsockopt(zmq.IDENTITY, identity.encode("utf-8")) diff --git a/tests/pytests/unit/transport/test_zeromq.py b/tests/pytests/unit/transport/test_zeromq.py index 6227c3268047..24021bf2c31b 100644 --- a/tests/pytests/unit/transport/test_zeromq.py +++ b/tests/pytests/unit/transport/test_zeromq.py @@ -2654,19 +2654,14 @@ def test_minion_daemon_identity_includes_pid_to_disambiguate_forks(minion_opts): """ Regression test for #69753. - The minion / syndic daemon branch of ``_init_socket`` uses a - process-lifetime ``itertools.count`` counter to hand each - ``AsyncReqMessageClient`` a distinct slot for its ZMQ IDENTITY. When - the minion daemon forks a child (scheduled job, published-command - handler) the child inherits the counter's current state -- so two - concurrent forked children calling ``next(_REQ_IDENTITY_SLOT)`` for the - first time BOTH get the same slot value. Combined with - ``ROUTER_HANDOVER=1`` on the master's ROUTER, in-flight replies for - one child are re-routed to the sibling and fail nonce verification. - - Fix: the daemon-branch IDENTITY must include ``os.getpid()`` so forked - children are disambiguated by pid even when they draw the same slot - number. + The minion / syndic daemon branch of ``_init_socket`` assigns each + ``AsyncReqMessageClient`` a fresh ``uuid.uuid4().hex`` as its ZMQ + IDENTITY slot. A per-instance UUID matches the client's own + open/close lifetime, and each forked child draws its own UUID, so + the identity-collision retry class that motivated #69753 is + impossible by construction. ``os.getpid()`` is also included as a + second disambiguator so the identity is human-parseable back to a + process. """ opts = dict(minion_opts) opts["__role"] = "minion" @@ -2675,12 +2670,13 @@ def test_minion_daemon_identity_includes_pid_to_disambiguate_forks(minion_opts): try: client.connect() ident = client.socket.getsockopt(zmq.IDENTITY).decode("utf-8") - # Format: salt-req/minion/// + # Format: salt-req/minion/// parts = ident.split("/") assert parts[0] == "salt-req" assert parts[1] == "minion" assert parts[2] == "test-minion" assert parts[3] == str(os.getpid()) - assert parts[4].isdigit() + assert len(parts[4]) == 32 + assert all(c in "0123456789abcdef" for c in parts[4]) finally: client.close() diff --git a/tests/pytests/unit/transport/test_zeromq_identity_uuid.py b/tests/pytests/unit/transport/test_zeromq_identity_uuid.py new file mode 100644 index 000000000000..2324a47db496 --- /dev/null +++ b/tests/pytests/unit/transport/test_zeromq_identity_uuid.py @@ -0,0 +1,131 @@ +""" +Unit tests for the per-instance UUID ZMQ IDENTITY assigned to daemon +``AsyncReqMessageClient`` sockets. + +The daemon (minion / syndic) branch of ``_init_socket`` gives each +``AsyncReqMessageClient`` a fresh ``uuid.uuid4().hex`` slot as its ZMQ +IDENTITY so the master ROUTER's routing-id entry maps 1:1 to a client +whose lifecycle Salt itself owns. Fork inheritance of the earlier +process-wide counter (root cause of #69753) is impossible by +construction -- each child draws a fresh UUID. +""" + +import os +import re + +import pytest + +import salt.transport.zeromq +from tests.support.mock import MagicMock + +DAEMON_IDENTITY_RE = re.compile(r"^salt-req/(?:minion|syndic)/[^/]+/\d+/[0-9a-f]{32}$") + + +@pytest.fixture +def _mock_socket_setsockopt_capture(): + """Yield a list that captures every setsockopt(IDENTITY, ...) call.""" + captured = [] + + def _fake_setsockopt(opt, value): + # Only capture the IDENTITY call; other options (LINGER, IPV6...) are + # noise for these tests. + import zmq + + if opt == zmq.IDENTITY: + captured.append(value) + + fake_socket = MagicMock() + fake_socket.setsockopt.side_effect = _fake_setsockopt + + yield captured, fake_socket + + +def _make_client_and_capture_identity(minion_opts, role="minion"): + """Instantiate one AsyncReqMessageClient with the socket mocked out. + + Returns the identity string (utf-8 decoded) that was passed to + ``setsockopt(zmq.IDENTITY, ...)``. + """ + import zmq + + opts = dict(minion_opts) + opts["__role"] = role + opts["id"] = "test-daemon" + + captured = [] + + def _fake_setsockopt(opt, value): + if opt == zmq.IDENTITY: + captured.append(value) + + fake_socket = MagicMock() + fake_socket.setsockopt.side_effect = _fake_setsockopt + fake_context = MagicMock() + fake_context.socket.return_value = fake_socket + + client = salt.transport.zeromq.AsyncReqMessageClient(opts, "tcp://127.0.0.1:4506") + # Bypass the real ZMQ context that ``connect`` would open. + client.context = fake_context + client._init_socket() + + assert captured, "expected setsockopt(zmq.IDENTITY, ...) to be called" + return captured[-1].decode("utf-8") + + +def test_daemon_identity_is_uuid_per_instance(minion_opts): + """ + Two consecutive AsyncReqMessageClient instances (same role, same + minion id, same pid) must produce IDENTITY strings whose final path + component (the uuid slot) differs. + """ + ident_a = _make_client_and_capture_identity(minion_opts) + ident_b = _make_client_and_capture_identity(minion_opts) + + slot_a = ident_a.rsplit("/", 1)[-1] + slot_b = ident_b.rsplit("/", 1)[-1] + + assert slot_a != slot_b, (ident_a, ident_b) + # Both slots must be 32-char lowercase hex (uuid4().hex). + assert re.fullmatch(r"[0-9a-f]{32}", slot_a), slot_a + assert re.fullmatch(r"[0-9a-f]{32}", slot_b), slot_b + + +@pytest.mark.parametrize("role", ["minion", "syndic"]) +def test_daemon_identity_format(minion_opts, role): + """ + The IDENTITY must match ``salt-req////`` + with a 32-char lowercase-hex uuid tail. + """ + ident = _make_client_and_capture_identity(minion_opts, role=role) + + assert DAEMON_IDENTITY_RE.match(ident), ident + + parts = ident.split("/") + assert parts[0] == "salt-req" + assert parts[1] == role + assert parts[2] == "test-daemon" + assert parts[3] == str(os.getpid()) + + +def test_cli_identity_slot_unchanged(): + """ + The CLI-mode process-lifetime slot (``_CLI_IDENTITY_SLOT``) is + orthogonal to the daemon UUID change and must still be present as a + module-level 24-bit integer, cached at import time. Guards against + accidental deletion while removing the (now-gone) daemon-side slot + counter. + """ + slot = salt.transport.zeromq._CLI_IDENTITY_SLOT + assert isinstance(slot, int) + assert 0 <= slot < 2**24 + # Cached at import time: two accesses return the same value. + assert slot == salt.transport.zeromq._CLI_IDENTITY_SLOT + + +def test_slot_counter_infrastructure_removed(): + """ + The old process-wide ``_REQ_IDENTITY_SLOT`` counter and its + associated environment-variable cap must be gone -- the per-instance + UUID design replaces both. + """ + assert not hasattr(salt.transport.zeromq, "_REQ_IDENTITY_SLOT") From c21f76a58fb658198f513e69a547c210882b02c6 Mon Sep 17 00:00:00 2001 From: twangboy Date: Tue, 11 Aug 2026 12:42:23 -0600 Subject: [PATCH 280/469] Fix pem_finger so PEM strings match file fingerprints path= stripped PEM headers while key= hashed the raw string, so master_finger from a key string did not match salt-key -F. Fixes #69970 --- changelog/69970.fixed.md | 1 + salt/crypt.py | 8 +++--- salt/utils/crypt.py | 37 +++++++++++++++++++++----- tests/pytests/unit/utils/test_crypt.py | 29 ++++++++++++++------ 4 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 changelog/69970.fixed.md diff --git a/changelog/69970.fixed.md b/changelog/69970.fixed.md new file mode 100644 index 000000000000..927bc7d7c65f --- /dev/null +++ b/changelog/69970.fixed.md @@ -0,0 +1 @@ +Fixed ``pem_finger`` so a PEM key string fingerprints the same as the same key on disk. ``master_finger`` now matches ``salt-key -F``. diff --git a/salt/crypt.py b/salt/crypt.py index 9970f45dc492..ff0489ee7dd9 100644 --- a/salt/crypt.py +++ b/salt/crypt.py @@ -1178,7 +1178,7 @@ def handle_signin_response(self, sign_in_payload, payload): if syndic_finger: if ( salt.utils.crypt.pem_finger( - m_pub_fn, sum_type=self.opts["hash_type"] + path=m_pub_fn, sum_type=self.opts["hash_type"] ) != syndic_finger ): @@ -1187,7 +1187,7 @@ def handle_signin_response(self, sign_in_payload, payload): if self.opts.get("master_finger", False): if ( salt.utils.crypt.pem_finger( - m_pub_fn, sum_type=self.opts["hash_type"] + path=m_pub_fn, sum_type=self.opts["hash_type"] ) != self.opts["master_finger"] ): @@ -1556,7 +1556,9 @@ def _finger_fail(self, finger, master_key): "matches the fingerprint of the correct master and that " "this minion is not subject to a man-in-the-middle attack.", finger, - salt.utils.crypt.pem_finger(master_key, sum_type=self.opts["hash_type"]), + salt.utils.crypt.pem_finger( + path=master_key, sum_type=self.opts["hash_type"] + ), ) sys.exit(42) diff --git a/salt/utils/crypt.py b/salt/utils/crypt.py index 5505c0eacf05..05b825f08114 100644 --- a/salt/utils/crypt.py +++ b/salt/utils/crypt.py @@ -94,22 +94,23 @@ def pem_finger(path=None, key=None, sum_type="sha256"): pem file, and the type of cryptographic hash to use. The default is SHA256. The fingerprint of the pem will be returned. + PEM input is normalized the same way for both ``path`` and ``key``: header + and footer lines are stripped, and CRLF line endings are treated as LF. + Non-PEM ``key`` values are hashed as-is. + If neither a key nor a path are passed in, a blank string will be returned. """ if not key: - if not os.path.isfile(path): + if not path or not os.path.isfile(path): return "" - with salt.utils.files.fopen(path, "rb") as fp_: - key = b"".join([x for x in fp_.readlines() if x.strip()][1:-1]) - # We should never have \r\n in a key file. This will cause the - # finger to be different even though the only difference is the line - # endings. - key = key.replace(b"\r\n", b"\n") + key = fp_.read() if not isinstance(key, bytes): key = key.encode("utf-8") + key = _fingerprint_key_bytes(key) + pre = getattr(hashlib, sum_type)(key).hexdigest() finger = "" for ind, _ in enumerate(pre): @@ -119,3 +120,25 @@ def pem_finger(path=None, key=None, sum_type="sha256"): else: finger += pre[ind] return finger.rstrip(":") + + +def _fingerprint_key_bytes(key): + """ + Return the bytes that should be hashed for a PEM fingerprint. + + PEM armor (BEGIN/END lines) is stripped so a key string fingerprints the + same as the same key read from a file. Body newlines are kept, matching + historical ``path=`` behavior. Non-PEM data is returned unchanged. + """ + # CRLF in a key file would change the fingerprint even though the only + # difference is the line endings. + normalized = key.replace(b"\r\n", b"\n") + pem_lines = [line for line in normalized.split(b"\n") if line.strip()] + if ( + len(pem_lines) >= 2 + and pem_lines[0].strip().startswith(b"-----BEGIN") + and pem_lines[-1].strip().startswith(b"-----END") + ): + # Keep a trailing newline on each body line (historical path= behavior). + return b"".join(line + b"\n" for line in pem_lines[1:-1]) + return key diff --git a/tests/pytests/unit/utils/test_crypt.py b/tests/pytests/unit/utils/test_crypt.py index 3aa1c4097518..3b7d4f1aaf15 100644 --- a/tests/pytests/unit/utils/test_crypt.py +++ b/tests/pytests/unit/utils/test_crypt.py @@ -6,6 +6,9 @@ import salt.utils.crypt +EXPECTED_PEM_FINGER = "9b:42:66:92:8a:d1:b9:27:42:e0:6d:f3:12:c9:74:74:b0:e0:0e:42:83:87:62:ad:95:49:9d:6f:8e:d0:ed:35" +EXPECTED_NON_PEM_FINGER = "dd:13:0a:84:9d:7b:29:e5:54:1b:05:d2:f7:f8:6a:4a:cd:4f:1e:c5:98:c1:c9:43:87:83:f5:6b:c4:f0:ff:80" + @pytest.fixture def pub_key_data(): @@ -27,19 +30,29 @@ def test_pem_finger_file_line_endings(tmp_path, pub_key_data, line_ending): key_file = tmp_path / "master_crlf.pub" key_file.write_bytes(line_ending.join(pub_key_data).encode("utf-8")) finger = salt.utils.crypt.pem_finger(path=str(key_file)) - assert ( - finger - == "9b:42:66:92:8a:d1:b9:27:42:e0:6d:f3:12:c9:74:74:b0:e0:0e:42:83:87:62:ad:95:49:9d:6f:8e:d0:ed:35" - ) + assert finger == EXPECTED_PEM_FINGER @pytest.mark.parametrize("key", [b"123abc", "123abc"]) def test_pem_finger_key(key): finger = salt.utils.crypt.pem_finger(key=key) - assert ( - finger - == "dd:13:0a:84:9d:7b:29:e5:54:1b:05:d2:f7:f8:6a:4a:cd:4f:1e:c5:98:c1:c9:43:87:83:f5:6b:c4:f0:ff:80" - ) + assert finger == EXPECTED_NON_PEM_FINGER + + +@pytest.mark.parametrize("line_ending", ["\n", "\r\n"]) +@pytest.mark.parametrize("as_bytes", [False, True]) +def test_pem_finger_key_matches_path(tmp_path, pub_key_data, line_ending, as_bytes): + """PEM passed as key= must fingerprint the same as the same PEM on disk. + + Regression for #69970: path= stripped PEM headers/footers while key= hashed + the raw string, so minions could reject a valid master_finger. + """ + pem = line_ending.join(pub_key_data) + key_file = tmp_path / "master.pub" + key_file.write_bytes(pem.encode("utf-8")) + key = pem.encode("utf-8") if as_bytes else pem + assert salt.utils.crypt.pem_finger(path=str(key_file)) == EXPECTED_PEM_FINGER + assert salt.utils.crypt.pem_finger(key=key) == EXPECTED_PEM_FINGER def test_pem_finger_sha512(): From 6a3e0f47e7f833fc5cb7e3be6cd5b8b003155947 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 12 Aug 2026 19:12:36 -0700 Subject: [PATCH 281/469] Disable pip's periodic version check in salt-pip `salt-pip` shells out to `python -m pip` against a packager-pinned onedir pip; pip's periodic "A new release of pip is available" HTTPS check is pure noise (the user can't do anything about it) and a proxy-config gotcha (cf. #69910). `salt/modules/pip.py` already suppresses it on 6 install/list/upgrade paths; salt-pip should too. Set `PIP_DISABLE_PIP_VERSION_CHECK=1` in `_pip_environment` via `setdefault` so operators can opt back in by exporting `PIP_DISABLE_PIP_VERSION_CHECK=0` before invoking salt-pip. Fixes #70024 --- changelog/70024.fixed.md | 1 + salt/scripts.py | 6 ++++++ tests/pytests/unit/test_scripts.py | 31 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 changelog/70024.fixed.md diff --git a/changelog/70024.fixed.md b/changelog/70024.fixed.md new file mode 100644 index 000000000000..71a3df621495 --- /dev/null +++ b/changelog/70024.fixed.md @@ -0,0 +1 @@ +Set ``PIP_DISABLE_PIP_VERSION_CHECK=1`` in ``salt-pip`` so every invocation no longer triggers pip's periodic "A new release of pip is available" HTTPS check against a packager-pinned onedir pip. Operators can opt back in by exporting ``PIP_DISABLE_PIP_VERSION_CHECK=0``. diff --git a/salt/scripts.py b/salt/scripts.py index f651cb02b87c..b1a501a916b5 100644 --- a/salt/scripts.py +++ b/salt/scripts.py @@ -624,6 +624,12 @@ def _pip_environment(env, extras): new_env["PYTHONPATH"] = f"{extras}{os.pathsep}{env['PYTHONPATH']}" else: new_env["PYTHONPATH"] = extras + # Suppress pip's periodic "A new release of pip is available" check. + # The onedir bundles a packager-pinned pip, so the outbound HTTPS + # round-trip is pure noise (and a proxy-config gotcha; cf. #69910). + # setdefault so operators can opt back in with + # PIP_DISABLE_PIP_VERSION_CHECK=0 in the environment. + new_env.setdefault("PIP_DISABLE_PIP_VERSION_CHECK", "1") return new_env diff --git a/tests/pytests/unit/test_scripts.py b/tests/pytests/unit/test_scripts.py index 217f7352ab81..18c0f896e306 100644 --- a/tests/pytests/unit/test_scripts.py +++ b/tests/pytests/unit/test_scripts.py @@ -58,6 +58,37 @@ def test_pip_environment_pypath_win(): ) +def test_pip_environment_disables_version_check(): + """ + Regression test for #70024. + + ``salt-pip`` shells out to ``python -m pip`` under a packager-pinned + onedir pip; pip's periodic "A new release of pip is available" check + is pure noise and a proxy-config gotcha (cf. #69910). ``_pip_environment`` + must inject ``PIP_DISABLE_PIP_VERSION_CHECK=1`` into the child's env. + """ + extras = "/tmp/footest" + env = {"HOME": "/home/dwoz"} + pipenv = _pip_environment(env, extras) + assert "PIP_DISABLE_PIP_VERSION_CHECK" not in env + assert pipenv["PIP_DISABLE_PIP_VERSION_CHECK"] == "1" + + +def test_pip_environment_respects_operator_override(): + """ + Regression test for #70024. + + Operators must be able to re-enable pip's periodic version check by + exporting ``PIP_DISABLE_PIP_VERSION_CHECK=0`` before invoking + ``salt-pip``. ``_pip_environment`` uses ``setdefault`` so a caller's + explicit setting wins over the salt-pip default. + """ + extras = "/tmp/footest" + env = {"HOME": "/home/dwoz", "PIP_DISABLE_PIP_VERSION_CHECK": "0"} + pipenv = _pip_environment(env, extras) + assert pipenv["PIP_DISABLE_PIP_VERSION_CHECK"] == "0" + + def test_pip_args_not_installing(): extras = "/tmp/footest" args = ["list"] From 6c1d1d5d4ab669e30696b71e9267150f4f2625d8 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Wed, 12 Aug 2026 19:17:42 -0700 Subject: [PATCH 282/469] Add functional test for salt-pip PIP_DISABLE_PIP_VERSION_CHECK The unit tests on `_pip_environment` prove the helper injects `PIP_DISABLE_PIP_VERSION_CHECK=1` (and respects an operator override) but would still pass if a future refactor stopped routing `salt_pip` through `_pip_environment`. Add two end-to-end tests that drive `salt.scripts.salt_pip` with a stubbed `subprocess.run` and assert on the `env` dict actually handed to the child `python -m pip` process. Refs #70024 --- tests/pytests/functional/cli/test_salt_pip.py | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/tests/pytests/functional/cli/test_salt_pip.py b/tests/pytests/functional/cli/test_salt_pip.py index 22284d8488a4..af83c7b80763 100644 --- a/tests/pytests/functional/cli/test_salt_pip.py +++ b/tests/pytests/functional/cli/test_salt_pip.py @@ -1,11 +1,12 @@ import os +import pathlib import pytest import salt.scripts import salt.utils.platform from tests.conftest import CODE_DIR -from tests.support.mock import patch +from tests.support.mock import MagicMock, patch def test_within_onedir_env(shell): @@ -29,3 +30,80 @@ def test_outside_onedir_env(capsys): salt.scripts.salt_pip() captured = capsys.readouterr() assert "'salt-pip' is only meant to be used from a Salt onedir." in captured.err + + +def _run_salt_pip_capturing_subprocess(tmp_path, monkeypatch, env_overrides): + """ + Drive ``salt.scripts.salt_pip`` end-to-end while stubbing out the + actual ``python -m pip`` invocation. Returns the ``env`` mapping + that ``salt_pip`` handed to ``subprocess.run``. + + ``env_overrides`` is applied to ``os.environ`` via ``monkeypatch`` + *before* ``salt_pip`` runs so the test controls whether + ``PIP_DISABLE_PIP_VERSION_CHECK`` is already set by the "operator". + """ + # Scrub any inherited copy of the var first, then apply overrides. + monkeypatch.delenv("PIP_DISABLE_PIP_VERSION_CHECK", raising=False) + for key, value in env_overrides.items(): + monkeypatch.setenv(key, value) + + # Force a deterministic argv so _pip_args stays a no-op. + monkeypatch.setattr("sys.argv", ["salt-pip", "--version"]) + + # Pretend we're inside an onedir; the path only feeds the + # ``extras-X.Y`` string appended to PYTHONPATH. + fake_relenv = pathlib.Path(tmp_path) + fake_subprocess_result = MagicMock() + fake_subprocess_result.returncode = 0 + + recorded = {} + + def fake_run(command, shell, check, env): + recorded["command"] = command + recorded["env"] = env + return fake_subprocess_result + + with patch("salt.scripts._get_onedir_env_path", return_value=fake_relenv), patch( + "salt.config.minion_config", return_value={"user": None} + ), patch("salt.utils.user.get_user", return_value="root"), patch( + "salt.scripts.subprocess.run", side_effect=fake_run + ): + with pytest.raises(SystemExit) as exc: + salt.scripts.salt_pip() + + assert exc.value.code == 0 + assert "command" in recorded, "subprocess.run was never invoked" + return recorded + + +def test_salt_pip_subprocess_gets_disable_version_check_env(tmp_path, monkeypatch): + """ + Regression test for #70024, end-to-end. + + Drive ``salt.scripts.salt_pip`` with a stubbed ``subprocess.run`` and + assert the ``env`` dict handed to the child ``python -m pip`` process + has ``PIP_DISABLE_PIP_VERSION_CHECK=1``. This catches a future + refactor that stopped routing ``salt_pip`` through + ``_pip_environment`` — the unit test on ``_pip_environment`` alone + would still pass in that case. + """ + recorded = _run_salt_pip_capturing_subprocess( + tmp_path, monkeypatch, env_overrides={} + ) + assert recorded["env"].get("PIP_DISABLE_PIP_VERSION_CHECK") == "1" + + +def test_salt_pip_subprocess_respects_operator_override(tmp_path, monkeypatch): + """ + Regression test for #70024, end-to-end. + + If an operator has already exported ``PIP_DISABLE_PIP_VERSION_CHECK=0`` + (i.e., they want pip's periodic version check back), ``salt-pip`` + must not stomp on it. + """ + recorded = _run_salt_pip_capturing_subprocess( + tmp_path, + monkeypatch, + env_overrides={"PIP_DISABLE_PIP_VERSION_CHECK": "0"}, + ) + assert recorded["env"].get("PIP_DISABLE_PIP_VERSION_CHECK") == "0" From 95c4666f1e2e376257dfc2dc6e065342bfe8ccdd Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 13 Aug 2026 15:25:36 -0700 Subject: [PATCH 283/469] Add configurable/cgroup-aware minion memory headroom check Adds opt-in minion_memory_headroom (accepts "5%" / "5G" / int bytes) and minion_memory_max (bytes / size string) config options with silent cgroup v1 / v2 detection. When either opt is set, the reference "total memory available" is resolved from config > cgroup-v2 > cgroup-v1 > psutil.virtual_memory().total, and used bytes come from the matching tier. When neither opt is set the check preserves the legacy psutil.virtual_memory().percent > 95 behavior byte-for-byte, so no minion changes behavior on upgrade. Refs #69884 --- changelog/69884.added.md | 1 + doc/ref/configuration/minion.rst | 64 ++++ salt/config/__init__.py | 11 + salt/minion.py | 276 +++++++++++++- .../unit/test_minion_memory_headroom.py | 350 ++++++++++++++++++ 5 files changed, 698 insertions(+), 4 deletions(-) create mode 100644 changelog/69884.added.md create mode 100644 tests/pytests/unit/test_minion_memory_headroom.py diff --git a/changelog/69884.added.md b/changelog/69884.added.md new file mode 100644 index 000000000000..7c275165b9f9 --- /dev/null +++ b/changelog/69884.added.md @@ -0,0 +1 @@ +Added opt-in ``minion_memory_headroom`` and ``minion_memory_max`` minion config options with cgroup v1 / v2 detection so the queue-admission memory check can be tuned on large hosts and cgroup-limited minions. Defaults preserve the existing 95%-of-system-RAM behavior. diff --git a/doc/ref/configuration/minion.rst b/doc/ref/configuration/minion.rst index ec934a1c0a65..c4a1cfab48b4 100644 --- a/doc/ref/configuration/minion.rst +++ b/doc/ref/configuration/minion.rst @@ -3330,6 +3330,70 @@ processed as slots become available. ``-1`` is the default and disables the limi process_count_max: -1 +.. conf_minion:: minion_memory_headroom + +``minion_memory_headroom`` +-------------------------- + +.. versionadded:: 3006.28 + +Default: ``None`` + +Required free memory the minion must be able to allocate before it will start +another job. When set, the minion queue admission check compares this against +the reference "total memory available to this minion" (see +:conf_minion:`minion_memory_max`) instead of the built-in 95%-of-system-RAM +rule. + +Accepts either a percentage string (``"5%"``) or an absolute size +(``"5G"``, ``"500M"``, ``"5368709120"``, or a raw int of bytes). + +When unset, the minion preserves the legacy behavior of pausing queue +processing when system-wide RAM usage exceeds 95%. Setting this opt is the +recommended way to give the minion useful headroom guidance on very large +hosts (where 5% of RAM is many GB) or on cgroup-limited minions. + +The reference memory used to evaluate the percentage / absolute check is +resolved in this order: + +1. ``minion_memory_max`` if set. +2. cgroup v2 ``memory.max`` if the minion process is confined by a v2 + cgroup with a finite limit. +3. cgroup v1 ``memory.limit_in_bytes`` if the minion process is confined by + a v1 memory cgroup with a finite limit. +4. ``psutil.virtual_memory().total`` (system-wide RAM). + +.. code-block:: yaml + + minion_memory_headroom: 5% + +.. code-block:: yaml + + minion_memory_headroom: 500M + +.. conf_minion:: minion_memory_max + +``minion_memory_max`` +--------------------- + +.. versionadded:: 3006.28 + +Default: ``None`` + +Explicit override for the reference "total memory available to this minion" +used by :conf_minion:`minion_memory_headroom`. Accepts an absolute size +(``"2G"``, ``"1073741824"``, or a raw int of bytes). + +When unset, the reference is auto-detected from cgroup v2 / v1 memory limits +(when the minion process is confined by a cgroup) and finally falls back to +``psutil.virtual_memory().total``. Set this opt to pin the reference on +hosts where cgroup detection is unavailable or where the operator wants to +enforce a lower cap than the cgroup allows. + +.. code-block:: yaml + + minion_memory_max: 2G + .. _minion-logging-settings: Minion Logging Settings diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 5ad7eb0ae85b..0174eaba666d 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -301,6 +301,15 @@ def _gather_buffer_space(): "multiprocessing": bool, # Maximum number of concurrently active processes at any given point in time "process_count_max": int, + # Opt-in memory-headroom guard for queue admission. Accepts a + # percentage string ("5%") or an absolute size ("5G", "500M", int + # bytes). None preserves the legacy 5%-of-system-RAM behavior. + "minion_memory_headroom": (str, int, type(None)), + # Optional explicit override for the reference "total memory" used + # by the headroom guard. Accepts int bytes or a size string. When + # unset the reference is auto-detected from cgroup v2 / v1 limits, + # falling back to system-wide RAM. + "minion_memory_max": (str, int, type(None)), # Whether or not the salt minion should run scheduled mine updates "mine_enabled": bool, # Whether or not scheduled mine updates should be accompanied by a job return for the job cache @@ -1187,6 +1196,8 @@ def _gather_buffer_space(): "autosign_timeout": 120, "multiprocessing": True, "process_count_max": -1, + "minion_memory_headroom": None, + "minion_memory_max": None, "mine_enabled": True, "mine_return_job": False, "mine_interval": 60, diff --git a/salt/minion.py b/salt/minion.py index 1bb9874fe988..e1e9fa64a654 100644 --- a/salt/minion.py +++ b/salt/minion.py @@ -61,6 +61,7 @@ import salt.utils.schedule import salt.utils.ssdp import salt.utils.state +import salt.utils.stringutils import salt.utils.user import salt.utils.zeromq from salt._compat import ipaddress @@ -92,6 +93,16 @@ except ImportError: HAS_PSUTIL = False +# Paths for cgroup-aware memory-limit detection. Module-level so tests can +# monkeypatch to a synthetic cgroup filesystem laid out under tmp_path. +_CGROUP_PROC_PATH = "/proc/self/cgroup" +_CGROUP_FS_ROOT = "/sys/fs/cgroup" +# cgroup v1 kernel "no limit" sentinel is (LONG_MAX / PAGE_SIZE) * PAGE_SIZE, +# i.e. ~9.22 EB. We compare against 2**62 (~4.6 EB) which is comfortably +# above any real limit but below the sentinel — anything at or above this +# is treated as "unlimited". +_CGROUP_V1_UNLIMITED_THRESHOLD = 1 << 62 + try: import resource @@ -485,6 +496,215 @@ def service_name(): return "salt_minion" if "bsd" in sys.platform else "salt-minion" +def _read_cgroup_file(path): + """ + Read a cgroup pseudo-file. Return the stripped contents on success or + ``None`` on any error. Cgroup files are stable on Linux; we intentionally + swallow every failure (missing file, permission denied, non-Linux host, + exotic mount layout) and let the caller fall back to system-wide memory. + """ + try: + with salt.utils.files.fopen(path, "r") as fh: + return fh.read().strip() + except Exception: # pylint: disable=broad-exception-caught + return None + + +def _parse_self_cgroup(content): + """ + Parse ``/proc/self/cgroup`` content. Return a tuple + ``(v2_path, v1_memory_path)`` where each element is a string starting + with ``/`` or ``None`` if that hierarchy isn't present. + + v2 line format: ``0::/system.slice/salt-minion.service`` + v1 line format: ``5:memory:/system.slice/salt-minion.service`` + """ + v2_path = None + v1_memory_path = None + if not content: + return v2_path, v1_memory_path + for line in content.splitlines(): + parts = line.split(":", 2) + if len(parts) != 3: + continue + hierarchy_id, controllers, cgroup_path = parts + if hierarchy_id == "0" and controllers == "": + v2_path = cgroup_path or "/" + elif "memory" in controllers.split(","): + v1_memory_path = cgroup_path or "/" + return v2_path, v1_memory_path + + +def _detect_cgroup_memory(proc_path=None, fs_root=None): + """ + Detect the memory limit and current usage that apply to this process + via cgroups. + + Returns ``(limit_bytes, used_bytes, source)`` where ``source`` is + ``"cgroup-v2"``, ``"cgroup-v1"``, or ``None``. When no cgroup limit + applies (unified hierarchy reports ``"max"``, v1 reports the unlimited + sentinel, or the files can't be read) returns ``(None, None, None)``. + + Any I/O or parse failure is logged at DEBUG and swallowed — this helper + must never raise into the caller. + """ + proc_path = proc_path or _CGROUP_PROC_PATH + fs_root = fs_root or _CGROUP_FS_ROOT + content = _read_cgroup_file(proc_path) + if content is None: + log.debug( + "No cgroup information at %s; skipping cgroup memory detection", proc_path + ) + return None, None, None + v2_path, v1_memory_path = _parse_self_cgroup(content) + + # Prefer cgroup v2 (unified hierarchy) when both are present. On a + # v1 host the v2 line will be absent; on a hybrid host the v2 line + # exists but has no controllers, in which case v2 memory files simply + # won't be found and we'll fall through to v1. + if v2_path is not None: + max_str = _read_cgroup_file( + os.path.join(fs_root, v2_path.lstrip("/"), "memory.max") + ) + if max_str is not None: + if max_str == "max": + log.debug("cgroup v2 memory.max is 'max' (unlimited)") + else: + try: + limit = int(max_str) + except ValueError: + log.debug("Unparseable cgroup v2 memory.max: %r", max_str) + else: + current_str = _read_cgroup_file( + os.path.join(fs_root, v2_path.lstrip("/"), "memory.current") + ) + try: + current = int(current_str) if current_str is not None else 0 + except ValueError: + current = 0 + return limit, current, "cgroup-v2" + + if v1_memory_path is not None: + v1_base = os.path.join(fs_root, "memory", v1_memory_path.lstrip("/")) + limit_str = _read_cgroup_file(os.path.join(v1_base, "memory.limit_in_bytes")) + if limit_str is not None: + try: + limit = int(limit_str) + except ValueError: + log.debug("Unparseable cgroup v1 memory.limit_in_bytes: %r", limit_str) + else: + if limit >= _CGROUP_V1_UNLIMITED_THRESHOLD: + log.debug( + "cgroup v1 memory.limit_in_bytes is at unlimited sentinel: %s", + limit, + ) + else: + usage_str = _read_cgroup_file( + os.path.join(v1_base, "memory.usage_in_bytes") + ) + try: + used = int(usage_str) if usage_str is not None else 0 + except ValueError: + used = 0 + return limit, used, "cgroup-v1" + + return None, None, None + + +def _parse_size_opt(value): + """ + Parse a size expressed as int-bytes or a string (``"5G"``, ``"500M"``, + or plain digits). Return an int number of bytes, or ``None`` on any + parse failure. Zero and negatives are treated as invalid. + """ + if value is None: + return None + if isinstance(value, bool): + # bool is a subclass of int — reject to avoid True->1-byte surprise. + return None + if isinstance(value, int): + return value if value > 0 else None + if not isinstance(value, str): + return None + text = value.strip() + if not text: + return None + try: + parsed = int(text) + if parsed > 0: + return parsed + except ValueError: + pass + bytes_ = salt.utils.stringutils.human_to_bytes(text) + return bytes_ if bytes_ > 0 else None + + +def _headroom_to_bytes(value, reference): + """ + Convert a ``minion_memory_headroom`` opt to an absolute byte count + relative to ``reference``. Accepts a percentage string (``"5%"``), a + size string / int (``"500M"``, ``5368709120``), or ``None``. Returns + ``None`` on any parse failure or when ``value`` is ``None``. + """ + if value is None: + return None + if isinstance(value, str) and value.strip().endswith("%"): + text = value.strip()[:-1].strip() + try: + pct = float(text) + except ValueError: + log.debug("Unparseable percentage in minion_memory_headroom: %r", value) + return None + if not 0 < pct <= 100: + log.debug("minion_memory_headroom percentage out of range: %r", value) + return None + return int(reference * pct / 100.0) + bytes_ = _parse_size_opt(value) + if bytes_ is None: + log.debug("Unparseable minion_memory_headroom: %r", value) + return bytes_ + + +def _resolve_memory_reference(max_opt): + """ + Resolve the reference "total memory available to this minion" and the + current used bytes. + + Precedence: + 1. ``minion_memory_max`` config (int bytes or size string). + 2. cgroup v2 ``memory.max`` (with ``memory.current`` for used). + 3. cgroup v1 ``memory.limit_in_bytes`` (with ``memory.usage_in_bytes``). + 4. ``psutil.virtual_memory().total`` (with ``.used``). + + Returns ``(reference_bytes, used_bytes, source)`` where ``source`` is + one of ``"config"``, ``"cgroup-v2"``, ``"cgroup-v1"``, ``"system"``. + """ + import psutil # local import mirrors the caller's guarded pattern + + if max_opt is not None: + configured = _parse_size_opt(max_opt) + if configured is not None: + vm = psutil.virtual_memory() + # If the operator's cap is <= system total we assume they're + # describing a per-process cap and use it as the reference; we + # still need a "used" number so we consult cgroup usage first + # (accurate for the process) then fall back to system used. + cg_limit, cg_used, cg_source = _detect_cgroup_memory() + if cg_source is not None: + used = cg_used + else: + used = vm.used + return configured, used, "config" + log.debug("Unparseable minion_memory_max: %r", max_opt) + + cg_limit, cg_used, cg_source = _detect_cgroup_memory() + if cg_source is not None: + return cg_limit, cg_used, cg_source + + vm = psutil.virtual_memory() + return vm.total, vm.used, "system" + + class MinionBase: def __init__(self, opts): self.opts = opts @@ -2255,6 +2475,21 @@ def _has_memory_headroom(self): """ Check if we have enough memory to start a new process. Returns True if we have headroom, False otherwise. + + The reference "total memory" and the required headroom can both be + tuned via minion config: + + * ``minion_memory_max`` — explicit override for the reference total + (int bytes or a size string like ``"5G"``). + * ``minion_memory_headroom`` — required free headroom, either a + percentage of the reference (``"5%"``) or an absolute size + (``"5G"`` / ``"500M"`` / int bytes). + + When neither opt is set the check preserves the legacy behavior + (``psutil.virtual_memory().percent > 95``) byte-for-byte. When an + opt is set the reference is resolved from + ``minion_memory_max`` > cgroup v2 > cgroup v1 > + ``psutil.virtual_memory().total``. """ if not HAS_PSUTIL: return True @@ -2262,11 +2497,44 @@ def _has_memory_headroom(self): try: import psutil - mem = psutil.virtual_memory() - if mem.percent > 95: + headroom_opt = self.opts.get("minion_memory_headroom") + max_opt = self.opts.get("minion_memory_max") + + if headroom_opt is None and max_opt is None: + # Legacy fast path — no config, no cgroup lookup, no + # behavior change on upgrade. + mem = psutil.virtual_memory() + if mem.percent > 95: + log.warning( + "Memory limit reached (Used: %s%%). Pausing queue processing.", + mem.percent, + ) + return False + return True + + reference, used, source = _resolve_memory_reference(max_opt) + headroom_bytes = _headroom_to_bytes(headroom_opt, reference) + if headroom_bytes is None: + # Parse failure already logged at DEBUG; fall back to a + # 5% headroom on the resolved reference so operator intent + # (they asked for cgroup-aware behavior) is honored. + headroom_bytes = int(reference * 0.05) + + log.debug( + "Memory headroom check: source=%s reference=%s used=%s headroom=%s", + source, + reference, + used, + headroom_bytes, + ) + if used + headroom_bytes > reference: log.warning( - "Memory limit reached (Used: %s%%). Pausing queue processing.", - mem.percent, + "Memory limit reached (Used: %s of %s, headroom: %s, source: %s). " + "Pausing queue processing.", + used, + reference, + headroom_bytes, + source, ) return False except Exception: # pylint: disable=broad-exception-caught diff --git a/tests/pytests/unit/test_minion_memory_headroom.py b/tests/pytests/unit/test_minion_memory_headroom.py new file mode 100644 index 000000000000..67713f0469e9 --- /dev/null +++ b/tests/pytests/unit/test_minion_memory_headroom.py @@ -0,0 +1,350 @@ +""" +Tests for the configurable / cgroup-aware ``_has_memory_headroom`` guard on +the minion queue-admission hot path. See issue #69884. +""" + +import types + +import pytest + +import salt.minion + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def cgroup_fs(tmp_path, monkeypatch): + """ + Lay out a synthetic ``/proc/self/cgroup`` + cgroupfs under ``tmp_path`` + and monkey-patch the module-level path constants so the detection helper + reads from it. + + Returns a callable ``lay(version, limit, current=0, cgroup_path=...)``. + Callers may invoke it multiple times; later calls overwrite the layout. + """ + proc_dir = tmp_path / "proc" / "self" + proc_dir.mkdir(parents=True) + proc_cgroup = proc_dir / "cgroup" + fs_root = tmp_path / "sys" / "fs" / "cgroup" + fs_root.mkdir(parents=True) + + monkeypatch.setattr(salt.minion, "_CGROUP_PROC_PATH", str(proc_cgroup)) + monkeypatch.setattr(salt.minion, "_CGROUP_FS_ROOT", str(fs_root)) + + def _lay(version, limit, current=0, cgroup_path="/salt.slice/salt-minion.service"): + if version == "v2": + proc_cgroup.write_text(f"0::{cgroup_path}\n") + unit_dir = fs_root / cgroup_path.lstrip("/") + unit_dir.mkdir(parents=True, exist_ok=True) + limit_str = "max" if limit == "max" else str(limit) + (unit_dir / "memory.max").write_text(limit_str + "\n") + (unit_dir / "memory.current").write_text(str(current) + "\n") + elif version == "v1": + proc_cgroup.write_text( + f"5:memory:{cgroup_path}\n2:cpu,cpuacct:{cgroup_path}\n" + ) + unit_dir = fs_root / "memory" / cgroup_path.lstrip("/") + unit_dir.mkdir(parents=True, exist_ok=True) + (unit_dir / "memory.limit_in_bytes").write_text(str(limit) + "\n") + (unit_dir / "memory.usage_in_bytes").write_text(str(current) + "\n") + elif version == "none": + # cgroupfs exists but /proc/self/cgroup missing entirely + if proc_cgroup.exists(): + proc_cgroup.unlink() + else: + raise ValueError(f"unknown cgroup version: {version}") + + return _lay + + +@pytest.fixture +def no_cgroup(tmp_path, monkeypatch): + """Point the cgroup constants at paths that do not exist.""" + monkeypatch.setattr( + salt.minion, "_CGROUP_PROC_PATH", str(tmp_path / "nonexistent-cgroup") + ) + monkeypatch.setattr( + salt.minion, "_CGROUP_FS_ROOT", str(tmp_path / "nonexistent-fs-root") + ) + + +def _minion(opts): + """Return a stand-in with just enough surface for ``_has_memory_headroom``.""" + return types.SimpleNamespace(opts=opts) + + +# --------------------------------------------------------------------------- +# Parser / helper unit tests +# --------------------------------------------------------------------------- + + +class TestParseSizeOpt: + def test_none(self): + assert salt.minion._parse_size_opt(None) is None + + def test_bool_rejected(self): + # bool is a subclass of int — must not silently mean 1 byte. + assert salt.minion._parse_size_opt(True) is None + assert salt.minion._parse_size_opt(False) is None + + def test_int(self): + assert salt.minion._parse_size_opt(5368709120) == 5368709120 + + def test_zero_and_negative_rejected(self): + assert salt.minion._parse_size_opt(0) is None + assert salt.minion._parse_size_opt(-1) is None + + def test_string_digits(self): + assert salt.minion._parse_size_opt("5368709120") == 5368709120 + + def test_string_g(self): + assert salt.minion._parse_size_opt("5G") == 5 * (1024**3) + + def test_string_m(self): + assert salt.minion._parse_size_opt("500M") == 500 * (1024**2) + + def test_empty_string(self): + assert salt.minion._parse_size_opt("") is None + assert salt.minion._parse_size_opt(" ") is None + + def test_garbage(self): + assert salt.minion._parse_size_opt("garbage") is None + + +class TestHeadroomToBytes: + def test_none(self): + assert salt.minion._headroom_to_bytes(None, 1024) is None + + def test_percent_5(self): + # 5% of 2 GB + assert salt.minion._headroom_to_bytes("5%", 2 * (1024**3)) == int( + 2 * (1024**3) * 0.05 + ) + + def test_percent_100(self): + assert salt.minion._headroom_to_bytes("100%", 1000) == 1000 + + def test_percent_zero_rejected(self): + assert salt.minion._headroom_to_bytes("0%", 1000) is None + + def test_percent_over_100_rejected(self): + assert salt.minion._headroom_to_bytes("101%", 1000) is None + + def test_percent_garbage_rejected(self): + assert salt.minion._headroom_to_bytes("abc%", 1000) is None + + def test_size_string(self): + assert salt.minion._headroom_to_bytes("500M", 999) == 500 * (1024**2) + + def test_int_bytes(self): + assert salt.minion._headroom_to_bytes(1024, 999) == 1024 + + +class TestParseSelfCgroup: + def test_v2(self): + v2, v1 = salt.minion._parse_self_cgroup( + "0::/system.slice/salt-minion.service\n" + ) + assert v2 == "/system.slice/salt-minion.service" + assert v1 is None + + def test_v1_memory(self): + v2, v1 = salt.minion._parse_self_cgroup( + "5:memory:/salt.slice\n3:cpu,cpuacct:/user.slice\n" + ) + assert v2 is None + assert v1 == "/salt.slice" + + def test_hybrid(self): + v2, v1 = salt.minion._parse_self_cgroup( + "0::/user.slice/foo\n5:memory:/salt.slice\n" + ) + assert v2 == "/user.slice/foo" + assert v1 == "/salt.slice" + + def test_empty(self): + assert salt.minion._parse_self_cgroup("") == (None, None) + assert salt.minion._parse_self_cgroup(None) == (None, None) + + +# --------------------------------------------------------------------------- +# Cgroup detection +# --------------------------------------------------------------------------- + + +class TestDetectCgroupMemory: + def test_no_proc_file(self, no_cgroup): + assert salt.minion._detect_cgroup_memory() == (None, None, None) + + def test_v2_limited(self, cgroup_fs): + cgroup_fs("v2", limit=1024**3, current=100 * (1024**2)) + limit, used, source = salt.minion._detect_cgroup_memory() + assert limit == 1024**3 + assert used == 100 * (1024**2) + assert source == "cgroup-v2" + + def test_v2_unlimited(self, cgroup_fs): + cgroup_fs("v2", limit="max") + assert salt.minion._detect_cgroup_memory() == (None, None, None) + + def test_v1_limited(self, cgroup_fs): + cgroup_fs("v1", limit=1024**3, current=200 * (1024**2)) + limit, used, source = salt.minion._detect_cgroup_memory() + assert limit == 1024**3 + assert used == 200 * (1024**2) + assert source == "cgroup-v1" + + def test_v1_unlimited_sentinel(self, cgroup_fs): + # Actual kernel sentinel + cgroup_fs("v1", limit=9223372036854771712) + assert salt.minion._detect_cgroup_memory() == (None, None, None) + + +# --------------------------------------------------------------------------- +# _has_memory_headroom matrix +# --------------------------------------------------------------------------- + + +class TestHasMemoryHeadroom: + """ + The matrix from the design report. Each case constructs a stand-in + minion (only ``opts`` needed), lays out a synthetic cgroupfs if the + scenario calls for one, and mocks ``psutil.virtual_memory`` when the + scenario cares about system-wide numbers. + """ + + def test_default_below_95(self, no_cgroup, monkeypatch): + """No config, no cgroup => legacy path, 50% used => True.""" + vm = types.SimpleNamespace(percent=50.0, total=8 * 1024**3, used=4 * 1024**3) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + assert salt.minion.Minion._has_memory_headroom(_minion({})) is True + + def test_default_over_95(self, no_cgroup, monkeypatch): + """No config, no cgroup => legacy path, 96% used => False.""" + vm = types.SimpleNamespace(percent=96.0, total=8 * 1024**3, used=7 * 1024**3) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + assert salt.minion.Minion._has_memory_headroom(_minion({})) is False + + def test_config_percent_headroom_pass(self, no_cgroup, monkeypatch): + """5% headroom, system reference, 90% used => True.""" + total = 100 * 1024**3 + vm = types.SimpleNamespace(percent=90.0, total=total, used=int(total * 0.9)) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + opts = {"minion_memory_headroom": "5%"} + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is True + + def test_config_percent_headroom_fail(self, no_cgroup, monkeypatch): + """5% headroom, system reference, 96% used => False.""" + total = 100 * 1024**3 + vm = types.SimpleNamespace(percent=96.0, total=total, used=int(total * 0.96)) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + opts = {"minion_memory_headroom": "5%"} + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is False + + def test_config_absolute_headroom(self, no_cgroup, monkeypatch): + """500 MB headroom on 8 GB host with 7.6 GB used => False.""" + total = 8 * 1024**3 + used = int(7.6 * 1024**3) + vm = types.SimpleNamespace(percent=95.0, total=total, used=used) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + opts = {"minion_memory_headroom": "500M"} + # 500M reserve; 400M free => False + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is False + + def test_cgroup_v2_with_no_config_uses_legacy(self, cgroup_fs, monkeypatch): + """ + Cgroup present but no config => legacy fast path still applies + (no default flip on LTS). System % determines the result, cgroup + is ignored. + """ + cgroup_fs("v2", limit=1024**3, current=990 * (1024**2)) + vm = types.SimpleNamespace(percent=10.0, total=64 * 1024**3, used=6 * 1024**3) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + assert salt.minion.Minion._has_memory_headroom(_minion({})) is True + + def test_cgroup_v2_with_config_fails(self, cgroup_fs, monkeypatch): + """1 GB cgroup, 990 MB used, 5% headroom (~51 MB) => False.""" + cgroup_fs("v2", limit=1024**3, current=990 * (1024**2)) + vm = types.SimpleNamespace(percent=10.0, total=64 * 1024**3, used=6 * 1024**3) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + opts = {"minion_memory_headroom": "5%"} + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is False + + def test_cgroup_v2_unlimited_falls_back(self, cgroup_fs, monkeypatch): + """v2 memory.max == 'max' => fall through to system-wide.""" + cgroup_fs("v2", limit="max") + # 90% used, 5% headroom => False on the system reference + total = 8 * 1024**3 + vm = types.SimpleNamespace(percent=90.0, total=total, used=int(total * 0.96)) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + opts = {"minion_memory_headroom": "5%"} + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is False + + def test_cgroup_v1_with_config_passes(self, cgroup_fs, monkeypatch): + """1 GB v1 cgroup, 200 MB used, 5% headroom => True (800 MB free).""" + cgroup_fs("v1", limit=1024**3, current=200 * (1024**2)) + vm = types.SimpleNamespace(percent=10.0, total=64 * 1024**3, used=6 * 1024**3) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + opts = {"minion_memory_headroom": "5%"} + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is True + + def test_cgroup_v1_unlimited_sentinel_falls_back(self, cgroup_fs, monkeypatch): + cgroup_fs("v1", limit=9223372036854771712) + vm = types.SimpleNamespace(percent=50.0, total=8 * 1024**3, used=4 * 1024**3) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + opts = {"minion_memory_headroom": "5%"} + # Plenty of headroom on system-wide fallback + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is True + + def test_config_max_overrides_cgroup(self, cgroup_fs, monkeypatch): + """ + minion_memory_max wins over cgroup detection. Cgroup says 1 GB + limit; we pin the reference at 2 GB. With 500 MB reserve and + 200 MB used (cgroup used), 1.3 GB is free of the 2 GB reference. + """ + cgroup_fs("v2", limit=1024**3, current=200 * (1024**2)) + vm = types.SimpleNamespace(percent=10.0, total=64 * 1024**3, used=6 * 1024**3) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + opts = { + "minion_memory_max": "2G", + "minion_memory_headroom": "500M", + } + # 200M used + 500M reserve = 700M < 2G => True + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is True + + def test_missing_cgroup_permission_denied_falls_back(self, tmp_path, monkeypatch): + """ + Cgroup files exist but reads fail (simulated via unreadable path). + Must not raise; must fall back to system-wide arithmetic. + """ + # Point at a path that surely can't be read: use a directory as the + # cgroup file so open() will raise IsADirectoryError. + bad_path = tmp_path / "cgroup-is-a-dir" + bad_path.mkdir() + monkeypatch.setattr(salt.minion, "_CGROUP_PROC_PATH", str(bad_path)) + monkeypatch.setattr(salt.minion, "_CGROUP_FS_ROOT", str(tmp_path)) + vm = types.SimpleNamespace(percent=50.0, total=8 * 1024**3, used=4 * 1024**3) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + opts = {"minion_memory_headroom": "5%"} + # 5% of 8G reserve is ~410M. 4G used + 410M = 4.4G < 8G => True. + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is True + + def test_no_psutil_returns_true(self, monkeypatch): + monkeypatch.setattr(salt.minion, "HAS_PSUTIL", False) + opts = {"minion_memory_headroom": "5%"} + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is True + + def test_bogus_headroom_string_does_not_raise(self, no_cgroup, monkeypatch): + """ + Bogus opt value must not raise. It should DEBUG-log and fall back + to an implicit 5% reserve on the resolved reference so the operator's + intent to opt in is still honored. + """ + total = 8 * 1024**3 + vm = types.SimpleNamespace(percent=50.0, total=total, used=4 * 1024**3) + monkeypatch.setattr("psutil.virtual_memory", lambda: vm) + opts = {"minion_memory_headroom": "garbage"} + # 5% of 8G = 410M implicit; 4G + 410M = 4.4G < 8G => True. + assert salt.minion.Minion._has_memory_headroom(_minion(opts)) is True From bc6d557fcb5da9b15937a534a0d9b4a0e93230e6 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sun, 26 Jul 2026 02:31:41 -0700 Subject: [PATCH 284/469] Make salt.loader.resource_modules deny-by-default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resource_modules() built its LazyLoader over _module_dirs(), which returns the full stock module set (~100+ salt/modules/*) with per-type overlay directories merely prepended. That left every stock function reachable in a resource-scoped loader. Targeting a resource with a name like cmd.run / grains.setval / file.remove / state.apply resolved through the resource loader, ran the stock function in the managing minion process, and returned the result attributed to the resource id — contradicting the documented Resources safety contract (unsupported functions must fail loudly; managing-minion access is explicit via __minion__). Add a new private helper _resource_type_module_dirs() that walks the same layer stack _module_dirs() walks (cli module_dirs, extension_modules, entry-point packages, SALT_BASE_PATH) but only accepts each layer's resources/// overlay subdir — never the layer's plain / dir. resource_modules() now uses this helper. Result: * salt — "Function 'X' is not supported for resource type 'Y'." (the _thread_return guard fires because the name is absent from the loader). * salt — reachable as before via the resources//modules/ overlay. * Resource-context modules that intentionally want managing-minion behavior still call __minion__["x.y"] explicitly. Tests: * tests/pytests/unit/loader/test_per_resource_overrides.py - Flip test_no_override_falls_through_to_standard_state_module (which asserted stock state.sls was present) → test_no_override_hides_stock_modules asserting no stock cmd/state/grains/file/… leak through. The prior assertion documented the buggy contract. - Add test_per_type_override_present_and_callable for the positive path. * tests/pytests/unit/cli/test_caller_resources.py - salt-call -r grains.items / state.apply against a dummy resource now returns "not supported for resource type 'dummy'" rejections instead of stock function results. * tests/pytests/integration/resources/test_resource_loader_strict.py (new) - End-to-end master+minion coverage: salt dummy-01 cmd.run / grains.setval / file.remove / sys.list_functions all return the per-type rejection; test.ping (a real per-type override) still runs; grains.setval leaves the managing minion's grains untouched. Fixes #69881 --- changelog/69881.fixed.md | 8 + salt/loader/__init__.py | 95 ++++++- .../resources/test_dummy_resource.py | 240 ++++++++---------- .../resources/test_resource_loader_strict.py | 113 +++++++++ .../pytests/unit/cli/test_caller_resources.py | 102 ++++---- .../loader/test_per_resource_overrides.py | 100 ++++++-- 6 files changed, 450 insertions(+), 208 deletions(-) create mode 100644 changelog/69881.fixed.md create mode 100644 tests/pytests/integration/resources/test_resource_loader_strict.py diff --git a/changelog/69881.fixed.md b/changelog/69881.fixed.md new file mode 100644 index 000000000000..16ed2b2051f2 --- /dev/null +++ b/changelog/69881.fixed.md @@ -0,0 +1,8 @@ +Per-resource-type execution loaders (``salt.loader.resource_modules``) no +longer include stock ``salt/modules/*`` — the loader is now deny-by-default +and exposes only modules discovered under ``resources//modules/`` +override directories. Managing-minion access remains available via the +``__minion__`` escape hatch. Restores the documented Resources safety +contract: ``salt cmd.run …`` (or ``grains.setval``, +``file.remove``, etc.) now returns "Function '…' is not supported for +resource type '…'" instead of silently executing on the managing minion. diff --git a/salt/loader/__init__.py b/salt/loader/__init__.py index 4990f6f1d809..a60472a22637 100644 --- a/salt/loader/__init__.py +++ b/salt/loader/__init__.py @@ -304,6 +304,89 @@ def _per_type(base): ) +def _resource_type_module_dirs( + opts, + ext_type, + tag=None, + int_type=None, + base_path=None, + load_extensions=True, +): + """ + Return ONLY the per-resource-type override directories for a given + ``ext_type`` (``modules``, ``states``, etc.) — no stock salt/ dir, + no plain extension_modules dir, no plain entry-point dir. + + Layers checked, in priority order: + + * ``/resources///`` for each + ``opts["module_dirs"]`` entry + * ``/resources///`` + * ``/resources///`` for every + entry-point-contributed package under ``salt.loader`` + * ``/resources///`` + + ``opts["resource_type"]`` MUST be set; if it is not, this returns an + empty list (callers should not build a resource-scoped loader + without a resource type). + + This helper is what :func:`resource_modules` uses to build a + per-resource-type execution loader that is deny-by-default: only + modules explicitly shipped for the resource type are reachable via + ``__salt__`` in a resource context. Managing-minion access remains + available via the ``__minion__`` escape hatch. + """ + rtype = opts.get("resource_type") + if not rtype: + return [] + + subpath_parts = ("resources", rtype, int_type or ext_type) + + def _per_type(base): + if not base: + return [] + candidate = os.path.join(base, *subpath_parts) + return [candidate] if os.path.isdir(candidate) else [] + + cli_per_type = [] + for _dir in opts.get("module_dirs", []): + cli_per_type.extend(_per_type(_dir)) + + ext_per_type = _per_type(opts.get("extension_modules")) + + # Walk the same entry-point packages :func:`_module_dirs` would + # walk, but only accept their per-type overlay dir — never the + # entry-point's own ```` root. + entry_point_per_type = [] + if load_extensions: + for entry_point in entrypoints.iter_entry_points("salt.loader"): + with catch_entry_points_exception(entry_point) as ctx: + loaded_entry_point = entry_point.load() + if ctx.exception_caught: + continue + if isinstance(loaded_entry_point, types.ModuleType): + for loaded_entry_point_path in loaded_entry_point.__path__: + entry_point_per_type.extend(_per_type(loaded_entry_point_path)) + # Function-style entry points are considered path providers + # in :func:`_module_dirs`; we take their parent dir as the + # package root and probe for the per-type overlay under it. + elif isinstance(loaded_entry_point, types.FunctionType): + with catch_entry_points_exception(entry_point) as ctx: + loaded_entry_point_value = loaded_entry_point() + if ctx.exception_caught: + continue + if isinstance(loaded_entry_point_value, dict): + for path in loaded_entry_point_value.get(ext_type, ()): + entry_point_per_type.extend(_per_type(os.path.dirname(path))) + else: + for path in loaded_entry_point_value: + entry_point_per_type.extend(_per_type(os.path.dirname(path))) + + sys_per_type = _per_type(base_path or str(SALT_BASE_PATH)) + + return cli_per_type + ext_per_type + entry_point_per_type + sys_per_type + + def minion_mods( opts, context=None, @@ -684,6 +767,16 @@ def resource_modules( a key), and call ``__salt__["x.y"]`` to dispatch through the resource itself. + The loader is **deny-by-default**: only modules discovered under + ``resources//modules/`` overlay directories are + reachable via ``__salt__``. Stock ``salt/modules/*`` are NOT + exposed here; targeting a resource with a stock function name + (``salt cmd.run …``) surfaces the "Function 'cmd.run' is not + supported for resource type 'X'" guard in ``_thread_return`` + instead of silently running against the managing minion. Types + that intentionally want stock behavior ship a thin override that + calls back through ``__minion__``. + :param dict opts: The Salt options dictionary. A copy is made and ``resource_type`` is injected before passing to the loader. :param str resource_type: The resource type string (e.g. ``"dummy"``). @@ -716,7 +809,7 @@ def resource_modules( pack["__minion__"] = minion_mods return LazyLoader( - _module_dirs(resource_opts, "modules", "module"), + _resource_type_module_dirs(resource_opts, "modules", "module"), resource_opts, tag="module", pack=pack, diff --git a/tests/pytests/integration/resources/test_dummy_resource.py b/tests/pytests/integration/resources/test_dummy_resource.py index d273207b02df..48ece0fe1c4d 100644 --- a/tests/pytests/integration/resources/test_dummy_resource.py +++ b/tests/pytests/integration/resources/test_dummy_resource.py @@ -269,46 +269,38 @@ def test_grain_targeting_only_matching_resource(salt_minion, salt_cli): assert data is True or data == {}, f"Unexpected response shape: {data!r}" -def test_grains_items_returns_resource_grains_not_minion_grains(salt_minion, salt_cli): +def test_grains_items_rejected_when_dummy_ships_no_grains_override( + salt_minion, salt_cli +): """ - ``salt 'dummy-01' grains.items`` must return the dummy resource's grains - (produced by ``salt.resources.dummy.grains``), not the managing minion's - grains. This exercises the end-to-end grain-swap pipeline: - - * Master targeting matches the bare resource id ``dummy-01`` and - dispatches a job whose payload includes ``resource_target`` for the - ``dummy`` type. - * Minion ``_thread_return`` packs ``__grains__`` from - ``resource_funcs["dummy.grains"]()`` before the function runs. - * The function (``grains.items``) returns the resource grain dict. - * Master ``_return`` re-keys ``resource_id`` → response key ``dummy-01``. + ``salt 'dummy-01' grains.items`` — the ``dummy`` resource type ships no + per-type ``grains`` override, so under the deny-by-default resource + loader (#69881) ``grains.items`` is not reachable via the resource + surface. The dispatch returns the "not supported for resource type" + rejection at the minion, and the CLI response is keyed to the + resource id (not the managing minion). + + NOTE: this replaces an earlier test that asserted + ``salt.resources.dummy.grains()`` was reachable through the resource + loader's ``__grains__`` swap. The swap still runs when a resource + function IS present in the loader — but ``grains.items`` itself + isn't there. A dummy resource type that wanted to expose grains + would ship ``salt/resources/dummy/modules/grains.py`` (thin-wrap + ``__resource_funcs__["dummy.grains"]()`` or ``__minion__["grains.items"]``). """ ret = salt_cli.run("grains.items", minion_tgt="dummy-01") - assert ret.returncode == 0, ret + # Rejection returns non-zero. + assert ret.returncode != 0, ret data = _salt_cli_json_dict(ret) assert isinstance(data, dict), f"Expected dict, got: {data!r}" - # Salt-factories unwraps the single-key envelope when ``minion_tgt`` is - # the only response key, so ``data`` may be either the grains dict itself - # or ``{"dummy-01": grains_dict}``. Accept both shapes. - grains = data.get("dummy-01") if "dummy-01" in data else data + payload = data.get("dummy-01") if "dummy-01" in data else data assert isinstance( - grains, dict - ), f"Expected dict for dummy-01 grains, got: {grains!r}" - - # The resource grains must be present. - assert grains.get("dummy_grain_1") == "one" - assert grains.get("dummy_grain_2") == "two" - assert grains.get("dummy_grain_3") == "three" - assert grains.get("resource_id") == "dummy-01" - - # The managing minion's grains must NOT bleed through. ``os`` is a stock - # core grain on every supported Linux/macOS test target; if it appears - # the swap didn't take effect. - assert "os" not in grains, ( - "Managing minion's 'os' grain leaked into resource grains response — " - "the dispatch path is returning minion grains instead of resource grains" - ) + payload, str + ), f"Expected rejection string for dummy-01 grains.items, got: {payload!r}" + assert "not supported for resource type 'dummy'" in payload, payload + # The managing minion must NOT appear at the top level. + assert salt_minion.id not in data, data def test_grain_pcre_targeting_matches_resources(salt_minion, salt_cli): @@ -414,34 +406,26 @@ def test_pillar_addition_at_runtime_registers_new_resource( def test_state_single_against_resource_no_phantom_no_response(salt_minion, salt_cli): """ - Regression for ``RESOURCE_STATE_RETURN_ATTRIBUTION_BUG.md``. + Regression for ``RESOURCE_STATE_RETURN_ATTRIBUTION_BUG.md`` — updated + for the #69881 deny-by-default loader. A merge-fun state job against a pure-resource compound target — ``salt -C 'T@dummy:dummy-01' state.single test.nop ...`` — must not produce a ``Minion did not return. [No response]`` line for the targeted resource id. The original bug report observed both a - successful state result *and* a phantom resource-id timeout in the - CLI output, indicating the master's wait set wrongly contained the + resource-side return *and* a phantom resource-id timeout in the CLI + output, indicating the master's wait set wrongly contained the resource id alongside the managing minion. - ``state.single`` is in :py:attr:`~salt.minion.Minion._MERGE_RESOURCE_FUNS`, - so the design has the managing minion run the state inline and - return ONE combined response under its own id. The master's - targeting path (``CkMinions._check_resource_minions``) is supposed - to remap pure-resource ``T@`` terms to the managing minion's id - for merge funs — the bug is when that remap is bypassed and the - resource id ends up in the wait set too, where it never produces a - separate return and times out. - - Mirrors the bug's reproduction shape against the bundled ``dummy`` - type (the original report used ``vcenter`` from a Salt extension). - Pins the in-tree contract end-to-end so a regression in the wait-set - logic — e.g. an `_augment_with_resources` path firing for compound - targets, or a merge-fun check skipped because ``fun`` plumbing - drops out somewhere — fails this assertion loudly. + Post-#69881: the ``dummy`` resource type ships no per-type + ``state.py`` override, so the per-resource loader rejects + ``state.single`` with "not supported for resource type 'dummy'." + The "no phantom did not return" contract still applies — the + rejection IS a real return; there must not be a separate + "did not return" line for the resource id alongside it. Asserts: - * The state runs (``test.nop`` chunk appears in the response). + * The rejection is present under the resource id key. * No ``did not return`` / ``No response`` text in stdout or stderr. * No top-level response key whose value is a "did not return" error string. @@ -469,45 +453,42 @@ def test_state_single_against_resource_no_phantom_no_response(salt_minion, salt_ data = _salt_cli_json_dict(ret) assert isinstance(data, dict), f"Expected dict, got: {data!r}" - # No top-level response key with an error-string value (the bug - # produced ``{"": "Minion did not return..."}`` - # alongside the real result). + # No top-level response key with a "did not return" error string + # (the original bug produced ``{"": "Minion did not + # return..."}`` alongside the real result). for key, value in data.items(): assert not (isinstance(value, str) and "did not return" in value.lower()), ( f"Response contains a 'did not return' string under key " f"{key!r}: {value!r}" ) - # The state must have actually run somewhere in the response. - def _has_state_result(node): - if isinstance(node, dict): - if any(k.endswith("_|-nop") for k in node): - return True - return any(_has_state_result(v) for v in node.values()) - return False - - assert _has_state_result( - data - ), f"No test.nop state result anywhere in the response payload: {data!r}" + # The rejection must land under the resource id (this IS the + # resource's return; the "no phantom did not return" contract is + # satisfied because the resource returned a real value, just a + # negative one). + assert ( + target_id in data + ), f"Expected resource id {target_id!r} in response; got {list(data)}" + body = data[target_id] + assert isinstance(body, str), f"Expected rejection string, got: {body!r}" + assert "not supported for resource type 'dummy'" in body, body def test_state_single_against_single_resource_keyed_by_resource_id( salt_minion, salt_cli ): """ - Desired API shape (Option B from the design discussion): for a - merge-fun state job against a pure-resource compound target, the + For a state job against a pure-resource compound target, the response must be keyed by the **resource id**, not by the managing minion. Matches the shape of ``test.ping`` against the same target so consumers can write one ``data[resource_id]`` pattern regardless of function. - Today the framework folds per-resource state results into a single - return under the managing minion's id with state-chunk keys - prefixed by the resource id. This test fails until the minion's - merge-fold path is changed to emit one return per resource with - ``ret["resource_id"]`` set (then the master's existing - ``resource_id`` remap re-keys the response to the resource id). + Post-#69881: the ``dummy`` resource type ships no per-type + ``state.py`` override, so ``state.single`` is rejected at the + per-resource loader with "not supported for resource type 'dummy'". + The key-by-resource-id shape contract still holds — the rejection + string lands under the resource id, not under the managing minion. """ target_id = DUMMY_RESOURCES[0] ret = salt_cli.run( @@ -517,48 +498,40 @@ def test_state_single_against_single_resource_keyed_by_resource_id( "name=resource-id-keyed-state-return", minion_tgt=f"T@dummy:{target_id}", ) - assert ret.returncode == 0, ret + # Rejection returns non-zero. + assert ret.returncode != 0, ret data = _salt_cli_json_dict(ret) assert isinstance(data, dict), f"Expected dict, got: {data!r}" - # Top-level key must be the resource id. - assert target_id in data, ( - f"Expected top-level response key {target_id!r}; " - f"got {list(data)} (managing-minion-id keying is the OLD shape)." - ) - # The managing minion must NOT appear at the top level. + # Top-level key must be the resource id (not the managing minion). + assert ( + target_id in data + ), f"Expected top-level response key {target_id!r}; got {list(data)}" assert salt_minion.id not in data, ( f"Managing minion {salt_minion.id!r} appears as response key; " - f"merge-fun state returns must be keyed by resource id only." + f"resource-scoped returns must be keyed by resource id only." ) body = data[target_id] - assert isinstance(body, dict), f"Resource body must be dict, got: {body!r}" - - # State-chunk keys inside the resource body must NOT be prefixed - # with the resource id any more — the wrapping key already conveys - # provenance, so the prefix is redundant noise. - chunk_keys = [k for k in body if k.endswith("_|-nop")] - assert chunk_keys, f"No test.nop chunk in resource body: {body!r}" - for k in chunk_keys: - parts = k.split("_|-") - # State low key shape: ``{module}_|-{id}_|-{name}_|-{function}``. - # parts[1] is the state id; with resource-id-keyed responses it - # should be the plain state id (no leading " " prefix). - assert not parts[1].startswith(f"{target_id} "), ( - f"State id {parts[1]!r} still has the redundant resource-id " - f"prefix. With resource-id-keyed responses the wrapping key " - f"already conveys the resource." - ) + assert isinstance( + body, str + ), f"Expected rejection string under {target_id!r}, got: {body!r}" + assert "not supported for resource type 'dummy'" in body, body def test_state_single_against_bare_type_returns_per_resource(salt_minion, salt_cli): """ - Bare-type merge fun (``T@dummy`` matches all 3 dummy resources): the - response must contain one top-level entry per resource — matching - how ``salt -C 'T@dummy' test.ping`` already renders — instead of a - single merged block under the managing minion id. + Bare-type resource target (``T@dummy`` matches all 3 dummy + resources): the response must contain one top-level entry per + resource — matching how ``salt -C 'T@dummy' test.ping`` already + renders — instead of a single merged block under the managing + minion id. + + Post-#69881: ``state.single`` is rejected per resource (dummy + ships no ``state.py`` override), so each per-resource entry + carries the "not supported for resource type" string. The + per-resource shape contract still holds. """ ret = salt_cli.run( "-C", @@ -567,7 +540,8 @@ def test_state_single_against_bare_type_returns_per_resource(salt_minion, salt_c "name=bare-type-per-resource-return", minion_tgt="T@dummy", ) - assert ret.returncode == 0, ret + # Rejection returns non-zero. + assert ret.returncode != 0, ret data = ret.data assert isinstance(data, dict), f"Expected dict, got: {data!r}" @@ -582,9 +556,8 @@ def test_state_single_against_bare_type_returns_per_resource(salt_minion, salt_c ), f"Managing minion unexpectedly in bare-type response: {list(data)}" for rid, body in data.items(): - assert isinstance(body, dict), f"{rid!r} body not dict: {body!r}" - chunk_keys = [k for k in body if k.endswith("_|-nop")] - assert chunk_keys, f"No test.nop chunk under {rid!r}: {body!r}" + assert isinstance(body, str), f"{rid!r} body not string: {body!r}" + assert "not supported for resource type 'dummy'" in body, (rid, body) def test_state_single_against_bare_resource_id_keyed_by_resource_id( @@ -595,28 +568,20 @@ def test_state_single_against_bare_resource_id_keyed_by_resource_id( resource id, ``tgt_type=glob``, no wildcards) must return under the resource id — same shape as ``salt 'dummy-01' test.ping``. - The minion-side bug: for a bare-id glob target, ``minion_matches`` - is False (the target string isn't the managing minion's id) so - ``minion_is_target`` would normally be False; meanwhile - ``_is_pure_resource_target`` only recognised compound ``T@`` / - ``M@`` expressions as pure-resource, so the merge-fold + per-resource - fan-out logic both got skipped. A bare-id glob with a merge-mode - state function ran nothing on the managing minion and produced no - return — only the master's "did not return" timeout. - - The fix is two-sided in ``salt/minion.py``: - - * ``_is_pure_resource_target`` recognises an exact (no-wildcard) - glob whose ``tgt`` names a managed resource as a pure-resource - target. - * ``_target_load`` treats the managing minion as a target whenever - ``is_merge_fun and resource_targets``, regardless of whether the - glob also matched the minion's own id — the managing minion has - to run the inline merge for the resource. - - Non-merge funs (``test.ping``) already worked through the - per-resource fan-out path; this test asserts merge funs now work - too with the same shape. + The minion-side bug this test guards against: for a bare-id glob + target, ``_is_pure_resource_target`` used to only recognise + compound ``T@`` / ``M@`` expressions as pure-resource, so a bare-id + glob for a merge-mode function produced no return — only the + master's "did not return" timeout. The fix in ``salt/minion.py`` + recognises an exact (no-wildcard) glob whose ``tgt`` names a + managed resource as a pure-resource target so the resource + dispatch fires. + + Post-#69881: ``state.single`` is rejected at the per-resource + loader (dummy ships no ``state.py`` override). The bare-id + keying + no-phantom-timeout contracts still hold — the response + lands under the resource id with the "not supported for resource + type" rejection, and there is no phantom "did not return" line. """ target_id = DUMMY_RESOURCES[0] ret = salt_cli.run( @@ -625,7 +590,8 @@ def test_state_single_against_bare_resource_id_keyed_by_resource_id( "name=bare-id-keyed-state-return", minion_tgt=target_id, ) - assert ret.returncode == 0, ret + # Rejection returns non-zero. + assert ret.returncode != 0, ret data = _salt_cli_json_dict(ret) # Salt-factories unwraps single-key envelopes when ``minion_tgt`` @@ -635,18 +601,22 @@ def test_state_single_against_bare_resource_id_keyed_by_resource_id( # Managing minion must not appear at the top level. assert salt_minion.id not in data, ( f"Managing minion {salt_minion.id!r} appears as response key; " - f"bare-id merge-fun state returns must be keyed by resource id." + f"bare-id resource returns must be keyed by resource id." ) else: # Unwrapped envelope: body IS the resource's payload. body = data - assert isinstance(body, dict), f"Resource body must be dict, got: {body!r}" - - chunk_keys = [k for k in body if k.endswith("_|-nop")] - assert chunk_keys, f"No test.nop chunk in resource body: {body!r}" + assert isinstance(body, str), f"Expected rejection string, got: {body!r}" + assert "not supported for resource type 'dummy'" in body, body # No phantom "did not return" entries. if isinstance(data, dict): for key, value in data.items(): assert not ( isinstance(value, str) and "did not return" in value.lower() ), f"Phantom 'did not return' under {key!r}: {value!r}" + # And no such phrase in stdout/stderr either. + combined_output = (ret.stdout or "") + "\n" + (ret.stderr or "") + assert "did not return" not in combined_output.lower(), ( + f"Phantom 'Minion did not return' in output: " + f"stdout={ret.stdout!r} stderr={ret.stderr!r}" + ) diff --git a/tests/pytests/integration/resources/test_resource_loader_strict.py b/tests/pytests/integration/resources/test_resource_loader_strict.py new file mode 100644 index 000000000000..df8285f14e38 --- /dev/null +++ b/tests/pytests/integration/resources/test_resource_loader_strict.py @@ -0,0 +1,113 @@ +""" +End-to-end integration tests for the deny-by-default surface of +:func:`salt.loader.resource_modules` (issue #69881). + +The per-resource execution loader must expose ONLY modules discovered +under ``resources//modules/`` overlay directories, plus the +``__minion__`` escape hatch. Targeting a resource with a stock salt +execution module (``cmd.run``, ``grains.setval``, ``file.remove``, …) +must surface the "not supported for resource type" rejection at the +minion — never silently execute on the managing minion. + +Runs against the real minion/master fixtures in :mod:`conftest`; the +``dummy`` resource type ships per-type ``test.py`` override only, so +these calls exercise the deny-by-default path for every other slot. +""" + +import pytest + +pytestmark = [pytest.mark.slow_test] + + +@pytest.mark.parametrize( + "fun,args", + [ + # ``cmd.run`` is the most dangerous stock leak — the reporter's + # PoC ran ``cmd.run 'hostname; id; pwd'`` and got managing-minion + # host identity attributed to the resource id. + ("cmd.run", ["echo strict-resource-loader-canary"]), + # ``grains.setval`` writes to the managing minion's grains file + # (``/etc/salt/grains``) — silent misattribution + persistent + # state mutation. + ("grains.setval", ["strict_probe", "resource-leak"]), + # ``file.remove`` is a destructive filesystem op on the managing + # minion. Just try to remove a benign path; the point is the + # dispatch never reaches the function. + ("file.remove", ["/tmp/strict-loader-nonexistent-canary"]), + # ``sys.list_functions`` used to leak the full stock surface — + # ~1300 functions — via the resource loader. After the fix it's + # rejected too (types that want introspection ship an override). + ("sys.list_functions", []), + ], +) +def test_stock_module_rejected_on_resource_target(salt_minion, salt_cli, fun, args): + """ + ``salt …`` returns the "not supported for + resource type" rejection instead of silently executing on the + managing minion. + + Regression guard for #69881. Before the fix, the resource loader + included every stock salt/modules/ file, so the dispatch happily + ran the function in the managing minion process while attributing + the return to the resource id. + """ + ret = salt_cli.run(fun, *args, minion_tgt="dummy-01") + # ret.data may be either the bare string (single-target) or a dict. + if isinstance(ret.data, dict): + payload = ret.data.get("dummy-01", ret.data) + else: + payload = ret.data + assert isinstance(payload, str), (fun, ret.data) + assert "not supported for resource type 'dummy'" in payload, (fun, payload) + # Sanity: the response is keyed to the resource id, not the minion id. + assert salt_minion.id not in (ret.data or {}), (fun, ret.data) + + +def test_per_type_override_reachable_on_resource_target(salt_minion, salt_cli): + """ + Positive case: ``test.ping`` IS shipped as a per-type override at + ``salt/resources/dummy/modules/test.py``, so it MUST be reachable + on a dummy resource target. Without this test, a regression that + over-restricts the loader (e.g. drops every layer including the + in-tree overlay) would still pass the deny-by-default tests above. + """ + ret = salt_cli.run("test.ping", minion_tgt="dummy-01") + assert ret.returncode == 0, ret + if isinstance(ret.data, dict): + payload = ret.data.get("dummy-01", ret.data) + else: + payload = ret.data + assert payload is True, ret.data + + +def test_grains_setval_does_not_touch_managing_minion( + salt_minion, salt_cli, salt_call_cli +): + """ + ``salt grains.setval …`` used to write to the managing + minion's persistent grains file. Assert the grain the operator + tried to set is NOT present in the managing minion's grains after + the dispatch is rejected — the resource-loader guard is the only + thing preventing the write, so any regression would show up here. + """ + grain_key = "strict_loader_persistent_probe" + grain_val = "resource-leak-must-not-persist" + + ret = salt_cli.run("grains.setval", grain_key, grain_val, minion_tgt="dummy-01") + # The rejection may come back with a non-zero rc; either way the + # write must not have happened. + payload = ret.data + if isinstance(payload, dict): + payload = payload.get("dummy-01", payload) + assert isinstance(payload, str), payload + assert "not supported for resource type 'dummy'" in payload, payload + + # Verify the managing minion's grains do NOT carry the probe. + grains_ret = salt_call_cli.run("grains.get", grain_key) + assert grains_ret.returncode == 0, grains_ret + # ``grains.get`` returns an empty string for a missing key. + assert grains_ret.data in ("", None), ( + f"managing minion's grains carry {grain_key}={grains_ret.data!r} " + "— the resource-loader guard leaked and grains.setval ran on the " + "managing minion." + ) diff --git a/tests/pytests/unit/cli/test_caller_resources.py b/tests/pytests/unit/cli/test_caller_resources.py index 0793242ef88f..1ddcf78e2336 100644 --- a/tests/pytests/unit/cli/test_caller_resources.py +++ b/tests/pytests/unit/cli/test_caller_resources.py @@ -151,29 +151,34 @@ def test_r_pure_compound_excludes_managing_minion(call_opts): assert set(payload.keys()) == {"dummy-01", "dummy-02", "dummy-03"} -def test_r_grains_items_returns_per_resource_grains(call_opts): +def test_r_grains_items_not_supported_without_override(call_opts): """ - ``salt-call -r --tgt dummy-01 grains.items`` must return the dummy - resource's own grain dict — not the managing minion's grains. The - per-resource ``__grains__`` swap mirrors what ``Minion._thread_return`` - does for master-driven resource jobs. + ``salt-call -r --tgt dummy-01 grains.items`` — the ``dummy`` resource + type ships no ``grains.py`` override, so ``grains.items`` is not + reachable via the per-resource loader (deny-by-default surface, + #69881 fix). The caller returns the "not supported for resource + type" rejection instead of falling through to the managing minion's + stock ``grains.items``. + + A type that legitimately wants to expose grains still can — by + shipping ``resources//modules/grains.py`` that returns the + resource's grains (or thin-wraps ``__minion__["grains.items"]``). """ call_opts["resources_dispatch"] = True call_opts["resources_tgt"] = "dummy-01" call_opts["fun"] = "grains.items" caller = _build_caller(call_opts) payload = caller._call_with_resources()["return"] - assert isinstance(payload, dict), payload - # Resource grains include a ``resource_id`` key set to the rid. - assert payload.get("resource_id") == "dummy-01", payload - assert payload.get("dummy_grain_1") == "one", payload + assert isinstance(payload, str), payload + assert "not supported for resource type 'dummy'" in payload, payload -def test_r_grains_items_per_resource_for_each_target(call_opts): +def test_r_grains_items_not_supported_per_target(call_opts): """ - With multiple resource targets, each entry in the response dict gets - the corresponding resource's own grains, not a shared snapshot from - the last loader call. + With multiple resource targets and no per-type ``grains`` override, + each entry in the response dict carries the "not supported for + resource type" rejection. This is the deny-by-default surface — + stock modules never leak through the per-resource loader. """ call_opts["resources_dispatch"] = True call_opts["resources_tgt"] = "T@dummy" @@ -184,33 +189,30 @@ def test_r_grains_items_per_resource_for_each_target(call_opts): assert isinstance(payload, dict), payload for rid in ("dummy-01", "dummy-02", "dummy-03"): assert rid in payload, payload - assert payload[rid].get("resource_id") == rid, (rid, payload[rid]) + assert isinstance(payload[rid], str), (rid, payload[rid]) + assert "not supported for resource type 'dummy'" in payload[rid], ( + rid, + payload[rid], + ) -@pytest.mark.timeout(180, func_only=True) -def test_r_state_apply_logical_resource_no_state_module(call_opts): +def test_r_state_apply_not_supported_without_override(call_opts): """ - state.apply against a logical resource type (no per-resource state - override module) routes through the standard ``state.py`` (the - narrow guard in ``salt/modules/state.py`` only opts out for - ``ssh``). The state run finds no matching state module for the - .sls referenced state (dummy resources don't ship a - ``dummy_test`` state module), and produces ``result: False`` - state entries — one per resource — keyed in the master merge - format with the resource id prefixed onto each state id. - - This is the expected behaviour for logical resources: the dispatch - succeeds (no caller-level rejection), the state machinery runs, - and the operator sees per-resource provenance for whatever the - state run produced. - - Runs ``state.apply`` three times (once per dummy resource), each of - which spins up a HighState and loads state modules. Local - wall-clock is ~3-5 s; under coverage tracing on a loaded GHA - runner the cumulative cost has been observed at 30-60 s. The - explicit ``@pytest.mark.timeout(180)`` override raises the global - 90 s pytest-timeout default so a slow runner doesn't trip the - wall-clock before the test's logical assertions run. + ``salt-call -r state.apply`` against a logical resource type with no + per-resource ``state.py`` override — after #69881 the resource + loader is deny-by-default, so ``state.apply`` is not present. The + caller emits the "not supported for resource type" rejection for + each matched resource. + + ``state.apply`` is a merge fun: the managing minion runs its own + state.apply first, then per-resource results are folded in with + prefixed keys. For a rejected resource, the fold produces a + ``no_|-_|-_|-None`` key whose comment carries the + rejection string. + + A resource type that intends operators to run state runs against it + ships ``resources//modules/state.py`` that either implements + the state protocol or thin-wraps ``__minion__["state.apply"]``. """ call_opts["resources_dispatch"] = True call_opts["fun"] = "state.apply" @@ -218,20 +220,12 @@ def test_r_state_apply_logical_resource_no_state_module(call_opts): caller = _build_caller(call_opts) payload = caller._call_with_resources()["return"] assert isinstance(payload, dict), payload - # Master merge format prefixes each state id with the rid; e.g. - # ``dummy_test_|-dummy-01 ping the resource_|-...`` - rid_keys = { - rid: [k for k in payload if isinstance(payload[k], dict) and f"{rid} " in k] - for rid in ("dummy-01", "dummy-02", "dummy-03") - } - for rid, keys in rid_keys.items(): - assert keys, f"No prefixed state entries for {rid}: {list(payload)}" - for k in keys: - entry = payload[k] - assert entry["result"] is False, (k, entry) - assert ( - "not available" in entry["comment"] or "not found" in entry["comment"] - ), ( - k, - entry, - ) + for rid in ("dummy-01", "dummy-02", "dummy-03"): + key = f"no_|-{rid}_|-{rid}_|-None" + assert key in payload, (rid, list(payload)) + entry = payload[key] + assert entry["result"] is False, (rid, entry) + assert "not supported for resource type 'dummy'" in entry["comment"], ( + rid, + entry, + ) diff --git a/tests/pytests/unit/loader/test_per_resource_overrides.py b/tests/pytests/unit/loader/test_per_resource_overrides.py index 1df7cad0092f..40e40afa6144 100644 --- a/tests/pytests/unit/loader/test_per_resource_overrides.py +++ b/tests/pytests/unit/loader/test_per_resource_overrides.py @@ -1,6 +1,7 @@ """ Unit tests for the per-type directory override mechanism introduced -for Gap 2 / Gap 4 / Gap 5. +for Gap 2 / Gap 4 / Gap 5, and for the deny-by-default surface of +:func:`salt.loader.resource_modules`. The salt loader's :func:`_module_dirs` checks for ``resources///`` subdirectories under every layer that @@ -9,14 +10,19 @@ per-type subdir is prepended before that layer's standard directory, giving per-type overrides priority for that layer. +The per-resource execution loader built by :func:`resource_modules` +must expose **only** per-type override modules (from those overlay +dirs) plus the ``__minion__`` escape hatch. Stock ``salt/modules/*`` +must not be reachable via that loader — resource-context code that +needs the managing minion calls ``__minion__["module.fun"]`` explicitly. + These tests exercise the override mechanism end-to-end: -* A resource type that opts in via ``/resources//modules/state.py`` - — its ``state.sls`` wins when the per-resource loader is built for - that rtype. -* A resource type with no override — the standard ``salt/modules/state.py`` - is the one that gets resolved (Gap 5 fix: standard ``state.py`` no - longer has a broad ``__virtual__`` guard against ``resource_type``). +* A resource type that opts in via ``/resources//modules/test.py`` + — its ``test.whoami`` is present and callable when the per-resource + loader is built for that rtype. +* A resource type with no override — the resource loader is empty + (no stock modules leak through). * The ``__minion__`` dunder is packed into the per-resource execution loader when ``minion_mods`` is supplied — providing the escape-hatch back to the managing minion's loader. @@ -81,13 +87,20 @@ def test_per_type_dir_override_wins_over_standard(loader_opts): assert loader["test.whoami"]() == "override-wins" -def test_no_override_falls_through_to_standard_state_module(loader_opts): +def test_no_override_hides_stock_modules(loader_opts): """ - Resource type with no per-type override for ``state.py``: the standard - ``salt.modules.state`` is loaded via the per-resource loader (post-Gap-5 - fix — no broad ``__virtual__`` guard). The operator can run - ``state.sls`` against the resource without the type having to ship its - own override. + Resource type with no per-type overrides: the per-resource loader + exposes NO stock ``salt/modules/*`` functions. The documented + Resources safety contract requires that ``salt cmd.run + …`` / ``grains.setval …`` / ``state.sls …`` fail with "not supported + for resource type" instead of silently executing on the managing + minion. The resource loader is the surface that decides this — if + stock modules are present here, they will run. + + NOTE: this replaces an earlier test that asserted stock ``state.sls`` + was present in the resource loader. That assertion documented the + buggy behavior fixed by #69881; the contract restored here matches + the resource-loader design (deny-by-default, type-local only). """ utils = salt.loader.utils(loader_opts) rfuncs = salt.loader.resource(loader_opts, utils=utils) @@ -95,11 +108,62 @@ def test_no_override_falls_through_to_standard_state_module(loader_opts): loader_opts, "logical_test", resource_funcs=rfuncs, utils=utils ) - # state.sls is present despite no per-type override existing for - # 'logical_test'. This is the GAP5 win. - assert "state.sls" in loader, sorted( - k for k in loader.keys() if k.startswith("state.") - )[:10] + leaked = sorted( + k + for k in loader.keys() + if k.split(".", 1)[0] + in ( + "cmd", + "state", + "grains", + "file", + "system", + "disk", + "pkg", + "service", + "sys", + "saltutil", + ) + ) + assert not leaked, ( + "stock salt/modules leaked into the resource loader for a type " + f"with no per-type overrides: {leaked[:20]}" + ) + # Empty surface is the correct default for an inventory-only resource + # type that ships no override modules. + assert list(loader.keys()) == [], sorted(loader.keys())[:20] + + +def test_per_type_override_present_and_callable(loader_opts): + """ + A per-type override at /resources//modules/.py + is present in the per-resource loader AND stock modules for the same + resource-type surface are absent. Confirms deny-by-default plus the + per-type overlay together — the override is what the operator gets + to invoke against the resource, nothing more. + """ + body = "def ping():\n return 'override-pong'\n" + _drop_override(loader_opts["extension_modules"], "posovr", "test", body) + + utils = salt.loader.utils(loader_opts) + rfuncs = salt.loader.resource(loader_opts, utils=utils) + loader = salt.loader.resource_modules( + loader_opts, "posovr", resource_funcs=rfuncs, utils=utils + ) + + assert "test.ping" in loader, sorted( + k for k in loader.keys() if k.startswith("test.") + ) + assert loader["test.ping"]() == "override-pong" + + # Only the override slot's functions are visible; no stock cmd/state/… + leaked = sorted( + k + for k in loader.keys() + if k.split(".", 1)[0] + in ("cmd", "state", "grains", "file", "system", "sys", "saltutil") + ) + assert not leaked, leaked[:20] def test_minion_mods_packed_as_dunder(loader_opts): From f52cedee45d1af57254f88153419ef9b00a9d301 Mon Sep 17 00:00:00 2001 From: twangboy Date: Thu, 13 Aug 2026 13:46:01 -0600 Subject: [PATCH 285/469] Update aiohttp to version 3.14.3 --- requirements/base.txt | 2 +- requirements/static/ci/py3.10/cloud.lock | 2 +- requirements/static/ci/py3.10/darwin.lock | 2 +- requirements/static/ci/py3.10/docs.lock | 2 +- requirements/static/ci/py3.10/freebsd.lock | 2 +- requirements/static/ci/py3.10/lint.lock | 2 +- requirements/static/ci/py3.10/linux.lock | 2 +- requirements/static/ci/py3.10/windows.lock | 2 +- requirements/static/ci/py3.11/cloud.lock | 2 +- requirements/static/ci/py3.11/darwin.lock | 2 +- requirements/static/ci/py3.11/docs.lock | 2 +- requirements/static/ci/py3.11/freebsd.lock | 2 +- requirements/static/ci/py3.11/lint.lock | 2 +- requirements/static/ci/py3.11/linux.lock | 2 +- requirements/static/ci/py3.11/windows.lock | 2 +- requirements/static/ci/py3.12/cloud.lock | 2 +- requirements/static/ci/py3.12/darwin.lock | 2 +- requirements/static/ci/py3.12/docs.lock | 2 +- requirements/static/ci/py3.12/freebsd.lock | 2 +- requirements/static/ci/py3.12/lint.lock | 2 +- requirements/static/ci/py3.12/linux.lock | 2 +- requirements/static/ci/py3.12/windows.lock | 2 +- requirements/static/ci/py3.13/cloud.lock | 2 +- requirements/static/ci/py3.13/darwin.lock | 2 +- requirements/static/ci/py3.13/docs.lock | 2 +- requirements/static/ci/py3.13/freebsd.lock | 2 +- requirements/static/ci/py3.13/lint.lock | 2 +- requirements/static/ci/py3.13/linux.lock | 2 +- requirements/static/ci/py3.13/windows.lock | 2 +- requirements/static/ci/py3.14/cloud.lock | 2 +- requirements/static/ci/py3.14/darwin.lock | 2 +- requirements/static/ci/py3.14/docs.lock | 2 +- requirements/static/ci/py3.14/freebsd.lock | 2 +- requirements/static/ci/py3.14/lint.lock | 2 +- requirements/static/ci/py3.14/linux.lock | 2 +- requirements/static/ci/py3.14/windows.lock | 2 +- requirements/static/ci/py3.9/freebsd.lock | 2 +- requirements/static/pkg/py3.10/darwin.lock | 2 +- requirements/static/pkg/py3.10/freebsd.lock | 2 +- requirements/static/pkg/py3.10/linux.lock | 2 +- requirements/static/pkg/py3.10/windows.lock | 2 +- requirements/static/pkg/py3.11/darwin.lock | 2 +- requirements/static/pkg/py3.11/freebsd.lock | 2 +- requirements/static/pkg/py3.11/linux.lock | 2 +- requirements/static/pkg/py3.11/windows.lock | 2 +- requirements/static/pkg/py3.12/darwin.lock | 2 +- requirements/static/pkg/py3.12/freebsd.lock | 2 +- requirements/static/pkg/py3.12/linux.lock | 2 +- requirements/static/pkg/py3.12/windows.lock | 2 +- requirements/static/pkg/py3.13/darwin.lock | 2 +- requirements/static/pkg/py3.13/freebsd.lock | 2 +- requirements/static/pkg/py3.13/linux.lock | 2 +- requirements/static/pkg/py3.13/windows.lock | 2 +- requirements/static/pkg/py3.14/darwin.lock | 2 +- requirements/static/pkg/py3.14/freebsd.lock | 2 +- requirements/static/pkg/py3.14/linux.lock | 2 +- requirements/static/pkg/py3.14/windows.lock | 2 +- requirements/static/pkg/py3.9/freebsd.lock | 2 +- 58 files changed, 58 insertions(+), 58 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 678d934f0ce6..a2c017a48e77 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -2,7 +2,7 @@ # Multiple entries for the same package (with different version constraints) are grouped together. aiohttp>=3.13.5,<3.14.0; python_version < '3.10' -aiohttp>=3.14.1; python_version >= '3.10' +aiohttp>=3.14.2; python_version >= '3.10' certifi>=2026.5.20 cffi>=2.0.0 # cheroot 8.5.2 fails to build with modern setuptools due to setuptools_scm_git_archive dependency diff --git a/requirements/static/ci/py3.10/cloud.lock b/requirements/static/ci/py3.10/cloud.lock index 59fc282e1a9b..33d5a620323e 100644 --- a/requirements/static/ci/py3.10/cloud.lock +++ b/requirements/static/ci/py3.10/cloud.lock @@ -5,7 +5,7 @@ aiohappyeyeballs==2.6.1 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/darwin.lock b/requirements/static/ci/py3.10/darwin.lock index 2b52e124b265..dde00d48ca08 100644 --- a/requirements/static/ci/py3.10/darwin.lock +++ b/requirements/static/ci/py3.10/darwin.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.10/darwin.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.10/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.10/docs.lock b/requirements/static/ci/py3.10/docs.lock index def7017436ba..b62d2db3dcf4 100644 --- a/requirements/static/ci/py3.10/docs.lock +++ b/requirements/static/ci/py3.10/docs.lock @@ -6,7 +6,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/ci/py3.10/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.10/freebsd.lock b/requirements/static/ci/py3.10/freebsd.lock index 0d552769256a..92ce590bb32b 100644 --- a/requirements/static/ci/py3.10/freebsd.lock +++ b/requirements/static/ci/py3.10/freebsd.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.10/freebsd.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.10/lint.lock b/requirements/static/ci/py3.10/lint.lock index a0229a43f522..6ffa0e0d399e 100644 --- a/requirements/static/ci/py3.10/lint.lock +++ b/requirements/static/ci/py3.10/lint.lock @@ -5,7 +5,7 @@ aiohappyeyeballs==2.6.1 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/linux.lock b/requirements/static/ci/py3.10/linux.lock index c4d5fd80ba05..9bb35d2ef3ba 100644 --- a/requirements/static/ci/py3.10/linux.lock +++ b/requirements/static/ci/py3.10/linux.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.10/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.10/windows.lock b/requirements/static/ci/py3.10/windows.lock index 2dd260550a5a..24e013dae1b7 100644 --- a/requirements/static/ci/py3.10/windows.lock +++ b/requirements/static/ci/py3.10/windows.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.10/windows.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.10/windows.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.11/cloud.lock b/requirements/static/ci/py3.11/cloud.lock index a54cbb57ebb8..6a07ce72c9aa 100644 --- a/requirements/static/ci/py3.11/cloud.lock +++ b/requirements/static/ci/py3.11/cloud.lock @@ -5,7 +5,7 @@ aiohappyeyeballs==2.6.1 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/darwin.lock b/requirements/static/ci/py3.11/darwin.lock index 991b812e5bd2..334528087da0 100644 --- a/requirements/static/ci/py3.11/darwin.lock +++ b/requirements/static/ci/py3.11/darwin.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.11/darwin.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.11/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.11/docs.lock b/requirements/static/ci/py3.11/docs.lock index 41219e88196d..7f076cd2b0bd 100644 --- a/requirements/static/ci/py3.11/docs.lock +++ b/requirements/static/ci/py3.11/docs.lock @@ -6,7 +6,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/ci/py3.11/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.11/freebsd.lock b/requirements/static/ci/py3.11/freebsd.lock index db9c99600761..51d5bdeb7858 100644 --- a/requirements/static/ci/py3.11/freebsd.lock +++ b/requirements/static/ci/py3.11/freebsd.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.11/freebsd.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.11/lint.lock b/requirements/static/ci/py3.11/lint.lock index b47ed094a2dc..4a9789b5feb5 100644 --- a/requirements/static/ci/py3.11/lint.lock +++ b/requirements/static/ci/py3.11/lint.lock @@ -5,7 +5,7 @@ aiohappyeyeballs==2.6.1 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/linux.lock b/requirements/static/ci/py3.11/linux.lock index ca7eb1ceeb42..bcdf1d6bbd2b 100644 --- a/requirements/static/ci/py3.11/linux.lock +++ b/requirements/static/ci/py3.11/linux.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.11/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.11/windows.lock b/requirements/static/ci/py3.11/windows.lock index 392f136d1287..69c8307dfc05 100644 --- a/requirements/static/ci/py3.11/windows.lock +++ b/requirements/static/ci/py3.11/windows.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.11/windows.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.12/cloud.lock b/requirements/static/ci/py3.12/cloud.lock index 153d621e47db..b71955ee5e21 100644 --- a/requirements/static/ci/py3.12/cloud.lock +++ b/requirements/static/ci/py3.12/cloud.lock @@ -5,7 +5,7 @@ aiohappyeyeballs==2.6.1 # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/darwin.lock b/requirements/static/ci/py3.12/darwin.lock index 6faab8367bd4..1c7e47f828dc 100644 --- a/requirements/static/ci/py3.12/darwin.lock +++ b/requirements/static/ci/py3.12/darwin.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.12/darwin.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.12/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.12/docs.lock b/requirements/static/ci/py3.12/docs.lock index 987f2fa7b903..2c4e4a6f45d2 100644 --- a/requirements/static/ci/py3.12/docs.lock +++ b/requirements/static/ci/py3.12/docs.lock @@ -6,7 +6,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/ci/py3.12/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.12/freebsd.lock b/requirements/static/ci/py3.12/freebsd.lock index b85ad26a8d0d..4a99648fe002 100644 --- a/requirements/static/ci/py3.12/freebsd.lock +++ b/requirements/static/ci/py3.12/freebsd.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.12/freebsd.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.12/lint.lock b/requirements/static/ci/py3.12/lint.lock index 03e49fb38fad..54a5561827d8 100644 --- a/requirements/static/ci/py3.12/lint.lock +++ b/requirements/static/ci/py3.12/lint.lock @@ -5,7 +5,7 @@ aiohappyeyeballs==2.6.1 # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/linux.lock b/requirements/static/ci/py3.12/linux.lock index 2bfa2ad45bf2..c927440073ae 100644 --- a/requirements/static/ci/py3.12/linux.lock +++ b/requirements/static/ci/py3.12/linux.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.12/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.12/windows.lock b/requirements/static/ci/py3.12/windows.lock index 8e4ee3cfcc59..0e33ab92e9b1 100644 --- a/requirements/static/ci/py3.12/windows.lock +++ b/requirements/static/ci/py3.12/windows.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.12/windows.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.13/cloud.lock b/requirements/static/ci/py3.13/cloud.lock index da47f56632c9..ee5d113467d4 100644 --- a/requirements/static/ci/py3.13/cloud.lock +++ b/requirements/static/ci/py3.13/cloud.lock @@ -5,7 +5,7 @@ aiohappyeyeballs==2.6.1 # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/darwin.lock b/requirements/static/ci/py3.13/darwin.lock index b035ae7bfd34..ce8ecf85f3a8 100644 --- a/requirements/static/ci/py3.13/darwin.lock +++ b/requirements/static/ci/py3.13/darwin.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.13/darwin.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.13/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.13/docs.lock b/requirements/static/ci/py3.13/docs.lock index 6ee78f637000..0d32e50d5b3a 100644 --- a/requirements/static/ci/py3.13/docs.lock +++ b/requirements/static/ci/py3.13/docs.lock @@ -6,7 +6,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/ci/py3.13/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.13/freebsd.lock b/requirements/static/ci/py3.13/freebsd.lock index 533f967da327..1f6d218b1ec5 100644 --- a/requirements/static/ci/py3.13/freebsd.lock +++ b/requirements/static/ci/py3.13/freebsd.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.13/freebsd.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.13/lint.lock b/requirements/static/ci/py3.13/lint.lock index cd9709221744..933ecfb426d3 100644 --- a/requirements/static/ci/py3.13/lint.lock +++ b/requirements/static/ci/py3.13/lint.lock @@ -5,7 +5,7 @@ aiohappyeyeballs==2.6.1 # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/linux.lock b/requirements/static/ci/py3.13/linux.lock index d940c1c94def..4e42ab5eee5d 100644 --- a/requirements/static/ci/py3.13/linux.lock +++ b/requirements/static/ci/py3.13/linux.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.13/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.13/windows.lock b/requirements/static/ci/py3.13/windows.lock index 8e70a1db2591..8f2f6972b680 100644 --- a/requirements/static/ci/py3.13/windows.lock +++ b/requirements/static/ci/py3.13/windows.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.13/windows.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/cloud.lock b/requirements/static/ci/py3.14/cloud.lock index ba3e12a00e6e..ee4469b94a31 100644 --- a/requirements/static/ci/py3.14/cloud.lock +++ b/requirements/static/ci/py3.14/cloud.lock @@ -5,7 +5,7 @@ aiohappyeyeballs==2.6.1 # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/darwin.lock b/requirements/static/ci/py3.14/darwin.lock index 497fdc123c14..e4bf8f758b0e 100644 --- a/requirements/static/ci/py3.14/darwin.lock +++ b/requirements/static/ci/py3.14/darwin.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.14/darwin.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/docs.lock b/requirements/static/ci/py3.14/docs.lock index f713a9a38aa0..759f3a982113 100644 --- a/requirements/static/ci/py3.14/docs.lock +++ b/requirements/static/ci/py3.14/docs.lock @@ -6,7 +6,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/ci/py3.14/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/freebsd.lock b/requirements/static/ci/py3.14/freebsd.lock index ae9eb0e1a47e..6f5aac39cc84 100644 --- a/requirements/static/ci/py3.14/freebsd.lock +++ b/requirements/static/ci/py3.14/freebsd.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.14/freebsd.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/lint.lock b/requirements/static/ci/py3.14/lint.lock index 8acb1b8acba3..12c527729e5a 100644 --- a/requirements/static/ci/py3.14/lint.lock +++ b/requirements/static/ci/py3.14/lint.lock @@ -5,7 +5,7 @@ aiohappyeyeballs==2.6.1 # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/linux.lock b/requirements/static/ci/py3.14/linux.lock index 42a7fed9f495..3d8d7df0e840 100644 --- a/requirements/static/ci/py3.14/linux.lock +++ b/requirements/static/ci/py3.14/linux.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.14/linux.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/windows.lock b/requirements/static/ci/py3.14/windows.lock index dfd2b41b48fb..b9b032440daa 100644 --- a/requirements/static/ci/py3.14/windows.lock +++ b/requirements/static/ci/py3.14/windows.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/static/pkg/py3.14/windows.lock # aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.9/freebsd.lock b/requirements/static/ci/py3.9/freebsd.lock index 3cc65dfc771e..a66441252d90 100644 --- a/requirements/static/ci/py3.9/freebsd.lock +++ b/requirements/static/ci/py3.9/freebsd.lock @@ -10,7 +10,7 @@ aiohttp==3.13.5 ; python_full_version < '3.10' # -r requirements/base.txt # -r requirements/static/ci/common.txt # etcd3-py -aiohttp==3.14.1 ; python_full_version >= '3.10' +aiohttp==3.14.3 ; python_full_version >= '3.10' # via # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.10/darwin.lock b/requirements/static/pkg/py3.10/darwin.lock index 67bce2744749..098b68a41b5d 100644 --- a/requirements/static/pkg/py3.10/darwin.lock +++ b/requirements/static/pkg/py3.10/darwin.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/darwin.txt --python-platform=macos --python-version=3.10 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.10/darwin.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.10/freebsd.lock b/requirements/static/pkg/py3.10/freebsd.lock index e1b9c90db4fd..3d4f4c9d477e 100644 --- a/requirements/static/pkg/py3.10/freebsd.lock +++ b/requirements/static/pkg/py3.10/freebsd.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/freebsd.txt --universal --python-version=3.10 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.10/freebsd.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.10/linux.lock b/requirements/static/pkg/py3.10/linux.lock index 8c5ee326ef6d..dd1be4d67a4d 100644 --- a/requirements/static/pkg/py3.10/linux.lock +++ b/requirements/static/pkg/py3.10/linux.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/linux.txt --constraint requirements/constraints.txt --no-emit-index-url --python-platform=linux --python-version=3.10 -o=requirements/static/pkg/py3.10/linux.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.10/windows.lock b/requirements/static/pkg/py3.10/windows.lock index 368ebdc0e76e..5f6c351ac50b 100644 --- a/requirements/static/pkg/py3.10/windows.lock +++ b/requirements/static/pkg/py3.10/windows.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/windows.txt requirements/static/pkg/windows.txt --python-platform=windows --python-version=3.10 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.10/windows.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.11/darwin.lock b/requirements/static/pkg/py3.11/darwin.lock index 2d8e28dd1542..87a292cdfe71 100644 --- a/requirements/static/pkg/py3.11/darwin.lock +++ b/requirements/static/pkg/py3.11/darwin.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/darwin.txt --python-platform=macos --python-version=3.11 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.11/darwin.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.11/freebsd.lock b/requirements/static/pkg/py3.11/freebsd.lock index 4fec8b22a63b..e1e52a49d649 100644 --- a/requirements/static/pkg/py3.11/freebsd.lock +++ b/requirements/static/pkg/py3.11/freebsd.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/freebsd.txt --universal --python-version=3.11 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.11/freebsd.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.11/linux.lock b/requirements/static/pkg/py3.11/linux.lock index a43940d6d801..6703bdac29c6 100644 --- a/requirements/static/pkg/py3.11/linux.lock +++ b/requirements/static/pkg/py3.11/linux.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/linux.txt --constraint requirements/constraints.txt --no-emit-index-url --python-platform=linux --python-version=3.11 -o=requirements/static/pkg/py3.11/linux.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.11/windows.lock b/requirements/static/pkg/py3.11/windows.lock index b4e48d8aa941..052314bdfa3c 100644 --- a/requirements/static/pkg/py3.11/windows.lock +++ b/requirements/static/pkg/py3.11/windows.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/windows.txt requirements/static/pkg/windows.txt --python-platform=windows --python-version=3.11 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.11/windows.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.12/darwin.lock b/requirements/static/pkg/py3.12/darwin.lock index 82321db5a0be..f76fbc6829ac 100644 --- a/requirements/static/pkg/py3.12/darwin.lock +++ b/requirements/static/pkg/py3.12/darwin.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/darwin.txt --python-platform=macos --python-version=3.12 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.12/darwin.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.12/freebsd.lock b/requirements/static/pkg/py3.12/freebsd.lock index 720361c6db50..0c8e9dff7d84 100644 --- a/requirements/static/pkg/py3.12/freebsd.lock +++ b/requirements/static/pkg/py3.12/freebsd.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/freebsd.txt --universal --python-version=3.12 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.12/freebsd.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.12/linux.lock b/requirements/static/pkg/py3.12/linux.lock index 0beaca269e50..08b428eb1835 100644 --- a/requirements/static/pkg/py3.12/linux.lock +++ b/requirements/static/pkg/py3.12/linux.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/linux.txt --constraint requirements/constraints.txt --no-emit-index-url --python-platform=linux --python-version=3.12 -o=requirements/static/pkg/py3.12/linux.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.12/windows.lock b/requirements/static/pkg/py3.12/windows.lock index 648662a77e90..0b17b8ca25ba 100644 --- a/requirements/static/pkg/py3.12/windows.lock +++ b/requirements/static/pkg/py3.12/windows.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/windows.txt requirements/static/pkg/windows.txt --python-platform=windows --python-version=3.12 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.12/windows.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.13/darwin.lock b/requirements/static/pkg/py3.13/darwin.lock index 93f917eae0c9..f4cd77ee63f8 100644 --- a/requirements/static/pkg/py3.13/darwin.lock +++ b/requirements/static/pkg/py3.13/darwin.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/darwin.txt --python-platform=macos --python-version=3.13 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.13/darwin.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.13/freebsd.lock b/requirements/static/pkg/py3.13/freebsd.lock index dcaf9a81e604..ae4fd79ccf01 100644 --- a/requirements/static/pkg/py3.13/freebsd.lock +++ b/requirements/static/pkg/py3.13/freebsd.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/freebsd.txt --universal --python-version=3.13 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.13/freebsd.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.13/linux.lock b/requirements/static/pkg/py3.13/linux.lock index 8317200ae381..13f72f2c16fd 100644 --- a/requirements/static/pkg/py3.13/linux.lock +++ b/requirements/static/pkg/py3.13/linux.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/linux.txt --constraint requirements/constraints.txt --no-emit-index-url --python-platform=linux --python-version=3.13 -o=requirements/static/pkg/py3.13/linux.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.13/windows.lock b/requirements/static/pkg/py3.13/windows.lock index 6aecdf677d81..703474bfadf0 100644 --- a/requirements/static/pkg/py3.13/windows.lock +++ b/requirements/static/pkg/py3.13/windows.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/windows.txt requirements/static/pkg/windows.txt --python-platform=windows --python-version=3.13 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.13/windows.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.14/darwin.lock b/requirements/static/pkg/py3.14/darwin.lock index 786c0e67b5e8..f21e821f0590 100644 --- a/requirements/static/pkg/py3.14/darwin.lock +++ b/requirements/static/pkg/py3.14/darwin.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/darwin.txt --python-platform=macos --python-version=3.14 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.14/darwin.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.14/freebsd.lock b/requirements/static/pkg/py3.14/freebsd.lock index 4a9b1e191034..5d692519adc6 100644 --- a/requirements/static/pkg/py3.14/freebsd.lock +++ b/requirements/static/pkg/py3.14/freebsd.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/freebsd.txt --universal --python-version=3.14 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.14/freebsd.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.14/linux.lock b/requirements/static/pkg/py3.14/linux.lock index d8e4a903e30b..a539c014a5b7 100644 --- a/requirements/static/pkg/py3.14/linux.lock +++ b/requirements/static/pkg/py3.14/linux.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/static/pkg/linux.txt --constraint requirements/constraints.txt --no-emit-index-url --python-platform=linux --python-version=3.14 -o=requirements/static/pkg/py3.14/linux.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.14/windows.lock b/requirements/static/pkg/py3.14/windows.lock index 24cac1327dd7..fce50b3662df 100644 --- a/requirements/static/pkg/py3.14/windows.lock +++ b/requirements/static/pkg/py3.14/windows.lock @@ -2,7 +2,7 @@ # uv pip compile requirements/base.txt requirements/zeromq.txt requirements/crypto.txt requirements/windows.txt requirements/static/pkg/windows.txt --python-platform=windows --python-version=3.14 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/pkg/py3.14/windows.lock aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.14.1 +aiohttp==3.14.3 # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp diff --git a/requirements/static/pkg/py3.9/freebsd.lock b/requirements/static/pkg/py3.9/freebsd.lock index c8f92d3a5c43..42d9752044d6 100644 --- a/requirements/static/pkg/py3.9/freebsd.lock +++ b/requirements/static/pkg/py3.9/freebsd.lock @@ -4,7 +4,7 @@ aiohappyeyeballs==2.6.1 # via aiohttp aiohttp==3.13.5 ; python_full_version < '3.10' # via -r requirements/base.txt -aiohttp==3.14.1 ; python_full_version >= '3.10' +aiohttp==3.14.3 ; python_full_version >= '3.10' # via -r requirements/base.txt aiosignal==1.4.0 # via aiohttp From 1ae9e2308d4c33cbc7361301724bf7f0eeeb7994 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 13 Aug 2026 17:49:57 -0700 Subject: [PATCH 286/469] Add scenario tests for minion memory headroom (#69884) Complements the unit-level matrix in tests/pytests/unit/test_minion_memory_headroom.py by booting a real minion daemon with each opt combination and driving the runtime check via a subprocess that loads the minion's on-disk config through salt.config.minion_config and calls Minion._has_memory_headroom. Four scenarios: * config round-trip via salt-call --local config.get * default preserved (no opts -> legacy True on any healthy host) * config override -> deterministic True (huge max, tiny reserve) * config override -> deterministic False (tiny max, full reserve) All scenarios use deterministic config values so no system-memory pressure is required. Refs #69884 --- .../minion_memory_headroom/__init__.py | 0 .../minion_memory_headroom/conftest.py | 65 +++++++ .../test_minion_memory_headroom.py | 178 ++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 tests/pytests/scenarios/minion_memory_headroom/__init__.py create mode 100644 tests/pytests/scenarios/minion_memory_headroom/conftest.py create mode 100644 tests/pytests/scenarios/minion_memory_headroom/test_minion_memory_headroom.py diff --git a/tests/pytests/scenarios/minion_memory_headroom/__init__.py b/tests/pytests/scenarios/minion_memory_headroom/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/pytests/scenarios/minion_memory_headroom/conftest.py b/tests/pytests/scenarios/minion_memory_headroom/conftest.py new file mode 100644 index 000000000000..04a31c549ac5 --- /dev/null +++ b/tests/pytests/scenarios/minion_memory_headroom/conftest.py @@ -0,0 +1,65 @@ +""" +Scenario fixtures for the ``minion_memory_headroom`` opt-in configurable +queue-admission memory check (issue #69884). Each test parametrizes on the +minion-config overrides so a fresh minion is booted per scenario, proving +end-to-end that the loader accepts the new opts and the minion runs with +them in effect. +""" + +import pytest +from saltfactories.utils import random_string + +from tests.conftest import FIPS_TESTRUN + + +@pytest.fixture(scope="package") +def salt_master(salt_factories): + factory = salt_factories.salt_master_daemon( + random_string("mem-headroom-master-"), + overrides={ + "open_mode": True, + "fips_mode": FIPS_TESTRUN, + "publish_signing_algorithm": ( + "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1" + ), + }, + ) + with factory.started(): + yield factory + + +def _minion_overrides(extra=None): + overrides = { + "fips_mode": FIPS_TESTRUN, + "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", + "signing_algorithm": "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1", + } + if extra: + overrides.update(extra) + return overrides + + +@pytest.fixture(scope="function") +def minion_with_opts(salt_master, request): + """ + Boot a fresh minion for the current test with the caller-supplied + ``minion_memory_headroom`` / ``minion_memory_max`` overrides applied. + + Use like: + + @pytest.mark.parametrize( + "minion_with_opts", + [{"minion_memory_headroom": "5G"}], + indirect=True, + ) + def test_something(minion_with_opts): + salt_call = minion_with_opts.salt_call_cli() + ... + """ + overrides = _minion_overrides(getattr(request, "param", None)) + factory = salt_master.salt_minion_daemon( + random_string("mem-headroom-minion-"), + overrides=overrides, + ) + with factory.started(): + yield factory diff --git a/tests/pytests/scenarios/minion_memory_headroom/test_minion_memory_headroom.py b/tests/pytests/scenarios/minion_memory_headroom/test_minion_memory_headroom.py new file mode 100644 index 000000000000..cfab0cb66766 --- /dev/null +++ b/tests/pytests/scenarios/minion_memory_headroom/test_minion_memory_headroom.py @@ -0,0 +1,178 @@ +""" +End-to-end scenario tests for the opt-in ``minion_memory_headroom`` / +``minion_memory_max`` minion config options and the runtime path through +``salt.minion.Minion._has_memory_headroom``. + +These complement the unit-level matrix in +``tests/pytests/unit/test_minion_memory_headroom.py`` (which mocks psutil +and injects a synthetic cgroupfs). Here we boot a real minion daemon with +each opt combination and drive the check via subprocess evaluation of the +minion's on-disk config, proving the loader accepts the new opts and the +runtime code path returns the expected value. + +See issue https://github.com/saltstack/salt/issues/69884. +""" + +import subprocess +import sys +import textwrap + +import pytest + +pytestmark = [ + pytest.mark.slow_test, +] + + +def _run_headroom_eval(config_path): + """ + Spawn a subprocess in the same Python interpreter that pytest is running + under, load the minion config from ``config_path``, construct an + ``SMinion``, and print the JSON-serialisable result of + ``_has_memory_headroom()``. + + Returns the string printed (``"True"`` or ``"False"``). Raises with the + subprocess stderr on any failure so debugging is straightforward. + """ + # ``_has_memory_headroom`` is defined on ``Minion`` (not ``SMinion``) + # and only reads ``self.opts``. Rather than constructing a full + # ``Minion`` (which requires master connectivity and a running loop), + # bind the unbound method to a ``SimpleNamespace`` carrying the loaded + # opts. This exercises the exact code path the running minion daemon + # takes: it loads the config from disk via ``salt.config.minion_config`` + # and calls ``Minion._has_memory_headroom``. + script = textwrap.dedent( + f""" + import types + import salt.config + import salt.minion + opts = salt.config.minion_config({config_path!r}) + stub = types.SimpleNamespace(opts=opts) + print(salt.minion.Minion._has_memory_headroom(stub)) + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + if result.returncode != 0: + raise AssertionError( + f"headroom-eval subprocess failed (rc={result.returncode})\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + return result.stdout.strip().splitlines()[-1] + + +# --------------------------------------------------------------------------- +# Scenario 1: opts round-trip through the config loader +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "minion_with_opts", + [{"minion_memory_headroom": "5G", "minion_memory_max": "10G"}], + indirect=True, + ids=["headroom-5g-max-10g"], +) +def test_config_round_trip(minion_with_opts): + """ + Boot a minion with the new opts in its config file, then use + ``salt-call --local config.get`` to read them back. Proves the loader + accepts both opts (i.e. they're in ``VALID_OPTS``) and that they + survive the write-file / read-file round trip. + """ + salt_call = minion_with_opts.salt_call_cli() + ret = salt_call.run("--local", "config.get", "minion_memory_headroom") + assert ret.returncode == 0, ret + assert ret.data == "5G" + + ret = salt_call.run("--local", "config.get", "minion_memory_max") + assert ret.returncode == 0, ret + assert ret.data == "10G" + + +# --------------------------------------------------------------------------- +# Scenario 2: default preserved end-to-end (no upgrade drift) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "minion_with_opts", + [{}], + indirect=True, + ids=["defaults"], +) +def test_default_preserved(minion_with_opts): + """ + With neither ``minion_memory_headroom`` nor ``minion_memory_max`` set, + the runtime check must return the legacy ``psutil.virtual_memory() + .percent > 95`` verdict. On any test host that has more than 5% RAM + free (a safe assumption) the result is True. + """ + # Runtime path: legacy branch. Any healthy CI host has >5% RAM free. + result = _run_headroom_eval(minion_with_opts.config_file) + assert result == "True" + + +# --------------------------------------------------------------------------- +# Scenario 3: config override forces a deterministic-True result +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "minion_with_opts", + [ + { + # 1 EB pinned as reference; 1% headroom (~10 PB). No matter + # what the actual system usage looks like, used+headroom + # is nowhere near 1 EB, so the check must return True. + "minion_memory_max": 1 << 60, + "minion_memory_headroom": "1%", + } + ], + indirect=True, + ids=["huge-max-tiny-percent"], +) +def test_config_override_deterministic_true(minion_with_opts): + """ + With ``minion_memory_max = 1 EB`` and ``minion_memory_headroom = "1%"`` + the runtime check is guaranteed to return True regardless of actual + system memory pressure. Proves the config path drives real + ``_has_memory_headroom()`` behavior. + """ + result = _run_headroom_eval(minion_with_opts.config_file) + assert result == "True" + + +# --------------------------------------------------------------------------- +# Scenario 4: config override forces a deterministic-False result +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "minion_with_opts", + [ + { + # 1 KB pinned as reference; 100% headroom means "reserve + # everything". used + headroom > reference is guaranteed + # (unless the process has literally 0 bytes RSS, which is + # impossible). + "minion_memory_max": 1024, + "minion_memory_headroom": "100%", + } + ], + indirect=True, + ids=["tiny-max-full-percent"], +) +def test_config_override_deterministic_false(minion_with_opts): + """ + With ``minion_memory_max = 1 KB`` and ``minion_memory_headroom = "100%"`` + the runtime check is guaranteed to return False. Proves the config + path can actually block queue admission when the operator asks it to. + """ + result = _run_headroom_eval(minion_with_opts.config_file) + assert result == "False" From 160e62f8e567b37dd26f434b963d731194fdd9ba Mon Sep 17 00:00:00 2001 From: jeanluc Date: Fri, 14 Aug 2026 10:23:45 +0200 Subject: [PATCH 287/469] Fix handling of specific x509_v2 GeneralNames --- changelog/70041.fixed.md | 1 + salt/utils/x509.py | 360 +++++-- tests/pytests/unit/utils/test_x509.py | 1239 +++++++++++++++++++++---- 3 files changed, 1321 insertions(+), 279 deletions(-) create mode 100644 changelog/70041.fixed.md diff --git a/changelog/70041.fixed.md b/changelog/70041.fixed.md new file mode 100644 index 000000000000..38caf4649bd7 --- /dev/null +++ b/changelog/70041.fixed.md @@ -0,0 +1 @@ +Fixed handling of several `x509_v2` GeneralNames: nameConstraints URI/IP definitions, encoding of URI path segments with non-ASCII characters, URI IPv6 hostnames, URI without authority/scheme, DNSNames with non-standard wildcards, and others. diff --git a/salt/utils/x509.py b/salt/utils/x509.py index 54dd893d39ea..cfbbe5fe3e20 100644 --- a/salt/utils/x509.py +++ b/salt/utils/x509.py @@ -7,7 +7,7 @@ from collections import OrderedDict from datetime import datetime, timedelta, timezone from enum import Enum -from urllib.parse import urlparse, urlunparse +from urllib.parse import quote, urlsplit, urlunsplit import cryptography from cryptography import x509 as cx509 @@ -1447,7 +1447,7 @@ def _parse_issuer_general_name(val, ca_crt): "It seems your version of cryptography does not have an " "internal API that the issuer:copy functionality relies on" ) from err - parsed.extend(_parse_general_names(val)) + parsed.extend(parse_general_names(val)) return parsed, critical @@ -1485,7 +1485,7 @@ def _create_subject_alt_name(val, **kwargs): val = tuple(list_) elif isinstance(val, str): val, critical = _deserialize_openssl_confstring(val, multiple=True) - parsed = _parse_general_names(val) + parsed = parse_general_names(val) return cx509.SubjectAlternativeName(parsed), critical @@ -1529,7 +1529,7 @@ def _parse_distribution_points(val): if crlissuer: if not isinstance(crlissuer, list): crlissuer = [crlissuer] - crlissuer = _parse_general_names( + crlissuer = parse_general_names( x.split(":", maxsplit=1) for x in crlissuer ) if reasons: @@ -1540,7 +1540,7 @@ def _parse_distribution_points(val): else: fullname = (dpoint,) if fullname: - fullname = _parse_general_names(fullname) + fullname = parse_general_names(fullname) try: parsed.append( cx509.DistributionPoint( @@ -1570,7 +1570,7 @@ def _create_issuing_distribution_point(val, **kwargs): if not isinstance(fullname, list): fullname = [fullname] fullname = (x.split(":", maxsplit=1) for x in fullname) - fullname = _parse_general_names(fullname) + fullname = parse_general_names(fullname) if relativename: relativename = _get_rdn(relativename) if onlysomereasons: @@ -1714,10 +1714,14 @@ def _create_name_constraints(val, **kwargs): } args = { "permitted_subtrees": ( - _parse_general_names(val["permitted"]) if "permitted" in val else None + parse_general_names(val["permitted"], name_constraints=True) + if "permitted" in val + else None ), "excluded_subtrees": ( - _parse_general_names(val["excluded"]) if "excluded" in val else None + parse_general_names(val["excluded"], name_constraints=True) + if "excluded" in val + else None ), } if not any(args.values()): @@ -1946,63 +1950,166 @@ def _parse_other_name(value): ) -def _parse_general_names(val): - def idna_encode(val, allow_leading_dot=False, allow_wildcard=False): - # A leading dot is allowed in some values (nameConstraints). - # idna complains about it not being a valid domain name +def _validate_dns_label(label, *, allow_wildcard=False): + """ + Reject strings that are not valid ASCII DNS labels. + """ + if not label: + raise CommandExecutionError("Empty Label") + label.encode(encoding="ascii") # ensure only ASCII chars + allowed = r"A-Za-z\d\-" + if allow_wildcard: + allowed += r"\*" + invalid = re.search(f"[^{allowed}]", label) + if invalid is not None: + raise CommandExecutionError( + f"Codepoint U+00{ord(invalid.group()):02X} at position {invalid.end()} of '{label}' not allowed" + ) + if label[0] == "-" or label[-1] == "-": + raise CommandExecutionError("Label must not start or end with a hyphen") + if len(label.replace("*", "") if allow_wildcard else label) > 63: + raise CommandExecutionError("Label too long") + + +def _validate_dns_name(dns_name, *, allow_wildcard=False, allow_trailing_dot=False): + """ + Reject strings that are not valid ASCII DNS domains. + """ + if not dns_name: + raise CommandExecutionError("Empty domain") + dns_name.encode( + encoding="ascii" + ) # ensure only ASCII chars, including label separators + labels = dns_name.split(".") + if allow_trailing_dot and not labels[-1]: + labels.pop() + for label in labels: + _validate_dns_label(label, allow_wildcard=allow_wildcard) + + +def idna_encode(domain, *, allow_leading_dot=False, allow_trailing_dot=False): + """ + Encode a domain that might contain unicode characters into punycode, as per IDNA. + + domain + Value to encode. + + allow_leading_dot + Allow DNSNames like ``.example.com``, as seen e.g. in nameConstraints. + """ + # A leading dot is allowed in some values (nameConstraints). + # idna complains about it not being a valid domain name + try: + leading_dot = domain[0] in ("\u002e", "\u3002", "\uff0e", "\uff61") + trailing_dot = domain[-1] in ("\u002e", "\u3002", "\uff0e", "\uff61") + except (KeyError, TypeError): + raise CommandExecutionError( + f"Expected string value, got {type(domain).__name__}: `{domain!r}`" + ) + except IndexError: + raise CommandExecutionError("Empty domain") + if trailing_dot and not allow_trailing_dot: + raise CommandExecutionError("Trailing dots are not allowed in this context") + if leading_dot: + if not allow_leading_dot: + raise CommandExecutionError("Leading dots are not allowed in this context") + domain = domain[1:] + if "*" in domain: + raise CommandExecutionError("Wildcards are not allowed in this context") + if HAS_IDNA: try: - has_dot = val.startswith(".") - except AttributeError: - raise SaltInvocationError( - f"Expected string value, got {type(val).__name__}: `{val}`" - ) - if has_dot: - if not allow_leading_dot: - raise CommandExecutionError( - "Leading dots are not allowed in this context" - ) - val = val.lstrip(".") - has_wildcard = val.startswith("*.") - if has_wildcard: - if not allow_wildcard: - raise CommandExecutionError("Wildcards are not allowed in this context") - if has_dot: - raise CommandExecutionError( - "Wildcards and leading dots cannot be present together" - ) - val = val[2:] - if val.startswith("."): - raise CommandExecutionError("Empty label") - if HAS_IDNA: + ret = idna.encode(domain).decode() + except idna.IDNAError as err: + raise CommandExecutionError(str(err)) from err + else: + try: + _validate_dns_name(domain, allow_trailing_dot=allow_trailing_dot) + except UnicodeEncodeError as err: + raise CommandExecutionError( + "Cannot encode non-ASCII strings to internationalized domain " + "name format, missing library: idna" + ) from err + if len(domain) > (254 if trailing_dot else 253): + raise CommandExecutionError("Domain too long") + ret = domain + if leading_dot: + return f".{ret}" + return ret + + +def idna_encode_with_wildcard(domain: str, *, allow_trailing_dot=False): + """ + Encode a domain that might contain unicode characters into punycode, as per IDNA. + Unlike ``idna_encode``, labels that contain a wildcard character are allowed. + These labels must consist entirely of valid ASCII DNS-label characters; + any internationalized portions must already be IDNA-encoded. + + domain + Value to encode. + """ + if not domain: + raise CommandExecutionError("Empty domain") + try: + labels = re.split("[\u002e\u3002\uff0e\uff61]", domain) + except TypeError: + raise SaltInvocationError( + f"Expected string value, got {type(domain).__name__}: `{domain!r}`" + ) + if trailing_dot := not labels[-1]: + if not allow_trailing_dot: + raise CommandExecutionError("Trailing dots are not allowed in this context") + labels.pop() + if labels[0] == "": + raise CommandExecutionError("Leading dots are not allowed in this context") + encoded = [] + for label in labels: + if "*" in label: try: - ret = idna.encode(val).decode() - except idna.IDNAError as err: - raise CommandExecutionError(str(err)) from err - else: - if not val: - raise CommandExecutionError("Empty domain") + _validate_dns_label(label, allow_wildcard=True) + except UnicodeEncodeError as err: + raise CommandExecutionError( + "Label with wildcard must contain ASCII characters only; " + "internationalized portions must already be IDNA-encoded" + ) from err + encoded.append(label) + elif not HAS_IDNA: try: - val.encode(encoding="ascii") + _validate_dns_label(label) except UnicodeEncodeError as err: raise CommandExecutionError( "Cannot encode non-ASCII strings to internationalized domain " "name format, missing library: idna" ) from err - for elem in val.split("."): - if not elem: - raise CommandExecutionError("Empty Label") - invalid = re.search(r"[^A-Za-z\d\-\.]", elem) - if invalid is not None: - raise CommandExecutionError( - f"Codepoint U+00{hex(ord(invalid.group()))[2:]} at position {invalid.end()} of '{val}' not allowed" - ) - ret = val - if has_dot: - return f".{ret}" - if has_wildcard: - return f"*.{ret}" - return ret + encoded.append(label) + else: + try: + alabel = idna.alabel(label) + except idna.IDNAError as err: + raise CommandExecutionError(str(err)) from err + encoded.append(alabel.decode()) + if trailing_dot: + encoded.append("") + ret = ".".join(encoded) + if len(ret.replace("*", "")) > (254 if trailing_dot else 253): + raise CommandExecutionError("Domain too long") + return ret + + +def parse_general_names(val, *, name_constraints=False): + """ + Hydrate a list of General Name definition tuples of ``(type, value)`` into + cryptography objects. + val + List of 2-tuples. Each tuple is of the form ``(, )``, where ```` + is one of ``email``, ``uri``, ``dns``, ``rid``, ``ip``, ``dirname`` or ``othername``. + ```` is case-insensitive. + + name_constraints + Indicate that the list of GNs is intended for the ``nameConstraints`` extension, which has + specific requirements (e.g. IP networks instead of addresses, allows leading dot in + domain names, but no wildcards). Defaults to false. + """ valid_types = { "email": cx509.general_name.RFC822Name, "uri": cx509.general_name.UniformResourceIdentifier, @@ -2013,44 +2120,139 @@ def idna_encode(val, allow_leading_dot=False, allow_wildcard=False): "othername": _parse_other_name, } + def _encode_domain( + domain, wildcards=False, nc_leading_dot=True, trailing_dot=False + ): + if name_constraints: + return idna_encode(domain, allow_leading_dot=nc_leading_dot) + if wildcards and "*" in str(domain): + return idna_encode_with_wildcard(domain, allow_trailing_dot=trailing_dot) + return idna_encode(domain, allow_trailing_dot=trailing_dot) + parsed = [] for typ, v in val: typ = typ.lower() if typ == "dirname": - v = _get_dn(v) + res = _get_dn(v) elif typ == "rid": - v = _get_oid(v) + res = _get_oid(v) elif typ == "ip": try: - v = ipaddress.ip_address(v) - except ValueError: + if name_constraints: + res = ipaddress.ip_network(v) + else: + res = ipaddress.ip_address(v) + except ValueError as err: + raise CommandExecutionError( + f"Provided value {v!r} does not seem to be an IPv4/IPv6 {'network range' if name_constraints else 'address'}." + ) from err + elif typ == "email": + try: + has_user = "@" in v + except TypeError as err: + raise CommandExecutionError( + f"Expected string value, got {type(v).__name__}: `{v!r}`" + ) from err + if has_user: + user, domain = v.rsplit("@", maxsplit=1) try: - v = ipaddress.ip_network(v) - except ValueError as err: + user.encode("ascii") + except UnicodeEncodeError as err: raise CommandExecutionError( - f"Provided value {v} does not seem to be an IP address or network range." + "Email address username must not contain non-ASCII chars, use SmtpUTF8Mailbox otherName instead" ) from err - elif typ == "email": - splits = v.rsplit("@", maxsplit=1) - if len(splits) > 1: - user, domain = splits - domain = idna_encode(domain) - v = "@".join((user, domain)) + elif not name_constraints: + raise CommandExecutionError(f"Not a valid email in this context: {v}") else: - # nameConstraints - v = idna_encode(splits[0], allow_leading_dot=True) + user, domain = None, v + domain = _encode_domain(domain, nc_leading_dot=user is None) + res = domain if user is None else f"{user}@{domain}" elif typ == "uri": - url = urlparse(v) - if url.netloc: - domain = idna_encode(url.netloc) - v = urlunparse( - (url.scheme, domain, url.path, url.params, url.query, url.fragment) + if ( + name_constraints + ): # A URI in nameConstraints is parsed exactly like a DNSName + try: + res = _encode_domain(v) + except CommandExecutionError as err: + # Friendlier error message for https://foo.bar etc. + # Cannot check this before because .foo.bar - allowed in NameConstraints - is parsed as a path, not a netloc + try: + url = urlsplit(v) + except (AttributeError, TypeError, ValueError): + raise CommandExecutionError( + f"Expected string value, got {type(v).__name__}: `{v!r}`" + ) from err + if url.scheme: + raise CommandExecutionError( + f"NameConstraints URI should be the same format as DNS, not a full URI. Got: {v}" + ) from err + if "*" in v: + raise CommandExecutionError( + "Wildcards are not allowed in this context" + ) + raise + else: + try: + if re.search(r"%(?![0-9A-Fa-f]{2})", v): + raise CommandExecutionError( + f"Invalid percent-encoding in URI: {v}" + ) + except TypeError as err: + raise CommandExecutionError( + f"Expected string value, got {type(v).__name__}: `{v!r}`" + ) from err + url = urlsplit(v) + if not url.scheme: + if v.startswith("."): + raise CommandExecutionError( + "Leading dots are not allowed in this context" + ) + raise CommandExecutionError("URI must contain a scheme") + + netloc = url.netloc + if hostname := url.hostname: + try: + ip = ipaddress.ip_address(hostname) + except ValueError: + host = _encode_domain( + hostname, wildcards=True, trailing_dot=True + ) + else: + host = f"[{ip}]" if ip.version == 6 else str(ip) + try: + port = url.port + except ValueError as err: + raise CommandExecutionError(str(err)) from err + if port is not None: + host = f"{host}:{port}" + + if url.username is not None: + userinfo = url.username + if url.password is not None: + userinfo += f":{url.password}" + userinfo = quote(userinfo, safe="!$&'()*+,;=:%") + netloc = f"{userinfo}@{host}" + else: + netloc = host + + # Also convert IRI to URI. % is safe since we already validated all of them, just pass through + safe_chars = "/:@!$&'()*+,;=%" + res = urlunsplit( + ( + url.scheme, + netloc, + quote(url.path, safe=safe_chars), + quote(url.query, safe=safe_chars + "?"), + quote(url.fragment, safe=safe_chars + "?"), + ) ) elif typ == "dns": - v = idna_encode(v, allow_leading_dot=True, allow_wildcard=True) + res = _encode_domain(v, wildcards=True) + else: + res = v if typ in valid_types: try: - parsed.append(valid_types[typ](v)) + parsed.append(valid_types[typ](res)) continue except (ValueError, TypeError) as err: raise CommandExecutionError(err) from err @@ -2082,7 +2284,7 @@ def _get_rdn(rdn): def _get_gn(gn): - return _parse_general_names((gn.split(":", maxsplit=1),))[0] + return parse_general_names((gn.split(":", maxsplit=1),))[0] def _get_serial_number(sn=None): diff --git a/tests/pytests/unit/utils/test_x509.py b/tests/pytests/unit/utils/test_x509.py index 7023d3c1f963..85737eb10a9d 100644 --- a/tests/pytests/unit/utils/test_x509.py +++ b/tests/pytests/unit/utils/test_x509.py @@ -563,10 +563,15 @@ def test_create_authority_info_access(self, val, expected): False, ), ( - ["critical", "dns:example.io"], - [cx509.DNSName("example.io")], + ["critical", "dns:*.example.io"], + [cx509.DNSName("*.example.io")], True, ), + ( + [{"ip": "1.2.3.4"}], + [cx509.IPAddress(ipaddress.ip_address("1.2.3.4"))], + False, + ), ( "critical,dns:example.io,email:hello@example.io", [ @@ -575,6 +580,37 @@ def test_create_authority_info_access(self, val, expected): ], True, ), + ( + "ip:2001:db8::1,uri:https://foo.bar.baz,uri:proto://*.foo.bar", + [ + cx509.IPAddress(ipaddress.ip_address("2001:db8::1")), + cx509.UniformResourceIdentifier("https://foo.bar.baz"), + cx509.UniformResourceIdentifier("proto://*.foo.bar"), + ], + False, + ), + ( + [ + "email:user@überexample.com", + "dns:überexample.com", + "dns:*.überexample.com", + "uri:https://überexample.com", + "uri:proto://*.überexample.com", + "otherName:1.3.6.1.5.5.7.8.9;FORMAT:UTF8,UTF8String:föö@nönasciinäme.example.com", + ], + [ + cx509.RFC822Name("user@xn--berexample-8db.com"), + cx509.DNSName("xn--berexample-8db.com"), + cx509.DNSName("*.xn--berexample-8db.com"), + cx509.UniformResourceIdentifier("https://xn--berexample-8db.com"), + cx509.UniformResourceIdentifier("proto://*.xn--berexample-8db.com"), + cx509.OtherName( + cx509.ObjectIdentifier("1.3.6.1.5.5.7.8.9"), + asn1.encode_der("föö@nönasciinäme.example.com"), + ), + ], + False, + ), ], ) def test_create_subject_alt_name(self, val, expected, critical): @@ -902,13 +938,64 @@ def test_create_inhibit_any_policy(self, val, expected, critical): ( { "critical": True, - "permitted": ["IP:192.168.0.0/255.255.0.0", "email:.example.com"], - "excluded": ["email:.com"], + "excluded": [ + "dns:.no.example.com", + "dns:no.example.com", + "ip:192.168.1.0/24", + "ip:2001:500::/40", + "email:foo@example.io", + "email:foo.example.com", + "email:.foo.example.com", + "uri:no.foo.bar", + "uri:.no.foo.bar", + ], + "permitted": [ + "dns:.example.com", + "dns:example.com", + "dns:überexample.com", + "dns:.überexample.com", + "ip:192.168.0.0/255.255.0.0", + "ip:2001:500::/32", + "email:foo@example.com", + "email:.example.com", + "email:example.io", + "email:foo@überexample.com", + "email:.überexample.com", + "email:überexample.io", + "uri:.foo.bar", + "uri:foo.bar.baz", + "uri:.föö.bar", + "uri:föö.bar.baz", + ], }, - [cx509.RFC822Name(".com")], [ + cx509.DNSName(".no.example.com"), + cx509.DNSName("no.example.com"), + cx509.IPAddress(ipaddress.ip_network("192.168.1.0/24")), + cx509.IPAddress(ipaddress.ip_network("2001:500::/40")), + cx509.RFC822Name("foo@example.io"), + cx509.RFC822Name("foo.example.com"), + cx509.RFC822Name(".foo.example.com"), + cx509.UniformResourceIdentifier("no.foo.bar"), + cx509.UniformResourceIdentifier(".no.foo.bar"), + ], + [ + cx509.DNSName(".example.com"), + cx509.DNSName("example.com"), + cx509.DNSName("xn--berexample-8db.com"), + cx509.DNSName(".xn--berexample-8db.com"), cx509.IPAddress(ipaddress.ip_network("192.168.0.0/16")), + cx509.IPAddress(ipaddress.ip_network("2001:500::/32")), + cx509.RFC822Name("foo@example.com"), cx509.RFC822Name(".example.com"), + cx509.RFC822Name("example.io"), + cx509.RFC822Name("foo@xn--berexample-8db.com"), + cx509.RFC822Name(".xn--berexample-8db.com"), + cx509.RFC822Name("xn--berexample-8db.io"), + cx509.UniformResourceIdentifier(".foo.bar"), + cx509.UniformResourceIdentifier("foo.bar.baz"), + cx509.UniformResourceIdentifier(".xn--f-1gaa.bar"), + cx509.UniformResourceIdentifier("xn--f-1gaa.bar.baz"), ], True, ), @@ -1044,190 +1131,209 @@ def test_create_invalidity_date(self, val, expected, critical): ext.assert_called_once_with(expected) +def _parse_gn_ids(inpt): + if isinstance(inpt, tuple): + return ":".join(str(x) for x in inpt) + if isinstance(inpt, bool): + return "nc" if inpt else "reg" + if isinstance(inpt, type): + return inpt.__name__ + + @pytest.mark.parametrize( - "inpt,cls,parsed", + "inpt,name_constraints,cls,parsed", [ - (("email", "me@example.com"), cx509.RFC822Name, "me@example.com"), - (("email", ".example.com"), cx509.RFC822Name, ".example.com"), + (("DNS", "example.com"), False, cx509.DNSName, "example.com"), + (("DNS", "example.com"), True, cx509.DNSName, "example.com"), ( - ("email", "me@überexample.com"), - cx509.RFC822Name, - "me@xn--berexample-8db.com", + ("DNS", "example.com."), + False, + salt.exceptions.CommandExecutionError, + "Trailing dots.*not allowed", ), ( - ("URI", "https://www.example.com"), - cx509.UniformResourceIdentifier, - "https://www.example.com", + ("DNS", "example.com."), + True, + salt.exceptions.CommandExecutionError, + "Trailing dots.*not allowed", ), ( - ("URI", "https://www.überexample.com"), + ("DNS", "*.example.com."), + False, + salt.exceptions.CommandExecutionError, + "Trailing dots.*not allowed", + ), + (("DNS", "example。com"), False, cx509.DNSName, "example.com"), + (("DNS", "example。com"), True, cx509.DNSName, "example.com"), + ( + ("DNS", "example。com。"), + False, + salt.exceptions.CommandExecutionError, + "Trailing dots.*not allowed", + ), + ( + ("DNS", "example。com。"), + True, + salt.exceptions.CommandExecutionError, + "Trailing dots.*not allowed", + ), + ( + ("DNS", ".example.com"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots.*not allowed", + ), + (("DNS", ".example.com"), True, cx509.DNSName, ".example.com"), + ( + ("DNS", ".example.com"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots.*not allowed", + ), + (("DNS", "。example。com"), True, cx509.DNSName, ".example.com"), + (("DNS", "*.example.com"), False, cx509.DNSName, "*.example.com"), + ( + ("DNS", "*.example.com"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", + ), + # Also check trailing dots with wilcards, which are only allowed with `URI` + ( + ("URI", "https://*.überexample.com."), + False, cx509.UniformResourceIdentifier, - "https://www.xn--berexample-8db.com", + "https://*.xn--berexample-8db.com.", ), - (("URI", "some/path/only"), cx509.UniformResourceIdentifier, "some/path/only"), - (("DNS", "example.com"), cx509.DNSName, "example.com"), - (("DNS", "überexample.com"), cx509.DNSName, "xn--berexample-8db.com"), - (("DNS", "*.überexample.com"), cx509.DNSName, "*.xn--berexample-8db.com"), - (("DNS", ".überexample.com"), cx509.DNSName, ".xn--berexample-8db.com"), + # The following two are not valid per modern specs, but we're not validating higher-level semantics + (("DNS", "foo.*.example.com"), False, cx509.DNSName, "foo.*.example.com"), ( - ("DNS", "γνῶθι.σεαυτόν.gr"), - cx509.DNSName, - "xn--oxakdo9327a.xn--mxahzvhf4c.gr", + ("DNS", "foo*.example.com"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", ), - (("RID", "1.2.3.4"), cx509.RegisteredID, cx509.ObjectIdentifier("1.2.3.4")), + (("DNS", "foo*.example.com"), False, cx509.DNSName, "foo*.example.com"), + (("DNS", "überexample.com"), False, cx509.DNSName, "xn--berexample-8db.com"), + (("DNS", "überexample.com"), True, cx509.DNSName, "xn--berexample-8db.com"), ( - ("IP", "13.37.13.37"), - cx509.IPAddress, - ipaddress.ip_address("13.37.13.37"), + ("DNS", ".überexample.com"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots.*not allowed", ), + (("DNS", ".überexample.com"), True, cx509.DNSName, ".xn--berexample-8db.com"), ( - ("IP", "13.37.13.0/24"), - cx509.IPAddress, - ipaddress.ip_network("13.37.13.0/24"), + ("DNS", "*.überexample.com"), + False, + cx509.DNSName, + "*.xn--berexample-8db.com", ), ( - ("IP", "13.37.13.0/255.255.255.0"), - cx509.IPAddress, - ipaddress.ip_network("13.37.13.0/255.255.255.0"), + ("DNS", "*.überexample.com"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", ), ( - ("IP", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"), - cx509.IPAddress, - ipaddress.ip_address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + ("DNS", "über*.example.com"), + False, + salt.exceptions.CommandExecutionError, + "Label with wildcard must contain ASCII characters only", ), ( - ("IP", "2001:db8:abcd:0012::0/64"), - cx509.IPAddress, - ipaddress.ip_network("2001:db8:abcd:0012::0/64"), + ("DNS", "γνῶθι.σεαυτόν.gr"), + False, + cx509.DNSName, + "xn--oxakdo9327a.xn--mxahzvhf4c.gr", ), - pytest.param( - ( - "dirName", - "CN=mysite.com,O=My Company,L=San Francisco,ST=California,C=US", - ), - cx509.Name, - [ - cx509.RelativeDistinguishedName( - [cx509.NameAttribute(cx509.ObjectIdentifier("2.5.4.6"), value="US")] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.8"), value="California" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.7"), value="San Francisco" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.10"), value="My Company" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.3"), value="mysite.com" - ) - ] - ), - ], + ( + ("DNS", "γνῶθι.σεαυτόν.gr"), + True, + cx509.DNSName, + "xn--oxakdo9327a.xn--mxahzvhf4c.gr", ), ( - ( - "dirName", - { - "C": "US", - "ST": "California", - "L": "San Francisco", - "O": "My Company", - "CN": "mysite.com", - }, - ), - cx509.Name, - [ - cx509.RelativeDistinguishedName( - [cx509.NameAttribute(cx509.ObjectIdentifier("2.5.4.6"), value="US")] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.8"), value="California" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.7"), value="San Francisco" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.10"), value="My Company" - ) - ] - ), - cx509.RelativeDistinguishedName( - [ - cx509.NameAttribute( - cx509.ObjectIdentifier("2.5.4.3"), value="mysite.com" - ) - ] - ), - ], + ("DNS", "some.invalid_doma.in"), + False, + salt.exceptions.CommandExecutionError, + "at position 8.*not allowed$", ), ( ("DNS", "some.invalid_doma.in"), + True, salt.exceptions.CommandExecutionError, "at position 8.*not allowed$", ), ( ("DNS", "some..invalid-doma.in"), + False, salt.exceptions.CommandExecutionError, "Empty Label", ), ( - ("DNS", "invalid*.wild.card"), + ("DNS", "some..invalid-doma.in"), + True, + salt.exceptions.CommandExecutionError, + "Empty Label", + ), + ( + ("DNS", "some.invalid-doma-.in"), + False, + salt.exceptions.CommandExecutionError, + "Label must not start or end with a hyphen", + ), + ( + ("DNS", "some.invalid-doma-.in"), + True, salt.exceptions.CommandExecutionError, - "at position 8.*not allowed", + "Label must not start or end with a hyphen", ), ( - ("DNS", "invalid.*.wild.card"), + ("DNS", ".*.wildcard-dot.test"), + False, salt.exceptions.CommandExecutionError, - "at position 1.*not allowed", + "Leading dots.*not allowed", ), ( ("DNS", "*..whats.this"), + False, + salt.exceptions.CommandExecutionError, + "Empty Label", + ), + ( + ("DNS", 42), + False, salt.exceptions.CommandExecutionError, - "Empty label", + "Expected string value, got int", ), ( ("DNS", 42), - salt.exceptions.SaltInvocationError, + True, + salt.exceptions.CommandExecutionError, "Expected string value, got int", ), ( ("DNS", ""), + False, + salt.exceptions.CommandExecutionError, + "Empty domain", + ), + ( + ("DNS", ""), + True, salt.exceptions.CommandExecutionError, "Empty domain", ), ( ("DNS", "ἀνεῤῥίφθω.κύβος͵.gr"), + False, salt.exceptions.CommandExecutionError, "not allowed at position 6 in 'κύβος͵'$", ), ( ("DNS", "می\u200cخواهم\u200c.iran"), + False, salt.exceptions.CommandExecutionError, # idna < 3.18 says "Joiner U+200C not allowed at position 9"; # idna 3.18+ says "Unknown codepoint adjacent to joiner U+200C @@ -1235,96 +1341,623 @@ def test_create_invalidity_date(self, val, expected, critical): # version range Salt 3006.x ships against. r"U\+200C.*at position 9 in '.*'", ), + # Label length checks. + # DNSName labels must be <64 chars. ( - ("DNS", ".*.wildcard-dot.test"), + ("DNS", 64 * "x" + ".bar.baz"), + False, salt.exceptions.CommandExecutionError, - "Wildcards and leading dots cannot be present together", + "Label too long", ), ( - ("email", "invalid@*.mail.address"), - salt.exceptions.CommandExecutionError, - "Wildcards are not allowed in this context", + ("DNS", 63 * "x" + ".bar.baz"), + False, + cx509.DNSName, + 63 * "x" + ".bar.baz", ), + # U-labels must be encoded to A-labels before the check. ( - ("email", "invalid@.mail.address"), + ("DNS", 62 * "x" + "á" + ".bar.baz"), + False, salt.exceptions.CommandExecutionError, - "Leading dots are not allowed in this context", + "Label too long", ), + # Wildcard chars should not count against the limit. ( - ("email", "Invalid Email "), - salt.exceptions.CommandExecutionError, - "not allowed$", + ("DNS", 63 * "x" + "*.bar.baz"), + False, + cx509.DNSName, + 63 * "x" + "*.bar.baz", ), ( - ("IP", "this is not an IP address"), + ("DNS", 64 * "x" + "*.bar.baz"), + False, salt.exceptions.CommandExecutionError, - "does not seem to be an IP address or network range.", + "Label too long", ), + # Domain length checks. + # Domains must be <255 chars (including implicit absolute root marker, i.e. trailing dot). ( - ("URI", "https://*.χάος.σκάλα.gr"), + ( + "DNS", + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 58 * "x", + ), + False, salt.exceptions.CommandExecutionError, - "Wildcards are not allowed in this context", + "Domain too long", ), ( - ("URI", "https://.invalid.host"), - salt.exceptions.CommandExecutionError, - "Leading dots are not allowed in this context", + ( + "DNS", + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 57 * "x", + ), + False, + cx509.DNSName, + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 57 * "x", ), + # Ensure an explicit trailing dot does not count against limit. Only `URI` allows them ( - ("dirName", "Et tu, Brute?"), - salt.exceptions.CommandExecutionError, - "Failed parsing rfc4514 dirName string", - ), + ( + "URI", + "https://f.f." + + 63 * "x" + + "." + + 63 * "x" + + "." + + 63 * "z" + + "." + + 57 * "x" + + ".", + ), + False, + cx509.UniformResourceIdentifier, + "https://f.f." + + 63 * "x" + + "." + + 63 * "x" + + "." + + 63 * "z" + + "." + + 57 * "x" + + ".", + ), + # U-labels must be encoded to A-labels before the check. ( - ("otherName", "1.2.3.4;UTF8:some other identifier"), - cx509.OtherName, ( - cx509.ObjectIdentifier("1.2.3.4"), - asn1.encode_der("some other identifier"), + "DNS", + "á.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 57 * "x", ), + False, + salt.exceptions.CommandExecutionError, + "Domain too long", ), + # Wildcard chars should not count against the limit. ( ( - "otherName", - "1.3.6.1.5.5.7.8.9;FORMAT:UTF8,UTF8String:nonasciinäme.example.com", + "DNS", + "*.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 58 * "x", ), - cx509.OtherName, + False, + cx509.DNSName, + "*.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 58 * "x", + ), + ( ( - cx509.ObjectIdentifier("1.3.6.1.5.5.7.8.9"), - asn1.encode_der("nonasciinäme.example.com"), + "DNS", + "*.á." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 58 * "x", ), + False, + salt.exceptions.CommandExecutionError, + "Domain too long", ), + (("email", "me@example.com"), False, cx509.RFC822Name, "me@example.com"), + (("email", "me@example.com"), True, cx509.RFC822Name, "me@example.com"), ( - ("otherName", "1.2.3.4;BOOL:TRUE"), - salt.exceptions.CommandExecutionError, - ".*only UTF8STRING is supported.*", + ("email", "me@überexample.com"), + False, + cx509.RFC822Name, + "me@xn--berexample-8db.com", ), ( - ("otherName", {"oid": "1.2.3.4", "value": "some other identifier"}), - cx509.OtherName, - ( - cx509.ObjectIdentifier("1.2.3.4"), - asn1.encode_der("some other identifier"), - ), + ("email", "me@überexample.com"), + True, + cx509.RFC822Name, + "me@xn--berexample-8db.com", ), ( - ("otherName", {"oid": "1.2.3.4", "value": True}), - cx509.OtherName, - (cx509.ObjectIdentifier("1.2.3.4"), asn1.encode_der(True)), + ("email", "mé@example.com"), + False, + salt.exceptions.CommandExecutionError, + "SmtpUTF8Mailbox", ), ( - ("otherName", {"oid": "1.2.3.4", "value": None}), - cx509.OtherName, - (cx509.ObjectIdentifier("1.2.3.4"), asn1.encode_der(asn1.Null())), + ("email", "mé@example.com"), + True, + salt.exceptions.CommandExecutionError, + "SmtpUTF8Mailbox", ), ( - ( - "otherName", - { + ("email", ".example.com"), + False, + salt.exceptions.CommandExecutionError, + "Not a valid.*in this context.*", + ), + (("email", ".example.com"), True, cx509.RFC822Name, ".example.com"), + ( + ("email", "example.com"), + False, + salt.exceptions.CommandExecutionError, + "Not a valid.*in this context.*", + ), + ( + ("email", "example.com"), + True, + cx509.RFC822Name, + "example.com", + ), + ( + ("email", "invalid@*.mail.address"), + False, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", + ), + ( + ("email", "invalid@*.mail.address"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", + ), + ( + ("email", "invalid@.mail.address"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots are not allowed in this context", + ), + ( + ("email", "invalid@.mail.address"), + True, + salt.exceptions.CommandExecutionError, + "Leading dots are not allowed in this context", + ), + ( + ("email", 42), + False, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("email", 42), + True, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("URI", "https://www.example.com"), + False, + cx509.UniformResourceIdentifier, + "https://www.example.com", + ), + ( + ("URI", "https://www.example.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "*.example.com"), + False, + salt.exceptions.CommandExecutionError, + "URI must contain a scheme", + ), + ( + ("URI", "*.example.com"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards.*not allowed", + ), + ( + ("URI", ".example.com"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots.*not allowed", + ), + ( + ("URI", ".example.com"), + True, + cx509.UniformResourceIdentifier, + ".example.com", + ), + ( + ("URI", "https://1.2.3.4"), + False, + cx509.UniformResourceIdentifier, + "https://1.2.3.4", + ), + ( + ("URI", "https://1.2.3.4"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "https://[2001:db8::1]"), + False, + cx509.UniformResourceIdentifier, + "https://[2001:db8::1]", + ), + ( + ("URI", "https://[2001:db8::1]"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "https://www.überexample.com"), + False, + cx509.UniformResourceIdentifier, + "https://www.xn--berexample-8db.com", + ), + ( + ("URI", "https://www.überexample.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "proto://*.example.com"), + False, + cx509.UniformResourceIdentifier, + "proto://*.example.com", + ), + ( + ("URI", "proto://*.example.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "proto://.example.com"), + False, + salt.exceptions.CommandExecutionError, + "Leading dots.*not allowed", + ), + ( + ("URI", "proto://.example.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "mailto:foo@example.com"), + False, + cx509.UniformResourceIdentifier, + "mailto:foo@example.com", + ), + ( + ("URI", "mailto:foo@example.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "mailto:föö@überexample.com"), + False, + cx509.UniformResourceIdentifier, + "mailto:f%C3%B6%C3%B6@%C3%BCberexample.com", # not ideal, but we can't account for everything + ), + ( + ("URI", "https://user@example.com:1337"), + False, + cx509.UniformResourceIdentifier, + "https://user@example.com:1337", + ), + ( + ("URI", "https://user@example.com:0x1337"), + False, + salt.exceptions.CommandExecutionError, + "Port could not be cast to integer value", + ), + ( + ("URI", "https://user:pass@χάος.σκάλα.gr:1337"), + False, + cx509.UniformResourceIdentifier, + "https://user:pass@xn--hxa2bjr.xn--hxakzf1b.gr:1337", + ), + ( + ( + "URI", + "https://user:pass@example.com:1337/path/segment?query_param=foo#fragment", + ), + False, + cx509.UniformResourceIdentifier, + "https://user:pass@example.com:1337/path/segment?query_param=foo#fragment", + ), + ( + ( + "URI", + "https://üsér:paß$WORD@example.com:1337/päth/ségment?quéry_päram=föö&othér=bär#frägment", + ), + False, + cx509.UniformResourceIdentifier, + "https://%C3%BCs%C3%A9r:pa%C3%9F$WORD@example.com:1337/p%C3%A4th/s%C3%A9gment?qu%C3%A9ry_p%C3%A4ram=f%C3%B6%C3%B6&oth%C3%A9r=b%C3%A4r#fr%C3%A4gment", + ), + ( + ( + "URI", + "https://üsér:paß$WORD@überexample.com:1337/päth/ségment?quéry_päram=föö&othér=bär#frägment", + ), + False, + cx509.UniformResourceIdentifier, + "https://%C3%BCs%C3%A9r:pa%C3%9F$WORD@xn--berexample-8db.com:1337/p%C3%A4th/s%C3%A9gment?qu%C3%A9ry_p%C3%A4ram=f%C3%B6%C3%B6&oth%C3%A9r=b%C3%A4r#fr%C3%A4gment", + ), + ( + ( + "URI", + "https://%C3%BCs%C3%A9r:pa%C3%9F$WORD@xn--berexample-8db.com:1337/p%C3%A4th/s%C3%A9gment?qu%C3%A9ry_p%C3%A4ram=f%C3%B6%C3%B6&oth%C3%A9r=b%C3%A4r#fr%C3%A4gment", + ), + False, + cx509.UniformResourceIdentifier, + "https://%C3%BCs%C3%A9r:pa%C3%9F$WORD@xn--berexample-8db.com:1337/p%C3%A4th/s%C3%A9gment?qu%C3%A9ry_p%C3%A4ram=f%C3%B6%C3%B6&oth%C3%A9r=b%C3%A4r#fr%C3%A4gment", + ), + ( + ("URI", "https://example.com/%foobar"), + False, + salt.exceptions.CommandExecutionError, + "Invalid percent-encoding", + ), + ( + ("URI", "some/path/only"), + False, + salt.exceptions.CommandExecutionError, + "URI must contain a scheme", + ), + ( + ("URI", "some/path/only"), + True, + salt.exceptions.CommandExecutionError, + r"Codepoint U\+002F.*5 of 'some/path/only' not allowed", + ), + ( + ("URI", 42), + False, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("URI", 42), + True, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("RID", "1.2.3.4"), + False, + cx509.RegisteredID, + cx509.ObjectIdentifier("1.2.3.4"), + ), + ( + ("IP", "13.37.13.37"), + False, + cx509.IPAddress, + ipaddress.ip_address("13.37.13.37"), + ), + ( + ("IP", "13.37.13.37"), + True, + cx509.IPAddress, + ipaddress.ip_network("13.37.13.37/32"), + ), + ( + ("IP", "13.37.13.0/24"), + False, + salt.exceptions.CommandExecutionError, + "does not seem to be an IPv4/IPv6 address", + ), + ( + ("IP", "13.37.13.0/24"), + True, + cx509.IPAddress, + ipaddress.ip_network("13.37.13.0/24"), + ), + ( + ("IP", "13.37.13.0/255.255.255.0"), + True, + cx509.IPAddress, + ipaddress.ip_network("13.37.13.0/255.255.255.0"), + ), + ( + ("IP", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + False, + cx509.IPAddress, + ipaddress.ip_address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + ), + ( + ("IP", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + True, + cx509.IPAddress, + ipaddress.ip_network("2001:0db8:85a3:0000:0000:8a2e:0370:7334/128"), + ), + ( + ("IP", "2001:db8:abcd:0012::0/64"), + False, + salt.exceptions.CommandExecutionError, + "does not seem to be an IPv4/IPv6 address", + ), + ( + ("IP", "2001:db8:abcd:0012::0/64"), + True, + cx509.IPAddress, + ipaddress.ip_network("2001:db8:abcd:0012::0/64"), + ), + ( + ("IP", "this is not an IP address"), + False, + salt.exceptions.CommandExecutionError, + "does not seem to be an IPv4/IPv6 address", + ), + ( + ("IP", ("hi", "there")), + False, + salt.exceptions.CommandExecutionError, + "does not seem to be an IPv4/IPv6 address", + ), + ( + ("IP", ("hi", "there")), + True, + salt.exceptions.CommandExecutionError, + "does not seem to be an IPv4/IPv6 network range", + ), + pytest.param( + ( + "dirName", + "CN=mysite.com,O=My Company,L=San Francisco,ST=California,C=US", + ), + False, + cx509.Name, + [ + cx509.RelativeDistinguishedName( + [cx509.NameAttribute(cx509.ObjectIdentifier("2.5.4.6"), value="US")] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.8"), value="California" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.7"), value="San Francisco" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.10"), value="My Company" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.3"), value="mysite.com" + ) + ] + ), + ], + ), + ( + ( + "dirName", + { + "C": "US", + "ST": "California", + "L": "San Francisco", + "O": "My Company", + "CN": "mysite.com", + }, + ), + False, + cx509.Name, + [ + cx509.RelativeDistinguishedName( + [cx509.NameAttribute(cx509.ObjectIdentifier("2.5.4.6"), value="US")] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.8"), value="California" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.7"), value="San Francisco" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.10"), value="My Company" + ) + ] + ), + cx509.RelativeDistinguishedName( + [ + cx509.NameAttribute( + cx509.ObjectIdentifier("2.5.4.3"), value="mysite.com" + ) + ] + ), + ], + ), + ( + ("dirName", "Et tu, Brute?"), + False, + salt.exceptions.CommandExecutionError, + "Failed parsing rfc4514 dirName string", + ), + ( + ("otherName", "1.2.3.4;UTF8:some other identifier"), + False, + cx509.OtherName, + ( + cx509.ObjectIdentifier("1.2.3.4"), + asn1.encode_der("some other identifier"), + ), + ), + ( + ( + "otherName", + "1.3.6.1.5.5.7.8.9;FORMAT:UTF8,UTF8String:nonasciinäme.example.com", + ), + False, + cx509.OtherName, + ( + cx509.ObjectIdentifier("1.3.6.1.5.5.7.8.9"), + asn1.encode_der("nonasciinäme.example.com"), + ), + ), + ( + ("otherName", "1.2.3.4;BOOL:TRUE"), + False, + salt.exceptions.CommandExecutionError, + ".*only UTF8STRING is supported.*", + ), + ( + ("otherName", {"oid": "1.2.3.4", "value": "some other identifier"}), + False, + cx509.OtherName, + ( + cx509.ObjectIdentifier("1.2.3.4"), + asn1.encode_der("some other identifier"), + ), + ), + ( + ("otherName", {"oid": "1.2.3.4", "value": True}), + False, + cx509.OtherName, + (cx509.ObjectIdentifier("1.2.3.4"), asn1.encode_der(True)), + ), + ( + ("otherName", {"oid": "1.2.3.4", "value": None}), + False, + cx509.OtherName, + (cx509.ObjectIdentifier("1.2.3.4"), asn1.encode_der(asn1.Null())), + ), + ( + ( + "otherName", + { "oid": "1.2.3.4", "der": "hex:" + asn1.encode_der("hex encoded utf8string").hex(), }, ), + False, cx509.OtherName, ( cx509.ObjectIdentifier("1.2.3.4"), @@ -1344,6 +1977,7 @@ def test_create_invalidity_date(self, val, expected, critical): ).decode(), }, ), + False, cx509.OtherName, ( cx509.ObjectIdentifier("1.2.3.4"), @@ -1354,41 +1988,47 @@ def test_create_invalidity_date(self, val, expected, critical): ), ( ("otherName", []), + False, salt.exceptions.CommandExecutionError, ".*dict or string required.*", ), ( ("otherName", {}), + False, salt.exceptions.CommandExecutionError, ".*missing `oid` key.*", ), ( ("otherName", {"oid": "1.2.3.4"}), + False, salt.exceptions.CommandExecutionError, ".*missing `value` or `der` key.*", ), ( ("otherName", {"oid": "1.2.3.4", "der": "foobar"}), + False, salt.exceptions.CommandExecutionError, ".*needs `hex:` or `b64:` prefix.*", ), ( ("invalidType", "L'état c'est moi!"), + False, salt.exceptions.CommandExecutionError, "GeneralName type invalidtype is invalid", ), ], + ids=_parse_gn_ids, ) -def test_parse_general_names(inpt, cls, parsed): +def test_parse_general_names(inpt, name_constraints, cls, parsed): if issubclass(cls, Exception): with pytest.raises(cls, match=parsed): - x509._parse_general_names([inpt]) + x509.parse_general_names([inpt], name_constraints=name_constraints) return if inpt[0] == "otherName": expected = cls(*parsed) else: expected = cls(parsed) - res = x509._parse_general_names([inpt]) + res = x509.parse_general_names([inpt], name_constraints=name_constraints) if inpt[0] == "dirName": assert res[0].value == expected else: @@ -1396,92 +2036,291 @@ def test_parse_general_names(inpt, cls, parsed): @pytest.mark.parametrize( - "inpt,cls,parsed", + "inpt,name_constraints,cls,parsed", [ - (("email", "me@example.com"), cx509.RFC822Name, "me@example.com"), + (("DNS", "example.com"), False, cx509.DNSName, "example.com"), + (("DNS", "example.com"), True, cx509.DNSName, "example.com"), + (("DNS", "*.example.com"), False, cx509.DNSName, "*.example.com"), ( - ("URI", "https://www.example.com"), - cx509.UniformResourceIdentifier, - "https://www.example.com", + ("DNS", "*.example.com"), + True, + salt.exceptions.CommandExecutionError, + "Wildcards are not allowed", ), - (("DNS", "example.com"), cx509.DNSName, "example.com"), - (("DNS", "*.example.com"), cx509.DNSName, "*.example.com"), - (("DNS", ".example.com"), cx509.DNSName, ".example.com"), ( - ("DNS", "invalid*.wild.card"), + ("DNS", ".example.com"), + False, salt.exceptions.CommandExecutionError, - "at position 8.*not allowed", + "Leading dots.*not allowed", ), + (("DNS", ".example.com"), True, cx509.DNSName, ".example.com"), ( - ("DNS", "invalid.*.wild.card"), + ("DNS", "some*.wild.card"), + False, + cx509.DNSName, + "some*.wild.card", + ), + ( + ("DNS", "some*.wild.card"), + True, salt.exceptions.CommandExecutionError, - "at position 1.*not allowed", + "Wildcards are not allowed", + ), + ( + ("DNS", "some.*.wild.card"), + False, + cx509.DNSName, + "some.*.wild.card", ), ( ("DNS", ".*.wildcard-dot.test"), + False, salt.exceptions.CommandExecutionError, - "Wildcards and leading dots cannot be present together", + "Leading dots.*not allowed", ), ( ("DNS", "gott.würfelt.nicht"), + False, salt.exceptions.CommandExecutionError, "Cannot encode non-ASCII strings", ), ( ("DNS", "some.invalid_doma.in"), + False, salt.exceptions.CommandExecutionError, "at position 8.*not allowed$", ), + ( + ("DNS", "some.invalid_doma.in"), + True, + salt.exceptions.CommandExecutionError, + "at position 8.*not allowed$", + ), + ( + ("DNS", "some-.invalid-doma.in"), + False, + salt.exceptions.CommandExecutionError, + "Label must not start or end with a hyphen", + ), + ( + ("DNS", "some-.invalid-doma.in"), + True, + salt.exceptions.CommandExecutionError, + "Label must not start or end with a hyphen", + ), ( ("DNS", "some..invalid-doma.in"), + False, salt.exceptions.CommandExecutionError, "Empty Label", ), ( ("DNS", 42), - salt.exceptions.SaltInvocationError, + False, + salt.exceptions.CommandExecutionError, "Expected string value, got int", ), ( ("DNS", ""), + False, salt.exceptions.CommandExecutionError, "Empty domain", ), ( ("DNS", "*..whats.this"), + False, + salt.exceptions.CommandExecutionError, + "Empty Label", + ), + # Label length checks. + # Don't need to check wildcard variants, handling of them does not differ when missing idna. + # Can't check U-label variants because we're simulating missing idna. + # DNSName labels must be <64 chars. + ( + ("DNS", 64 * "x" + ".bar.baz"), + False, salt.exceptions.CommandExecutionError, - "Empty label", + "Label too long", + ), + ( + ("DNS", 63 * "x" + ".bar.baz"), + False, + cx509.DNSName, + 63 * "x" + ".bar.baz", + ), + # Domain length checks. + # Domains must be <255 chars (including implicit absolute root marker, i.e. trailing dot). + ( + ( + "DNS", + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 58 * "x", + ), + False, + salt.exceptions.CommandExecutionError, + "Domain too long", + ), + ( + ( + "DNS", + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 57 * "x", + ), + False, + cx509.DNSName, + "f.f." + 63 * "x" + "." + 63 * "x" + "." + 63 * "z" + "." + 57 * "x", + ), + # Ensure an explicit trailing dot does not count against limit. Only `URI` allows them + ( + ( + "URI", + "https://f.f." + + 63 * "x" + + "." + + 63 * "x" + + "." + + 63 * "z" + + "." + + 57 * "x" + + ".", + ), + False, + cx509.UniformResourceIdentifier, + "https://f.f." + + 63 * "x" + + "." + + 63 * "x" + + "." + + 63 * "z" + + "." + + 57 * "x" + + ".", + ), + (("email", "me@example.com"), False, cx509.RFC822Name, "me@example.com"), + (("email", "me@example.com"), True, cx509.RFC822Name, "me@example.com"), + ( + ("email", "me@überexample.com"), + False, + salt.exceptions.CommandExecutionError, + "missing library: idna", + ), + ( + ("email", "me@überexample.com"), + True, + salt.exceptions.CommandExecutionError, + "missing library: idna", + ), + ( + ("email", "mé@example.com"), + False, + salt.exceptions.CommandExecutionError, + "SmtpUTF8Mailbox", + ), + ( + ("email", "mé@example.com"), + True, + salt.exceptions.CommandExecutionError, + "SmtpUTF8Mailbox", ), ( ("email", "invalid@*.mail.address"), + False, salt.exceptions.CommandExecutionError, "Wildcards are not allowed in this context", ), ( ("email", "invalid@.mail.address"), + False, salt.exceptions.CommandExecutionError, "Leading dots are not allowed in this context", ), ( ("email", "Invalid Email "), + False, salt.exceptions.CommandExecutionError, "not allowed$", ), + ( + ("email", 42), + False, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("email", 42), + True, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("URI", "https://www.example.com"), + False, + cx509.UniformResourceIdentifier, + "https://www.example.com", + ), + ( + ("URI", "https://www.example.com"), + True, + salt.exceptions.CommandExecutionError, + "NameConstraints URI should be the same format as DNS", + ), + ( + ("URI", "https://1.2.3.4"), + False, + cx509.UniformResourceIdentifier, + "https://1.2.3.4", + ), + ( + ("URI", "https://[2001:db8::1]"), + False, + cx509.UniformResourceIdentifier, + "https://[2001:db8::1]", + ), + ( + ("URI", "https://www.überexample.com"), + False, + salt.exceptions.CommandExecutionError, + "missing library: idna", + ), + ( + ("URI", "https://www.example.com/päth/ségment"), + False, + cx509.UniformResourceIdentifier, + "https://www.example.com/p%C3%A4th/s%C3%A9gment", + ), ( ("URI", "https://.invalid.host"), + False, salt.exceptions.CommandExecutionError, - "Leading dots are not allowed in this context", + "Leading dots.*not allowed", + ), + ( + ("URI", "https://*.example.com"), + False, + cx509.UniformResourceIdentifier, + "https://*.example.com", + ), + ( + ("URI", 42), + False, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", + ), + ( + ("URI", 42), + True, + salt.exceptions.CommandExecutionError, + "Expected string value, got int", ), ], + ids=_parse_gn_ids, ) -def test_parse_general_names_without_idna(inpt, cls, parsed): +def test_parse_general_names_without_idna(inpt, name_constraints, cls, parsed): with patch("salt.utils.x509.HAS_IDNA", False): if issubclass(cls, Exception): with pytest.raises(cls, match=parsed): - x509._parse_general_names([inpt]) + x509.parse_general_names([inpt], name_constraints=name_constraints) return expected = cls(parsed) - res = x509._parse_general_names([inpt]) + res = x509.parse_general_names([inpt], name_constraints=name_constraints) if inpt[0] == "dirName": assert res[0].value == expected else: @@ -1503,7 +2342,7 @@ def test_parse_general_names_without_idna(inpt, cls, parsed): ) def test_parse_general_names_rejects_invalid(inpt): with pytest.raises(salt.exceptions.CommandExecutionError): - x509._parse_general_names([inpt]) + x509.parse_general_names([inpt]) @pytest.mark.parametrize( From da45305109c379f5c395fea3014334902af34f6d Mon Sep 17 00:00:00 2001 From: jeanluc Date: Fri, 14 Aug 2026 16:57:00 +0200 Subject: [PATCH 288/469] Account for issuer's basicConstraints pathlen in CA certs --- changelog/70042.fixed.md | 1 + salt/utils/x509.py | 19 ++++++++++- tests/pytests/unit/utils/test_x509.py | 47 ++++++++++++++++++++------- 3 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 changelog/70042.fixed.md diff --git a/changelog/70042.fixed.md b/changelog/70042.fixed.md new file mode 100644 index 000000000000..033cd79c260f --- /dev/null +++ b/changelog/70042.fixed.md @@ -0,0 +1 @@ +Fixed handling of `x509_v2` `basicConstraints` `pathlen` when issuer certificate has an explicit `pathlen`: We now validate the requested `pathlen` against the issuer certificate and default it to one lower if unspecified diff --git a/salt/utils/x509.py b/salt/utils/x509.py index cfbbe5fe3e20..aee02f4a1138 100644 --- a/salt/utils/x509.py +++ b/salt/utils/x509.py @@ -1244,7 +1244,7 @@ def _create_extension(name, val, subject_pubkey=None, ca_crt=None, ca_pub=None): ) -def _create_basic_constraints(val, **kwargs): +def _create_basic_constraints(val, ca_crt, **_): try: critical = val.get("critical", False) except AttributeError: @@ -1259,6 +1259,23 @@ def _create_basic_constraints(val, **kwargs): raise SaltInvocationError( f"Invalid configuration for basicContraints: {err}" ) from err + if val.get("ca") and ca_crt: + try: + ca_bc = ca_crt.extensions.get_extension_for_class(cx509.BasicConstraints) + except cx509.ExtensionNotFound: + pass + else: + if ca_bc.value.path_length is not None: + if ( + val.get("pathlen") is not None + and ca_bc.value.path_length <= val["pathlen"] + ): + raise CommandExecutionError( + f"Issuing CA certificate has pathlen {ca_bc.value.path_length}, " + f"which is less than or equal to requested pathlen of {val['pathlen']}" + ) + if val.get("pathlen") is None: + val["pathlen"] = ca_bc.value.path_length - 1 try: return ( cx509.BasicConstraints(val["ca"], val.get("pathlen")), diff --git a/tests/pytests/unit/utils/test_x509.py b/tests/pytests/unit/utils/test_x509.py index 85737eb10a9d..04688644051d 100644 --- a/tests/pytests/unit/utils/test_x509.py +++ b/tests/pytests/unit/utils/test_x509.py @@ -143,20 +143,45 @@ def ca_crt(self): return ca @pytest.mark.parametrize( - "val,expected,critical", + "val,expected,critical,self_signed", [ - ("critical,CA:FALSE", (False, None), True), - ("critical, CA:TRUE, pathlen:2", (True, 2), True), - ("CA:TRUE", (True, None), False), - ({"ca": False, "critical": True}, (False, None), True), - ({"ca": True, "pathlen": 3}, (True, 3), False), + ("critical,CA:FALSE", (False, None), True, False), + ("critical, CA:TRUE, pathlen:2", (True, 2), True, True), + ("CA:TRUE", (True, None), False, True), + ({"ca": False, "critical": True}, (False, None), True, False), + ({"ca": True, "pathlen": 3}, (True, 3), False, True), + ( + {"ca": True}, + (True, 0), + False, + False, + ), # default to one less than the issuer, which has 1 + ({"ca": True, "pathlen": 0}, (True, 0), False, False), ], ) - def test_create_basic_constraints(self, val, expected, critical): - with patch("cryptography.x509.BasicConstraints", autospec=True) as ext: - _, crit = x509._create_extension("basicConstraints", val) - assert crit == critical - ext.assert_called_once_with(*expected) + def test_create_basic_constraints( + self, val, expected, critical, self_signed, ca_cert + ): + issuer_cert = x509.load_cert(ca_cert) + exp = cx509.BasicConstraints(*expected) + ext, crit = x509._create_extension( + "basicConstraints", + val, + ca_crt=issuer_cert if not self_signed else None, + ) + assert crit == critical + assert ext == exp + + def test_create_basic_constraints_validates_pathlen(self, ca_cert): + with pytest.raises( + salt.exceptions.CommandExecutionError, + match="less than or equal to requested pathlen", + ): + x509._create_extension( + "basicConstraints", + {"ca": True, "pathlen": 1, "critical": True}, + ca_crt=x509.load_cert(ca_cert), + ) @pytest.mark.parametrize( "val,expected,critical", From cff853ef512254bbdf27a6a690cf5440356937fe Mon Sep 17 00:00:00 2001 From: jeanluc Date: Fri, 14 Aug 2026 17:15:27 +0200 Subject: [PATCH 289/469] Respect `get_encoding` param --- changelog/70046.fixed.md | 1 + salt/utils/x509.py | 11 ++++++++-- tests/pytests/unit/utils/test_x509.py | 29 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 changelog/70046.fixed.md diff --git a/changelog/70046.fixed.md b/changelog/70046.fixed.md new file mode 100644 index 000000000000..d6d91abc11aa --- /dev/null +++ b/changelog/70046.fixed.md @@ -0,0 +1 @@ +Made `salt.utils.x509.load_pubkey`'s `get_encoding` parameter work as expected diff --git a/salt/utils/x509.py b/salt/utils/x509.py index aee02f4a1138..aa4416a83b34 100644 --- a/salt/utils/x509.py +++ b/salt/utils/x509.py @@ -818,6 +818,7 @@ def load_privkey(pk, passphrase=None, get_encoding=False): def load_pubkey(pk, get_encoding=False): """ Return a public key instance from + * a class instance * a file path on the local system * a string (PEM) @@ -842,15 +843,21 @@ def load_pubkey(pk, get_encoding=False): pk = load_file_or_bytes(pk) if PEM_BEGIN in pk: try: - return serialization.load_pem_public_key(pk) + ret = serialization.load_pem_public_key(pk) except ValueError as err: raise PubDeserializationError( "Could not load PEM-encoded public key." ) from err + if get_encoding: + return ret, "pem" + return ret try: - return serialization.load_der_public_key(pk) + ret = serialization.load_der_public_key(pk) except ValueError as err: raise PubDeserializationError("Could not load DER-encoded public key.") from err + if get_encoding: + return ret, "der" + return ret def order_certs_naively(bundle, allow_orphans=True, require_leaf=True): diff --git a/tests/pytests/unit/utils/test_x509.py b/tests/pytests/unit/utils/test_x509.py index 04688644051d..e51fd403ae81 100644 --- a/tests/pytests/unit/utils/test_x509.py +++ b/tests/pytests/unit/utils/test_x509.py @@ -18,6 +18,9 @@ cprim = pytest.importorskip( "cryptography.hazmat.primitives", reason="Needs cryptography library" ) +rsa = pytest.importorskip( + "cryptography.hazmat.primitives.asymmetric.rsa", reason="Needs cryptography library" +) @pytest.fixture @@ -131,6 +134,32 @@ def test_split_pems_garbage_between(single_pem): assert len(x.splitlines()) == 27 +@pytest.fixture +def pubkey(): + return """\ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAumZ4+aD8Ez8ZTM2bg1K+ +qN33oigksrzju9VsUS8cz1Hh1g43z9YAfOuLUw8ivGj3c3QNagJT0gonfEwhtvOk +7R89RsQ248qCPY3ItMK73nmWZC27YIarytJrMj/6yZ5QlPrequTNSxDnva/5qhUn +czf96zWG5vb9ow6fqbFQ3KsF0JpyLdCTDvXPH7Ghj0hIVOSxeItPLHjhf1Mpqeqr +n1WExZZvPDvdcMl8ufDwil86eXmKRgb5xfVnnSBdJUyglJr6IOHbtaGbkEM4RRJa +qrvPr5acj4aSWob8VBxqn5cnJ+vs331uQv5oUzQHyLvYaGPeUy0TT62QKhjkG6y1 +4wIDAQAB +-----END PUBLIC KEY----- +""" + + +@pytest.mark.parametrize("get_encoding", (False, True)) +def test_load_pubkey(pubkey, get_encoding): + if get_encoding: + pk, encoding = x509.load_pubkey(pubkey, get_encoding=True) + assert encoding == "pem" + else: + pk = x509.load_pubkey(pubkey) + assert isinstance(pk, rsa.RSAPublicKey) + assert x509.to_pem(pk).decode().strip() == pubkey.strip() + + class TestCreateExtension: @pytest.fixture def aki(self): From b82635cad92d099d719e74d91a6474f127b0d640 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Fri, 14 Aug 2026 17:16:04 +0200 Subject: [PATCH 290/469] Minor lint fixes --- salt/utils/x509.py | 53 +++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/salt/utils/x509.py b/salt/utils/x509.py index aa4416a83b34..d439a5834cf1 100644 --- a/salt/utils/x509.py +++ b/salt/utils/x509.py @@ -448,7 +448,7 @@ def build_csr(private_key, private_key_passphrase=None, subject=None, **kwargs): builder = cx509.CertificateSigningRequestBuilder() subject_name = _get_dn(subject or kwargs) builder = builder.subject_name(subject_name) - for extname, oid in EXTENSIONS_OID.items(): + for extname, _ in EXTENSIONS_OID.items(): if any( ( extname not in CERT_EXTS, @@ -730,6 +730,7 @@ def to_der(pub_or_cert): def load_privkey(pk, passphrase=None, get_encoding=False): """ Return a private key instance from + * a class instance * a file path on the local system * a string (PEM) @@ -955,6 +956,7 @@ def _paths_from( def load_cert(cert, passphrase=None, load_chain=False, get_encoding=False): """ Return a certificate instance from + * a class instance * a file path on the local system * a string (PEM) @@ -1296,7 +1298,7 @@ def _create_basic_constraints(val, ca_crt, **_): raise SaltInvocationError(err) from err -def _create_key_usage(val, **kwargs): +def _create_key_usage(val, **_): critical = "critical" in val args = { "digital_signature": "digitalSignature" in val, @@ -1315,7 +1317,7 @@ def _create_key_usage(val, **kwargs): raise SaltInvocationError(err) from err -def _create_extended_key_usage(val, **kwargs): +def _create_extended_key_usage(val, **_): critical = "critical" in val if isinstance(val, str): val, critical = _deserialize_openssl_confstring(val) @@ -1330,7 +1332,7 @@ def _create_extended_key_usage(val, **kwargs): return cx509.ExtendedKeyUsage(usages), critical -def _create_subject_key_identifier(val, subject_pubkey, **kwargs): +def _create_subject_key_identifier(val, subject_pubkey, **_): if "critical" in val: raise SaltInvocationError("subjectKeyIdentifier must be marked as non-critical") if val == "hash": @@ -1358,7 +1360,7 @@ def _create_subject_key_identifier(val, subject_pubkey, **kwargs): return cx509.SubjectKeyIdentifier(val), False -def _create_authority_key_identifier(val, ca_crt, ca_pub, **kwargs): +def _create_authority_key_identifier(val, ca_crt, ca_pub, **_): if "critical" in val: raise SaltInvocationError( "authorityKeyIdentifier must be marked as non-critical" @@ -1425,12 +1427,12 @@ def _create_authority_key_identifier(val, ca_crt, ca_pub, **kwargs): return cx509.AuthorityKeyIdentifier(**args), False -def _create_issuer_alt_name(val, ca_crt, **kwargs): +def _create_issuer_alt_name(val, ca_crt, **_): parsed, critical = _parse_issuer_general_name(val, ca_crt) return cx509.IssuerAlternativeName(parsed), critical -def _create_certificate_issuer(val, ca_crt, **kwargs): +def _create_certificate_issuer(val, ca_crt, **_): parsed, critical = _parse_issuer_general_name(val, ca_crt) return cx509.CertificateIssuer(parsed), critical @@ -1475,7 +1477,7 @@ def _parse_issuer_general_name(val, ca_crt): return parsed, critical -def _create_authority_info_access(val, **kwargs): +def _create_authority_info_access(val, **_): if isinstance(val, str): val = (x.strip().split(";") for x in val.split(",") if x.strip() != "critical") elif isinstance(val, dict): @@ -1494,7 +1496,7 @@ def _create_authority_info_access(val, **kwargs): return cx509.AuthorityInformationAccess(parsed), False # always noncritical -def _create_subject_alt_name(val, **kwargs): +def _create_subject_alt_name(val, **_): # Note: subjectAltName must be marked as critical if subject is empty. # This is not checked. critical = "critical" in val @@ -1513,12 +1515,12 @@ def _create_subject_alt_name(val, **kwargs): return cx509.SubjectAlternativeName(parsed), critical -def _create_crl_distribution_points(val, **kwargs): +def _create_crl_distribution_points(val, **_): parsed, critical = _parse_distribution_points(val) return cx509.CRLDistributionPoints(parsed), critical -def _create_freshest_crl(val, **kwargs): +def _create_freshest_crl(val, **_): parsed, _ = _parse_distribution_points(val) return cx509.FreshestCRL(parsed), False # must be non-critical @@ -1538,7 +1540,7 @@ def _parse_distribution_points(val): val = tuple(list_) parsed = [] for dpoint in val: - fullname = relativename = crlissuer = reasons = None + relativename = crlissuer = reasons = None if isinstance(dpoint, dict): fullname = dpoint.get("fullname") relativename = dpoint.get("relativename") @@ -1579,7 +1581,7 @@ def _parse_distribution_points(val): return parsed, critical -def _create_issuing_distribution_point(val, **kwargs): +def _create_issuing_distribution_point(val, **_): if not isinstance(val, dict): raise SaltInvocationError("issuingDistributionPoint must be a dictionary") critical = val.get("critical", False) @@ -1619,7 +1621,7 @@ def _create_issuing_distribution_point(val, **kwargs): raise SaltInvocationError(err) from err -def _create_certificate_policies(val, **kwargs): +def _create_certificate_policies(val, **_): if isinstance(val, str): try: critical = val.startswith("critical") @@ -1647,7 +1649,6 @@ def _create_certificate_policies(val, **kwargs): # pointer to the practice statement published by the certificate authority parsed_qualifiers.append(qual) continue - notice = None organization = qual.get("organization") notice_numbers = qual.get("noticeNumbers") text = qual.get("text") @@ -1670,7 +1671,7 @@ def _create_certificate_policies(val, **kwargs): return cx509.CertificatePolicies(parsed), critical -def _create_policy_constraints(val, **kwargs): +def _create_policy_constraints(val, **_): critical = "critical" in val if isinstance(val, str): val, critical = _deserialize_openssl_confstring(val) @@ -1692,7 +1693,7 @@ def _create_policy_constraints(val, **kwargs): raise SaltInvocationError(err) from err -def _create_inhibit_any_policy(val, **kwargs): +def _create_inhibit_any_policy(val, **_): critical = "critical" in val if not isinstance(val, int) else False if isinstance(val, str): val, critical = _deserialize_openssl_confstring(val) @@ -1710,7 +1711,7 @@ def _create_inhibit_any_policy(val, **kwargs): raise SaltInvocationError(err) from err -def _create_name_constraints(val, **kwargs): +def _create_name_constraints(val, **_): critical = "critical" in val if isinstance(val, dict): parsed = {} @@ -1753,11 +1754,11 @@ def _create_name_constraints(val, **kwargs): return cx509.NameConstraints(**args), critical -def _create_no_check(val, **kwargs): +def _create_no_check(val, **_): return cx509.OCSPNoCheck(), "critical" in str(val) -def _create_tlsfeature(val, **kwargs): +def _create_tlsfeature(val, **_): if isinstance(val, str): val = [x.strip() for x in val.split(",")] critical = "critical" in val @@ -1768,15 +1769,15 @@ def _create_tlsfeature(val, **kwargs): return cx509.TLSFeature(types), critical -def _create_ns_comment(val, **kwargs): +def _create_ns_comment(val, **_): raise SaltInvocationError("nsComment is currently not implemented.") -def _create_ns_cert_type(val, **kwargs): +def _create_ns_cert_type(val, **_): raise SaltInvocationError("nsCertType is currently not implemented.") -def _create_crl_number(val, **kwargs): +def _create_crl_number(val, **_): try: return cx509.CRLNumber(int(val)), False except ValueError as err: @@ -1785,7 +1786,7 @@ def _create_crl_number(val, **kwargs): ) from err -def _create_delta_crl_indicator(val, **kwargs): +def _create_delta_crl_indicator(val, **_): critical = "critical" in str(val) val = re.findall(r"[\d]+", str(val)) if len(val) != 1: @@ -1795,7 +1796,7 @@ def _create_delta_crl_indicator(val, **kwargs): return cx509.DeltaCRLIndicator(int(val[0])), critical -def _create_crl_reason(val, **kwargs): +def _create_crl_reason(val, **_): critical = False if isinstance(val, str): val, critical = _deserialize_openssl_confstring(val) @@ -1810,7 +1811,7 @@ def _create_crl_reason(val, **kwargs): raise SaltInvocationError(str(err)) from err -def _create_invalidity_date(val, **kwargs): +def _create_invalidity_date(val, **_): if not isinstance(val, str): raise SaltInvocationError("invalidityDate must be a string") critical = val.startswith("critical") From 99cc5e5510c181330c6fad0b95848ca9fb9681f5 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 11 Jul 2026 20:24:30 -0400 Subject: [PATCH 291/469] Fix Junos timeout wrappers crashing on dev_timeout=None (#58108) napalm.junos_cli defaults dev_timeout to None and forwards it, so the Junos _timeout_decorator / _timeout_decorator_cleankwargs wrappers hit max(None, 0) -- which raises TypeError -- and would otherwise try to set the junos-eznc connection timeout to None, which it rejects. Coalesce None to 0 when computing the effective timeout, and only override the connection timeout when a real (>0) dev_timeout/timeout is given; otherwise run the command with the connection's default timeout. This makes junos_cli and the other timeout-decorated calls work when no timeout is passed, while still honouring an explicit dev_timeout/timeout. --- changelog/58108.fixed.md | 1 + salt/modules/junos.py | 18 ++++++++++++++---- tests/pytests/unit/modules/test_junos.py | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 changelog/58108.fixed.md diff --git a/changelog/58108.fixed.md b/changelog/58108.fixed.md new file mode 100644 index 000000000000..021cfc57909d --- /dev/null +++ b/changelog/58108.fixed.md @@ -0,0 +1 @@ +Fixed ``salt '*' napalm.junos_cli`` (and other Junos calls) raising ``TypeError``/``RuntimeError`` when no timeout was requested. ``napalm.junos_cli`` forwards ``dev_timeout=None`` by default, and the Junos ``_timeout_decorator``/``_timeout_decorator_cleankwargs`` wrappers treated that as a real value, so ``max(None, 0)`` raised (and setting the connection timeout to ``None`` is rejected by junos-eznc). The wrappers now coalesce ``None`` to ``0`` and only override the connection timeout when a real (>0) ``dev_timeout``/``timeout`` is given. diff --git a/salt/modules/junos.py b/salt/modules/junos.py index 4aa8da83ffb6..52ddff328a46 100644 --- a/salt/modules/junos.py +++ b/salt/modules/junos.py @@ -153,8 +153,14 @@ def __exit__(self, exc_type, exc_value, exc_traceback): def _timeout_decorator(function): @wraps(function) def wrapper(*args, **kwargs): - if "dev_timeout" in kwargs or "timeout" in kwargs: - ldev_timeout = max(kwargs.pop("dev_timeout", 0), kwargs.pop("timeout", 0)) + # Callers such as napalm.junos_cli pass dev_timeout=None when no timeout + # was requested; coalesce None to 0 so max() does not raise, and only + # override the connection timeout when a real (>0) value is given + # (junos-eznc rejects a None/0 timeout). + ldev_timeout = max( + kwargs.pop("dev_timeout", 0) or 0, kwargs.pop("timeout", 0) or 0 + ) + if ldev_timeout: conn = __proxy__["junos.conn"]() restore_timeout = conn.timeout conn.timeout = ldev_timeout @@ -174,8 +180,12 @@ def wrapper(*args, **kwargs): def _timeout_decorator_cleankwargs(function): @wraps(function) def wrapper(*args, **kwargs): - if "dev_timeout" in kwargs or "timeout" in kwargs: - ldev_timeout = max(kwargs.pop("dev_timeout", 0), kwargs.pop("timeout", 0)) + # See _timeout_decorator: dev_timeout=None must not raise, and the + # connection timeout is only overridden when a real (>0) value is given. + ldev_timeout = max( + kwargs.pop("dev_timeout", 0) or 0, kwargs.pop("timeout", 0) or 0 + ) + if ldev_timeout: conn = __proxy__["junos.conn"]() restore_timeout = conn.timeout conn.timeout = ldev_timeout diff --git a/tests/pytests/unit/modules/test_junos.py b/tests/pytests/unit/modules/test_junos.py index cfc792f5dd34..95ddb235ab9f 100644 --- a/tests/pytests/unit/modules/test_junos.py +++ b/tests/pytests/unit/modules/test_junos.py @@ -200,6 +200,25 @@ def function(x): mock_timeout.assert_has_calls(calls) +def test__timeout_decorator_none_dev_timeout(): + # napalm.junos_cli (and friends) forward dev_timeout=None when no timeout + # was requested. That must not raise, and must leave the connection timeout + # untouched -- only a real (>0) value overrides it (#58108). + with patch("jnpr.junos.Device.timeout", new_callable=PropertyMock) as mock_timeout: + mock_timeout.return_value = 30 + + def function(x): + return x + + decorator = junos._timeout_decorator(function) + assert decorator("Test Mock", dev_timeout=None) == "Test Mock" + mock_timeout.assert_not_called() + + decorator = junos._timeout_decorator_cleankwargs(function) + assert decorator("Test Mock", dev_timeout=None, __pub_args="abc") == "Test Mock" + mock_timeout.assert_not_called() + + def test_facts_refresh(): with patch("salt.modules.saltutil.sync_grains") as mock_sync_grains: ret = { From d467ae58668d6c33cffc5de965a119731aa97514 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 16 Jul 2026 14:20:12 -0400 Subject: [PATCH 292/469] Strip reserved __kwarg__ marker before forwarding Junos RPC options (#65867) junos.rpc (used by napalm.junos_rpc) builds the RPC ``op`` dict from __pub_arg, which carries the reserved ``__kwarg__`` marker that the Salt CLI appends to keyword arguments. On a ``get-config`` call with a ``filter`` the marker leaked into the RPC options, and junos-eznc's ElementMaker raised ``KeyError: `` while rendering the ``True`` value as an XML attribute, so the filter option stopped working after upgrading from 3004. Strip dunder keys from ``op`` with salt.utils.args.clean_kwargs (already used by junos.diff) right after it is assembled, so reserved markers are dropped on every RPC path. Adds regression tests for the get-config and non get-config paths. --- changelog/65867.fixed.md | 1 + salt/modules/junos.py | 5 +++ tests/pytests/unit/modules/test_junos.py | 52 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 changelog/65867.fixed.md diff --git a/changelog/65867.fixed.md b/changelog/65867.fixed.md new file mode 100644 index 000000000000..31c104e303a6 --- /dev/null +++ b/changelog/65867.fixed.md @@ -0,0 +1 @@ +Fixed ``junos.rpc`` (used by ``napalm.junos_rpc``) so the reserved ``__kwarg__`` marker carried in through ``__pub_arg`` is stripped before the request is sent to the device. Previously a ``get-config`` call with a ``filter`` would fail after upgrading from 3004, because the marker leaked into the RPC options. diff --git a/salt/modules/junos.py b/salt/modules/junos.py index 52ddff328a46..ea656e6e333c 100644 --- a/salt/modules/junos.py +++ b/salt/modules/junos.py @@ -356,6 +356,11 @@ def rpc(cmd=None, dest=None, **kwargs): else: op.update(kwargs) + # Reserved kwargs such as __kwarg__ can be carried in via __pub_arg. Strip + # them so they are not forwarded to the device as RPC options/arguments, + # which would otherwise raise (e.g. the get-config filter reply below). + op = salt.utils.args.clean_kwargs(**op) + if cmd is None: ret["message"] = "Please provide the rpc to execute." ret["out"] = False diff --git a/tests/pytests/unit/modules/test_junos.py b/tests/pytests/unit/modules/test_junos.py index 95ddb235ab9f..724a78e7005d 100644 --- a/tests/pytests/unit/modules/test_junos.py +++ b/tests/pytests/unit/modules/test_junos.py @@ -2341,6 +2341,58 @@ def test_rpc_get_config_filter(): assert etree.tostring(exec_args[0][0]) == expected_rpc +def test_rpc_get_config_filter_ignores_kwarg_marker(): + # The CLI and napalm.junos_rpc carry the reserved __kwarg__ marker in via + # __pub_arg. It must be stripped, otherwise it leaks into the get-config + # options and the request fails (issue #65867). + with patch("jnpr.junos.device.Device.execute") as mock_execute: + mock_execute.return_value = etree.XML("") + args = { + "__pub_user": "root", + "__pub_arg": [ + "get-config", + { + "filter": "", + "__kwarg__": True, + }, + ], + "__pub_fun": "napalm.junos_rpc", + "__pub_jid": "20170314162715866528", + "__pub_tgt": "mac_min", + "__pub_tgt_type": "glob", + "filter": "", + "__pub_ret": "", + } + ret = junos.rpc("get-config", **args) + assert ret["out"] is True + rendered = etree.tostring(mock_execute.call_args[0][0]) + expected_rpc = b'' + assert rendered == expected_rpc + assert b"__kwarg__" not in rendered + + +def test_rpc_non_get_config_ignores_kwarg_marker(): + # The __kwarg__ marker must also be stripped on the non get-config path, + # where op is forwarded as RPC arguments rather than options (issue #65867). + with patch("jnpr.junos.device.Device.execute") as mock_execute: + mock_execute.return_value = etree.XML("") + args = { + "__pub_arg": [ + "get-interface-information", + {"terse": True, "interface_name": "lo0", "__kwarg__": True}, + ], + "terse": True, + "interface_name": "lo0", + "__pub_fun": "junos.rpc", + } + ret = junos.rpc("get-interface-information", **args) + assert ret["out"] is True + rendered = etree.tostring(mock_execute.call_args[0][0]) + expected_rpc = b'lo0' + assert rendered == expected_rpc + assert b"__kwarg__" not in rendered + + def test_rpc_get_interface_information(): with patch("jnpr.junos.device.Device.execute") as mock_execute: junos.rpc("get-interface-information", format="json") From e7e4b3b96b8f62678445786052a9d24bcd6d102b Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 11 Jul 2026 22:38:36 -0400 Subject: [PATCH 293/469] Fix NTP/SNMP/probe config on NAPALM proxy minions (bare-name templates) napalm_ntp (set_peers/set_servers/delete_peers/delete_servers), napalm_snmp (update_config/remove_config) and napalm_probes (set_probes/delete_probes/ schedule_probes) all call net.load_template with a bare template name (e.g. "set_ntp_peers"). net.load_template used to route bare names into NAPALM's own renderer, but that path was removed in the Sodium release (#57370) -- whose own deprecation warning explicitly told netntp/netsnmp/netusers users to ignore it. The bare name now falls through to the fileserver as "Local file source set_ntp_peers does not exist", so every one of these functions fails. Lift the resolver introduced for users in #62170 into a shared salt.utils.napalm.template_path (walks the driver class MRO + inspect.getfile, catching TypeError/OSError, returns None when the driver ships no such template) plus salt.utils.napalm.template_not_available (standard failure payload). The three modules resolve the driver's NAPALM-shipped template to an absolute path and render it through the Salt pipeline, or return the clear failure message. template_not_available also closes the per-call connection a non-always-alive proxy/minion opened, since it short-circuits net.load_template (which would otherwise close it). napalm_users is intentionally left to #62170; a follow-up dedups its local resolver onto the shared one. Validated on a live Juniper EX3400 (Junos 23.4R2): ntp.set_servers renders, commits, is confirmed in the device config, and is removed again. (NAPALM's junos snmp/probes templates still use py2 dict.iteritems() and need modernizing upstream in NAPALM; the NTP templates are py3-clean.) New tests/pytests/unit/utils/test_napalm.py covers the resolver (MRO concrete-over-base precedence, base fall-through, OSError/TypeError skip, missing -> None) and the close behaviour; test_ntp/test_snmp are rewritten and test_probes added with routing tests asserting the correct template name, the resolved path, forwarded flags, and inherit_napalm_device identity. --- salt/modules/napalm_ntp.py | 26 ++- salt/modules/napalm_probes.py | 17 +- salt/modules/napalm_snmp.py | 18 +- salt/utils/napalm.py | 63 +++++++ tests/pytests/unit/modules/napalm/test_ntp.py | 147 ++++++++-------- .../unit/modules/napalm/test_probes.py | 95 ++++++++++ .../pytests/unit/modules/napalm/test_snmp.py | 93 ++++++---- tests/pytests/unit/utils/test_napalm.py | 163 ++++++++++++++++++ 8 files changed, 511 insertions(+), 111 deletions(-) create mode 100644 tests/pytests/unit/modules/napalm/test_probes.py create mode 100644 tests/pytests/unit/utils/test_napalm.py diff --git a/salt/modules/napalm_ntp.py b/salt/modules/napalm_ntp.py index d737c149e23f..1f865f62033d 100644 --- a/salt/modules/napalm_ntp.py +++ b/salt/modules/napalm_ntp.py @@ -230,8 +230,11 @@ def set_peers(*peers, **options): commit = options.pop("commit", True) # pylint: disable=undefined-variable + resolved = salt.utils.napalm.template_path(napalm_device, "set_ntp_peers") + if resolved is None: + return salt.utils.napalm.template_not_available("set_ntp_peers", napalm_device) return __salt__["net.load_template"]( - "set_ntp_peers", + resolved, peers=peers, test=test, commit=commit, @@ -268,8 +271,13 @@ def set_servers(*servers, **options): commit = options.pop("commit", True) # pylint: disable=undefined-variable + resolved = salt.utils.napalm.template_path(napalm_device, "set_ntp_servers") + if resolved is None: + return salt.utils.napalm.template_not_available( + "set_ntp_servers", napalm_device + ) return __salt__["net.load_template"]( - "set_ntp_servers", + resolved, servers=servers, test=test, commit=commit, @@ -307,8 +315,13 @@ def delete_peers(*peers, **options): commit = options.pop("commit", True) # pylint: disable=undefined-variable + resolved = salt.utils.napalm.template_path(napalm_device, "delete_ntp_peers") + if resolved is None: + return salt.utils.napalm.template_not_available( + "delete_ntp_peers", napalm_device + ) return __salt__["net.load_template"]( - "delete_ntp_peers", + resolved, peers=peers, test=test, commit=commit, @@ -347,8 +360,13 @@ def delete_servers(*servers, **options): commit = options.pop("commit", True) # pylint: disable=undefined-variable + resolved = salt.utils.napalm.template_path(napalm_device, "delete_ntp_servers") + if resolved is None: + return salt.utils.napalm.template_not_available( + "delete_ntp_servers", napalm_device + ) return __salt__["net.load_template"]( - "delete_ntp_servers", + resolved, servers=servers, test=test, commit=commit, diff --git a/salt/modules/napalm_probes.py b/salt/modules/napalm_probes.py index 132b984d6b19..3bf37858cedc 100644 --- a/salt/modules/napalm_probes.py +++ b/salt/modules/napalm_probes.py @@ -254,8 +254,11 @@ def set_probes( """ # pylint: disable=undefined-variable + resolved = salt.utils.napalm.template_path(napalm_device, "set_probes") + if resolved is None: + return salt.utils.napalm.template_not_available("set_probes", napalm_device) return __salt__["net.load_template"]( - "set_probes", + resolved, probes=probes, test=test, commit=commit, @@ -310,8 +313,11 @@ def delete_probes( """ # pylint: disable=undefined-variable + resolved = salt.utils.napalm.template_path(napalm_device, "delete_probes") + if resolved is None: + return salt.utils.napalm.template_not_available("delete_probes", napalm_device) return __salt__["net.load_template"]( - "delete_probes", + resolved, probes=probes, test=test, commit=commit, @@ -367,8 +373,13 @@ def schedule_probes( """ # pylint: disable=undefined-variable + resolved = salt.utils.napalm.template_path(napalm_device, "schedule_probes") + if resolved is None: + return salt.utils.napalm.template_not_available( + "schedule_probes", napalm_device + ) return __salt__["net.load_template"]( - "schedule_probes", + resolved, probes=probes, test=test, commit=commit, diff --git a/salt/modules/napalm_snmp.py b/salt/modules/napalm_snmp.py index ee6ca24b38ba..5d090b90c70c 100644 --- a/salt/modules/napalm_snmp.py +++ b/salt/modules/napalm_snmp.py @@ -129,7 +129,14 @@ def remove_config( salt '*' snmp.remove_config community='abcd' """ - dic = {"template_name": "delete_snmp_config", "test": test, "commit": commit} + resolved = salt.utils.napalm.template_path( + napalm_device, "delete_snmp_config" # pylint: disable=undefined-variable + ) + if resolved is None: + return salt.utils.napalm.template_not_available( + "delete_snmp_config", napalm_device # pylint: disable=undefined-variable + ) + dic = {"template_name": resolved, "test": test, "commit": commit} if chassis_id: dic["chassis_id"] = chassis_id @@ -206,7 +213,14 @@ def update_config( True """ - dic = {"template_name": "snmp_config", "test": test, "commit": commit} + resolved = salt.utils.napalm.template_path( + napalm_device, "snmp_config" # pylint: disable=undefined-variable + ) + if resolved is None: + return salt.utils.napalm.template_not_available( + "snmp_config", napalm_device # pylint: disable=undefined-variable + ) + dic = {"template_name": resolved, "test": test, "commit": commit} if chassis_id: dic["chassis_id"] = chassis_id diff --git a/salt/utils/napalm.py b/salt/utils/napalm.py index 9fc15e45fdb1..cc75bd547196 100644 --- a/salt/utils/napalm.py +++ b/salt/utils/napalm.py @@ -16,7 +16,9 @@ import copy import importlib +import inspect import logging +import os.path import traceback from functools import wraps @@ -100,6 +102,67 @@ def virtual(opts, virtualname, filename): ) +def template_path(napalm_device, template_name): + """ + Return the absolute path to a NAPALM-shipped Jinja template (e.g. + ``set_ntp_peers``) for the driver backing this proxy, or ``None`` if the + driver does not ship one. + + NAPALM keeps these config templates in a ``templates`` directory next to + each driver module and resolves them by walking the driver class MRO + (concrete driver first, then its bases). ``net.load_template`` used to route + bare template names into NAPALM's own renderer, but that path was removed in + the Sodium release; resolving the template to an absolute path lets the + still-supported Salt rendering pipeline render it instead. + """ + driver = napalm_device.get("DRIVER") if napalm_device else None + if driver is None: + return None + for klass in type(driver).__mro__: + try: + module_file = inspect.getfile(klass) + except (TypeError, OSError): + # Built-in types (e.g. ``object``) raise TypeError; classes without + # an on-disk source (``__main__``, frozen) raise OSError. Neither + # can ship a template dir, so move on. + continue + candidate = os.path.join( + os.path.dirname(module_file), "templates", f"{template_name}.j2" + ) + if os.path.isfile(candidate): + return candidate + return None + + +def template_not_available(template_name, napalm_device): + """ + Standard failure payload returned by the NAPALM config helpers when the + driver backing this proxy does not ship ``template_name`` (e.g. the ``ios`` + driver has no user templates). Mirrors the shape of ``net.load_template``'s + return so callers and states handle it uniformly. + + Returning here short-circuits ``net.load_template``, which would otherwise be + responsible for closing the per-call connection a non-always-alive proxy / + minion opened (``proxy_napalm_wrap`` only closes on ``force_reconnect``). So + close it here in that mode to avoid leaking the session. + """ + driver_name = napalm_device.get("DRIVER_NAME") if napalm_device else None + opts = napalm_device.get("__opts__") if napalm_device else None + if opts and not_always_alive(opts) and napalm_device.get("CLOSE", True): + try: + napalm_device["DRIVER"].close() + except Exception: # pylint: disable=broad-except + log.debug("Failed to close the connection", exc_info=True) + return { + "result": False, + "out": None, + "comment": ( + f"The '{template_name}' template is not available for the" + f" '{driver_name}' driver." + ), + } + + def call(napalm_device, method, *args, **kwargs): """ Calls arbitrary methods from the network driver instance. diff --git a/tests/pytests/unit/modules/napalm/test_ntp.py b/tests/pytests/unit/modules/napalm/test_ntp.py index 1f146603e041..8005540b5468 100644 --- a/tests/pytests/unit/modules/napalm/test_ntp.py +++ b/tests/pytests/unit/modules/napalm/test_ntp.py @@ -5,93 +5,98 @@ import pytest import salt.modules.napalm_ntp as napalm_ntp +import salt.utils.napalm import tests.support.napalm as napalm_test_support from tests.support.mock import MagicMock, patch -def mock_net_load_template(template, *args, **kwargs): - if template == "set_ntp_peers" or template == "delete_ntp_peers": - assert "1.2.3.4" in kwargs["peers"] - if template == "set_ntp_servers" or template == "delete_ntp_servers": - assert "2.2.3.4" in kwargs["servers"] - - @pytest.fixture def configure_loader_modules(): - module_globals = { - "__salt__": { - "config.get": MagicMock( - return_value={"test": {"driver": "test", "key": "2orgk34kgk34g"}} - ), - "file.file_exists": napalm_test_support.true, - "file.join": napalm_test_support.join, - "file.get_managed": napalm_test_support.get_managed_file, - "random.hash": napalm_test_support.random_hash, - "net.load_template": mock_net_load_template, - } - } - - return {napalm_ntp: module_globals} - - -def test_peers(): - with patch( - "salt.utils.napalm.get_device", - MagicMock(return_value=napalm_test_support.MockNapalmDevice()), - ): - ret = napalm_ntp.peers() - assert "172.17.17.1" in ret["out"] + return {napalm_ntp: {"__salt__": {"config.get": MagicMock(return_value={})}}} -def test_servers(): - with patch( - "salt.utils.napalm.get_device", - MagicMock(return_value=napalm_test_support.MockNapalmDevice()), - ): - ret = napalm_ntp.servers() - assert "172.17.17.1" in ret["out"] - - -def test_stats(): - with patch( +def _mock_device(): + return patch( "salt.utils.napalm.get_device", MagicMock(return_value=napalm_test_support.MockNapalmDevice()), - ): - ret = napalm_ntp.stats() - assert ret["out"][0]["reachability"] == 377 + ) -def test_set_peers(): - with patch( - "salt.utils.napalm.get_device", - MagicMock(return_value=napalm_test_support.MockNapalmDevice()), - ): - ret = napalm_ntp.set_peers("1.2.3.4", "5.6.7.8") - assert ret is None +# --- read pass-throughs (unchanged behaviour) ------------------------------- -def test_set_servers(): - with patch( - "salt.utils.napalm.get_device", - MagicMock(return_value=napalm_test_support.MockNapalmDevice()), - ): - ret = napalm_ntp.set_servers("2.2.3.4", "6.6.7.8") - assert ret is None +def test_peers(): + with _mock_device(): + assert "172.17.17.1" in napalm_ntp.peers()["out"] -def test_delete_servers(): - with patch( - "salt.utils.napalm.get_device", - MagicMock(return_value=napalm_test_support.MockNapalmDevice()), - ): - ret = napalm_ntp.delete_servers("2.2.3.4", "6.6.7.8") - assert ret is None +def test_servers(): + with _mock_device(): + assert "172.17.17.1" in napalm_ntp.servers()["out"] -def test_delete_peers(): - with patch( - "salt.utils.napalm.get_device", - MagicMock(return_value=napalm_test_support.MockNapalmDevice()), +def test_stats(): + with _mock_device(): + assert napalm_ntp.stats()["out"][0]["reachability"] == 377 + + +# --- config writers: route onto the driver's resolved template (#62170) ----- + +RESOLVED = "/opt/napalm/junos/templates/tpl.j2" + + +def _route(func, *args): + """Run a config writer with template resolution + net.load_template mocked.""" + device = napalm_test_support.MockNapalmDevice() + load_template = MagicMock(return_value={"result": True, "comment": "", "out": None}) + tpath = MagicMock(return_value=RESOLVED) + with patch("salt.utils.napalm.get_device", MagicMock(return_value=device)), patch( + "salt.utils.napalm.template_path", tpath + ), patch.dict(napalm_ntp.__salt__, {"net.load_template": load_template}): + ret = func(*args, test=True, commit=False) + return ret, tpath, load_template, device + + +@pytest.mark.parametrize( + "func_name, template_name, arg_key, values", + [ + ("set_peers", "set_ntp_peers", "peers", ("1.2.3.4", "5.6.7.8")), + ("set_servers", "set_ntp_servers", "servers", ("2.2.3.4", "6.6.7.8")), + ("delete_peers", "delete_ntp_peers", "peers", ("1.2.3.4", "5.6.7.8")), + ("delete_servers", "delete_ntp_servers", "servers", ("2.2.3.4", "6.6.7.8")), + ], +) +def test_writer_routes_resolved_template(func_name, template_name, arg_key, values): + ret, tpath, load_template, device = _route(getattr(napalm_ntp, func_name), *values) + assert ret == {"result": True, "comment": "", "out": None} + # Correct template name requested (guards a set/delete or peers/servers mixup). + assert tpath.call_args[0][1] == template_name + load_template.assert_called_once() + args, kwargs = load_template.call_args + # Absolute resolved path passed, not the bare name. + assert args[0] == RESOLVED + assert values[0] in kwargs[arg_key] + assert kwargs["test"] is True + assert kwargs["commit"] is False + # The open proxy device is threaded through by identity (guards =None / wrong). + assert kwargs["inherit_napalm_device"] is device + + +@pytest.mark.parametrize( + "func_name, template_name", + [ + ("set_peers", "set_ntp_peers"), + ("set_servers", "set_ntp_servers"), + ("delete_peers", "delete_ntp_peers"), + ("delete_servers", "delete_ntp_servers"), + ], +) +def test_writer_no_template_for_driver(func_name, template_name): + with _mock_device(), patch( + "salt.utils.napalm.template_path", MagicMock(return_value=None) ): - ret = napalm_ntp.delete_peers("1.2.3.4", "5.6.7.8") - assert ret is None + ret = getattr(napalm_ntp, func_name)("1.2.3.4") + assert ret["result"] is False + # Exact quoted name (guards a set/delete literal swap in the failure branch). + assert f"'{template_name}'" in ret["comment"] + assert "not available" in ret["comment"] diff --git a/tests/pytests/unit/modules/napalm/test_probes.py b/tests/pytests/unit/modules/napalm/test_probes.py new file mode 100644 index 000000000000..6809623182d9 --- /dev/null +++ b/tests/pytests/unit/modules/napalm/test_probes.py @@ -0,0 +1,95 @@ +""" +Unit tests for the napalm_probes execution module. +""" + +import pytest + +import salt.modules.napalm_probes as napalm_probes +import salt.utils.napalm +import tests.support.napalm as napalm_test_support +from tests.support.mock import MagicMock, patch + + +@pytest.fixture +def configure_loader_modules(): + return {napalm_probes: {"__salt__": {"config.get": MagicMock(return_value={})}}} + + +def _mock_device(): + return patch( + "salt.utils.napalm.get_device", + MagicMock(return_value=napalm_test_support.MockNapalmDevice()), + ) + + +# --- read pass-throughs ----------------------------------------------------- + + +def test_config(): + with _mock_device(): + ret = napalm_probes.config() + assert ret["result"] is True + assert ret["out"] == napalm_test_support.TEST_PROBES_CONFIG.copy() + + +def test_results(): + with _mock_device(): + ret = napalm_probes.results() + assert ret["result"] is True + assert ret["out"] == napalm_test_support.TEST_PROBES_RESULTS.copy() + + +# --- config writers: route onto the driver's resolved template (#62170) ----- + +RESOLVED = "/opt/napalm/junos/templates/tpl.j2" +PROBES = {"new_probe": {"new_test1": {}}} + + +def _route(func): + device = napalm_test_support.MockNapalmDevice() + load_template = MagicMock(return_value={"result": True, "comment": "", "out": None}) + tpath = MagicMock(return_value=RESOLVED) + with patch("salt.utils.napalm.get_device", MagicMock(return_value=device)), patch( + "salt.utils.napalm.template_path", tpath + ), patch.dict(napalm_probes.__salt__, {"net.load_template": load_template}): + ret = func(PROBES, test=True, commit=False) + return ret, tpath, load_template, device + + +@pytest.mark.parametrize( + "func_name, template_name", + [ + ("set_probes", "set_probes"), + ("delete_probes", "delete_probes"), + ("schedule_probes", "schedule_probes"), + ], +) +def test_writer_routes_resolved_template(func_name, template_name): + ret, tpath, load_template, device = _route(getattr(napalm_probes, func_name)) + assert ret == {"result": True, "comment": "", "out": None} + assert tpath.call_args[0][1] == template_name + load_template.assert_called_once() + args, kwargs = load_template.call_args + assert args[0] == RESOLVED + assert kwargs["probes"] == PROBES + assert kwargs["test"] is True + assert kwargs["commit"] is False + assert kwargs["inherit_napalm_device"] is device + + +@pytest.mark.parametrize( + "func_name, template_name", + [ + ("set_probes", "set_probes"), + ("delete_probes", "delete_probes"), + ("schedule_probes", "schedule_probes"), + ], +) +def test_writer_no_template_for_driver(func_name, template_name): + with _mock_device(), patch( + "salt.utils.napalm.template_path", MagicMock(return_value=None) + ): + ret = getattr(napalm_probes, func_name)(PROBES) + assert ret["result"] is False + assert f"'{template_name}'" in ret["comment"] + assert "not available" in ret["comment"] diff --git a/tests/pytests/unit/modules/napalm/test_snmp.py b/tests/pytests/unit/modules/napalm/test_snmp.py index 5166372ca68c..cf0d8056c109 100644 --- a/tests/pytests/unit/modules/napalm/test_snmp.py +++ b/tests/pytests/unit/modules/napalm/test_snmp.py @@ -4,52 +4,83 @@ import pytest -import salt.modules.napalm_network as napalm_network import salt.modules.napalm_snmp as napalm_snmp +import salt.utils.napalm import tests.support.napalm as napalm_test_support from tests.support.mock import MagicMock, patch @pytest.fixture def configure_loader_modules(): - module_globals = { - "__salt__": { - "config.get": MagicMock( - return_value={"test": {"driver": "test", "key": "2orgk34kgk34g"}} - ), - "file.file_exists": napalm_test_support.true, - "file.join": napalm_test_support.join, - "file.get_managed": napalm_test_support.get_managed_file, - "random.hash": napalm_test_support.random_hash, - "net.load_template": napalm_network.load_template, - } - } - - return {napalm_snmp: module_globals, napalm_network: module_globals} + return {napalm_snmp: {"__salt__": {"config.get": MagicMock(return_value={})}}} -def test_config(): - with patch( +def _mock_device(): + return patch( "salt.utils.napalm.get_device", MagicMock(return_value=napalm_test_support.MockNapalmDevice()), - ): + ) + + +def test_config(): + with _mock_device(): ret = napalm_snmp.config() assert ret["out"] == napalm_test_support.TEST_SNMP_INFO.copy() -def test_remove_config(): - with patch( - "salt.utils.napalm.get_device", - MagicMock(return_value=napalm_test_support.MockNapalmDevice()), - ): - ret = napalm_snmp.remove_config("1.2.3.4") - assert ret["result"] is False +# --- config writers: route onto the driver's resolved template (#62170) ----- +RESOLVED = "/opt/napalm/junos/templates/tpl.j2" -def test_update_config(): - with patch( - "salt.utils.napalm.get_device", - MagicMock(return_value=napalm_test_support.MockNapalmDevice()), + +def _route(func, **kwargs): + device = napalm_test_support.MockNapalmDevice() + load_template = MagicMock(return_value={"result": True, "comment": "", "out": None}) + tpath = MagicMock(return_value=RESOLVED) + with patch("salt.utils.napalm.get_device", MagicMock(return_value=device)), patch( + "salt.utils.napalm.template_path", tpath + ), patch.dict(napalm_snmp.__salt__, {"net.load_template": load_template}): + ret = func(test=True, commit=False, **kwargs) + return ret, tpath, load_template, device + + +@pytest.mark.parametrize( + "func_name, template_name", + [ + ("update_config", "snmp_config"), + ("remove_config", "delete_snmp_config"), + ], +) +def test_writer_routes_resolved_template(func_name, template_name): + ret, tpath, load_template, device = _route( + getattr(napalm_snmp, func_name), location="Greenwich, UK" + ) + assert ret == {"result": True, "comment": "", "out": None} + assert tpath.call_args[0][1] == template_name + load_template.assert_called_once() + _args, kwargs = load_template.call_args + # snmp builds a dict and calls net.load_template(**dic), so the resolved + # path arrives as the template_name kwarg, not positionally. + assert kwargs["template_name"] == RESOLVED + assert kwargs["location"] == "Greenwich, UK" + assert kwargs["test"] is True + assert kwargs["commit"] is False + assert kwargs["inherit_napalm_device"] is device + + +@pytest.mark.parametrize( + "func_name, template_name", + [ + ("update_config", "snmp_config"), + ("remove_config", "delete_snmp_config"), + ], +) +def test_writer_no_template_for_driver(func_name, template_name): + with _mock_device(), patch( + "salt.utils.napalm.template_path", MagicMock(return_value=None) ): - ret = napalm_snmp.update_config("1.2.3.4") - assert ret["result"] is False + ret = getattr(napalm_snmp, func_name)(location="x") + assert ret["result"] is False + # Exact quoted name: "'snmp_config'" must not match "'delete_snmp_config'". + assert f"'{template_name}'" in ret["comment"] + assert "not available" in ret["comment"] diff --git a/tests/pytests/unit/utils/test_napalm.py b/tests/pytests/unit/utils/test_napalm.py new file mode 100644 index 000000000000..37640a0cffe2 --- /dev/null +++ b/tests/pytests/unit/utils/test_napalm.py @@ -0,0 +1,163 @@ +""" +Unit tests for salt.utils.napalm helpers. +""" + +import salt.utils.napalm as napalm_utils +from tests.support.mock import MagicMock, patch + + +class _BaseDriver: + pass + + +class _ConcreteDriver(_BaseDriver): + pass + + +def _getfile_map(mapping): + """ + Build an ``inspect.getfile`` replacement that returns a distinct path per + class and raises (like the real one) for anything not in the map -- notably + ``object``, so the resolver's exception-continue is genuinely exercised. + """ + + def fake_getfile(klass): + try: + return mapping[klass] + except KeyError: + raise TypeError(f"{klass!r} is a built-in class") + + return fake_getfile + + +def _ship(directory, template_name): + tpl_dir = directory / "templates" + tpl_dir.mkdir(parents=True) + (tpl_dir / f"{template_name}.j2").write_text("system { }") + return tpl_dir / f"{template_name}.j2" + + +def test_template_path_walks_mro_to_base(tmp_path): + """ + Templates can be inherited: the concrete driver ships none but a base class + does. The resolver must walk the MRO (concrete -> base) and skip ``object`` + (which raises from getfile) rather than stopping at the first class. + """ + (tmp_path / "concrete").mkdir() + base_tpl = _ship(tmp_path / "base", "set_ntp_peers") + + device = {"DRIVER": _ConcreteDriver()} + getfile = _getfile_map( + { + _ConcreteDriver: str(tmp_path / "concrete" / "driver.py"), + _BaseDriver: str(tmp_path / "base" / "base.py"), + } + ) + with patch("salt.utils.napalm.inspect.getfile", side_effect=getfile): + resolved = napalm_utils.template_path(device, "set_ntp_peers") + assert resolved == str(base_tpl) + + +def test_template_path_prefers_concrete_over_base(tmp_path): + """ + When both the concrete driver and a base class ship the same template, the + concrete override wins -- the walk must be concrete-first, not reversed. + """ + concrete_tpl = _ship(tmp_path / "concrete", "set_ntp_peers") + _ship(tmp_path / "base", "set_ntp_peers") + + device = {"DRIVER": _ConcreteDriver()} + getfile = _getfile_map( + { + _ConcreteDriver: str(tmp_path / "concrete" / "driver.py"), + _BaseDriver: str(tmp_path / "base" / "base.py"), + } + ) + with patch("salt.utils.napalm.inspect.getfile", side_effect=getfile): + resolved = napalm_utils.template_path(device, "set_ntp_peers") + assert resolved == str(concrete_tpl) + + +def test_template_path_skips_oserror(tmp_path): + """ + ``inspect.getfile`` raises ``OSError`` for classes with no on-disk source + (frozen / ``__main__``); that class must be skipped, not propagated. + """ + base_tpl = _ship(tmp_path / "base", "set_ntp_peers") + + def getfile(klass): + if klass is _BaseDriver: + return str(tmp_path / "base" / "base.py") + raise OSError("source code not available") # _ConcreteDriver + object + + device = {"DRIVER": _ConcreteDriver()} + with patch("salt.utils.napalm.inspect.getfile", side_effect=getfile): + resolved = napalm_utils.template_path(device, "set_ntp_peers") + assert resolved == str(base_tpl) + + +def test_template_path_missing_returns_none(tmp_path): + """ + Drivers that ship no matching template anywhere in the MRO (e.g. ios has no + user templates) resolve to ``None``, and a device with no / an empty / + a missing ``DRIVER`` is handled too. + """ + (tmp_path / "concrete").mkdir() + (tmp_path / "base").mkdir() + device = {"DRIVER": _ConcreteDriver()} + getfile = _getfile_map( + { + _ConcreteDriver: str(tmp_path / "concrete" / "driver.py"), + _BaseDriver: str(tmp_path / "base" / "base.py"), + } + ) + with patch("salt.utils.napalm.inspect.getfile", side_effect=getfile): + assert napalm_utils.template_path(device, "set_ntp_peers") is None + # A truthy device whose DRIVER key is absent -> None (not a KeyError). + assert napalm_utils.template_path({"NOT_DRIVER": object()}, "x") is None + # No device / driver at all is handled too. + assert napalm_utils.template_path({}, "x") is None + assert napalm_utils.template_path(None, "x") is None + + +def test_template_not_available_shape(): + """ + The failure payload mirrors net.load_template's shape and names the driver. + """ + ret = napalm_utils.template_not_available("set_ntp_peers", {"DRIVER_NAME": "ios"}) + assert ret["result"] is False + assert ret["out"] is None + assert ( + ret["comment"] + == "The 'set_ntp_peers' template is not available for the 'ios' driver." + ) + # Tolerates a missing / None device without raising. + assert napalm_utils.template_not_available("x", None)["result"] is False + + +def test_template_not_available_closes_non_always_alive(): + """ + Because it short-circuits net.load_template, the failure path must close the + per-call connection a non-always-alive proxy / minion opened. + """ + driver = MagicMock() + device = {"DRIVER": driver, "DRIVER_NAME": "junos", "__opts__": {"id": "sw01"}} + with patch("salt.utils.napalm.not_always_alive", MagicMock(return_value=True)): + napalm_utils.template_not_available("set_ntp_peers", device) + driver.close.assert_called_once() + + +def test_template_not_available_leaves_always_alive_open(): + """An always-alive proxy's persistent session must NOT be closed here.""" + driver = MagicMock() + device = {"DRIVER": driver, "DRIVER_NAME": "junos", "__opts__": {"id": "sw01"}} + with patch("salt.utils.napalm.not_always_alive", MagicMock(return_value=False)): + napalm_utils.template_not_available("set_ntp_peers", device) + driver.close.assert_not_called() + # CLOSE explicitly False also suppresses the close. + with patch("salt.utils.napalm.not_always_alive", MagicMock(return_value=True)): + napalm_utils.template_not_available( + "set_ntp_peers", + {"DRIVER": driver, "__opts__": {"id": "sw01"}, "CLOSE": False}, + ) + driver.close.assert_not_called() From cde89ca7525e3a4d57b91ba0032f803ee458cdc9 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 11 Jul 2026 22:38:59 -0400 Subject: [PATCH 294/469] Add changelog for #69793 --- changelog/69793.fixed.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog/69793.fixed.md diff --git a/changelog/69793.fixed.md b/changelog/69793.fixed.md new file mode 100644 index 000000000000..8e00dd50bfd5 --- /dev/null +++ b/changelog/69793.fixed.md @@ -0,0 +1,9 @@ +Fixed NTP, SNMP and RPM-probe configuration on NAPALM (proxy) minions. +``ntp.set_peers`` / ``set_servers`` / ``delete_peers`` / ``delete_servers``, +``snmp.update_config`` / ``remove_config`` and ``probes.set_probes`` / +``delete_probes`` / ``schedule_probes`` no longer fail with ``Local file source +set_ntp_peers does not exist``. Like ``users.set_users`` (see #62170), these +functions passed bare template names to ``net.load_template``, which stopped +resolving when native NAPALM template support was removed in the Sodium release. +They now resolve the NAPALM-shipped per-driver template to an absolute path and +render it through the Salt pipeline. From 7fd38d811be462bf261cd8465a206bd53f042605 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 12 Jul 2026 01:16:26 -0400 Subject: [PATCH 295/469] Drop unused salt.utils.napalm import from the napalm tests --- tests/pytests/unit/modules/napalm/test_ntp.py | 1 - tests/pytests/unit/modules/napalm/test_probes.py | 1 - tests/pytests/unit/modules/napalm/test_snmp.py | 1 - 3 files changed, 3 deletions(-) diff --git a/tests/pytests/unit/modules/napalm/test_ntp.py b/tests/pytests/unit/modules/napalm/test_ntp.py index 8005540b5468..4404abe0b4ab 100644 --- a/tests/pytests/unit/modules/napalm/test_ntp.py +++ b/tests/pytests/unit/modules/napalm/test_ntp.py @@ -5,7 +5,6 @@ import pytest import salt.modules.napalm_ntp as napalm_ntp -import salt.utils.napalm import tests.support.napalm as napalm_test_support from tests.support.mock import MagicMock, patch diff --git a/tests/pytests/unit/modules/napalm/test_probes.py b/tests/pytests/unit/modules/napalm/test_probes.py index 6809623182d9..3333b56ed5d0 100644 --- a/tests/pytests/unit/modules/napalm/test_probes.py +++ b/tests/pytests/unit/modules/napalm/test_probes.py @@ -5,7 +5,6 @@ import pytest import salt.modules.napalm_probes as napalm_probes -import salt.utils.napalm import tests.support.napalm as napalm_test_support from tests.support.mock import MagicMock, patch diff --git a/tests/pytests/unit/modules/napalm/test_snmp.py b/tests/pytests/unit/modules/napalm/test_snmp.py index cf0d8056c109..f26547015a78 100644 --- a/tests/pytests/unit/modules/napalm/test_snmp.py +++ b/tests/pytests/unit/modules/napalm/test_snmp.py @@ -5,7 +5,6 @@ import pytest import salt.modules.napalm_snmp as napalm_snmp -import salt.utils.napalm import tests.support.napalm as napalm_test_support from tests.support.mock import MagicMock, patch From b862e2c8e0c2297fa2a8c8bf283feaf6c14e321c Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 12 Jul 2026 01:22:08 -0400 Subject: [PATCH 296/469] Remove legacy test_napalm_probes.py superseded by the pytest tests napalm_probes.set_probes/delete_probes/schedule_probes now resolve the driver's template and no longer return result=True for the bare-name mock, so the legacy unittest-style tests (which asserted the pre-fix behaviour) fail. They are superseded by tests/pytests/unit/modules/napalm/test_probes.py added here, which covers config/results plus the routing and no-template paths. --- tests/unit/modules/test_napalm_probes.py | 90 ------------------------ 1 file changed, 90 deletions(-) delete mode 100644 tests/unit/modules/test_napalm_probes.py diff --git a/tests/unit/modules/test_napalm_probes.py b/tests/unit/modules/test_napalm_probes.py deleted file mode 100644 index 6eaa80b83104..000000000000 --- a/tests/unit/modules/test_napalm_probes.py +++ /dev/null @@ -1,90 +0,0 @@ -""" - :codeauthor: :email:`Anthony Shaw ` -""" - -import salt.modules.napalm_probes as napalm_probes -import tests.support.napalm as napalm_test_support -from tests.support.mixins import LoaderModuleMockMixin -from tests.support.mock import MagicMock, patch -from tests.support.unit import TestCase - - -class NapalmProbesModuleTestCase(TestCase, LoaderModuleMockMixin): - @classmethod - def setUpClass(cls): - cls._test_probes = { - "new_probe": { - "new_test1": { - "probe_type": "icmp-ping", - "target": "192.168.0.1", - "source": "192.168.0.2", - "probe_count": 13, - "test_interval": 3, - } - } - } - cls._test_delete_probes = { - "existing_probe": {"existing_test1": {}, "existing_test2": {}} - } - cls._test_schedule_probes = { - "test_probe": {"existing_test1": {}, "existing_test2": {}} - } - - @classmethod - def tearDownClass(cls): - cls._test_probes = cls._test_delete_probes = cls._test_schedule_probes = None - - def setup_loader_modules(self): - patcher = patch( - "salt.utils.napalm.get_device", - MagicMock(return_value=napalm_test_support.MockNapalmDevice()), - ) - patcher.start() - self.addCleanup(patcher.stop) - - def mock_net_load(template, *args, **kwargs): - if template == "set_probes": - assert kwargs["probes"] == self._test_probes.copy() - return napalm_test_support.TEST_TERM_CONFIG.copy() - if template == "delete_probes": - assert kwargs["probes"] == self._test_delete_probes.copy() - return napalm_test_support.TEST_TERM_CONFIG.copy() - if template == "schedule_probes": - assert kwargs["probes"] == self._test_schedule_probes.copy() - return napalm_test_support.TEST_TERM_CONFIG.copy() - raise ValueError(f"incorrect template {template}") - - module_globals = { - "__salt__": { - "config.get": MagicMock( - return_value={"test": {"driver": "test", "key": "2orgk34kgk34g"}} - ), - "file.file_exists": napalm_test_support.true, - "file.join": napalm_test_support.join, - "file.get_managed": napalm_test_support.get_managed_file, - "random.hash": napalm_test_support.random_hash, - "net.load_template": mock_net_load, - } - } - - return {napalm_probes: module_globals} - - def test_probes_config(self): - ret = napalm_probes.config() - assert ret["out"] == napalm_test_support.TEST_PROBES_CONFIG.copy() - - def test_probes_results(self): - ret = napalm_probes.results() - assert ret["out"] == napalm_test_support.TEST_PROBES_RESULTS.copy() - - def test_set_probes(self): - ret = napalm_probes.set_probes(self._test_probes.copy()) - assert ret["result"] is True - - def test_delete_probes(self): - ret = napalm_probes.delete_probes(self._test_delete_probes.copy()) - assert ret["result"] is True - - def test_schedule_probes(self): - ret = napalm_probes.schedule_probes(self._test_schedule_probes.copy()) - assert ret["result"] is True From 082dc0af6926e47d10e3b7a8a1f8a346f7dbdd49 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 16 Jul 2026 14:52:50 -0400 Subject: [PATCH 297/469] Serialize concurrent NAPALM calls on a shared device connection (#55332) An always-alive NAPALM proxy runs with multiprocessing disabled, so jobs executing at the same time are threads that share one cached device object and its single command channel (get_device returns the same device by reference). Two concurrent calls -- e.g. net.cli, or a grains refresh landing during a state run -- can then interleave on that channel, mixing each other's output and, on drivers that share a raw CLI session without their own locking, corrupting the connection. Give each device a reentrant lock, created in get_device(), and hold it in salt.utils.napalm.call() for the duration of the call. A reentrant lock is required because call() re-enters itself (close/open/re-exec) on a reconnect; a plain Lock would deadlock. Devices built without a LOCK (hand-constructed in tests, or inherited via inherit_napalm_device) run unserialized, unchanged. The lock is per-device, not global, so a deltaproxy hosting many sub-proxies in one process does not needlessly serialize calls across unrelated devices. --- changelog/55332.fixed.md | 1 + salt/utils/napalm.py | 23 +++++ tests/pytests/unit/utils/test_napalm.py | 114 ++++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 changelog/55332.fixed.md diff --git a/changelog/55332.fixed.md b/changelog/55332.fixed.md new file mode 100644 index 000000000000..d8e31eb7f39e --- /dev/null +++ b/changelog/55332.fixed.md @@ -0,0 +1 @@ +Serialized concurrent access to a shared NAPALM device connection. An always-alive proxy minion runs without multiprocessing, so jobs executing at the same time are threads that share a single device object and its one command channel; their driver calls could interleave and corrupt each other's output. Each device now carries a reentrant lock that ``salt.utils.napalm.call`` holds for the duration of a call, so calls on the same device are serialized. diff --git a/salt/utils/napalm.py b/salt/utils/napalm.py index cc75bd547196..97aa162faae8 100644 --- a/salt/utils/napalm.py +++ b/salt/utils/napalm.py @@ -19,6 +19,7 @@ import inspect import logging import os.path +import threading import traceback from functools import wraps @@ -204,6 +205,22 @@ def call(napalm_device, method, *args, **kwargs): ] ) """ + # Hold the per-device lock (if present) around the whole operation so that + # concurrent jobs sharing an always-alive device do not interleave on the + # single command channel (see #55332). Devices built without a LOCK (e.g. + # hand-constructed in tests, or inherited via inherit_napalm_device) simply + # run unserialised, preserving backwards compatibility. + lock = napalm_device.get("LOCK") + if lock is None: + return _call(napalm_device, method, *args, **kwargs) + with lock: + return _call(napalm_device, method, *args, **kwargs) + + +def _call(napalm_device, method, *args, **kwargs): + """ + Implementation of :func:`call`, executed while holding the device lock. + """ result = False out = None opts = napalm_device.get("__opts__", {}) @@ -377,6 +394,12 @@ def get_device(opts, salt_obj=None): """ log.debug("Setting up NAPALM connection") network_device = get_device_opts(opts, salt_obj=salt_obj) + # Serialise access to this device's connection. An always-alive proxy runs + # with multiprocessing disabled, so concurrent jobs are threads that share + # this one device object and its single command channel; without a lock two + # calls can interleave on the channel (see #55332). A reentrant lock is used + # because call() re-enters itself (close/open/re-exec) on a reconnect. + network_device["LOCK"] = threading.RLock() provider_lib = napalm.base if network_device.get("PROVIDER"): # Configuration example: diff --git a/tests/pytests/unit/utils/test_napalm.py b/tests/pytests/unit/utils/test_napalm.py index 37640a0cffe2..5fac64eae604 100644 --- a/tests/pytests/unit/utils/test_napalm.py +++ b/tests/pytests/unit/utils/test_napalm.py @@ -2,6 +2,9 @@ Unit tests for salt.utils.napalm helpers. """ +import threading +import time + import salt.utils.napalm as napalm_utils from tests.support.mock import MagicMock, patch @@ -161,3 +164,114 @@ def test_template_not_available_leaves_always_alive_open(): {"DRIVER": driver, "__opts__": {"id": "sw01"}, "CLOSE": False}, ) driver.close.assert_not_called() + + +def test_call_serialises_concurrent_access(): + # An always-alive proxy shares one device (and one command channel) across + # worker threads. call() must serialise them so their driver interactions + # do not interleave (#55332). + order = [] + entered = threading.Event() + release = threading.Event() + + class FakeDriver: + def cli(self, *args, **kwargs): + order.append("enter") + entered.set() + # Hold the "channel" until the test releases it. + release.wait(timeout=5) + order.append("exit") + return {"show version": "ok"} + + device = { + "DRIVER": FakeDriver(), + "UP": True, + "LOCK": threading.RLock(), + "__opts__": {}, + } + + def worker(): + napalm_utils.call(device, "cli", ["show version"]) + + first = threading.Thread(target=worker) + second = threading.Thread(target=worker) + first.start() + # Wait until the first thread is inside cli() holding the lock. + assert entered.wait(timeout=5) + second.start() + # Give the second thread time to reach the lock; it must block, so only the + # first thread's "enter" is recorded so far. + time.sleep(0.25) + assert order == ["enter"] + # Let the first thread finish; the second may now proceed. + release.set() + first.join(timeout=5) + second.join(timeout=5) + assert not first.is_alive() and not second.is_alive() + # Strictly serialised: one full enter/exit pair before the next begins. + assert order == ["enter", "exit", "enter", "exit"] + + +def test_call_acquires_device_lock(): + # call() must enter and exit the device lock around the driver interaction. + lock = MagicMock() + driver = MagicMock() + driver.cli.return_value = {"show version": "ok"} + device = {"DRIVER": driver, "UP": True, "LOCK": lock, "__opts__": {}} + result = napalm_utils.call(device, "cli", ["show version"]) + assert result["result"] is True + lock.__enter__.assert_called_once() + lock.__exit__.assert_called_once() + driver.cli.assert_called_once() + + +def test_call_uses_reentrant_lock_on_reconnect(): + # On a dropped connection call() recurses into itself (close/open/re-exec) + # while still holding the device lock, so the lock must be reentrant. A + # plain Lock would deadlock here; RLock must not. + class _Disconnect(Exception): + pass + + driver = MagicMock() + driver.cli.side_effect = [_Disconnect("dropped"), {"show version": "ok"}] + lock = threading.RLock() + device = { + "DRIVER": driver, + "UP": True, + "LOCK": lock, + "__opts__": {}, + "HOSTNAME": "device1", + } + + result = [] + + def run(): + with patch("salt.utils.napalm.HAS_CONN_CLOSED_EXC_CLASS", True), patch( + "salt.utils.napalm.ConnectionClosedException", _Disconnect, create=True + ): + result.append(napalm_utils.call(device, "cli", ["show version"])) + + worker = threading.Thread(target=run) + worker.start() + worker.join(timeout=10) + assert ( + not worker.is_alive() + ), "call() deadlocked during reconnect; the device lock must be reentrant" + assert result and result[0]["result"] is True + assert result[0]["out"] == {"show version": "ok"} + assert driver.cli.call_count == 2 + # The lock is fully released after the nested reconnect calls unwind. + assert lock.acquire(blocking=False) + lock.release() + + +def test_call_without_lock_runs_unserialised(): + # Devices built without a LOCK (hand-constructed, or inherited via + # inherit_napalm_device) must still work, unserialised. + driver = MagicMock() + driver.cli.return_value = {"show version": "ok"} + device = {"DRIVER": driver, "UP": True, "__opts__": {}} + result = napalm_utils.call(device, "cli", ["show version"]) + assert result["result"] is True + assert result["out"] == {"show version": "ok"} + driver.cli.assert_called_once() From aa300ad9cc8b18e82b369b3dab768b33dd9c099d Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Fri, 14 Aug 2026 18:21:35 -0700 Subject: [PATCH 298/469] Optimize EventPublisher fan-out: raw_payload passthrough + tag peek The EP fan-out hot path did one msgpack.dumps per event (in frame_msg(package)) followed by a full msgpack.loads on the outer IPC frame (in TCPPuller) and again on the inner event body (in SaltEvent.unpack), even though the routing logic only needed to inspect the event tag. Under a 50-minion highstate return burst this produced ~120 MB of transient Python dict/list objects per burst that stressed the glibc arena, driving EP RSS growth. Two related changes: 1. raw_payload passthrough (salt/transport/tcp.py): TCPPuller.handle_stream reads the length-prefixed msgpack frame with raw=True (skips per-key str allocation) and passes the original wire bytes as raw_payload=payload to the handler. PublishServer.publish_payload and PubServer.publish_payload accept and forward raw_payload; when set, PubServer writes raw_payload to subscribers instead of re-packing via frame_msg. This removes one msgpack.dumps per event on the EP hot path. 2. Tag peek (salt/channel/server.py MasterPubServerChannel.publish_payload): Bytes-level load.partition(TAGEND) grabs the tag without deserializing the body. Full salt.payload.loads is called lazily via a _decode_data() closure, only in the five cluster/runner/* branches that actually need the decoded dict. For non-cluster masters (the >99% case), the full unpack is never performed. The cluster-peer fanout path (self.pushers non-empty) still decodes data via _decode_data() where it needs to wrap the event in a cluster/event envelope, so cluster deployments retain the same behavior. Local fanout forwards raw_payload to transport.publish_payload; cluster branches do not, because they mutate load before publishing. Measured under a 50-minion state.apply/highstate stress rig: - Peak Python allocation (memray): -28% (124 -> 89 MB) - Leaked Python bytes (memray): -34% (122 -> 81 MB) - msgpack.unpackb calls: 6084 -> 66 - Return throughput ceiling: +55% with passthrough alone, +111% with tag peek layered on top Non-cluster deployments see the full benefit. Cluster deployments retain identical semantics; the cluster branches still call _decode_data() before publishing. --- changelog/70052.added.md | 1 + salt/channel/server.py | 24 ++++++++++++++++-------- salt/transport/tcp.py | 23 ++++++++++++++++------- 3 files changed, 33 insertions(+), 15 deletions(-) create mode 100644 changelog/70052.added.md diff --git a/changelog/70052.added.md b/changelog/70052.added.md new file mode 100644 index 000000000000..631058e1eff9 --- /dev/null +++ b/changelog/70052.added.md @@ -0,0 +1 @@ +Optimized ``EventPublisher`` fan-out: ``TCPPuller`` now forwards the raw wire bytes to ``PubServer`` via a new ``raw_payload`` keyword, letting the fan-out skip a redundant ``msgpack.dumps`` per event. ``MasterPubServerChannel.publish_payload`` uses a bytes-level tag peek (``load.partition(TAGEND)``) and only calls ``salt.payload.loads`` on the event body when the tag matches one of the ``cluster/runner/*`` special-cases. On non-cluster masters (the >99 % case), the full ``SaltEvent.unpack`` on the fan-out hot path is now skipped entirely, eliminating the transient dict/list tree that dominated ``EventPublisher`` allocation churn under highstate-return bursts. Measured: -28 % peak Python allocation under sustained stress, +55-111 % return throughput ceiling. diff --git a/salt/channel/server.py b/salt/channel/server.py index f42e85210de4..e1f963a93b62 100644 --- a/salt/channel/server.py +++ b/salt/channel/server.py @@ -3823,14 +3823,20 @@ def extract_cluster_event(self, peer_id, data): return event_data raise salt.exceptions.AuthenticationError("Peer aes key not available") - async def publish_payload(self, load, *args): - tag, data = salt.utils.event.SaltEvent.unpack(load) + async def publish_payload(self, load, *args, raw_payload=None): + _tagend = salt.utils.stringutils.to_bytes(salt.utils.event.TAGEND) + mtag_bytes, _, mdata = load.partition(_tagend) + tag = salt.utils.stringutils.to_str(mtag_bytes) + + def _decode_data(): + return salt.payload.loads(mdata, encoding="utf-8") + # Operator-triggered cluster operations originate as ``cluster/runner/*`` # events fired by the runner subprocess. Intercept them here so the # event is consumed locally rather than broadcast as a regular # cluster event. if tag == "cluster/runner/sync_roots": - channels = data.get("channels") or ["file_roots", "pillar_roots"] + channels = _decode_data().get("channels") or ["file_roots", "pillar_roots"] asyncio.create_task(self._run_root_sync_to_peers(channels)) return if tag == "cluster/runner/collect_from_peers": @@ -3840,7 +3846,7 @@ async def publish_payload(self, load, *args): # initiates an outbound state-sync send to us. Receiver # side reuses the existing state-sync chunk handler at # ``cluster/peer/state-sync-chunk``. - channels = data.get("channels") or ["keys", "denied_keys"] + channels = _decode_data().get("channels") or ["keys", "denied_keys"] asyncio.create_task(self._run_collect_from_peers(channels)) return if tag == "cluster/runner/shed_unowned_all": @@ -3850,7 +3856,7 @@ async def publish_payload(self, load, *args): # writes a per-master sentinel. The originator runner # subprocess (which fired this event) also ran its own # local shed inline — no need to repeat that here. - asyncio.create_task(self._run_shed_unowned_all(data)) + asyncio.create_task(self._run_shed_unowned_all(_decode_data())) return if tag == "cluster/runner/delegate_write": # Delegate-on-miss: the EventMonitor on this master saw @@ -3861,7 +3867,7 @@ async def publish_payload(self, load, *args): # replication already delivered the original event to # the owner — this delegate is a safety net for # asymmetric topologies (or a guard against bus drops). - asyncio.create_task(self._run_delegate_write(data)) + asyncio.create_task(self._run_delegate_write(_decode_data())) return if tag in ( "cluster/runner/ring_create", @@ -3878,6 +3884,7 @@ async def publish_payload(self, load, *args): # currently the leader picks it up. Followers that # receive the fan-out log "not leader" and skip — no # double-commit because the leader is unique. + data = _decode_data() self._handle_multi_ring_runner_event(tag, data) asyncio.create_task(self._fanout_multi_ring_request(tag, data)) return @@ -3885,7 +3892,8 @@ async def publish_payload(self, load, *args): if not tag.startswith("cluster/peer"): tasks = [ asyncio.create_task( - self.transport.publish_payload(load), name=self.opts["id"] + self.transport.publish_payload(load, raw_payload=raw_payload), + name=self.opts["id"], ) ] for pusher in self.pushers: @@ -3899,7 +3907,7 @@ async def publish_payload(self, load, *args): crypticle = _get_crypticle( self.opts, salt.master.SMaster.secrets["aes"]["secret"].value ) - load = {"event_payload": data} + load = {"event_payload": _decode_data()} event_data = salt.utils.event.SaltEvent.pack( salt.utils.event.tagify(tag, self.opts["id"], "cluster/event"), crypticle.dumps(load), diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index 167ef0c0fd7b..3e0394668f32 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -1500,11 +1500,14 @@ async def _validate_ssl_and_add_client(self, stream, address): stream.close() # TODO: ACK the publish through IPC - async def publish_payload(self, package, topic_list=None): + async def publish_payload(self, package, topic_list=None, raw_payload=None): log.trace( "TCP PubServer sending payload: topic_list=%r %r", topic_list, package ) - payload = salt.transport.frame.frame_msg(package) + if raw_payload is not None: + payload = raw_payload + else: + payload = salt.transport.frame.frame_msg(package) to_remove = [] def _make_drain_task(client): @@ -1674,8 +1677,8 @@ async def handle_stream(self, stream): length_bytes = await stream.read_bytes(4) length = struct.unpack(">I", length_bytes)[0] payload = await stream.read_bytes(length) - framed_msg = salt.utils.msgpack.unpackb(payload, raw=False) - body = framed_msg["body"] + framed_msg = salt.utils.msgpack.unpackb(payload, raw=True) + body = framed_msg[b"body"] # Await the payload handler inline instead of firing it # as a background task. ``create_task`` here made the # reader loop return immediately, so under sustained @@ -1697,7 +1700,11 @@ async def handle_stream(self, stream): # peer eventually blocks on write -- which is exactly # the natural backpressure we want. try: - await self.payload_handler(body) + try: + coro = self.payload_handler(body, raw_payload=payload) + except TypeError: + coro = self.payload_handler(body) + await coro except Exception as exc: # pylint: disable=broad-except # A misbehaving handler must not break the whole # reader loop; a single bad event is dropped and the @@ -1955,8 +1962,10 @@ def pre_fork(self, process_manager, *args, **kwargs): name=self.__class__.__name__, ) - async def publish_payload(self, payload, topic_list=None): - return await self.pub_server.publish_payload(payload, topic_list) + async def publish_payload(self, payload, topic_list=None, raw_payload=None): + return await self.pub_server.publish_payload( + payload, topic_list, raw_payload=raw_payload + ) def connect(self, timeout=None): self.pub_sock = salt.utils.asynchronous.SyncWrapper( From ecc256ee5414a479b010b3977188528d886cbd27 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Fri, 14 Aug 2026 18:36:23 -0700 Subject: [PATCH 299/469] Add tests for EventPublisher fan-out raw_payload passthrough + tag peek (#70052) Unit + functional coverage for PR #70052: - salt/transport/tcp.py * PubServer.publish_payload writes raw_payload bytes verbatim when supplied and skips frame_msg; falls back to frame_msg otherwise. * PubServer.publish_payload raw bypass applies with topic_list too. * PublishServer.publish_payload forwards raw_payload= kwarg down to self.pub_server.publish_payload (default None preserves framing). * TCPPuller.handle_stream passes raw_payload= to the handler, falls back to positional-only handler on TypeError, and unpacks the outer frame with raw=True (dict keys are bytes). - salt/channel/server.py MasterPubServerChannel.publish_payload * Non-cluster tags (salt/job/..., salt/auth) never invoke salt.payload.loads -- verified by patching and asserting .called. * raw_payload is forwarded to self.transport.publish_payload for the non-cluster local-fanout branch (and defaults to None). * All five cluster/runner/* branches (sync_roots, collect_from_peers, shed_unowned_all, delegate_write, ring_create/destroy/route_set/ route_clear/ring_set) invoke _decode_data() and dispatch the decoded body into the appropriate _run_* / _handle_multi_ring_* method. * cluster/peer* tags do not re-broadcast locally. * cluster-peer fanout branch (self.pushers non-empty, tag NOT cluster/peer*) decodes the body to build the cluster/event envelope AND still forwards raw_payload to the local transport. - tests/pytests/functional/transport/tcp/test_pub_server.py * End-to-end regression: real PublishServer + PublishClient + TCPPuller path, verifying raw_payload flows from the pull socket to the subscriber and the received payload still decodes back to the original dict. Fixes two pre-existing tests that broke on the raw=True unpack switch (test_tcp_puller_handle_stream_awaits_payload_handler and test_tcp_puller_handle_stream_survives_handler_exception): body values now arrive as bytes. --- .../transport/tcp/test_pub_server.py | 132 +++++++ tests/pytests/unit/channel/test_server.py | 306 +++++++++++++++++ tests/pytests/unit/transport/test_tcp.py | 325 +++++++++++++++++- 3 files changed, 759 insertions(+), 4 deletions(-) diff --git a/tests/pytests/functional/transport/tcp/test_pub_server.py b/tests/pytests/functional/transport/tcp/test_pub_server.py index 2d451721aadf..f2a83fe86a2f 100644 --- a/tests/pytests/functional/transport/tcp/test_pub_server.py +++ b/tests/pytests/functional/transport/tcp/test_pub_server.py @@ -7,7 +7,10 @@ import tornado.gen import tornado.iostream +import salt.transport.frame import salt.transport.tcp +import salt.utils.msgpack +from tests.support.mock import patch async def test_publisher_close_during_connect_no_attribute_error_69187( @@ -188,3 +191,132 @@ async def on_recv(message): finally: server.close() client.close() + + +async def test_pub_channel_raw_payload_passthrough(master_opts, minion_opts, io_loop): + """ + PR #70052 regression: end-to-end pack -> pull -> raw_payload + passthrough -> subscriber round-trip. + + ``TCPPuller.handle_stream`` hands the pull-side wire bytes to the + ``payload_handler`` as ``raw_payload=``. When the handler + calls ``PublishServer.publish_payload(package, raw_payload=raw)`` + the wire bytes are written to subscribers verbatim, skipping the + ``frame_msg`` step in ``PubServer.publish_payload``. This test + exercises the whole loop against a real TCP transport and asserts + the message decodes correctly on the client side -- proving the + passthrough bytes are still a valid framed msgpack payload. + """ + + def presence_callback(client): + pass + + def remove_presence_callback(client): + pass + + master_opts["transport"] = "tcp" + minion_opts.update(master_ip="127.0.0.1", transport="tcp") + + server = salt.transport.tcp.PublishServer( + master_opts, + pub_host="127.0.0.1", + pub_port=master_opts["publish_port"], + pull_path=os.path.join(master_opts["sock_dir"], "publish_pull_raw.ipc"), + ) + + client = salt.transport.tcp.PublishClient( + minion_opts, + io_loop, + host="127.0.0.1", + port=master_opts["publish_port"], + ) + + frame_calls = [] + publishes = [] + handler_calls = [] + + async def publish_payload(payload, raw_payload=None): + # ``TCPPuller.handle_stream`` calls the handler with + # ``raw_payload=``. Forward those bytes + # into the pub_server so the passthrough path is taken. + handler_calls.append(raw_payload) + await server.publish_payload(payload, raw_payload=raw_payload) + + async def on_recv(message): + publishes.append(message) + + real_frame_msg = salt.transport.frame.frame_msg + + def counting_frame_msg(*args, **kwargs): + frame_calls.append(args) + return real_frame_msg(*args, **kwargs) + + io_loop.add_callback( + server.publisher, publish_payload, presence_callback, remove_presence_callback + ) + + # Wait for socket to bind. + await asyncio.sleep(3) + + await client.connect(master_opts["publish_port"]) + client.on_recv(on_recv) + + payload = {"meh": "bah", "nested": {"a": 1, "b": [1, 2, 3]}} + + # Patch frame_msg for the duration of the publish so we can assert + # the passthrough branch (raw_payload provided) does NOT re-frame. + with patch( + "salt.transport.tcp.salt.transport.frame.frame_msg", + side_effect=counting_frame_msg, + ): + await server.publish(payload) + + start = time.monotonic() + try: + while not publishes: + await tornado.gen.sleep(0.3) + if time.monotonic() - start > 30: + assert False, "Message not published after 30 seconds" + finally: + server.close() + client.close() + + # The handler saw the raw wire bytes from the pull side. + assert handler_calls, "handle_stream must forward raw_payload to handler" + assert handler_calls[0] is not None, ( + "raw_payload should be the framed msgpack bytes read from the " + "pull socket, not None" + ) + assert isinstance(handler_calls[0], (bytes, bytearray)) + + # And the subscriber received a body that decodes back to the + # original dict -- the wire bytes weren't corrupted by the + # passthrough. ``PublishClient`` unpacks with default ``raw=True`` + # semantics so top-level dict keys/values arrive as bytes; walk + # the structure to normalize before comparing. + assert publishes, "subscriber must have received the passthrough payload" + + def _normalize(obj): + if isinstance(obj, dict): + return {_normalize(k): _normalize(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_normalize(x) for x in obj] + if isinstance(obj, bytes): + try: + return obj.decode() + except UnicodeDecodeError: + return obj + return obj + + assert _normalize(publishes[0]) == payload + + # PubServer.publish_payload's re-framing branch was NOT hit for + # our publish (raw_payload was supplied). frame_msg IS still + # called elsewhere in the pipeline (e.g. IPC-side send), so we + # can't assert zero calls -- but we assert the pub_server did not + # reframe our payload dict. + for call_args in frame_calls: + assert call_args and call_args[0] != payload, ( + "pub_server.publish_payload must not re-frame the payload dict " + "when raw_payload is supplied" + ) diff --git a/tests/pytests/unit/channel/test_server.py b/tests/pytests/unit/channel/test_server.py index 9c8f66db5bf1..134d3dcd5c24 100644 --- a/tests/pytests/unit/channel/test_server.py +++ b/tests/pytests/unit/channel/test_server.py @@ -838,3 +838,309 @@ def test_send_aes_key_event_finds_peer_pub_with_bare_name(cluster_master_opts): "'Peer key missing' for every configured cluster_peer and is the " "root cause of issue #68462." ) + + +# ============================================================================ +# PR #70052: MasterPubServerChannel.publish_payload tag-peek fast path. +# +# ``publish_payload`` used to call ``SaltEvent.unpack(load)`` on every +# event, which msgpack-decodes the entire body just to inspect the +# tag. For non-cluster masters the decoded body is never used -- +# ``self.transport.publish_payload(load)`` forwards the same original +# wire bytes. #70052 replaces the unconditional unpack with a +# bytes-level ``load.partition(TAGEND)`` and calls the full +# ``salt.payload.loads`` lazily via a ``_decode_data()`` closure only +# in the five ``cluster/runner/*`` branches that need the decoded +# dict. The local-fanout branch also now forwards +# ``raw_payload=raw_payload`` to the transport so the pull-side wire +# bytes reach the fast path in ``PubServer.publish_payload``. +# ============================================================================ + + +def _pub_channel(opts, **overrides): + """ + Build a bare ``MasterPubServerChannel`` with minimal attribute + stubs so ``publish_payload`` can be exercised in isolation. We + bypass ``__init__`` to avoid ``MasterKeys`` / socket setup and + stub only the attributes the method touches. + """ + from tests.support.mock import AsyncMock, MagicMock + + channel = server.MasterPubServerChannel.__new__(server.MasterPubServerChannel) + channel.opts = opts + channel.transport = MagicMock() + channel.transport.publish_payload = AsyncMock(return_value=None) + channel.pushers = overrides.get("pushers", []) + channel._raft_service = overrides.get("_raft_service", None) + return channel + + +async def test_publish_payload_non_cluster_tag_does_not_decode(master_opts): + """ + For a run-of-the-mill ``salt/job/...`` event ``publish_payload`` + must never call ``salt.payload.loads`` -- the tag is peeked out of + the wire bytes with ``load.partition(TAGEND)`` and the body is + forwarded verbatim. This is the whole point of the tag-peek fast + path: >99% of events on a non-cluster master skip the full + msgpack round-trip. + """ + channel = _pub_channel(master_opts) + + tag = "salt/job/20260814000000000000/ret/minion1" + body = {"jid": "20260814000000000000", "id": "minion1", "return": {"foo": "bar"}} + load = salt.utils.event.SaltEvent.pack(tag, body) + + with patch("salt.payload.loads") as fake_loads: + await channel.publish_payload(load, raw_payload=b"wire-bytes") + + assert fake_loads.called is False, "non-cluster path must not decode the event body" + channel.transport.publish_payload.assert_awaited_once_with( + load, raw_payload=b"wire-bytes" + ) + + +async def test_publish_payload_forwards_raw_payload_to_transport(master_opts): + """ + The local-fanout branch (no cluster peers, non-cluster tag) must + forward ``raw_payload`` through to + ``self.transport.publish_payload`` so the underlying + ``PubServer`` can skip its ``frame_msg`` step. + """ + channel = _pub_channel(master_opts) + + tag = "salt/auth" + load = salt.utils.event.SaltEvent.pack(tag, {"act": "accept", "id": "minion1"}) + raw = b"raw-wire-bytes-sentinel" + + await channel.publish_payload(load, raw_payload=raw) + + channel.transport.publish_payload.assert_awaited_once_with(load, raw_payload=raw) + + +async def test_publish_payload_default_raw_payload_is_none(master_opts): + """ + When called without ``raw_payload=`` (older callers or tests that + don't have the wire bytes handy), ``publish_payload`` must + forward ``raw_payload=None`` so the transport falls back to its + own framing. + """ + channel = _pub_channel(master_opts) + + tag = "salt/auth" + load = salt.utils.event.SaltEvent.pack(tag, {"act": "accept", "id": "minion1"}) + + await channel.publish_payload(load) + + channel.transport.publish_payload.assert_awaited_once_with(load, raw_payload=None) + + +async def test_publish_payload_cluster_runner_sync_roots_decodes(master_opts): + """ + ``cluster/runner/sync_roots`` must invoke ``_decode_data()`` and + dispatch ``_run_root_sync_to_peers`` with the ``channels`` value + from the decoded body. + """ + channel = _pub_channel(master_opts) + channel._run_root_sync_to_peers = AsyncMock(return_value=None) + + tag = "cluster/runner/sync_roots" + body = {"channels": ["file_roots"]} + load = salt.utils.event.SaltEvent.pack(tag, body) + + with patch("salt.payload.loads", wraps=salt.payload.loads) as spy_loads: + await channel.publish_payload(load) + # Give the create_task chance to schedule and run. + import asyncio as _asyncio + + await _asyncio.sleep(0) + + spy_loads.assert_called() + channel._run_root_sync_to_peers.assert_called_once_with(["file_roots"]) + # Cluster runner branch does NOT fan out to the transport. + channel.transport.publish_payload.assert_not_called() + + +async def test_publish_payload_cluster_runner_sync_roots_default_channels(master_opts): + """ + Empty/missing ``channels`` falls back to the default + ``["file_roots", "pillar_roots"]``. + """ + channel = _pub_channel(master_opts) + channel._run_root_sync_to_peers = AsyncMock(return_value=None) + + load = salt.utils.event.SaltEvent.pack("cluster/runner/sync_roots", {}) + + await channel.publish_payload(load) + import asyncio as _asyncio + + await _asyncio.sleep(0) + + channel._run_root_sync_to_peers.assert_called_once_with( + ["file_roots", "pillar_roots"] + ) + + +async def test_publish_payload_cluster_runner_collect_from_peers_decodes(master_opts): + """ + ``cluster/runner/collect_from_peers`` decodes and dispatches + ``_run_collect_from_peers`` with the decoded channel list. + """ + channel = _pub_channel(master_opts) + channel._run_collect_from_peers = AsyncMock(return_value=None) + + load = salt.utils.event.SaltEvent.pack( + "cluster/runner/collect_from_peers", {"channels": ["keys"]} + ) + + await channel.publish_payload(load) + import asyncio as _asyncio + + await _asyncio.sleep(0) + + channel._run_collect_from_peers.assert_called_once_with(["keys"]) + channel.transport.publish_payload.assert_not_called() + + +async def test_publish_payload_cluster_runner_shed_unowned_all_decodes(master_opts): + """ + ``cluster/runner/shed_unowned_all`` decodes and dispatches + ``_run_shed_unowned_all`` with the entire decoded body dict. + """ + channel = _pub_channel(master_opts) + channel._run_shed_unowned_all = AsyncMock(return_value=None) + + body = {"scope": "all", "issued_by": "op1"} + load = salt.utils.event.SaltEvent.pack("cluster/runner/shed_unowned_all", body) + + await channel.publish_payload(load) + import asyncio as _asyncio + + await _asyncio.sleep(0) + + channel._run_shed_unowned_all.assert_called_once_with(body) + channel.transport.publish_payload.assert_not_called() + + +async def test_publish_payload_cluster_runner_delegate_write_decodes(master_opts): + """ + ``cluster/runner/delegate_write`` decodes and dispatches + ``_run_delegate_write`` with the decoded payload. + """ + channel = _pub_channel(master_opts) + channel._run_delegate_write = AsyncMock(return_value=None) + + body = {"owner": "peer-2", "target_id": "minion-x", "value": b"..."} + load = salt.utils.event.SaltEvent.pack("cluster/runner/delegate_write", body) + + await channel.publish_payload(load) + import asyncio as _asyncio + + await _asyncio.sleep(0) + + channel._run_delegate_write.assert_called_once_with(body) + channel.transport.publish_payload.assert_not_called() + + +@pytest.mark.parametrize( + "runner_tag", + [ + "cluster/runner/ring_create", + "cluster/runner/ring_destroy", + "cluster/runner/route_set", + "cluster/runner/route_clear", + "cluster/runner/ring_set", + ], +) +async def test_publish_payload_multi_ring_runner_decodes(master_opts, runner_tag): + """ + Every multi-ring ``cluster/runner/*`` tag must decode the body, + dispatch it into ``_handle_multi_ring_runner_event`` synchronously + and schedule ``_fanout_multi_ring_request`` as an asyncio task -- + both with the same decoded dict. + """ + channel = _pub_channel(master_opts) + channel._handle_multi_ring_runner_event = MagicMock() + channel._fanout_multi_ring_request = AsyncMock(return_value=None) + + body = {"ring_id": "R1", "founding_voters": ["a", "b"]} + load = salt.utils.event.SaltEvent.pack(runner_tag, body) + + await channel.publish_payload(load) + import asyncio as _asyncio + + await _asyncio.sleep(0) + + channel._handle_multi_ring_runner_event.assert_called_once_with(runner_tag, body) + channel._fanout_multi_ring_request.assert_called_once_with(runner_tag, body) + channel.transport.publish_payload.assert_not_called() + + +async def test_publish_payload_cluster_peer_tag_skips_local_transport(master_opts): + """ + Tags that start with ``cluster/peer`` are inbound from a sibling + master and must NOT be re-broadcast locally via + ``self.transport.publish_payload``. They're delivered only to + pushers (which we leave empty here to isolate the branch). + """ + channel = _pub_channel(master_opts) + + load = salt.utils.event.SaltEvent.pack( + "cluster/peer/state-sync-chunk", {"chunk": b"..."} + ) + + with patch("salt.payload.loads") as fake_loads: + await channel.publish_payload(load, raw_payload=b"raw") + + # No pushers, no local broadcast: nothing to do. + channel.transport.publish_payload.assert_not_called() + # cluster/peer* branch doesn't need the decoded body either. + assert fake_loads.called is False + + +async def test_publish_payload_cluster_peer_fanout_decodes_for_envelope( + master_opts, +): + """ + When ``self.pushers`` is non-empty AND the tag is NOT + ``cluster/peer*``, each event is wrapped in a + ``cluster/event/`` envelope for every pusher. + Building that envelope requires the decoded body -- so + ``_decode_data()`` is called here even though the non-cluster + fast path does not decode. + """ + import salt.master + + fake_pusher = MagicMock() + fake_pusher.pull_host = "peer-1" + fake_pusher.pull_port = 55596 + fake_pusher.publish = AsyncMock(return_value=None) + + channel = _pub_channel(master_opts, pushers=[fake_pusher]) + + tag = "salt/job/20260814000000000000/ret/minion1" + body = {"foo": "bar"} + load = salt.utils.event.SaltEvent.pack(tag, body) + + # Stub the crypticle so we don't need real AES setup; we only care + # that _decode_data() was invoked to build the event_payload. + fake_crypticle_instance = MagicMock() + fake_crypticle_instance.dumps.return_value = b"encrypted-envelope" + + with patch( + "salt.channel.server._get_crypticle", return_value=fake_crypticle_instance + ), patch.dict( + salt.master.SMaster.secrets, + {"aes": {"secret": MagicMock(value=b"aes-secret")}}, + clear=False, + ), patch( + "salt.payload.loads", wraps=salt.payload.loads + ) as spy_loads: + await channel.publish_payload(load, raw_payload=b"raw") + + # cluster-peer fanout branch: _decode_data() was called to build + # the wrapped envelope. + spy_loads.assert_called() + # The pusher received the encrypted envelope, not the raw event. + fake_pusher.publish.assert_called_once() + # And the local transport still got the raw_payload fast path. + channel.transport.publish_payload.assert_awaited_once_with(load, raw_payload=b"raw") diff --git a/tests/pytests/unit/transport/test_tcp.py b/tests/pytests/unit/transport/test_tcp.py index 59285195163f..87016ab486dc 100644 --- a/tests/pytests/unit/transport/test_tcp.py +++ b/tests/pytests/unit/transport/test_tcp.py @@ -17,7 +17,7 @@ import salt.exceptions import salt.transport.tcp import salt.utils.platform -from tests.support.mock import MagicMock, PropertyMock, patch +from tests.support.mock import AsyncMock, MagicMock, PropertyMock, patch pytestmark = [ pytest.mark.core_test, @@ -1462,7 +1462,9 @@ def closed(self): handler_release.set() await asyncio.wait_for(reader_task, timeout=5) - assert handled == ["first", "second"] + # PR #70052 switched the outer-frame unpack to ``raw=True`` so + # ``body`` values arrive as bytes. + assert handled == [b"first", b"second"] async def test_tcp_puller_handle_stream_survives_handler_exception(master_opts): @@ -1477,7 +1479,9 @@ async def test_tcp_puller_handle_stream_survives_handler_exception(master_opts): handled = [] async def handler(body): - if body == "boom": + # PR #70052 switched the outer-frame unpack to ``raw=True`` so + # ``body`` values arrive as bytes. + if body == b"boom": raise RuntimeError("simulated handler failure") handled.append(body) @@ -1508,7 +1512,7 @@ def closed(self): # The "boom" was dropped by the except-log-and-continue guard; the # other two got through. - assert handled == ["ok1", "ok2"] + assert handled == [b"ok1", b"ok2"] # --------------------------------------------------------------------------- @@ -1676,3 +1680,316 @@ class Stream2: stream2 = Stream2() server2._apply_write_buffer_cap(stream2) assert stream2.max_write_buffer_size == "sentinel" + + +# --------------------------------------------------------------------------- +# PR #70052: EventPublisher fan-out raw_payload passthrough. +# +# Under a burst of returns the EP fan-out did one msgpack.dumps per event +# (inside ``frame_msg(package)``) even though the wire bytes were already +# in hand from the pull-socket read. ``PubServer.publish_payload`` and +# ``PublishServer.publish_payload`` now accept ``raw_payload=`` and, +# when supplied, write those bytes directly to subscribers instead of +# re-framing. ``TCPPuller.handle_stream`` passes the wire bytes through +# as ``raw_payload=payload`` with a ``TypeError`` fallback for older +# handlers that don't accept the kwarg. +# --------------------------------------------------------------------------- + + +async def test_pub_server_publish_payload_uses_raw_payload_when_supplied( + master_opts, io_loop +): + """ + When ``publish_payload`` is called with ``raw_payload=`` those + bytes are written to subscribers verbatim -- ``frame_msg`` is NOT + called. This is the PR #70052 fast path that removes one + ``msgpack.dumps`` per event on the EP hot path. + """ + server = salt.transport.tcp.PubServer(master_opts, io_loop=io_loop) + package = {"foo": "bar"} + raw = b"pre-framed-wire-bytes" + + future = tornado.concurrent.Future() + future.set_result(None) + client = MagicMock() + client.stream = MagicMock() + client.stream.write.side_effect = [future] + client.id_ = "meh" + server.clients = [client] + + with patch( + "salt.transport.frame.frame_msg", side_effect=AssertionError("must not reframe") + ) as fake_frame: + await server.publish_payload(package, raw_payload=raw) + + fake_frame.assert_not_called() + client.stream.write.assert_called_once_with(raw) + + +async def test_pub_server_publish_payload_frames_when_no_raw_payload( + master_opts, io_loop +): + """ + Backwards compatibility: when ``raw_payload`` is not supplied, + ``publish_payload`` must still frame the outgoing package via + ``frame_msg`` and write the framed bytes to subscribers. + """ + server = salt.transport.tcp.PubServer(master_opts, io_loop=io_loop) + package = {"foo": "bar"} + framed = b"framed-bytes-sentinel" + + future = tornado.concurrent.Future() + future.set_result(None) + client = MagicMock() + client.stream = MagicMock() + client.stream.write.side_effect = [future] + client.id_ = "meh" + server.clients = [client] + + with patch("salt.transport.frame.frame_msg", return_value=framed) as fake_frame: + await server.publish_payload(package) + + fake_frame.assert_called_once_with(package) + client.stream.write.assert_called_once_with(framed) + + +async def test_pub_server_publish_payload_raw_bypass_with_topic_list( + master_opts, io_loop +): + """ + ``raw_payload`` bypass must apply on the topic-filtered path too -- + the fast path is chosen based solely on ``raw_payload``, not on the + presence or absence of ``topic_list``. + """ + server = salt.transport.tcp.PubServer(master_opts, io_loop=io_loop) + raw = b"topic-raw-bytes" + + future = tornado.concurrent.Future() + future.set_result(None) + client = MagicMock() + client.stream = MagicMock() + client.stream.write.side_effect = [future] + client.id_ = "target" + server.clients = [client] + + with patch( + "salt.transport.frame.frame_msg", side_effect=AssertionError("must not reframe") + ): + await server.publish_payload( + {"foo": "bar"}, topic_list=["target"], raw_payload=raw + ) + + client.stream.write.assert_called_once_with(raw) + + +async def test_publish_server_publish_payload_forwards_raw_payload( + master_opts, io_loop +): + """ + ``PublishServer.publish_payload`` is a thin wrapper that must + forward ``raw_payload`` through to ``self.pub_server.publish_payload`` + -- otherwise the fast path never reaches the layer that actually + writes to subscribers. + """ + pubserv = salt.transport.tcp.PublishServer( + master_opts, + pub_host="127.0.0.1", + pub_port=5151, + pull_host="127.0.0.1", + pull_port=5152, + ) + pubserv.pub_server = MagicMock() + pubserv.pub_server.publish_payload = AsyncMock(return_value=None) + + raw = b"raw-wire-bytes" + await pubserv.publish_payload({"foo": "bar"}, ["t1"], raw_payload=raw) + + pubserv.pub_server.publish_payload.assert_awaited_once_with( + {"foo": "bar"}, ["t1"], raw_payload=raw + ) + + +async def test_publish_server_publish_payload_default_raw_payload_none( + master_opts, io_loop +): + """ + When ``PublishServer.publish_payload`` is called without a + ``raw_payload`` kwarg (older callers) it must still forward the + default ``raw_payload=None`` -- ensuring the underlying pub server + falls back to its ``frame_msg`` path. + """ + pubserv = salt.transport.tcp.PublishServer( + master_opts, + pub_host="127.0.0.1", + pub_port=5151, + pull_host="127.0.0.1", + pull_port=5152, + ) + pubserv.pub_server = MagicMock() + pubserv.pub_server.publish_payload = AsyncMock(return_value=None) + + await pubserv.publish_payload({"foo": "bar"}) + + pubserv.pub_server.publish_payload.assert_awaited_once_with( + {"foo": "bar"}, None, raw_payload=None + ) + + +async def test_tcp_puller_handle_stream_passes_raw_payload_kwarg(master_opts): + """ + ``TCPPuller.handle_stream`` reads the length-prefixed frame with + ``raw=True`` (dict keys are bytes) and passes the original wire + bytes as ``raw_payload=payload`` to the handler. Verify the handler + receives both ``body`` and ``raw_payload=``. + """ + import struct + + received = [] + + async def handler(body, raw_payload=None): + received.append((body, raw_payload)) + + puller = salt.transport.tcp.TCPPuller(payload_handler=handler) + + def _frame(body): + payload = salt.utils.msgpack.packb({"body": body}, use_bin_type=True) + return struct.pack(">I", len(payload)) + payload, payload + + frame_bytes, raw_wire = _frame(b"hello-world") + + class FakeStream: + def __init__(self, chunks): + self._buf = b"".join(chunks) + self._closed = False + + async def read_bytes(self, n): + if len(self._buf) < n: + self._closed = True + raise tornado.iostream.StreamClosedError() + chunk, self._buf = self._buf[:n], self._buf[n:] + return chunk + + def closed(self): + return self._closed + + stream = FakeStream([frame_bytes]) + await asyncio.wait_for(puller.handle_stream(stream), timeout=5) + + assert len(received) == 1 + body, raw = received[0] + # ``raw=True`` unpack keeps bytes keys/values, so ``body`` is bytes. + assert body == b"hello-world" + # The original wire bytes (msgpack of the framed dict, no length + # prefix) are what we handed off as ``raw_payload``. + assert raw == raw_wire + + +async def test_tcp_puller_handle_stream_typeerror_fallback(master_opts): + """ + Older payload handlers only accept ``(body,)`` and raise + ``TypeError`` when called with ``raw_payload=...``. The reader must + catch that ``TypeError`` and retry without the kwarg so pre-#70052 + handlers keep working. + """ + import struct + + call_log = [] + + async def async_handler_no_raw(body): + # This is the successful path. + call_log.append(("handled", body)) + + def wrapping_handler(body, *, raw_payload=None): + # First call: raises TypeError, mimicking a handler whose + # signature doesn't accept ``raw_payload``. The reader is + # expected to fall back to ``payload_handler(body)`` (a fresh + # call), which returns the coroutine we await. + call_log.append(("raw-call", raw_payload is not None)) + raise TypeError("handler does not accept raw_payload") + + # Combine into one callable so the reader's first call raises and + # the second call succeeds. + calls = {"count": 0} + + def payload_handler(*args, **kwargs): + calls["count"] += 1 + if calls["count"] == 1: + # First invocation: kwarg present -> raise TypeError. + call_log.append(("raw-call", "raw_payload" in kwargs)) + raise TypeError("handler does not accept raw_payload") + # Second invocation: positional only -> return an awaitable. + return async_handler_no_raw(*args) + + puller = salt.transport.tcp.TCPPuller(payload_handler=payload_handler) + + def _frame(body): + payload = salt.utils.msgpack.packb({"body": body}, use_bin_type=True) + return struct.pack(">I", len(payload)) + payload + + class FakeStream: + def __init__(self, chunks): + self._buf = b"".join(chunks) + self._closed = False + + async def read_bytes(self, n): + if len(self._buf) < n: + self._closed = True + raise tornado.iostream.StreamClosedError() + chunk, self._buf = self._buf[:n], self._buf[n:] + return chunk + + def closed(self): + return self._closed + + stream = FakeStream([_frame(b"fallback-body")]) + await asyncio.wait_for(puller.handle_stream(stream), timeout=5) + + # Two calls total: one that raised TypeError, one that succeeded. + assert calls["count"] == 2 + assert call_log == [ + ("raw-call", True), + ("handled", b"fallback-body"), + ] + + +async def test_tcp_puller_handle_stream_unpacks_with_raw_true(master_opts): + """ + The outer-frame unpack now uses ``raw=True`` so dict keys are bytes + (``framed_msg[b"body"]``). A message whose ``body`` value contains + non-ASCII bytes must still be routed correctly through + ``payload_handler`` -- proves the ``raw=True`` switch didn't break + ``body`` extraction. + """ + import struct + + received = [] + + async def handler(body, raw_payload=None): + received.append(body) + + puller = salt.transport.tcp.TCPPuller(payload_handler=handler) + + # Non-ASCII body to exercise ``raw=True`` bytes handling. + body = b"\x81\xa3foo\xa3bar" + payload = salt.utils.msgpack.packb({"body": body}, use_bin_type=True) + frame = struct.pack(">I", len(payload)) + payload + + class FakeStream: + def __init__(self, chunks): + self._buf = b"".join(chunks) + self._closed = False + + async def read_bytes(self, n): + if len(self._buf) < n: + self._closed = True + raise tornado.iostream.StreamClosedError() + chunk, self._buf = self._buf[:n], self._buf[n:] + return chunk + + def closed(self): + return self._closed + + stream = FakeStream([frame]) + await asyncio.wait_for(puller.handle_stream(stream), timeout=5) + + assert received == [body] From a20cbf5204d7eab6c63b3607af677b23d3971554 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 11 Jul 2026 23:58:44 -0400 Subject: [PATCH 300/469] Fix crash/mutation/log bugs in the shared napalm utils and proxy - get_device_opts: optional_args explicitly set to null yielded None (the get default only applies to a missing key), crashing the "config_lock" membership test; and a present optional_args dict was mutated in place, leaking the config_lock / keepalive defaults into the caller's opts/pillar. Use copy.deepcopy(device_dict.get("optional_args") or {}). - proxy_napalm_wrap: force_reconnect did opts["proxy"].update(**kwargs) unconditionally, raising KeyError on a straight (non-proxy) minion which has no 'proxy' key. That merge is only for the always-alive proxy path; a straight minion picks the override up from clean_kwargs, so guard it with is_proxy(). - proxy.shutdown: a trailing comma made 'port' a 1-tuple, so a failed close() logged ':(830,)' / ':(None,)'. Remove it. --- salt/proxy/napalm.py | 2 +- salt/utils/napalm.py | 23 ++++++++--- tests/pytests/unit/proxy/test_napalm.py | 27 +++++++++++++ tests/pytests/unit/utils/test_napalm.py | 51 +++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/salt/proxy/napalm.py b/salt/proxy/napalm.py index 13f2663cf0ac..6d45e92ccd37 100644 --- a/salt/proxy/napalm.py +++ b/salt/proxy/napalm.py @@ -292,7 +292,7 @@ def shutdown(opts): port = ( __context__["napalm_device"]["network_device"] .get("OPTIONAL_ARGS", {}) - .get("port"), + .get("port") ) log.error( "Cannot close connection with %s%s! Please check error: %s", diff --git a/salt/utils/napalm.py b/salt/utils/napalm.py index cc75bd547196..f1cdd335f5fb 100644 --- a/salt/utils/napalm.py +++ b/salt/utils/napalm.py @@ -354,7 +354,13 @@ def get_device_opts(opts, salt_obj=None): or "" ) network_device["TIMEOUT"] = device_dict.get("timeout", 60) - network_device["OPTIONAL_ARGS"] = device_dict.get("optional_args", {}) + # ``or {}`` (not a ``get`` default) so an explicit ``optional_args: null`` in + # the config yields a dict rather than None; deepcopy so the config_lock / + # keepalive injected below are not written back into the caller's live + # opts / pillar structure. + network_device["OPTIONAL_ARGS"] = copy.deepcopy( + device_dict.get("optional_args") or {} + ) network_device["ALWAYS_ALIVE"] = device_dict.get("always_alive", True) network_device["PROVIDER"] = device_dict.get("provider") network_device["UP"] = False @@ -445,11 +451,16 @@ def func_wrapper(*args, **kwargs): force_reconnect = kwargs.get("force_reconnect", False) if force_reconnect: log.debug("Usage of reconnect force detected") - log.debug("Opts before merging") - log.debug(opts["proxy"]) - opts["proxy"].update(**kwargs) - log.debug("Opts after merging") - log.debug(opts["proxy"]) + # The credential override is merged into opts['proxy'] for the + # always-alive proxy path below. A straight minion has no 'proxy' + # key (this raised KeyError) and picks the override up from + # clean_kwargs further down, so only touch opts['proxy'] for a proxy. + if is_proxy(opts): + log.debug("Opts before merging") + log.debug(opts["proxy"]) + opts["proxy"].update(**kwargs) + log.debug("Opts after merging") + log.debug(opts["proxy"]) if is_proxy(opts) and always_alive: # if it is running in a NAPALM Proxy and it's using the default # always alive behaviour, will get the cached copy of the network diff --git a/tests/pytests/unit/proxy/test_napalm.py b/tests/pytests/unit/proxy/test_napalm.py index dbf108b69774..81a1e20dfe55 100644 --- a/tests/pytests/unit/proxy/test_napalm.py +++ b/tests/pytests/unit/proxy/test_napalm.py @@ -311,3 +311,30 @@ def test_call(test_opts): with patch.dict(napalm_proxy.__context__, mock_context): ret = napalm_proxy.call("get_arp_table") assert ret == {"result": False, "comment": "Not initialised yet", "out": None} + + +def _shutdown_port_log(optional_args): + """Run shutdown() with a failing close() and return the port fragment that + was passed to the error log (index 2 of the log.error args).""" + driver = MagicMock() + driver.close.side_effect = Exception("boom") + network_device = { + "DRIVER": driver, + "UP": True, + "HOSTNAME": "core05.nrt02", + "OPTIONAL_ARGS": optional_args, + } + mock_context = {"napalm_device": {"network_device": network_device}} + with patch.dict(napalm_proxy.__context__, mock_context), patch( + "salt.proxy.napalm.log" + ) as mock_log: + ret = napalm_proxy.shutdown({}) + assert ret is True + return mock_log.error.call_args[0][2] + + +def test_shutdown_logs_scalar_port_on_close_failure(): + # The trailing comma made ``port`` a 1-tuple, so the error log rendered + # ``:(830,)`` / ``:(None,)`` instead of ``:830`` / ``""``. + assert _shutdown_port_log({"port": 830}) == ":830" + assert _shutdown_port_log({}) == "" diff --git a/tests/pytests/unit/utils/test_napalm.py b/tests/pytests/unit/utils/test_napalm.py index 37640a0cffe2..fc315c4d05f6 100644 --- a/tests/pytests/unit/utils/test_napalm.py +++ b/tests/pytests/unit/utils/test_napalm.py @@ -2,6 +2,8 @@ Unit tests for salt.utils.napalm helpers. """ +import types + import salt.utils.napalm as napalm_utils from tests.support.mock import MagicMock, patch @@ -161,3 +163,52 @@ def test_template_not_available_leaves_always_alive_open(): {"DRIVER": driver, "__opts__": {"id": "sw01"}, "CLOSE": False}, ) driver.close.assert_not_called() + + +def test_get_device_opts_null_optional_args(): + # ``optional_args: null`` in the config yields None from ``.get(..., {})`` + # (the default only applies to a missing key), which then crashed the + # ``"config_lock" not in ...`` membership test. + opts = {"napalm": {"driver": "junos", "optional_args": None}} + device = napalm_utils.get_device_opts(opts) + assert isinstance(device["OPTIONAL_ARGS"], dict) + assert device["OPTIONAL_ARGS"]["config_lock"] is False + + +def test_get_device_opts_does_not_mutate_caller(): + # The injected config_lock / keepalive must land in a copy, not in the + # caller's live opts / pillar ``optional_args`` dict. + optional_args = {"port": 830} + opts = {"napalm": {"driver": "junos", "optional_args": optional_args}} + napalm_utils.get_device_opts(opts) + assert optional_args == {"port": 830} + + +def _wrapped_with_opts(opts): + """A ``proxy_napalm_wrap``-decorated function whose module globals carry the + given ``__opts__`` (so we can drive the wrapper without a real minion).""" + + def _fn(*args, **kwargs): + return "ok" + + func_globals = { + "__opts__": opts, + "__proxy__": {}, + "__salt__": {"config.get": MagicMock(return_value={"driver": "junos"})}, + } + fn = types.FunctionType(_fn.__code__, func_globals, "_fn") + return napalm_utils.proxy_napalm_wrap(fn) + + +def test_proxy_napalm_wrap_force_reconnect_straight_minion(): + # force_reconnect on a straight (non-proxy) minion has no ``opts['proxy']``; + # the wrapper must not blindly do ``opts['proxy'].update(...)`` (KeyError). + # The override reaches the device through clean_kwargs on the straight path. + opts = {"napalm": {"driver": "junos"}} + wrapped = _wrapped_with_opts(opts) + get_device = MagicMock(return_value={"DRIVER": MagicMock()}) + with patch("salt.utils.napalm.get_device", get_device): + result = wrapped(force_reconnect=True) + assert result == "ok" + get_device.assert_called_once() + assert get_device.call_args[0][0]["napalm"].get("force_reconnect") is True From 8e05b7a602ddb90df0c4b6399945d188b431c4c5 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 11 Jul 2026 23:58:59 -0400 Subject: [PATCH 301/469] Add changelog for #69796 --- changelog/69796.fixed.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog/69796.fixed.md diff --git a/changelog/69796.fixed.md b/changelog/69796.fixed.md new file mode 100644 index 000000000000..c4d42344b104 --- /dev/null +++ b/changelog/69796.fixed.md @@ -0,0 +1,5 @@ +Fixed three bugs in the shared NAPALM support code. ``salt.utils.napalm.get_device_opts`` +no longer crashes on ``optional_args: null`` and no longer mutates the caller's +opts/pillar; ``force_reconnect`` no longer raises ``KeyError: 'proxy'`` on a +straight (non-proxy) NAPALM minion; and the NAPALM proxy's shutdown error log no +longer renders the port as a tuple. From 77e8378b5feedcd9e6949748af9523c0b2ed15bd Mon Sep 17 00:00:00 2001 From: jeanluc Date: Sat, 15 Aug 2026 12:28:14 +0200 Subject: [PATCH 302/469] Fix functional tests after pathlen fix --- tests/pytests/functional/modules/test_x509_v2.py | 2 +- tests/pytests/functional/states/test_x509_v2.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/pytests/functional/modules/test_x509_v2.py b/tests/pytests/functional/modules/test_x509_v2.py index 394b34987e02..f36d06d51d9f 100644 --- a/tests/pytests/functional/modules/test_x509_v2.py +++ b/tests/pytests/functional/modules/test_x509_v2.py @@ -843,7 +843,7 @@ def test_create_certificate_with_ca_cert_needs_any_pubkey_source(x509, ca_key, c def test_create_certificate_with_extensions(x509, ca_key, ca_cert, rsa_privkey): extensions = { - "basicConstraints": "critical, CA:TRUE, pathlen:1", + "basicConstraints": "critical, CA:TRUE, pathlen:0", "keyUsage": "critical, cRLSign, keyCertSign", "extendedKeyUsage": "OCSPSigning", "subjectKeyIdentifier": "hash", diff --git a/tests/pytests/functional/states/test_x509_v2.py b/tests/pytests/functional/states/test_x509_v2.py index b0cb774ec377..d486ed040dfe 100644 --- a/tests/pytests/functional/states/test_x509_v2.py +++ b/tests/pytests/functional/states/test_x509_v2.py @@ -498,7 +498,7 @@ def cert_args(tmp_path, ca_cert_file, ca_key_file): @pytest.fixture def cert_args_exts(): return { - "basicConstraints": "critical, CA:TRUE, pathlen:1", + "basicConstraints": "critical, CA:TRUE, pathlen:0", "keyUsage": "critical, cRLSign, keyCertSign", "extendedKeyUsage": "OCSPSigning", "subjectKeyIdentifier": "hash", @@ -1433,19 +1433,19 @@ def test_pkcs12_friendlyname_change(x509, cert_args, ca_cert, ca_key, rsa_privke @pytest.mark.usefixtures("existing_cert") def test_certificate_managed_extension_added(x509, cert_args, rsa_privkey, ca_key): - cert_args["basicConstraints"] = "critical, CA:TRUE, pathlen:1" + cert_args["basicConstraints"] = "critical, CA:TRUE, pathlen:0" ret = x509.certificate_managed(**cert_args) cert = _assert_cert_basic(ret, cert_args["name"], rsa_privkey, ca_key) assert "extensions" in ret.changes assert ret.changes["extensions"]["added"] == ["basicConstraints"] assert cert.extensions[0].critical assert cert.extensions[0].value.ca - assert cert.extensions[0].value.path_length + assert cert.extensions[0].value.path_length == 0 @pytest.mark.usefixtures("existing_cert_exts") def test_certificate_managed_extension_changed(x509, cert_args, rsa_privkey, ca_key): - cert_args["basicConstraints"] = "critical, CA:TRUE, pathlen:2" + cert_args["basicConstraints"] = "critical, CA:FALSE" cert_args["subjectAltName"] = "DNS:sub.salt.ca,email:subnew@salt.ca" ret = x509.certificate_managed(**cert_args) cert = _assert_cert_basic(ret, cert_args["name"], rsa_privkey, ca_key) @@ -1456,8 +1456,7 @@ def test_certificate_managed_extension_changed(x509, cert_args, rsa_privkey, ca_ } bc = cert.extensions.get_extension_for_class(cx509.BasicConstraints) assert bc.critical - assert bc.value.ca - assert bc.value.path_length == 2 + assert bc.value.ca is False @pytest.mark.usefixtures("existing_cert_exts") @@ -2776,7 +2775,7 @@ def test_certificate_managed_warns_about_long_name_attributes( def test_certificate_managed_warns_about_long_extensions(x509, cert_args, rsa_privkey): - cert_args["X509v3 Basic Constraints"] = "critical CA:TRUE, pathlen:1" + cert_args["X509v3 Basic Constraints"] = "critical CA:TRUE, pathlen:0" cert_args["days_valid"] = 30 cert_args["days_remaining"] = 7 cert_args["private_key"] = rsa_privkey @@ -2788,7 +2787,7 @@ def test_certificate_managed_warns_about_long_extensions(x509, cert_args, rsa_pr assert isinstance(cert.extensions[0].value, cx509.BasicConstraints) assert cert.extensions[0].critical assert cert.extensions[0].value.ca - assert cert.extensions[0].value.path_length == 1 + assert cert.extensions[0].value.path_length == 0 @pytest.mark.parametrize("arg", [{"version": 1}, {"text": True}]) From f5b288020e71b16ddb5cc4d5d087080dac12c8d9 Mon Sep 17 00:00:00 2001 From: twangboy Date: Tue, 11 Aug 2026 15:00:47 -0600 Subject: [PATCH 303/469] Widen retry window for salt-minion service re-registration race The NSIS stress tests still hit intermittent installer Abort (exit code 2) after the previous retry fix, on both 3006.x and 3007.x. Timing recovered from the CI logs shows the SCM held the salt-minion service key for 25s+ before the uninstall side's own wait_svc_deleted loop and the test harness's post-uninstall wait both gave up, leaving only ~10s of retry budget on the install side before it aborted -- not enough headroom for the observed delay. Wait for the salt-minion service registry key to disappear before even attempting "ssm install" (CreateService), instead of only reacting after CreateService fails. This is a no-op on a normal install, since the key was never present. Also widen the existing retry budgets: the install-side CreateService retry from 5x2s to 10x2s, and the uninstall-side wait_svc_deleted from 10s to 15s. Add diagnostics so future occurrences don't require a fresh repro: print the tail of the relevant %TEMP%\SaltInstaller\*.log directly into the pytest failure output on any non-zero exit or timeout, and upload the full log directory as a CI artifact from both the Logic Tests and Stress Tests jobs. --- .github/workflows/nsis-tests.yml | 36 +++++++++++++++++ .../nsis/installer/Salt-Minion-Setup.nsi | 39 +++++++++++++++---- pkg/windows/nsis/tests/conftest.py | 37 ++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/.github/workflows/nsis-tests.yml b/.github/workflows/nsis-tests.yml index 5fecd1e6bc94..cd4fe306b0fb 100644 --- a/.github/workflows/nsis-tests.yml +++ b/.github/workflows/nsis-tests.yml @@ -47,6 +47,24 @@ jobs: run: .\pkg\windows\nsis\tests\test.cmd -CICD .\config_tests shell: cmd + - name: Collect NSIS Installer Logs + if: always() + shell: pwsh + run: | + $dest = "$env:GITHUB_WORKSPACE\nsis-install-logs" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + if (Test-Path "$env:TEMP\SaltInstaller") { + Copy-Item "$env:TEMP\SaltInstaller\*" $dest -Recurse -Force + } + + - name: Upload NSIS Installer Logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: nsis-logic-test-logs + path: nsis-install-logs + if-no-files-found: ignore + Test-NSIS-Stress: name: Stress Tests runs-on: @@ -74,3 +92,21 @@ jobs: - name: Run Stress Test run: .\pkg\windows\nsis\tests\test.cmd -CICD .\stress_tests shell: cmd + + - name: Collect NSIS Installer Logs + if: always() + shell: pwsh + run: | + $dest = "$env:GITHUB_WORKSPACE\nsis-install-logs" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + if (Test-Path "$env:TEMP\SaltInstaller") { + Copy-Item "$env:TEMP\SaltInstaller\*" $dest -Recurse -Force + } + + - name: Upload NSIS Installer Logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: nsis-stress-test-logs + path: nsis-install-logs + if-no-files-found: ignore diff --git a/pkg/windows/nsis/installer/Salt-Minion-Setup.nsi b/pkg/windows/nsis/installer/Salt-Minion-Setup.nsi index 67cf43e3904b..5f066bdbd35c 100644 --- a/pkg/windows/nsis/installer/Salt-Minion-Setup.nsi +++ b/pkg/windows/nsis/installer/Salt-Minion-Setup.nsi @@ -1090,9 +1090,34 @@ Section -Post # races that pending delete and fails, which used to Abort the install # (NSIS error level 2 -- the intermittent installer failure). # - # The condition is self-clearing within a second or two once the handles - # close, so retry a handful of times before giving up rather than aborting - # on the first failure. + # CI stress runs have measured this pending-delete window taking well + # over 10 seconds under load -- both the uninstaller's own + # wait_svc_deleted loop and the test harness's post-uninstall wait have + # been observed to exhaust their budgets while the service key was still + # present, which then burned through the retry loop below and Aborted. + # So, before even attempting CreateService, wait for the service registry + # key to disappear. This costs 0s on a normal install (the key was never + # present, so ReadRegDWORD errors immediately) and only spends time in + # the race case this is meant to cover. + ${LogMsg} "Checking for a pending salt-minion service deletion" + StrCpy $R0 0 + wait_svc_deleted_before_install: + ClearErrors + ReadRegDWORD $R1 HKLM "SYSTEM\CurrentControlSet\Services\salt-minion" "Type" + ${If} ${Errors} + ${LogMsg} "No pending service deletion detected" + ${ElseIf} $R0 < 30 + IntOp $R0 $R0 + 1 + Sleep 500 + Goto wait_svc_deleted_before_install + ${Else} + ${LogMsg} "Service key still present after 15s -- proceeding anyway" + ${EndIf} + + # The condition is also self-clearing within a couple of seconds once the + # SCM finishes closing out the old service's handles, so retry + # CreateService itself a number of times before giving up rather than + # aborting on the first failure. ${LogMsg} "Registering the salt-minion service" StrCpy $SvcInstallTries 0 retry_svc_install: @@ -1100,10 +1125,10 @@ Section -Post pop $0 # ExitCode pop $1 # StdOut ${If} $0 != 0 - ${AndIf} $SvcInstallTries < 5 + ${AndIf} $SvcInstallTries < 10 IntOp $SvcInstallTries $SvcInstallTries + 1 ${LogMsg} "Service registration failed (ExitCode: $0). \ - Retry $SvcInstallTries/5 in 2s (SCM delete may still be pending)" + Retry $SvcInstallTries/10 in 2s (SCM delete may still be pending)" ${LogMsg} "StdOut: $1" Sleep 2000 Goto retry_svc_install @@ -1362,12 +1387,12 @@ Function ${un}uninstallSalt ReadRegDWORD $R1 HKLM "SYSTEM\CurrentControlSet\Services\salt-minion" "Type" ${If} ${Errors} ${LogMsg} "Service key removed" - ${ElseIf} $R0 < 20 + ${ElseIf} $R0 < 30 IntOp $R0 $R0 + 1 Sleep 500 Goto wait_svc_deleted ${Else} - ${LogMsg} "Service key still present after 10s — continuing anyway" + ${LogMsg} "Service key still present after 15s — continuing anyway" ${EndIf} ${Else} diff --git a/pkg/windows/nsis/tests/conftest.py b/pkg/windows/nsis/tests/conftest.py index b404ec2402ae..c37488b72bfe 100644 --- a/pkg/windows/nsis/tests/conftest.py +++ b/pkg/windows/nsis/tests/conftest.py @@ -349,6 +349,41 @@ def install_salt(args): pass +SALT_INSTALLER_LOG_DIR = os.path.join(os.environ.get("TEMP", ""), "SaltInstaller") + + +def _print_latest_installer_log(tail_lines=100): + """ + Print the tail of the most recently modified NSIS install/uninstall log. + + ${LogMsg} in the NSIS script writes a timestamped log for every + install/uninstall run to %TEMP%\\SaltInstaller. Surfacing the tail here + means a failure (e.g. an Abort during service registration) shows the + NSIS-side detail -- including ssm.exe's exit code and stdout -- directly + in the pytest failure output, without needing to dig through CI + artifacts or reproduce locally. + """ + if not os.path.isdir(SALT_INSTALLER_LOG_DIR): + return + try: + logs = [ + os.path.join(SALT_INSTALLER_LOG_DIR, name) + for name in os.listdir(SALT_INSTALLER_LOG_DIR) + if name.endswith(".log") + ] + if not logs: + return + latest = max(logs, key=os.path.getmtime) + with open(latest, encoding="utf-8", errors="replace") as fp: + lines = fp.readlines() + print(f"\n----- tail of {latest} -----") + for line in lines[-tail_lines:]: + print(line.rstrip()) + print("----- end of log -----") + except OSError as exc: + print(f"\nWARNING: could not read installer log: {exc}") + + def is_file_locked(path): """ Try to see if a file is locked @@ -418,6 +453,7 @@ def run_command(cmd_args, timeout=60): print( f"\nWARNING: process exited with code {proc.returncode}: {cmd_args[:120]}" ) + _print_latest_installer_log() return False return True except subprocess.TimeoutExpired: @@ -426,5 +462,6 @@ def run_command(cmd_args, timeout=60): print( f"\nWARNING: process timed out after {timeout}s — force-killing: {cmd_args[:120]}" ) + _print_latest_installer_log() _kill_process_tree(proc) return False From bf939d384b6ed77d2c1ba79974f7bd8f2f2bc230 Mon Sep 17 00:00:00 2001 From: twangboy Date: Wed, 12 Aug 2026 10:54:14 -0600 Subject: [PATCH 304/469] Update GitPython to 3.1.59 and pyasn1 to 0.6.4 --- requirements/base.txt | 4 ++-- requirements/static/ci/py3.10/cloud.lock | 4 ++-- requirements/static/ci/py3.10/darwin.lock | 4 ++-- requirements/static/ci/py3.10/docs.lock | 4 ++-- requirements/static/ci/py3.10/freebsd.lock | 4 ++-- requirements/static/ci/py3.10/lint.lock | 4 ++-- requirements/static/ci/py3.10/linux.lock | 4 ++-- requirements/static/ci/py3.10/windows.lock | 4 ++-- requirements/static/ci/py3.11/cloud.lock | 4 ++-- requirements/static/ci/py3.11/darwin.lock | 4 ++-- requirements/static/ci/py3.11/docs.lock | 4 ++-- requirements/static/ci/py3.11/freebsd.lock | 4 ++-- requirements/static/ci/py3.11/lint.lock | 4 ++-- requirements/static/ci/py3.11/linux.lock | 4 ++-- requirements/static/ci/py3.11/windows.lock | 4 ++-- requirements/static/ci/py3.12/cloud.lock | 4 ++-- requirements/static/ci/py3.12/darwin.lock | 4 ++-- requirements/static/ci/py3.12/docs.lock | 4 ++-- requirements/static/ci/py3.12/freebsd.lock | 4 ++-- requirements/static/ci/py3.12/lint.lock | 4 ++-- requirements/static/ci/py3.12/linux.lock | 4 ++-- requirements/static/ci/py3.12/windows.lock | 4 ++-- requirements/static/ci/py3.13/cloud.lock | 4 ++-- requirements/static/ci/py3.13/darwin.lock | 4 ++-- requirements/static/ci/py3.13/docs.lock | 4 ++-- requirements/static/ci/py3.13/freebsd.lock | 4 ++-- requirements/static/ci/py3.13/lint.lock | 4 ++-- requirements/static/ci/py3.13/linux.lock | 4 ++-- requirements/static/ci/py3.13/windows.lock | 4 ++-- requirements/static/ci/py3.14/cloud.lock | 4 ++-- requirements/static/ci/py3.14/darwin.lock | 4 ++-- requirements/static/ci/py3.14/docs.lock | 4 ++-- requirements/static/ci/py3.14/freebsd.lock | 4 ++-- requirements/static/ci/py3.14/lint.lock | 4 ++-- requirements/static/ci/py3.14/linux.lock | 4 ++-- requirements/static/ci/py3.14/windows.lock | 4 ++-- requirements/static/ci/py3.9/cloud.lock | 4 ++-- requirements/static/ci/py3.9/darwin.lock | 4 ++-- requirements/static/ci/py3.9/docs.lock | 4 ++-- requirements/static/ci/py3.9/freebsd.lock | 4 ++-- requirements/static/ci/py3.9/lint.lock | 4 ++-- requirements/static/ci/py3.9/linux.lock | 4 ++-- requirements/static/ci/py3.9/windows.lock | 4 ++-- requirements/static/pkg/py3.10/darwin.lock | 4 ++-- requirements/static/pkg/py3.10/freebsd.lock | 4 ++-- requirements/static/pkg/py3.10/linux.lock | 4 ++-- requirements/static/pkg/py3.10/windows.lock | 4 ++-- requirements/static/pkg/py3.11/darwin.lock | 4 ++-- requirements/static/pkg/py3.11/freebsd.lock | 4 ++-- requirements/static/pkg/py3.11/linux.lock | 4 ++-- requirements/static/pkg/py3.11/windows.lock | 4 ++-- requirements/static/pkg/py3.12/darwin.lock | 4 ++-- requirements/static/pkg/py3.12/freebsd.lock | 4 ++-- requirements/static/pkg/py3.12/linux.lock | 4 ++-- requirements/static/pkg/py3.12/windows.lock | 4 ++-- requirements/static/pkg/py3.13/darwin.lock | 4 ++-- requirements/static/pkg/py3.13/freebsd.lock | 4 ++-- requirements/static/pkg/py3.13/linux.lock | 4 ++-- requirements/static/pkg/py3.13/windows.lock | 4 ++-- requirements/static/pkg/py3.14/darwin.lock | 4 ++-- requirements/static/pkg/py3.14/freebsd.lock | 4 ++-- requirements/static/pkg/py3.14/linux.lock | 4 ++-- requirements/static/pkg/py3.14/windows.lock | 4 ++-- requirements/static/pkg/py3.9/darwin.lock | 4 ++-- requirements/static/pkg/py3.9/freebsd.lock | 4 ++-- requirements/static/pkg/py3.9/linux.lock | 4 ++-- requirements/static/pkg/py3.9/windows.lock | 4 ++-- 67 files changed, 134 insertions(+), 134 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index a2c017a48e77..987a2ffd9c3a 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -21,7 +21,7 @@ cryptography>=46.0.7,<48.0.0 distro>=1.9.0 frozenlist>=1.8.0; python_version < '3.11' frozenlist>=1.5.0; python_version >= '3.11' -gitpython>=3.1.50 +gitpython>=3.1.59 immutables>=0.21 importlib-metadata>=8.7.0 # jaraco.functools 4.5.0 and jaraco.context 6.1.2 drop Python 3.9; keep the @@ -98,7 +98,7 @@ idna>=3.18 # more-itertools 11.0.0 drops Python 3.9; keep the last 3.9-compatible release there. more-itertools>=10.8.0,<11.0.0; python_version < '3.10' more-itertools>=10.8.0; python_version >= '3.10' -pyasn1>=0.6.3 +pyasn1>=0.6.4 # pycparser 3.0 drops Python 3.9; keep the last 3.9-compatible release there. pycparser>=2.23,<3.0; python_version < '3.10' pycparser>=2.23; python_version >= '3.10' diff --git a/requirements/static/ci/py3.10/cloud.lock b/requirements/static/ci/py3.10/cloud.lock index 33d5a620323e..f9887c366cb1 100644 --- a/requirements/static/ci/py3.10/cloud.lock +++ b/requirements/static/ci/py3.10/cloud.lock @@ -213,7 +213,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock @@ -463,7 +463,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/darwin.lock b/requirements/static/ci/py3.10/darwin.lock index dde00d48ca08..4b8a28002773 100644 --- a/requirements/static/ci/py3.10/darwin.lock +++ b/requirements/static/ci/py3.10/darwin.lock @@ -158,7 +158,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.10/darwin.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.10/darwin.lock # -r requirements/base.txt @@ -334,7 +334,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.10/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.10/docs.lock b/requirements/static/ci/py3.10/docs.lock index b62d2db3dcf4..6261f45fea0c 100644 --- a/requirements/static/ci/py3.10/docs.lock +++ b/requirements/static/ci/py3.10/docs.lock @@ -107,7 +107,7 @@ gitdb==4.0.12 # via # -c requirements/static/ci/py3.10/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt @@ -228,7 +228,7 @@ psutil==7.2.2 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.10/freebsd.lock b/requirements/static/ci/py3.10/freebsd.lock index 92ce590bb32b..7b0f6fc96ec3 100644 --- a/requirements/static/ci/py3.10/freebsd.lock +++ b/requirements/static/ci/py3.10/freebsd.lock @@ -174,7 +174,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.10/freebsd.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt @@ -374,7 +374,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.10/lint.lock b/requirements/static/ci/py3.10/lint.lock index 6ffa0e0d399e..c2807816efee 100644 --- a/requirements/static/ci/py3.10/lint.lock +++ b/requirements/static/ci/py3.10/lint.lock @@ -224,7 +224,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock @@ -485,7 +485,7 @@ psutil==7.2.2 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/linux.lock b/requirements/static/ci/py3.10/linux.lock index 9bb35d2ef3ba..700d4ec1d9e2 100644 --- a/requirements/static/ci/py3.10/linux.lock +++ b/requirements/static/ci/py3.10/linux.lock @@ -174,7 +174,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.10/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt @@ -370,7 +370,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.10/windows.lock b/requirements/static/ci/py3.10/windows.lock index 24e013dae1b7..c867d80456fe 100644 --- a/requirements/static/ci/py3.10/windows.lock +++ b/requirements/static/ci/py3.10/windows.lock @@ -161,7 +161,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.10/windows.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.10/windows.lock # -r requirements/base.txt @@ -318,7 +318,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.10/windows.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.11/cloud.lock b/requirements/static/ci/py3.11/cloud.lock index 6a07ce72c9aa..ce0a17a299ea 100644 --- a/requirements/static/ci/py3.11/cloud.lock +++ b/requirements/static/ci/py3.11/cloud.lock @@ -200,7 +200,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock @@ -448,7 +448,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/darwin.lock b/requirements/static/ci/py3.11/darwin.lock index 334528087da0..10ba3cbf69a1 100644 --- a/requirements/static/ci/py3.11/darwin.lock +++ b/requirements/static/ci/py3.11/darwin.lock @@ -150,7 +150,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.11/darwin.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.11/darwin.lock # -r requirements/base.txt @@ -329,7 +329,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.11/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.11/docs.lock b/requirements/static/ci/py3.11/docs.lock index 7f076cd2b0bd..d998242a5682 100644 --- a/requirements/static/ci/py3.11/docs.lock +++ b/requirements/static/ci/py3.11/docs.lock @@ -103,7 +103,7 @@ gitdb==4.0.12 # via # -c requirements/static/ci/py3.11/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt @@ -223,7 +223,7 @@ psutil==7.2.2 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.11/freebsd.lock b/requirements/static/ci/py3.11/freebsd.lock index 51d5bdeb7858..bc7ccbcae20a 100644 --- a/requirements/static/ci/py3.11/freebsd.lock +++ b/requirements/static/ci/py3.11/freebsd.lock @@ -171,7 +171,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.11/freebsd.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt @@ -375,7 +375,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.11/lint.lock b/requirements/static/ci/py3.11/lint.lock index 4a9789b5feb5..d60c1e7d16fb 100644 --- a/requirements/static/ci/py3.11/lint.lock +++ b/requirements/static/ci/py3.11/lint.lock @@ -212,7 +212,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock @@ -470,7 +470,7 @@ psutil==7.2.2 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/linux.lock b/requirements/static/ci/py3.11/linux.lock index bcdf1d6bbd2b..36fbc9a67766 100644 --- a/requirements/static/ci/py3.11/linux.lock +++ b/requirements/static/ci/py3.11/linux.lock @@ -164,7 +164,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.11/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt @@ -361,7 +361,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.11/windows.lock b/requirements/static/ci/py3.11/windows.lock index 69c8307dfc05..64657624e674 100644 --- a/requirements/static/ci/py3.11/windows.lock +++ b/requirements/static/ci/py3.11/windows.lock @@ -154,7 +154,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.11/windows.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt @@ -312,7 +312,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.12/cloud.lock b/requirements/static/ci/py3.12/cloud.lock index b71955ee5e21..fcb82dfa5050 100644 --- a/requirements/static/ci/py3.12/cloud.lock +++ b/requirements/static/ci/py3.12/cloud.lock @@ -195,7 +195,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock @@ -444,7 +444,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/darwin.lock b/requirements/static/ci/py3.12/darwin.lock index 1c7e47f828dc..36f1b4fdd0bf 100644 --- a/requirements/static/ci/py3.12/darwin.lock +++ b/requirements/static/ci/py3.12/darwin.lock @@ -146,7 +146,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.12/darwin.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.12/darwin.lock # -r requirements/base.txt @@ -322,7 +322,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.12/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.12/docs.lock b/requirements/static/ci/py3.12/docs.lock index 2c4e4a6f45d2..57d549236b1a 100644 --- a/requirements/static/ci/py3.12/docs.lock +++ b/requirements/static/ci/py3.12/docs.lock @@ -99,7 +99,7 @@ gitdb==4.0.12 # via # -c requirements/static/ci/py3.12/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt @@ -219,7 +219,7 @@ psutil==7.2.2 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.12/freebsd.lock b/requirements/static/ci/py3.12/freebsd.lock index 4a99648fe002..bd95eadcbd1f 100644 --- a/requirements/static/ci/py3.12/freebsd.lock +++ b/requirements/static/ci/py3.12/freebsd.lock @@ -162,7 +162,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.12/freebsd.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt @@ -355,7 +355,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.12/lint.lock b/requirements/static/ci/py3.12/lint.lock index 54a5561827d8..b5d977ba058d 100644 --- a/requirements/static/ci/py3.12/lint.lock +++ b/requirements/static/ci/py3.12/lint.lock @@ -207,7 +207,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock @@ -466,7 +466,7 @@ psutil==7.2.2 # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/linux.lock b/requirements/static/ci/py3.12/linux.lock index c927440073ae..1c888d240246 100644 --- a/requirements/static/ci/py3.12/linux.lock +++ b/requirements/static/ci/py3.12/linux.lock @@ -160,7 +160,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.12/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt @@ -354,7 +354,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.12/windows.lock b/requirements/static/ci/py3.12/windows.lock index 0e33ab92e9b1..e14d87447746 100644 --- a/requirements/static/ci/py3.12/windows.lock +++ b/requirements/static/ci/py3.12/windows.lock @@ -149,7 +149,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.12/windows.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/base.txt @@ -306,7 +306,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.13/cloud.lock b/requirements/static/ci/py3.13/cloud.lock index ee5d113467d4..8ad067de1149 100644 --- a/requirements/static/ci/py3.13/cloud.lock +++ b/requirements/static/ci/py3.13/cloud.lock @@ -196,7 +196,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock @@ -445,7 +445,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/darwin.lock b/requirements/static/ci/py3.13/darwin.lock index ce8ecf85f3a8..ea5a9182ce64 100644 --- a/requirements/static/ci/py3.13/darwin.lock +++ b/requirements/static/ci/py3.13/darwin.lock @@ -147,7 +147,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.13/darwin.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.13/darwin.lock # -r requirements/base.txt @@ -323,7 +323,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.13/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.13/docs.lock b/requirements/static/ci/py3.13/docs.lock index 0d32e50d5b3a..1350dba46c55 100644 --- a/requirements/static/ci/py3.13/docs.lock +++ b/requirements/static/ci/py3.13/docs.lock @@ -99,7 +99,7 @@ gitdb==4.0.12 # via # -c requirements/static/ci/py3.13/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt @@ -219,7 +219,7 @@ psutil==7.2.2 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.13/freebsd.lock b/requirements/static/ci/py3.13/freebsd.lock index 1f6d218b1ec5..e755154411b2 100644 --- a/requirements/static/ci/py3.13/freebsd.lock +++ b/requirements/static/ci/py3.13/freebsd.lock @@ -163,7 +163,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.13/freebsd.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt @@ -356,7 +356,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.13/lint.lock b/requirements/static/ci/py3.13/lint.lock index 933ecfb426d3..aa9fb77e9fc0 100644 --- a/requirements/static/ci/py3.13/lint.lock +++ b/requirements/static/ci/py3.13/lint.lock @@ -207,7 +207,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock @@ -466,7 +466,7 @@ psutil==7.2.2 # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/linux.lock b/requirements/static/ci/py3.13/linux.lock index 4e42ab5eee5d..f9802f1f261d 100644 --- a/requirements/static/ci/py3.13/linux.lock +++ b/requirements/static/ci/py3.13/linux.lock @@ -161,7 +161,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.13/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt @@ -355,7 +355,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.13/windows.lock b/requirements/static/ci/py3.13/windows.lock index 8f2f6972b680..c7ff80469810 100644 --- a/requirements/static/ci/py3.13/windows.lock +++ b/requirements/static/ci/py3.13/windows.lock @@ -150,7 +150,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.13/windows.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt @@ -307,7 +307,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/cloud.lock b/requirements/static/ci/py3.14/cloud.lock index ee4469b94a31..a58a3864af05 100644 --- a/requirements/static/ci/py3.14/cloud.lock +++ b/requirements/static/ci/py3.14/cloud.lock @@ -196,7 +196,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock @@ -445,7 +445,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/darwin.lock b/requirements/static/ci/py3.14/darwin.lock index e4bf8f758b0e..cacf29a6c755 100644 --- a/requirements/static/ci/py3.14/darwin.lock +++ b/requirements/static/ci/py3.14/darwin.lock @@ -147,7 +147,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.14/darwin.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -323,7 +323,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/docs.lock b/requirements/static/ci/py3.14/docs.lock index 759f3a982113..dc13d47e1265 100644 --- a/requirements/static/ci/py3.14/docs.lock +++ b/requirements/static/ci/py3.14/docs.lock @@ -99,7 +99,7 @@ gitdb==4.0.12 # via # -c requirements/static/ci/py3.14/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -219,7 +219,7 @@ psutil==7.2.2 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/freebsd.lock b/requirements/static/ci/py3.14/freebsd.lock index 6f5aac39cc84..05477b62b844 100644 --- a/requirements/static/ci/py3.14/freebsd.lock +++ b/requirements/static/ci/py3.14/freebsd.lock @@ -163,7 +163,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.14/freebsd.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -356,7 +356,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/lint.lock b/requirements/static/ci/py3.14/lint.lock index 12c527729e5a..86042d3b269b 100644 --- a/requirements/static/ci/py3.14/lint.lock +++ b/requirements/static/ci/py3.14/lint.lock @@ -207,7 +207,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock @@ -467,7 +467,7 @@ psutil==7.2.2 # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/linux.lock b/requirements/static/ci/py3.14/linux.lock index 3d8d7df0e840..930e3d202220 100644 --- a/requirements/static/ci/py3.14/linux.lock +++ b/requirements/static/ci/py3.14/linux.lock @@ -161,7 +161,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.14/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt @@ -357,7 +357,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.14/windows.lock b/requirements/static/ci/py3.14/windows.lock index b9b032440daa..9ac747d2a783 100644 --- a/requirements/static/ci/py3.14/windows.lock +++ b/requirements/static/ci/py3.14/windows.lock @@ -150,7 +150,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.14/windows.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -307,7 +307,7 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.9/cloud.lock b/requirements/static/ci/py3.9/cloud.lock index eaaff7b1be2c..bca7ff52e7ab 100644 --- a/requirements/static/ci/py3.9/cloud.lock +++ b/requirements/static/ci/py3.9/cloud.lock @@ -218,7 +218,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock @@ -501,7 +501,7 @@ psutil==5.9.8 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock diff --git a/requirements/static/ci/py3.9/darwin.lock b/requirements/static/ci/py3.9/darwin.lock index 6668eff0b93d..c32141f0c3ce 100644 --- a/requirements/static/ci/py3.9/darwin.lock +++ b/requirements/static/ci/py3.9/darwin.lock @@ -161,7 +161,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.9/darwin.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.9/darwin.lock # -r requirements/base.txt @@ -363,7 +363,7 @@ psutil==5.9.8 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.9/darwin.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.9/docs.lock b/requirements/static/ci/py3.9/docs.lock index 86d478e82cfb..cba4006337ed 100644 --- a/requirements/static/ci/py3.9/docs.lock +++ b/requirements/static/ci/py3.9/docs.lock @@ -111,7 +111,7 @@ gitdb==4.0.12 # via # -c requirements/static/ci/py3.9/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt @@ -233,7 +233,7 @@ psutil==5.9.8 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.9/freebsd.lock b/requirements/static/ci/py3.9/freebsd.lock index a66441252d90..362d29f335ed 100644 --- a/requirements/static/ci/py3.9/freebsd.lock +++ b/requirements/static/ci/py3.9/freebsd.lock @@ -213,7 +213,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.9/freebsd.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt @@ -492,7 +492,7 @@ psutil==7.2.2 ; python_full_version >= '3.10' # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.9/lint.lock b/requirements/static/ci/py3.9/lint.lock index 513db7ad841d..bbc791c16721 100644 --- a/requirements/static/ci/py3.9/lint.lock +++ b/requirements/static/ci/py3.9/lint.lock @@ -220,7 +220,7 @@ gitdb==4.0.12 # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock @@ -512,7 +512,7 @@ psutil==5.9.8 # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock diff --git a/requirements/static/ci/py3.9/linux.lock b/requirements/static/ci/py3.9/linux.lock index efd6ad696a30..4570389abb12 100644 --- a/requirements/static/ci/py3.9/linux.lock +++ b/requirements/static/ci/py3.9/linux.lock @@ -172,7 +172,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.9/linux.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/base.txt @@ -390,7 +390,7 @@ psutil==5.9.8 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/base.txt diff --git a/requirements/static/ci/py3.9/windows.lock b/requirements/static/ci/py3.9/windows.lock index 556aaeb165cf..e063542a5353 100644 --- a/requirements/static/ci/py3.9/windows.lock +++ b/requirements/static/ci/py3.9/windows.lock @@ -159,7 +159,7 @@ gitdb==4.0.12 # via # -c requirements/static/pkg/py3.9/windows.lock # gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via # -c requirements/static/pkg/py3.9/windows.lock # -r requirements/base.txt @@ -322,7 +322,7 @@ psutil==5.9.8 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -pyasn1==0.6.3 +pyasn1==0.6.4 # via # -c requirements/static/pkg/py3.9/windows.lock # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.10/darwin.lock b/requirements/static/pkg/py3.10/darwin.lock index 098b68a41b5d..c3f9cba460ae 100644 --- a/requirements/static/pkg/py3.10/darwin.lock +++ b/requirements/static/pkg/py3.10/darwin.lock @@ -56,7 +56,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -129,7 +129,7 @@ propcache==0.3.2 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.10/freebsd.lock b/requirements/static/pkg/py3.10/freebsd.lock index 3d4f4c9d477e..2ca5b017f9b9 100644 --- a/requirements/static/pkg/py3.10/freebsd.lock +++ b/requirements/static/pkg/py3.10/freebsd.lock @@ -68,7 +68,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -149,7 +149,7 @@ propcache==0.3.2 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.10/linux.lock b/requirements/static/pkg/py3.10/linux.lock index dd1be4d67a4d..8dd0a3984d51 100644 --- a/requirements/static/pkg/py3.10/linux.lock +++ b/requirements/static/pkg/py3.10/linux.lock @@ -60,7 +60,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -136,7 +136,7 @@ propcache==0.3.2 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.10/windows.lock b/requirements/static/pkg/py3.10/windows.lock index 5f6c351ac50b..df6437e802d1 100644 --- a/requirements/static/pkg/py3.10/windows.lock +++ b/requirements/static/pkg/py3.10/windows.lock @@ -61,7 +61,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -136,7 +136,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.11/darwin.lock b/requirements/static/pkg/py3.11/darwin.lock index 87a292cdfe71..a1b324874de1 100644 --- a/requirements/static/pkg/py3.11/darwin.lock +++ b/requirements/static/pkg/py3.11/darwin.lock @@ -54,7 +54,7 @@ frozenlist==1.7.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -125,7 +125,7 @@ propcache==0.3.2 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.11/freebsd.lock b/requirements/static/pkg/py3.11/freebsd.lock index e1e52a49d649..c9b3f6e97e49 100644 --- a/requirements/static/pkg/py3.11/freebsd.lock +++ b/requirements/static/pkg/py3.11/freebsd.lock @@ -66,7 +66,7 @@ frozenlist==1.7.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -143,7 +143,7 @@ propcache==0.3.2 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.11/linux.lock b/requirements/static/pkg/py3.11/linux.lock index 6703bdac29c6..35de2f26690d 100644 --- a/requirements/static/pkg/py3.11/linux.lock +++ b/requirements/static/pkg/py3.11/linux.lock @@ -58,7 +58,7 @@ frozenlist==1.7.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -132,7 +132,7 @@ propcache==0.3.2 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.11/windows.lock b/requirements/static/pkg/py3.11/windows.lock index 052314bdfa3c..d66652940523 100644 --- a/requirements/static/pkg/py3.11/windows.lock +++ b/requirements/static/pkg/py3.11/windows.lock @@ -59,7 +59,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -132,7 +132,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.12/darwin.lock b/requirements/static/pkg/py3.12/darwin.lock index f76fbc6829ac..7fdfff996b95 100644 --- a/requirements/static/pkg/py3.12/darwin.lock +++ b/requirements/static/pkg/py3.12/darwin.lock @@ -52,7 +52,7 @@ frozenlist==1.7.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -123,7 +123,7 @@ propcache==0.3.2 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.12/freebsd.lock b/requirements/static/pkg/py3.12/freebsd.lock index 0c8e9dff7d84..6cfeef07f5e7 100644 --- a/requirements/static/pkg/py3.12/freebsd.lock +++ b/requirements/static/pkg/py3.12/freebsd.lock @@ -64,7 +64,7 @@ frozenlist==1.7.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -141,7 +141,7 @@ propcache==0.3.2 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.12/linux.lock b/requirements/static/pkg/py3.12/linux.lock index 08b428eb1835..265bf67ff5a9 100644 --- a/requirements/static/pkg/py3.12/linux.lock +++ b/requirements/static/pkg/py3.12/linux.lock @@ -56,7 +56,7 @@ frozenlist==1.7.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -130,7 +130,7 @@ propcache==0.3.2 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.12/windows.lock b/requirements/static/pkg/py3.12/windows.lock index 0b17b8ca25ba..a32529fd6ba5 100644 --- a/requirements/static/pkg/py3.12/windows.lock +++ b/requirements/static/pkg/py3.12/windows.lock @@ -57,7 +57,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -130,7 +130,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.13/darwin.lock b/requirements/static/pkg/py3.13/darwin.lock index f4cd77ee63f8..aad2b20dbdac 100644 --- a/requirements/static/pkg/py3.13/darwin.lock +++ b/requirements/static/pkg/py3.13/darwin.lock @@ -52,7 +52,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -123,7 +123,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.13/freebsd.lock b/requirements/static/pkg/py3.13/freebsd.lock index ae4fd79ccf01..d50773498282 100644 --- a/requirements/static/pkg/py3.13/freebsd.lock +++ b/requirements/static/pkg/py3.13/freebsd.lock @@ -64,7 +64,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -141,7 +141,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.13/linux.lock b/requirements/static/pkg/py3.13/linux.lock index 13f72f2c16fd..d274d28e7c35 100644 --- a/requirements/static/pkg/py3.13/linux.lock +++ b/requirements/static/pkg/py3.13/linux.lock @@ -56,7 +56,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -130,7 +130,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.13/windows.lock b/requirements/static/pkg/py3.13/windows.lock index 703474bfadf0..223fe7fadf39 100644 --- a/requirements/static/pkg/py3.13/windows.lock +++ b/requirements/static/pkg/py3.13/windows.lock @@ -57,7 +57,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -130,7 +130,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.14/darwin.lock b/requirements/static/pkg/py3.14/darwin.lock index f21e821f0590..8c167ce2e258 100644 --- a/requirements/static/pkg/py3.14/darwin.lock +++ b/requirements/static/pkg/py3.14/darwin.lock @@ -52,7 +52,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -123,7 +123,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.14/freebsd.lock b/requirements/static/pkg/py3.14/freebsd.lock index 5d692519adc6..7be82ef0e5a6 100644 --- a/requirements/static/pkg/py3.14/freebsd.lock +++ b/requirements/static/pkg/py3.14/freebsd.lock @@ -64,7 +64,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -141,7 +141,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.14/linux.lock b/requirements/static/pkg/py3.14/linux.lock index a539c014a5b7..66d5ebcf1b70 100644 --- a/requirements/static/pkg/py3.14/linux.lock +++ b/requirements/static/pkg/py3.14/linux.lock @@ -56,7 +56,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -130,7 +130,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.14/windows.lock b/requirements/static/pkg/py3.14/windows.lock index fce50b3662df..1e0b62ba8052 100644 --- a/requirements/static/pkg/py3.14/windows.lock +++ b/requirements/static/pkg/py3.14/windows.lock @@ -57,7 +57,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -130,7 +130,7 @@ propcache==0.4.1 # yarl psutil==7.2.2 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==3.0 # via diff --git a/requirements/static/pkg/py3.9/darwin.lock b/requirements/static/pkg/py3.9/darwin.lock index 396d3e918ea4..b03259738bf5 100644 --- a/requirements/static/pkg/py3.9/darwin.lock +++ b/requirements/static/pkg/py3.9/darwin.lock @@ -58,7 +58,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -131,7 +131,7 @@ propcache==0.3.2 # yarl psutil==5.9.8 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==2.23 # via diff --git a/requirements/static/pkg/py3.9/freebsd.lock b/requirements/static/pkg/py3.9/freebsd.lock index 42d9752044d6..2fdef729c71d 100644 --- a/requirements/static/pkg/py3.9/freebsd.lock +++ b/requirements/static/pkg/py3.9/freebsd.lock @@ -81,7 +81,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -189,7 +189,7 @@ psutil==5.9.8 ; python_full_version < '3.10' # via -r requirements/base.txt psutil==7.2.2 ; python_full_version >= '3.10' # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==2.23 ; python_full_version < '3.10' # via diff --git a/requirements/static/pkg/py3.9/linux.lock b/requirements/static/pkg/py3.9/linux.lock index 5e09d2ed3465..31828909f061 100644 --- a/requirements/static/pkg/py3.9/linux.lock +++ b/requirements/static/pkg/py3.9/linux.lock @@ -62,7 +62,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -138,7 +138,7 @@ propcache==0.3.2 # yarl psutil==5.9.8 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==2.23 # via diff --git a/requirements/static/pkg/py3.9/windows.lock b/requirements/static/pkg/py3.9/windows.lock index 14ff292bc5ae..1c0a8b632a0a 100644 --- a/requirements/static/pkg/py3.9/windows.lock +++ b/requirements/static/pkg/py3.9/windows.lock @@ -61,7 +61,7 @@ frozenlist==1.8.0 # aiosignal gitdb==4.0.12 # via gitpython -gitpython==3.1.50 +gitpython==3.1.59 # via -r requirements/base.txt idna==3.18 # via @@ -136,7 +136,7 @@ propcache==0.4.1 # yarl psutil==5.9.8 # via -r requirements/base.txt -pyasn1==0.6.3 +pyasn1==0.6.4 # via -r requirements/base.txt pycparser==2.23 # via From df50d24cc8ce3e89cba94daf35409b77a41de787 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 17 Aug 2026 22:36:50 -0700 Subject: [PATCH 305/469] Fan-out: propagate raw_payload kwarg to base + zeromq + ws PublishServers Adding raw_payload=None to the TCP PublishServer.publish_payload signature broke the transport-agnostic signature-parity contract test (tests/pytests/unit/transport/test_publish_server.py) and, worse, would raise TypeError at runtime on non-TCP master transports because MasterPubServerChannel.publish_payload calls ``self.transport.publish_payload(load, raw_payload=raw_payload)`` unconditionally. Add ``raw_payload=None`` to the abstract base signature and to the zeromq/ws implementations as accept-and-ignore. Both transports frame their own outbound bytes (libzmq / salt.payload.dumps), so the passthrough shortcut TCP uses has no equivalent to apply here. --- salt/transport/base.py | 2 +- salt/transport/ws.py | 7 ++++++- salt/transport/zeromq.py | 8 +++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/salt/transport/base.py b/salt/transport/base.py index 32f543ebacb6..3ddaf6780fbe 100644 --- a/salt/transport/base.py +++ b/salt/transport/base.py @@ -471,7 +471,7 @@ async def publisher( raise NotImplementedError @abstractmethod - async def publish_payload(self, payload, topic_list=None): + async def publish_payload(self, payload, topic_list=None, raw_payload=None): raise NotImplementedError @abstractmethod diff --git a/salt/transport/ws.py b/salt/transport/ws.py index bb600d4ad9ed..4ea7de0fc2c0 100644 --- a/salt/transport/ws.py +++ b/salt/transport/ws.py @@ -512,7 +512,12 @@ async def publish( self.pub_writer.write(salt.payload.dumps(payload, use_bin_type=True)) await self.pub_writer.drain() - async def publish_payload(self, payload, topic_list=None): + async def publish_payload(self, payload, topic_list=None, raw_payload=None): + # ``raw_payload`` is accepted for interface parity with the + # TCP PublishServer, which uses it to skip a redundant + # msgpack round-trip on the EP fan-out hot path. The ws + # transport frames with ``salt.payload.dumps`` below, so the + # unframed passthrough shortcut doesn't apply here. payload = salt.payload.dumps(payload, use_bin_type=True) for ws in list(self.clients): try: diff --git a/salt/transport/zeromq.py b/salt/transport/zeromq.py index 44f1652953e4..d40780d4c819 100644 --- a/salt/transport/zeromq.py +++ b/salt/transport/zeromq.py @@ -1913,7 +1913,13 @@ async def publisher( exc_info_on_loglevel=logging.DEBUG, ) - async def publish_payload(self, payload, topic_list=None): + async def publish_payload(self, payload, topic_list=None, raw_payload=None): + # ``raw_payload`` is accepted for interface parity with + # :class:`salt.transport.tcp.PublishServer`, which uses it to + # skip a redundant msgpack round-trip on the EP fan-out hot + # path. zeromq's own framing is handled by libzmq -- there is + # no equivalent framing shortcut here, so we ignore it and + # fall through to the normal send path with ``payload``. log.trace("Publish payload %r", payload) if self.opts["zmq_filtering"]: if topic_list: From 31770c5e4fee7e75a3f9a7cfaafcd78911ee720e Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 11 Jul 2026 19:33:18 -0400 Subject: [PATCH 306/469] Stop the loader from shadowing top-level imports via sys.path (#69139) The LazyLoader put Salt's own source directories on the global sys.path while a module body executed (__populate_sys_path, and the fpath_dirname append in _load_module). Any bare import issued while that module ran could then resolve to a same-named single-file Salt module and get cached in sys.modules for the life of the process. That is what broke napalm on modern Salt. Loading salt/utils/napalm.py runs its top-level `import napalm`, which reaches ncclient.transport, which does a bare `import ssh` to detect the optional ssh-python/libssh package. With salt/utils on sys.path that bound to salt/utils/ssh.py (a plain module, not the ssh-python package), so ncclient's `from ssh.channel import Channel` raised "'ssh' is not a package", `import napalm` failed, HAS_NAPALM was False, and the napalm proxy/execution modules never passed their __virtual__ gate. This is the root cause behind the "Proxymodule napalm is missing an init()" reports in #69139 (which #69330 only improved the error message for). Salt-internal modules are imported via their fully-qualified salt.* names, so they never needed sys.path; only external/custom module dirs do, so a custom module's bare sibling imports keep resolving. Skip appending any directory under SALT_BASE_PATH in both __populate_sys_path and the fpath_dirname append. As a side effect, a module whose optional same-named dependency is not installed no longer "loads" by importing itself. --- changelog/69139.fixed.md | 1 + salt/loader/lazy.py | 33 ++++- tests/pytests/unit/loader/test_loader.py | 171 +++++++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 changelog/69139.fixed.md diff --git a/changelog/69139.fixed.md b/changelog/69139.fixed.md new file mode 100644 index 000000000000..da56b0a16be5 --- /dev/null +++ b/changelog/69139.fixed.md @@ -0,0 +1 @@ +Fixed the module loader putting Salt's own source directories on ``sys.path`` while a module body executes. That let a single-file Salt module (for example ``salt/utils/ssh.py``) shadow a same-named top-level third-party package that a loaded module's import chain pulls in, and the shadow was cached in ``sys.modules`` for the life of the process. In practice this broke ``import napalm``: ncclient's bare ``import ssh`` (used to detect the optional ssh-python/libssh package) bound to ``salt/utils/ssh.py`` instead, so ``HAS_NAPALM`` was ``False`` and the napalm proxy/execution modules never loaded. Salt-internal directories are no longer added to ``sys.path``; only external/custom module directories are, so a custom module's sibling imports still resolve. As a side effect, a module whose optional same-named dependency is not installed no longer loads by importing itself. diff --git a/salt/loader/lazy.py b/salt/loader/lazy.py index 7e2027980dde..2effd8f680cb 100644 --- a/salt/loader/lazy.py +++ b/salt/loader/lazy.py @@ -65,6 +65,21 @@ SALT_BASE_PATH = pathlib.Path(salt.syspaths.INSTALL_DIR).resolve() LOADED_BASE_NAME = "salt.loaded" + + +def _is_salt_internal_path(path): + """ + Return True if ``path`` is the Salt install directory or lives under it. + + Uses a path-component boundary (not a raw string prefix), so a sibling + directory whose name merely begins with the Salt package name -- e.g. the + ``saltext.*`` extensions, which install next to the ``salt`` package in + site-packages -- is correctly treated as external. + """ + salt_base = str(SALT_BASE_PATH) + return path == salt_base or path.startswith(salt_base + os.sep) + + PY3_PRE_EXT = re.compile(r"\.cpython-{}{}(\.opt-[1-9])?".format(*sys.version_info[:2])) # Will be set to pyximport module at runtime if cython is enabled in config. @@ -772,6 +787,16 @@ def _reload_submodules(self, mod): def __populate_sys_path(self): for directory in self.extra_module_dirs: + # Never put a Salt-internal module dir (e.g. salt/utils) on + # sys.path. Internal modules are imported via their fully-qualified + # ``salt.*`` names, so they gain nothing from this, and their + # single-file modules (salt/utils/ssh.py, salt/utils/yaml.py, ...) + # would shadow same-named third-party/stdlib top-level packages for + # any bare import triggered while a module body runs -- which then + # gets cached in sys.modules for the life of the process. Only + # external (custom/extension) dirs need to be importable this way. + if _is_salt_internal_path(directory): + continue if directory not in sys.path: sys.path.append(directory) self._clean_module_dirs.append(directory) @@ -830,7 +855,13 @@ def _load_module(self, name): fpath_appended = False try: self.__populate_sys_path() - if fpath_dirname not in sys.path: + # Only append external module dirs, so a custom module's bare + # sibling imports resolve. A Salt-internal dir (salt/modules, + # salt/utils, ...) must never go on sys.path: a file such as + # salt/modules/ssh.py would shadow a same-named third-party/stdlib + # top-level package for any bare import made while this module + # executes. Internal modules import siblings via ``salt.*`` names. + if not _is_salt_internal_path(fpath) and fpath_dirname not in sys.path: sys.path.append(fpath_dirname) fpath_appended = True if suffix == ".pyx": diff --git a/tests/pytests/unit/loader/test_loader.py b/tests/pytests/unit/loader/test_loader.py index c278fbe87db8..828811a6fb8f 100644 --- a/tests/pytests/unit/loader/test_loader.py +++ b/tests/pytests/unit/loader/test_loader.py @@ -6,7 +6,9 @@ """ import os +import pathlib import shutil +import sys import textwrap import pytest @@ -16,6 +18,7 @@ import salt.loader import salt.loader.lazy import salt.utils.files +from tests.support.mock import patch @pytest.fixture @@ -117,3 +120,171 @@ def foobar(): with pytest.helpers.temp_file("mymod.py", contents, directory=tmp_path): loader = salt.loader.LazyLoader([tmp_path], opts, pack={"__test__": "meh"}) assert loader["mymod.foobar"]() == "meh" + + +def test_loader_does_not_shadow_top_level_import(tmp_path): + """ + Loading a Salt-internal module must not put its directory on sys.path. + + Otherwise a same-named single-file Salt module (e.g. salt/utils/ssh.py) + shadows a real top-level third-party package that a loaded module's import + chain pulls in, and the shadow is cached in sys.modules for the life of the + process. This is the root cause behind #69139: ncclient's bare ``import + ssh`` (to detect ssh-python, which is normally not installed) bound to + salt/utils/ssh.py, which broke ``import napalm``. + """ + # Fake SALT_BASE_PATH tree. ``shadowmod`` stands in for salt/utils/ssh.py: + # a plain single-file Salt module whose stem matches a top-level package a + # loaded module's import chain would probe for. ``importer`` stands in for + # salt.utils.napalm doing a bare third-party import while its body runs. + # There is no real ``shadowmod`` package installed (as ssh-python normally + # is not), so the only way ``import shadowmod`` can resolve is if the loader + # leaks the Salt-internal dir onto sys.path. + salt_root = tmp_path / "saltroot" + loaderdir = salt_root / "mymods" + loaderdir.mkdir(parents=True) + (loaderdir / "shadowmod.py").write_text("MARKER = 'salt-internal-shadow'\n") + (loaderdir / "importer.py").write_text( + "try:\n" + " import shadowmod\n" + " RESULT = getattr(shadowmod, 'MARKER', 'other')\n" + "except ImportError:\n" + " RESULT = 'not-importable'\n\n\n" + "def result():\n" + " return RESULT\n" + ) + + opts = {"optimization_order": [0]} + saved_path = list(sys.path) + try: + with patch.object( + salt.loader.lazy, "SALT_BASE_PATH", pathlib.Path(str(salt_root)) + ): + loader = salt.loader.LazyLoader([str(loaderdir)], opts) + # With the fix, loaderdir (under SALT_BASE_PATH) is never appended to + # sys.path, so a bare ``import shadowmod`` from another loaded module + # cannot bind to loaderdir/shadowmod.py. Without the fix, loaderdir + # is appended and the bare import binds to the Salt-internal file. + assert loader["importer.result"]() == "not-importable" + finally: + sys.path[:] = saved_path + sys.modules.pop("shadowmod", None) + + +def test_loader_self_named_module_not_self_shadowed_when_dep_absent(tmp_path): + """ + A module named after its optional third-party dependency must not "load" by + importing itself when that dependency is absent. + + This is the ethtool / dson / json5 class (32 salt modules bare-import their + own name). salt/modules/ethtool.py does ``import ethtool``; with the + module's own directory on sys.path that bound to the salt file itself, so + the module falsely "loaded" while being self-referential and non-functional + at runtime. With this fix it correctly does not load. + """ + salt_root = tmp_path / "saltroot" + loaderdir = salt_root / "mymods" + loaderdir.mkdir(parents=True) + # No real "widget" package is installed, mirroring an absent optional dep. + (loaderdir / "widget.py").write_text( + "import widget\n\n\ndef ok():\n return getattr(widget, 'REAL', 'self')\n" + ) + opts = {"optimization_order": [0]} + saved_path = list(sys.path) + try: + with patch.object( + salt.loader.lazy, "SALT_BASE_PATH", pathlib.Path(str(salt_root)) + ): + loader = salt.loader.LazyLoader([str(loaderdir)], opts) + # With the fix loaderdir is not on sys.path, so ``import widget`` + # raises ImportError and widget.py does not load. Without the fix it + # self-imports and widget.ok shows up in the loader. + assert "widget.ok" not in loader + finally: + sys.path[:] = saved_path + sys.modules.pop("widget", None) + + +def test_loader_self_named_module_loads_via_real_dep_when_present(tmp_path): + """ + Companion to the above: when the real same-named dependency IS installed, + the module must still load and bind to the real package (not the salt file). + Confirms the fix does not break these modules in the normal case. + """ + # A real external "widget" package -- stands in for the installed dependency. + site = tmp_path / "site" + (site / "widget").mkdir(parents=True) + (site / "widget" / "__init__.py").write_text("REAL = 'real-widget'\n") + + salt_root = tmp_path / "saltroot" + loaderdir = salt_root / "mymods" + loaderdir.mkdir(parents=True) + (loaderdir / "widget.py").write_text( + "import widget\n\n\ndef ok():\n return getattr(widget, 'REAL', 'self')\n" + ) + opts = {"optimization_order": [0]} + saved_path = list(sys.path) + sys.path.insert(0, str(site)) + try: + with patch.object( + salt.loader.lazy, "SALT_BASE_PATH", pathlib.Path(str(salt_root)) + ): + loader = salt.loader.LazyLoader([str(loaderdir)], opts) + assert loader["widget.ok"]() == "real-widget" + finally: + sys.path[:] = saved_path + sys.modules.pop("widget", None) + + +def test_loader_external_module_dir_still_on_sys_path(tmp_path): + """ + External (custom) module dirs must still be added to sys.path so a custom + module's bare sibling import keeps resolving -- the fix only excludes + Salt-internal dirs. + """ + extdir = tmp_path / "ext" + extdir.mkdir() + (extdir / "sibling.py").write_text("VALUE = 'sib'\n") + (extdir / "usesibling.py").write_text( + "import sibling\n\n\ndef ok():\n return sibling.VALUE\n" + ) + opts = {"optimization_order": [0]} + try: + # extdir is not under the real SALT_BASE_PATH, so it is appended and the + # bare ``import sibling`` resolves. + loader = salt.loader.LazyLoader([str(extdir)], opts) + assert loader["usesibling.ok"]() == "sib" + finally: + sys.modules.pop("sibling", None) + + +def test_loader_external_dir_sharing_salt_prefix_still_appended(tmp_path): + """ + A module directory whose path merely *begins* with the Salt install path -- + e.g. a ``saltext.*`` extension installed sibling to the ``salt`` package in + site-packages (``.../site-packages/saltext/...`` vs ``.../site-packages/salt``) + -- must still be added to sys.path. A raw ``startswith`` guard would + misclassify it as internal because "saltext" starts with "salt"; the fix + uses a path-component boundary instead. + """ + salt_root = tmp_path / "saltroot" + salt_root.mkdir() + # External dir that shares the "saltroot" textual prefix but is NOT under it. + extdir = tmp_path / "saltroot_ext" + extdir.mkdir() + assert str(extdir).startswith(str(salt_root)) # the boundary condition + (extdir / "sibling.py").write_text("VALUE = 'sib'\n") + (extdir / "usesibling.py").write_text( + "import sibling\n\n\ndef ok():\n return sibling.VALUE\n" + ) + opts = {"optimization_order": [0]} + try: + with patch.object( + salt.loader.lazy, "SALT_BASE_PATH", pathlib.Path(str(salt_root)) + ): + loader = salt.loader.LazyLoader([str(extdir)], opts) + # extdir is a sibling of salt_root, not under it, so it must be + # appended and the bare sibling import must resolve. + assert loader["usesibling.ok"]() == "sib" + finally: + sys.modules.pop("sibling", None) From 248eb32b21272a061bc4b6276c554ef5d38c7f8d Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 12 Jul 2026 22:55:14 -0400 Subject: [PATCH 307/469] Add functional regression test for the loader sys.path shadow (#69139) The unit tests monkeypatch SALT_BASE_PATH and use a synthetic shadow file. This drives the real minion_mods + utils loaders against the real SALT_BASE_PATH and asserts, from inside a module body executed mid-load, that no Salt-internal directory is ever placed on sys.path -- the name-independent guarantee that protects every salt/utils and salt/modules collision (dns, napalm, git, pip, consul, ...), not just ssh. The concrete salt/utils/ssh.py shadow is also asserted when a real top-level ssh is absent. --- .../functional/loader/test_syspath_shadow.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/pytests/functional/loader/test_syspath_shadow.py diff --git a/tests/pytests/functional/loader/test_syspath_shadow.py b/tests/pytests/functional/loader/test_syspath_shadow.py new file mode 100644 index 000000000000..6ccdd77d4b73 --- /dev/null +++ b/tests/pytests/functional/loader/test_syspath_shadow.py @@ -0,0 +1,139 @@ +""" +Functional regression test for the loader ``sys.path`` shadowing fix (#69139). + +The unit tests in ``test_loader.py`` monkeypatch ``SALT_BASE_PATH`` to a +synthetic tree and use a hand-made shadow file. This test exercises the real +loader wiring end to end: a real ``minion_mods`` loader built with the real +``utils`` loader (whose ``module_dirs`` really contains ``salt/utils``), against +the real ``SALT_BASE_PATH``. + +The concern is not limited to what ships in a default Salt install. Any +``salt/utils/.py`` shadows a same-named top-level package that some loaded +module's import chain pulls in, and the packages people actually install to +drive Salt modules are the real victims -- e.g. ``salt/utils/dns.py`` vs +dnspython's ``dns``, ``salt/utils/napalm.py`` vs ``napalm`` (the #69139 +neighbourhood itself), ``salt/utils/github.py`` vs PyGithub's ``github``, +``salt/utils/slack.py`` vs ``slack``. The salt/modules side is worse still +(``git`` vs GitPython, ``pip``, ``consul``, ``elasticsearch``, ...). + +Rather than pin one name's symptom, the probe records -- while its own body runs +mid-load -- whether *any* Salt-internal directory is on ``sys.path`` at all. +That is the root guarantee that protects the whole class of collisions, and it +does not depend on which third-party packages happen to be installed in CI. The +concrete ``salt/utils/ssh.py`` shadow (the reported #69139 case) is asserted in +addition, when a real top-level ``ssh`` is absent. +""" + +import copy +import importlib.util +import os +import sys +import textwrap + +import pytest + +import salt.loader +from salt.loader.lazy import SALT_BASE_PATH + + +@pytest.fixture +def shadow_probe_dir(tmp_path): + """ + An on-disk execution-module directory whose module, in its body (executed + while the loader has it open), records which Salt-internal directories are + on ``sys.path`` and whether a bare ``import ssh`` binds to + ``salt/utils/ssh.py``. + """ + base = tmp_path / "shadow-mod-base" + (base / "modules").mkdir(parents=True) + (base / "modules" / "shadowprobe.py").write_text( + textwrap.dedent( + ''' + """Regression probe for the loader sys.path shadow (#69139).""" + import os + import sys + + from salt.loader.lazy import SALT_BASE_PATH + + _base = str(SALT_BASE_PATH) + # Any Salt-internal dir visible on sys.path *right now* (mid-load) + # is a shadow vector: a bare ``import X`` for any X matching a + # salt/utils/*.py file (dns, napalm, github, slack, ssh, ...) would + # bind to the Salt file instead of the real package. + _leaked = [ + p for p in sys.path if p == _base or p.startswith(_base + os.sep) + ] + + # Concrete #69139 symptom: the real salt/utils/ssh.py shadowing a + # bare ``import ssh`` (napalm -> ncclient -> import ssh). + try: + import ssh as _ssh + + _resolved = os.path.abspath(getattr(_ssh, "__file__", "") or "") + _ssh_shadowed = os.path.join("salt", "utils") in _resolved + except ImportError: + _ssh_shadowed = False + + + def leaked_internal_dirs(): + return _leaked + + + def ssh_shadowed(): + return _ssh_shadowed + ''' + ) + ) + return str(base) + + +def test_loader_never_leaks_salt_internal_dirs_onto_sys_path_69139( + minion_opts, shadow_probe_dir +): + """ + Build the real ``minion_mods`` loader with the real ``utils`` loader and + load a module that inspects ``sys.path`` from inside its own body. With the + fix, no Salt-internal directory is ever placed on ``sys.path``, so no + ``salt/utils/*.py`` (or ``salt/modules/*.py``) file can shadow a same-named + third-party package that any loaded module's import chain pulls in. + + Regression guard: without the fix the loader appends ``salt/utils`` to + ``sys.path`` for the duration of the load, so the probe sees it there and a + bare ``import ssh`` binds to ``salt/utils/ssh.py`` -- the shadow then gets + cached in ``sys.modules`` for the life of the process, which is exactly what + broke napalm/ncclient loading. + """ + opts = copy.deepcopy(minion_opts) + opts["module_dirs"] = [shadow_probe_dir] + + saved_path = list(sys.path) + saved_ssh = sys.modules.pop("ssh", None) + try: + utils = salt.loader.utils(opts) + # Guard the test's own premise: the real salt/utils directory is among + # the extra module dirs the loader would otherwise leak onto sys.path, + # so an empty ``leaked_internal_dirs`` below is a real result and not a + # vacuous pass from the wiring having changed. + assert any( + os.path.basename(directory) == "utils" and str(SALT_BASE_PATH) in directory + for directory in utils.module_dirs + ), "premise broken: real salt/utils not found in utils.module_dirs" + + funcs = salt.loader.minion_mods(opts, utils=utils) + assert ( + "shadowprobe.leaked_internal_dirs" in funcs + ), "probe module failed to load" + + # Root guarantee, name-independent: protects the whole class of + # collisions (dns, napalm, git, pip, consul, ...), not just ssh. + assert funcs["shadowprobe.leaked_internal_dirs"]() == [] + + # Concrete #69139 symptom, when a genuine top-level ``ssh`` is not + # installed (otherwise a successful import is not proof of a shadow). + if importlib.util.find_spec("ssh") is None: + assert funcs["shadowprobe.ssh_shadowed"]() is False + finally: + sys.path[:] = saved_path + sys.modules.pop("ssh", None) + if saved_ssh is not None: + sys.modules["ssh"] = saved_ssh From 03014ce2976e23ba1af4f74881899dd33d04d26e Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Mon, 17 Aug 2026 23:19:08 -0700 Subject: [PATCH 308/469] Revert PR #69622 (mis-merge of 3008.x content onto 3007.x) PR #69622 (merge commit 2746c4549a0) was titled 'Merge forward 3006.x into 3007.x' but its head branch was constructed as a 3007.x -> 3008.x forward merge, then re-targeted at 3007.x as the base. The result pulled the entire 3008.x delta (as of 2026-06-30) backward onto 3007.x, including the Salt Resources framework, cluster/raft consensus, tracing/otel, MessageServerChannel/AuthFuncs/PoolRoutingChannel, and much more that was intended to remain 3008.x-only. This reverts that merge (git revert -m 1 2746c4549a0), restoring pre-mis-merge 3007.x while preserving the 132 legitimate follow-on commits that landed on 3007.x after the mis-merge. Per-file agents resolved 32 code conflicts; lock files were regenerated from .in files by pre-commit; missing imports/helpers reintroduced where follow-on commits depend on them. --- .coveragerc | 33 +- .github/ISSUE_TEMPLATE/bug.yml | 95 - .github/ISSUE_TEMPLATE/bug_report.md | 48 + .github/ISSUE_TEMPLATE/config.yml | 7 +- .github/ISSUE_TEMPLATE/docs.md | 2 +- .github/ISSUE_TEMPLATE/feature_request.md | 23 + .github/ISSUE_TEMPLATE/test-failure.md | 4 +- .github/actions/build-onedir-deps/action.yml | 25 +- .github/actions/build-onedir-salt/action.yml | 12 +- .../setup-python-tools-scripts/action.yml | 4 +- .github/config.yml | 20 +- .github/scripts/hash-files.py | 146 - .../scripts/verify-draft-signing-manifest.sh | 55 - .github/workflows/build-deps-ci-action.yml | 8 +- .github/workflows/build-docs.yml | 9 +- .github/workflows/build-salt-onedir.yml | 15 +- .github/workflows/ci.yml | 36 +- .github/workflows/depcheck.yml | 19 +- .github/workflows/doc-linkcheck.yml | 86 - .github/workflows/nightly-stress-test.yml | 145 +- .github/workflows/nightly.yml | 96 +- .github/workflows/release.yml | 24 +- .github/workflows/run-nightly.yml | 70 - .github/workflows/scheduled.yml | 30 +- .github/workflows/staging.yml | 30 +- .../templates/build-ci-deps.yml.jinja | 1 - .github/workflows/templates/ci.yml.jinja | 7 +- .github/workflows/templates/layout.yml.jinja | 14 +- .github/workflows/templates/nightly.yml.jinja | 7 + .../templates/test-salt-pkg.yml.jinja | 1 - .../workflows/templates/test-salt.yml.jinja | 1 - .github/workflows/test-action.yml | 148 +- .github/workflows/test-packages-action.yml | 6 +- .github/workflows/triage.yml | 63 + .github/workflows/workflow-finished.yml | 3 +- .gitignore | 11 +- .pre-commit-config.yaml | 80 +- .pylintrc | 5 +- CHANGELOG.md | 179 - CONTRIBUTING.rst | 4 +- DEPENDENCIES.md | 1 + FIXED_TESTS.md | 71 + GAP5.md | 153 - MULTI_RING_DESIGN.md | 446 - README.rst | 5 - SECURITY.md | 87 +- agents/CLAUDE.md | 2 +- agents/COPILOT.md | 12 +- agents/CURSOR.md | 10 +- agents/GEMINI.md | 2 +- agents/README.md | 2 +- agents/docs/architecture.md | 2 - agents/docs/development-setup.md | 10 +- agents/docs/testing.md | 6 +- agents/mcp/salt_test/README.md | 4 +- changelog/62852.added.md | 1 - changelog/66603.fixed.md | 9 + changelog/69018.fixed.md | 4 - changelog/69228.fixed.md | 1 - changelog/69303.fixed.md | 1 - changelog/69307.fixed.md | 1 - changelog/69418.fixed.md | 3 - changelog/69448.fixed.md | 1 - changelog/69451.fixed.md | 1 - changelog/69453.added.md | 1 - changelog/69454.fixed.md | 1 - changelog/69472.fixed.md | 1 - changelog/69488.removed.md | 1 - changelog/69494.added.md | 1 - cicd/shared-gh-workflows-context.yml | 10 +- conf/master | 61 - conf/suse/master | 12 - doc/Makefile | 18 +- doc/conf.py | 9 +- doc/contents.rst | 4 - doc/ref/auth/all/index.rst | 5 + doc/ref/auth/all/salt.auth.django.rst | 5 + doc/ref/auth/all/salt.auth.keystone.rst | 5 + doc/ref/auth/all/salt.auth.mysql.rst | 5 + doc/ref/auth/all/salt.auth.pki.rst | 5 + doc/ref/auth/all/salt.auth.yubico.rst | 5 + doc/ref/beacons/all/index.rst | 15 + doc/ref/beacons/all/salt.beacons.adb.rst | 5 + .../beacons/all/salt.beacons.aix_account.rst | 5 + .../all/salt.beacons.avahi_announce.rst | 6 + .../all/salt.beacons.bonjour_announce.rst | 6 + doc/ref/beacons/all/salt.beacons.btmp.rst | 5 + doc/ref/beacons/all/salt.beacons.glxinfo.rst | 5 + doc/ref/beacons/all/salt.beacons.haproxy.rst | 6 + .../all/salt.beacons.junos_rre_keys.rst | 5 + .../all/salt.beacons.napalm_beacon.rst | 5 + doc/ref/beacons/all/salt.beacons.sensehat.rst | 5 + .../all/salt.beacons.smartos_imgadm.rst | 5 + .../all/salt.beacons.smartos_vmadm.rst | 5 + .../all/salt.beacons.telegram_bot_msg.rst | 5 + .../all/salt.beacons.twilio_txt_msg.rst | 5 + doc/ref/beacons/all/salt.beacons.wtmp.rst | 5 + doc/ref/cache/all/index.rst | 3 - doc/ref/cache/all/salt.cache.localfs_key.rst | 5 - doc/ref/cache/all/salt.cache.mmap_cache.rst | 10 - doc/ref/cache/all/salt.cache.mmap_key.rst | 12 - doc/ref/cli/salt-call.rst | 43 +- doc/ref/clouds/all/index.rst | 31 + .../clouds/all/salt.cloud.clouds.aliyun.rst | 5 + doc/ref/clouds/all/salt.cloud.clouds.clc.rst | 5 + .../all/salt.cloud.clouds.cloudstack.rst | 5 + .../all/salt.cloud.clouds.digitalocean.rst | 5 + .../all/salt.cloud.clouds.dimensiondata.rst | 5 + doc/ref/clouds/all/salt.cloud.clouds.ec2.rst | 5 + doc/ref/clouds/all/salt.cloud.clouds.gce.rst | 5 + .../clouds/all/salt.cloud.clouds.gogrid.rst | 5 + .../clouds/all/salt.cloud.clouds.hetzner.rst | 5 + .../clouds/all/salt.cloud.clouds.joyent.rst | 5 + .../clouds/all/salt.cloud.clouds.libvirt.rst | 5 + .../clouds/all/salt.cloud.clouds.linode.rst | 6 + doc/ref/clouds/all/salt.cloud.clouds.lxc.rst | 5 + .../all/salt.cloud.clouds.oneandone.rst | 5 + .../all/salt.cloud.clouds.opennebula.rst | 5 + .../all/salt.cloud.clouds.openstack.rst | 5 + .../clouds/all/salt.cloud.clouds.packet.rst | 5 + .../all/salt.cloud.clouds.parallels.rst | 5 + .../all/salt.cloud.clouds.profitbricks.rst | 5 + .../clouds/all/salt.cloud.clouds.proxmox.rst | 5 + .../clouds/all/salt.cloud.clouds.pyrax.rst | 5 + .../all/salt.cloud.clouds.qingcloud.rst | 5 + .../clouds/all/salt.cloud.clouds.scaleway.rst | 5 + .../all/salt.cloud.clouds.softlayer.rst | 5 + .../all/salt.cloud.clouds.softlayer_hw.rst | 5 + .../all/salt.cloud.clouds.tencentcloud.rst | 5 + .../clouds/all/salt.cloud.clouds.vagrant.rst | 5 + .../all/salt.cloud.clouds.virtualbox.rst | 6 + .../clouds/all/salt.cloud.clouds.vmware.rst | 6 + .../clouds/all/salt.cloud.clouds.vultrpy.rst | 5 + doc/ref/clouds/all/salt.cloud.clouds.xen.rst | 5 + .../configuration/logging/handlers/index.rst | 5 + .../handlers/salt.log_handlers.fluent_mod.rst | 5 + .../salt.log_handlers.log4mongo_mod.rst | 5 + .../salt.log_handlers.logstash_mod.rst | 5 + .../handlers/salt.log_handlers.sentry_mod.rst | 5 + doc/ref/configuration/logging/index.rst | 31 +- doc/ref/configuration/master.rst | 672 +- doc/ref/configuration/minion.rst | 122 - doc/ref/engines/all/index.rst | 14 + .../all/salt.engines.docker_events.rst | 5 + doc/ref/engines/all/salt.engines.fluent.rst | 5 + .../all/salt.engines.http_logstash.rst | 5 + doc/ref/engines/all/salt.engines.ircbot.rst | 5 + .../engines/all/salt.engines.junos_syslog.rst | 6 + .../all/salt.engines.libvirt_events.rst | 6 + .../engines/all/salt.engines.logentries.rst | 5 + .../all/salt.engines.logstash_engine.rst | 5 + .../all/salt.engines.napalm_syslog.rst | 5 + .../all/salt.engines.redis_sentinel.rst | 5 + doc/ref/engines/all/salt.engines.slack.rst | 5 + .../all/salt.engines.slack_bolt_engine.rst | 5 + .../engines/all/salt.engines.sqs_events.rst | 5 + doc/ref/engines/all/salt.engines.stalekey.rst | 6 + doc/ref/executors/all/index.rst | 2 + .../executors/all/salt.executors.docker.rst | 5 + .../salt.executors.transactional_update.rst | 5 + doc/ref/file_server/all/index.rst | 3 + .../file_server/all/salt.fileserver.hgfs.rst | 4 + .../file_server/all/salt.fileserver.s3fs.rst | 4 + .../file_server/all/salt.fileserver.svnfs.rst | 4 + doc/ref/grains/all/index.rst | 21 +- doc/ref/grains/all/salt.grains.chronos.rst | 5 + doc/ref/grains/all/salt.grains.cimc.rst | 5 + doc/ref/grains/all/salt.grains.esxi.rst | 5 + .../grains/all/salt.grains.fibre_channel.rst | 5 + doc/ref/grains/all/salt.grains.fx2.rst | 5 + doc/ref/grains/all/salt.grains.iscsi.rst | 5 + doc/ref/grains/all/salt.grains.junos.rst | 5 + doc/ref/grains/all/salt.grains.marathon.rst | 5 + doc/ref/grains/all/salt.grains.mdata.rst | 5 + doc/ref/grains/all/salt.grains.metadata.rst | 5 + .../grains/all/salt.grains.metadata_gce.rst | 5 + doc/ref/grains/all/salt.grains.napalm.rst | 5 + doc/ref/grains/all/salt.grains.nvme.rst | 5 + doc/ref/grains/all/salt.grains.nxos.rst | 5 + doc/ref/grains/all/salt.grains.panos.rst | 5 + .../grains/all/salt.grains.philips_hue.rst | 5 + doc/ref/grains/all/salt.grains.resources.rst | 5 - doc/ref/grains/all/salt.grains.smartos.rst | 5 + doc/ref/grains/all/salt.grains.ssh_sample.rst | 5 + doc/ref/grains/all/salt.grains.truststore.rst | 5 - doc/ref/grains/all/salt.grains.zfs.rst | 5 + doc/ref/modules/all/index.rst | 279 +- doc/ref/modules/all/salt.modules.acme.rst | 5 + doc/ref/modules/all/salt.modules.apcups.rst | 5 + doc/ref/modules/all/salt.modules.apkpkg.rst | 6 + doc/ref/modules/all/salt.modules.aptly.rst | 6 + .../modules/all/salt.modules.asymmetric.rst | 6 - .../modules/all/salt.modules.augeas_cfg.rst | 5 + doc/ref/modules/all/salt.modules.aws_sqs.rst | 5 + doc/ref/modules/all/salt.modules.bamboohr.rst | 5 + doc/ref/modules/all/salt.modules.bigip.rst | 5 + .../all/salt.modules.bluez_bluetooth.rst | 5 + .../all/salt.modules.boto3_elasticache.rst | 6 + .../all/salt.modules.boto3_elasticsearch.rst | 5 + .../all/salt.modules.boto3_route53.rst | 6 + .../modules/all/salt.modules.boto3_sns.rst | 6 + .../all/salt.modules.boto_apigateway.rst | 5 + doc/ref/modules/all/salt.modules.boto_asg.rst | 5 + doc/ref/modules/all/salt.modules.boto_cfn.rst | 5 + .../all/salt.modules.boto_cloudfront.rst | 5 + .../all/salt.modules.boto_cloudtrail.rst | 5 + .../all/salt.modules.boto_cloudwatch.rst | 5 + .../salt.modules.boto_cloudwatch_event.rst | 6 + .../all/salt.modules.boto_cognitoidentity.rst | 5 + .../all/salt.modules.boto_datapipeline.rst | 5 + .../all/salt.modules.boto_dynamodb.rst | 5 + doc/ref/modules/all/salt.modules.boto_ec2.rst | 5 + doc/ref/modules/all/salt.modules.boto_efs.rst | 6 + .../all/salt.modules.boto_elasticache.rst | 5 + ...salt.modules.boto_elasticsearch_domain.rst | 5 + doc/ref/modules/all/salt.modules.boto_elb.rst | 5 + .../modules/all/salt.modules.boto_elbv2.rst | 6 + doc/ref/modules/all/salt.modules.boto_iam.rst | 5 + doc/ref/modules/all/salt.modules.boto_iot.rst | 5 + .../modules/all/salt.modules.boto_kinesis.rst | 6 + doc/ref/modules/all/salt.modules.boto_kms.rst | 5 + .../modules/all/salt.modules.boto_lambda.rst | 5 + doc/ref/modules/all/salt.modules.boto_rds.rst | 5 + .../modules/all/salt.modules.boto_route53.rst | 5 + doc/ref/modules/all/salt.modules.boto_s3.rst | 6 + .../all/salt.modules.boto_s3_bucket.rst | 5 + .../all/salt.modules.boto_secgroup.rst | 5 + doc/ref/modules/all/salt.modules.boto_sns.rst | 5 + doc/ref/modules/all/salt.modules.boto_sqs.rst | 5 + doc/ref/modules/all/salt.modules.boto_ssm.rst | 6 + doc/ref/modules/all/salt.modules.boto_vpc.rst | 5 + doc/ref/modules/all/salt.modules.bower.rst | 5 + .../modules/all/salt.modules.bsd_shadow.rst | 5 + doc/ref/modules/all/salt.modules.btrfs.rst | 5 + doc/ref/modules/all/salt.modules.cabal.rst | 5 + .../modules/all/salt.modules.capirca_acl.rst | 5 + doc/ref/modules/all/salt.modules.ceph.rst | 6 + doc/ref/modules/all/salt.modules.chassis.rst | 5 + doc/ref/modules/all/salt.modules.cimc.rst | 5 + .../all/salt.modules.ciscoconfparse_mod.rst | 5 + doc/ref/modules/all/salt.modules.cisconso.rst | 6 + doc/ref/modules/all/salt.modules.composer.rst | 5 + doc/ref/modules/all/salt.modules.consul.rst | 5 + .../all/salt.modules.container_resource.rst | 5 + doc/ref/modules/all/salt.modules.cpan.rst | 5 + doc/ref/modules/all/salt.modules.csf.rst | 5 + doc/ref/modules/all/salt.modules.cyg.rst | 5 + .../modules/all/salt.modules.daemontools.rst | 5 + .../modules/all/salt.modules.datadog_api.rst | 5 + doc/ref/modules/all/salt.modules.ddns.rst | 5 + .../modules/all/salt.modules.deb_apache.rst | 5 + .../modules/all/salt.modules.deb_postgres.rst | 5 + .../modules/all/salt.modules.djangomod.rst | 5 + doc/ref/modules/all/salt.modules.dnsmasq.rst | 5 + .../all/salt.modules.dockercompose.rst | 5 + .../modules/all/salt.modules.dockermod.rst | 6 + doc/ref/modules/all/salt.modules.drac.rst | 5 + doc/ref/modules/all/salt.modules.dracr.rst | 5 + doc/ref/modules/all/salt.modules.drbd.rst | 5 + .../modules/all/salt.modules.ebuildpkg.rst | 6 + doc/ref/modules/all/salt.modules.eix.rst | 5 + .../all/salt.modules.elasticsearch.rst | 5 + doc/ref/modules/all/salt.modules.eselect.rst | 5 + .../modules/all/salt.modules.esxcluster.rst | 6 + .../all/salt.modules.esxdatacenter.rst | 6 + doc/ref/modules/all/salt.modules.esxi.rst | 5 + doc/ref/modules/all/salt.modules.esxvm.rst | 6 + .../all/salt.modules.freebsd_sysctl.rst | 5 + .../all/salt.modules.freebsd_update.rst | 6 + .../modules/all/salt.modules.freebsdjail.rst | 5 + .../modules/all/salt.modules.freebsdkmod.rst | 5 + .../modules/all/salt.modules.freebsdpkg.rst | 6 + .../modules/all/salt.modules.freebsdports.rst | 5 + .../all/salt.modules.freebsdservice.rst | 5 + doc/ref/modules/all/salt.modules.freezer.rst | 6 + .../modules/all/salt.modules.gcp_addon.rst | 6 + doc/ref/modules/all/salt.modules.gem.rst | 5 + doc/ref/modules/all/salt.modules.genesis.rst | 5 + .../all/salt.modules.gentoo_service.rst | 5 + .../all/salt.modules.gentoolkitmod.rst | 5 + doc/ref/modules/all/salt.modules.github.rst | 5 + doc/ref/modules/all/salt.modules.glanceng.rst | 5 + .../modules/all/salt.modules.glassfish.rst | 6 + .../modules/all/salt.modules.glusterfs.rst | 5 + .../modules/all/salt.modules.gnomedesktop.rst | 5 + .../modules/all/salt.modules.google_chat.rst | 5 + doc/ref/modules/all/salt.modules.grafana4.rst | 6 + .../modules/all/salt.modules.grub_legacy.rst | 5 + doc/ref/modules/all/salt.modules.guestfs.rst | 5 + doc/ref/modules/all/salt.modules.hadoop.rst | 5 + .../modules/all/salt.modules.haproxyconn.rst | 5 + doc/ref/modules/all/salt.modules.heat.rst | 6 + doc/ref/modules/all/salt.modules.helm.rst | 6 + doc/ref/modules/all/salt.modules.hg.rst | 5 + doc/ref/modules/all/salt.modules.icinga2.rst | 6 + doc/ref/modules/all/salt.modules.ifttt.rst | 5 + doc/ref/modules/all/salt.modules.ilo.rst | 5 + .../all/salt.modules.influxdb08mod.rst | 6 + .../modules/all/salt.modules.influxdbmod.rst | 5 + doc/ref/modules/all/salt.modules.infoblox.rst | 5 + .../all/salt.modules.inspectlib.collector.rst | 5 + .../all/salt.modules.inspectlib.dbhandle.rst | 5 + .../all/salt.modules.inspectlib.entities.rst | 6 + .../salt.modules.inspectlib.exceptions.rst | 5 + .../all/salt.modules.inspectlib.fsdb.rst | 6 + .../all/salt.modules.inspectlib.kiwiproc.rst | 6 + .../all/salt.modules.inspectlib.query.rst | 5 + .../modules/all/salt.modules.inspectlib.rst | 18 + .../modules/all/salt.modules.inspector.rst | 6 + .../modules/all/salt.modules.introspect.rst | 5 + doc/ref/modules/all/salt.modules.ipmi.rst | 5 + doc/ref/modules/all/salt.modules.jboss7.rst | 5 + .../modules/all/salt.modules.jboss7_cli.rst | 5 + .../modules/all/salt.modules.jenkinsmod.rst | 5 + doc/ref/modules/all/salt.modules.jira_mod.rst | 5 + doc/ref/modules/all/salt.modules.k8s.rst | 5 + .../modules/all/salt.modules.kapacitor.rst | 5 + doc/ref/modules/all/salt.modules.kerberos.rst | 5 + doc/ref/modules/all/salt.modules.keystone.rst | 5 + .../modules/all/salt.modules.keystoneng.rst | 5 + doc/ref/modules/all/salt.modules.keystore.rst | 5 + doc/ref/modules/all/salt.modules.kubeadm.rst | 5 + .../all/salt.modules.kubernetesmod.rst | 5 + .../all/salt.modules.launchctl_service.rst | 5 + doc/ref/modules/all/salt.modules.layman.rst | 5 + doc/ref/modules/all/salt.modules.ldap3.rst | 5 + doc/ref/modules/all/salt.modules.ldapmod.rst | 5 + .../all/salt.modules.libcloud_compute.rst | 6 + .../modules/all/salt.modules.libcloud_dns.rst | 6 + .../salt.modules.libcloud_loadbalancer.rst | 5 + .../all/salt.modules.libcloud_storage.rst | 6 + doc/ref/modules/all/salt.modules.logadm.rst | 5 + doc/ref/modules/all/salt.modules.lvs.rst | 5 + doc/ref/modules/all/salt.modules.lxc.rst | 6 + doc/ref/modules/all/salt.modules.lxd.rst | 5 + doc/ref/modules/all/salt.modules.makeconf.rst | 5 + doc/ref/modules/all/salt.modules.mandrill.rst | 5 + doc/ref/modules/all/salt.modules.marathon.rst | 5 + .../modules/all/salt.modules.mattermost.rst | 6 + doc/ref/modules/all/salt.modules.mdata.rst | 5 + .../modules/all/salt.modules.memcached.rst | 6 + doc/ref/modules/all/salt.modules.modjk.rst | 5 + doc/ref/modules/all/salt.modules.mongodb.rst | 5 + doc/ref/modules/all/salt.modules.monit.rst | 5 + doc/ref/modules/all/salt.modules.moosefs.rst | 5 + doc/ref/modules/all/salt.modules.mssql.rst | 5 + doc/ref/modules/all/salt.modules.msteams.rst | 6 + doc/ref/modules/all/salt.modules.munin.rst | 5 + doc/ref/modules/all/salt.modules.nagios.rst | 5 + .../modules/all/salt.modules.nagios_rpc.rst | 5 + .../all/salt.modules.namecheap_domains.rst | 6 + .../salt.modules.namecheap_domains_dns.rst | 6 + .../all/salt.modules.namecheap_domains_ns.rst | 6 + .../all/salt.modules.namecheap_ssl.rst | 6 + .../all/salt.modules.namecheap_users.rst | 6 + doc/ref/modules/all/salt.modules.netbox.rst | 5 + .../all/salt.modules.netbsd_sysctl.rst | 5 + .../all/salt.modules.netbsdservice.rst | 5 + .../modules/all/salt.modules.netmiko_mod.rst | 5 + .../modules/all/salt.modules.netscaler.rst | 5 + doc/ref/modules/all/salt.modules.neutron.rst | 5 + .../modules/all/salt.modules.neutronng.rst | 5 + doc/ref/modules/all/salt.modules.nexus.rst | 6 + doc/ref/modules/all/salt.modules.nginx.rst | 5 + doc/ref/modules/all/salt.modules.nilrt_ip.rst | 6 + doc/ref/modules/all/salt.modules.nix.rst | 5 + doc/ref/modules/all/salt.modules.nixpkg.rst | 6 - doc/ref/modules/all/salt.modules.nova.rst | 5 + doc/ref/modules/all/salt.modules.nspawn.rst | 6 + doc/ref/modules/all/salt.modules.omapi.rst | 5 + .../all/salt.modules.openbsd_sysctl.rst | 5 + .../modules/all/salt.modules.openbsdpkg.rst | 6 + .../all/salt.modules.openbsdrcctl_service.rst | 5 + .../all/salt.modules.openbsdservice.rst | 5 + doc/ref/modules/all/salt.modules.openscap.rst | 6 + .../all/salt.modules.openstack_config.rst | 5 + .../all/salt.modules.openstack_mng.rst | 6 + .../modules/all/salt.modules.openvswitch.rst | 5 + doc/ref/modules/all/salt.modules.opkg.rst | 5 + doc/ref/modules/all/salt.modules.opsgenie.rst | 5 + .../modules/all/salt.modules.pagerduty.rst | 5 + .../all/salt.modules.pagerduty_util.rst | 5 + doc/ref/modules/all/salt.modules.panos.rst | 5 + .../modules/all/salt.modules.parallels.rst | 5 + doc/ref/modules/all/salt.modules.pcs.rst | 5 + doc/ref/modules/all/salt.modules.pdbedit.rst | 5 + doc/ref/modules/all/salt.modules.pecl.rst | 5 + .../modules/all/salt.modules.peeringdb.rst | 5 + doc/ref/modules/all/salt.modules.pf.rst | 5 + .../modules/all/salt.modules.philips_hue.rst | 5 + doc/ref/modules/all/salt.modules.pkg.rst | 3 - .../all/salt.modules.portage_config.rst | 5 + doc/ref/modules/all/salt.modules.postfix.rst | 5 + .../modules/all/salt.modules.poudriere.rst | 5 + .../modules/all/salt.modules.powerpath.rst | 5 + doc/ref/modules/all/salt.modules.purefa.rst | 5 + doc/ref/modules/all/salt.modules.purefb.rst | 5 + .../modules/all/salt.modules.pushbullet.rst | 5 + .../all/salt.modules.pushover_notify.rst | 5 + doc/ref/modules/all/salt.modules.qemu_img.rst | 5 + doc/ref/modules/all/salt.modules.qemu_nbd.rst | 5 + doc/ref/modules/all/salt.modules.rallydev.rst | 5 + .../modules/all/salt.modules.random_org.rst | 5 + doc/ref/modules/all/salt.modules.rbenv.rst | 5 + .../modules/all/salt.modules.rebootmgr.rst | 5 + doc/ref/modules/all/salt.modules.redismod.rst | 5 + doc/ref/modules/all/salt.modules.restconf.rst | 6 + doc/ref/modules/all/salt.modules.riak.rst | 5 + doc/ref/modules/all/salt.modules.runit.rst | 5 + doc/ref/modules/all/salt.modules.rvm.rst | 5 + doc/ref/modules/all/salt.modules.s3.rst | 5 + doc/ref/modules/all/salt.modules.s6.rst | 5 + doc/ref/modules/all/salt.modules.sensehat.rst | 5 + doc/ref/modules/all/salt.modules.sensors.rst | 5 + .../all/salt.modules.serverdensity_device.rst | 5 + .../modules/all/salt.modules.servicenow.rst | 6 + .../all/salt.modules.slackware_service.rst | 5 + .../all/salt.modules.smartos_imgadm.rst | 5 + .../all/salt.modules.smartos_nictagadm.rst | 5 + .../modules/all/salt.modules.smartos_virt.rst | 5 + .../all/salt.modules.smartos_vmadm.rst | 5 + doc/ref/modules/all/salt.modules.smtp.rst | 5 + doc/ref/modules/all/salt.modules.solr.rst | 5 + .../modules/all/salt.modules.solrcloud.rst | 6 + doc/ref/modules/all/salt.modules.splunk.rst | 5 + .../all/salt.modules.splunk_search.rst | 5 + doc/ref/modules/all/salt.modules.ssh_pki.rst | 5 - .../modules/all/salt.modules.statuspage.rst | 5 + .../modules/all/salt.modules.suse_apache.rst | 5 + doc/ref/modules/all/salt.modules.suse_ip.rst | 6 + doc/ref/modules/all/salt.modules.svn.rst | 5 + doc/ref/modules/all/salt.modules.swarm.rst | 5 + doc/ref/modules/all/salt.modules.swift.rst | 5 + doc/ref/modules/all/salt.modules.sysbench.rst | 5 + doc/ref/modules/all/salt.modules.sysrc.rst | 5 + .../all/salt.modules.system_profiler.rst | 5 + doc/ref/modules/all/salt.modules.telegram.rst | 5 + .../modules/all/salt.modules.telemetry.rst | 5 + .../modules/all/salt.modules.testinframod.rst | 6 + doc/ref/modules/all/salt.modules.tomcat.rst | 5 + .../all/salt.modules.trafficserver.rst | 5 + .../all/salt.modules.transactional_update.rst | 5 + doc/ref/modules/all/salt.modules.travisci.rst | 5 + doc/ref/modules/all/salt.modules.tuned.rst | 5 + .../all/salt.modules.twilio_notify.rst | 5 + doc/ref/modules/all/salt.modules.uptime.rst | 5 + doc/ref/modules/all/salt.modules.uwsgi.rst | 5 + doc/ref/modules/all/salt.modules.varnish.rst | 5 + doc/ref/modules/all/salt.modules.vault.rst | 6 + .../modules/all/salt.modules.vbox_guest.rst | 5 + .../modules/all/salt.modules.vboxmanage.rst | 5 + doc/ref/modules/all/salt.modules.vcenter.rst | 6 + .../modules/all/salt.modules.victorops.rst | 5 + doc/ref/modules/all/salt.modules.virt.rst | 5 + doc/ref/modules/all/salt.modules.vmctl.rst | 5 + .../all/salt.modules.win_dsc_resource.rst | 5 - doc/ref/modules/all/salt.modules.win_lgpo.rst | 1 - .../modules/all/salt.modules.wordpress.rst | 6 + .../modules/all/salt.modules.xapi_virt.rst | 5 + doc/ref/modules/all/salt.modules.xbpspkg.rst | 6 + doc/ref/modules/all/salt.modules.xmpp.rst | 5 + doc/ref/modules/all/salt.modules.zabbix.rst | 5 + .../modules/all/salt.modules.zcbuildout.rst | 5 + doc/ref/modules/all/salt.modules.zenoss.rst | 5 + doc/ref/modules/all/salt.modules.zfs.rst | 5 + doc/ref/modules/all/salt.modules.znc.rst | 5 + .../modules/all/salt.modules.zookeeper.rst | 5 + doc/ref/modules/all/salt.modules.zpool.rst | 5 + doc/ref/output/all/index.rst | 7 + doc/ref/output/all/salt.output.dson.rst | 5 + .../all/salt.output.newline_values_only.rst | 5 + .../output/all/salt.output.no_out_quiet.rst | 5 + .../output/all/salt.output.overstatestage.rst | 5 + doc/ref/output/all/salt.output.pony.rst | 6 + doc/ref/output/all/salt.output.profile.rst | 5 + doc/ref/output/all/salt.output.virt_query.rst | 5 + doc/ref/pillar/all/index.rst | 36 + doc/ref/pillar/all/salt.pillar.cmd_yamlex.rst | 5 + doc/ref/pillar/all/salt.pillar.cobbler.rst | 5 + doc/ref/pillar/all/salt.pillar.confidant.rst | 5 + .../pillar/all/salt.pillar.consul_pillar.rst | 5 + doc/ref/pillar/all/salt.pillar.csvpillar.rst | 6 + doc/ref/pillar/all/salt.pillar.digicert.rst | 6 + doc/ref/pillar/all/salt.pillar.django_orm.rst | 5 + doc/ref/pillar/all/salt.pillar.ec2_pillar.rst | 5 + .../pillar/all/salt.pillar.etcd_pillar.rst | 5 + doc/ref/pillar/all/salt.pillar.foreman.rst | 5 + doc/ref/pillar/all/salt.pillar.hg_pillar.rst | 5 + doc/ref/pillar/all/salt.pillar.hiera.rst | 5 + doc/ref/pillar/all/salt.pillar.http_json.rst | 6 + doc/ref/pillar/all/salt.pillar.http_yaml.rst | 5 + doc/ref/pillar/all/salt.pillar.libvirt.rst | 5 + doc/ref/pillar/all/salt.pillar.makostack.rst | 6 + doc/ref/pillar/all/salt.pillar.mongo.rst | 5 + doc/ref/pillar/all/salt.pillar.mysql.rst | 5 + doc/ref/pillar/all/salt.pillar.nacl.rst | 5 + doc/ref/pillar/all/salt.pillar.netbox.rst | 5 + doc/ref/pillar/all/salt.pillar.neutron.rst | 5 + doc/ref/pillar/all/salt.pillar.pepa.rst | 5 + .../pillar/all/salt.pillar.pillar_ldap.rst | 5 + doc/ref/pillar/all/salt.pillar.postgres.rst | 2 +- doc/ref/pillar/all/salt.pillar.puppet.rst | 5 + doc/ref/pillar/all/salt.pillar.redismod.rst | 5 + .../all/salt.pillar.rethinkdb_pillar.rst | 5 + doc/ref/pillar/all/salt.pillar.s3.rst | 5 + doc/ref/pillar/all/salt.pillar.saltclass.rst | 5 + doc/ref/pillar/all/salt.pillar.sql_base.rst | 1 - doc/ref/pillar/all/salt.pillar.sqlcipher.rst | 5 + doc/ref/pillar/all/salt.pillar.sqlite3.rst | 5 + doc/ref/pillar/all/salt.pillar.svn_pillar.rst | 5 + .../all/salt.pillar.varstack_pillar.rst | 5 + doc/ref/pillar/all/salt.pillar.vault.rst | 5 + doc/ref/pillar/all/salt.pillar.venafi.rst | 6 + doc/ref/pillar/all/salt.pillar.virtkey.rst | 5 + .../pillar/all/salt.pillar.vmware_pillar.rst | 6 + doc/ref/proxy/all/index.rst | 22 + .../proxy/all/salt.proxy.arista_pyeapi.rst | 5 + doc/ref/proxy/all/salt.proxy.chronos.rst | 5 + doc/ref/proxy/all/salt.proxy.cimc.rst | 5 + doc/ref/proxy/all/salt.proxy.cisconso.rst | 6 + doc/ref/proxy/all/salt.proxy.docker.rst | 6 + doc/ref/proxy/all/salt.proxy.esxcluster.rst | 6 + .../proxy/all/salt.proxy.esxdatacenter.rst | 6 + doc/ref/proxy/all/salt.proxy.esxi.rst | 5 + doc/ref/proxy/all/salt.proxy.esxvm.rst | 6 + doc/ref/proxy/all/salt.proxy.fx2.rst | 5 + doc/ref/proxy/all/salt.proxy.junos.rst | 5 + doc/ref/proxy/all/salt.proxy.marathon.rst | 5 + doc/ref/proxy/all/salt.proxy.napalm.rst | 5 + doc/ref/proxy/all/salt.proxy.netmiko_px.rst | 5 + doc/ref/proxy/all/salt.proxy.nxos.rst | 5 + doc/ref/proxy/all/salt.proxy.nxos_api.rst | 5 + doc/ref/proxy/all/salt.proxy.panos.rst | 5 + doc/ref/proxy/all/salt.proxy.philips_hue.rst | 5 + doc/ref/proxy/all/salt.proxy.rest_sample.rst | 5 + doc/ref/proxy/all/salt.proxy.restconf.rst | 6 + doc/ref/proxy/all/salt.proxy.ssh_sample.rst | 5 + doc/ref/proxy/all/salt.proxy.vcenter.rst | 6 + doc/ref/queues/all/index.rst | 3 + .../queues/all/salt.queues.pgjsonb_queue.rst | 5 + .../queues/all/salt.queues.sqlite_queue.rst | 5 + doc/ref/renderers/all/index.rst | 9 + .../renderers/all/salt.renderers.aws_kms.rst | 5 + .../renderers/all/salt.renderers.cheetah.rst | 5 + doc/ref/renderers/all/salt.renderers.dson.rst | 5 + .../renderers/all/salt.renderers.genshi.rst | 5 + .../renderers/all/salt.renderers.hjson.rst | 5 + .../renderers/all/salt.renderers.json5.rst | 5 + doc/ref/renderers/all/salt.renderers.pass.rst | 6 + .../renderers/all/salt.renderers.pydsl.rst | 5 + .../renderers/all/salt.renderers.wempy.rst | 5 + doc/ref/resources/all/index.rst | 28 - .../all/salt.resources.dummy.modules.test.rst | 6 - .../resources/all/salt.resources.dummy.rst | 6 - .../all/salt.resources.ssh.modules.cmd.rst | 6 - .../all/salt.resources.ssh.modules.pkg.rst | 6 - .../all/salt.resources.ssh.modules.state.rst | 6 - .../all/salt.resources.ssh.modules.test.rst | 6 - doc/ref/resources/all/salt.resources.ssh.rst | 6 - doc/ref/resources/index.rst | 44 - doc/ref/returners/all/index.rst | 30 +- .../all/salt.returners.appoptics_return.rst | 6 + .../all/salt.returners.carbon_return.rst | 5 + .../salt.returners.cassandra_cql_return.rst | 6 + .../all/salt.returners.couchbase_return.rst | 5 + .../all/salt.returners.couchdb_return.rst | 6 + .../salt.returners.elasticsearch_return.rst | 5 + .../all/salt.returners.etcd_return.rst | 6 + .../all/salt.returners.influxdb_return.rst | 6 + .../all/salt.returners.kafka_return.rst | 5 + .../all/salt.returners.librato_return.rst | 5 + .../salt.returners.mattermost_returner.rst | 6 + .../all/salt.returners.memcache_return.rst | 6 + .../salt.returners.mongo_future_return.rst | 6 + .../all/salt.returners.mongo_return.rst | 6 + .../returners/all/salt.returners.mysql.rst | 6 + .../all/salt.returners.nagios_nrdp_return.rst | 5 + doc/ref/returners/all/salt.returners.odbc.rst | 6 + .../all/salt.returners.pushover_returner.rst | 5 + .../all/salt.returners.redis_return.rst | 6 + .../all/salt.returners.salt_cache.rst | 5 - .../all/salt.returners.sentry_return.rst | 5 + .../all/salt.returners.slack_returner.rst | 5 + .../salt.returners.slack_webhook_return.rst | 5 + .../all/salt.returners.sms_return.rst | 5 + .../all/salt.returners.smtp_return.rst | 5 + .../returners/all/salt.returners.splunk.rst | 5 + .../all/salt.returners.sqlite3_return.rst | 6 + .../all/salt.returners.telegram_return.rst | 5 + .../all/salt.returners.xmpp_return.rst | 5 + .../all/salt.returners.zabbix_return.rst | 6 + doc/ref/roster/all/index.rst | 3 + doc/ref/roster/all/salt.roster.cloud.rst | 5 + .../roster/all/salt.roster.clustershell.rst | 5 + doc/ref/roster/all/salt.roster.terraform.rst | 5 + doc/ref/runners/all/index.rst | 24 +- doc/ref/runners/all/salt.runners.asam.rst | 6 + doc/ref/runners/all/salt.runners.batch.rst | 5 - doc/ref/runners/all/salt.runners.bgp.rst | 5 + doc/ref/runners/all/salt.runners.cloud.rst | 5 + doc/ref/runners/all/salt.runners.cluster.rst | 5 - doc/ref/runners/all/salt.runners.ddns.rst | 5 + .../runners/all/salt.runners.digicertapi.rst | 6 + doc/ref/runners/all/salt.runners.drac.rst | 5 + doc/ref/runners/all/salt.runners.f5.rst | 5 + doc/ref/runners/all/salt.runners.launchd.rst | 5 + doc/ref/runners/all/salt.runners.lxc.rst | 5 + .../runners/all/salt.runners.mattermost.rst | 12 + doc/ref/runners/all/salt.runners.nacl.rst | 5 + .../runners/all/salt.runners.pagerduty.rst | 5 + doc/ref/runners/all/salt.runners.pkg.rst | 5 + doc/ref/runners/all/salt.runners.pki.rst | 9 - doc/ref/runners/all/salt.runners.resource.rst | 9 - .../all/salt.runners.smartos_vmadm.rst | 6 + .../runners/all/salt.runners.spacewalk.rst | 5 + doc/ref/runners/all/salt.runners.thin.rst | 5 + doc/ref/runners/all/salt.runners.vault.rst | 6 + .../runners/all/salt.runners.venafiapi.rst | 6 + doc/ref/runners/all/salt.runners.virt.rst | 5 + doc/ref/runners/all/salt.runners.vistara.rst | 6 + doc/ref/sdb/all/index.rst | 12 + doc/ref/sdb/all/salt.sdb.cache.rst | 6 + doc/ref/sdb/all/salt.sdb.confidant.rst | 5 + doc/ref/sdb/all/salt.sdb.consul.rst | 5 + doc/ref/sdb/all/salt.sdb.couchdb.rst | 5 + doc/ref/sdb/all/salt.sdb.etcd_db.rst | 5 + doc/ref/sdb/all/salt.sdb.keyring_db.rst | 5 + doc/ref/sdb/all/salt.sdb.memcached.rst | 5 + doc/ref/sdb/all/salt.sdb.redis_sdb.rst | 5 + doc/ref/sdb/all/salt.sdb.rest.rst | 5 + doc/ref/sdb/all/salt.sdb.sqlite3.rst | 5 + doc/ref/sdb/all/salt.sdb.tism.rst | 6 + doc/ref/sdb/all/salt.sdb.vault.rst | 5 + doc/ref/serializers/all/index.rst | 3 + .../all/salt.serializers.configparser.rst | 1 - .../serializers/all/salt.serializers.json.rst | 1 - .../all/salt.serializers.keyvalue.rst | 5 + .../all/salt.serializers.msgpack.rst | 1 - .../all/salt.serializers.plist.rst | 5 + .../all/salt.serializers.python.rst | 5 + doc/ref/states/all/index.rst | 229 +- doc/ref/states/all/salt.states.acme.rst | 5 + .../states/all/salt.states.alternatives.rst | 5 + doc/ref/states/all/salt.states.aptpkg.rst | 5 + .../states/all/salt.states.artifactory.rst | 5 + doc/ref/states/all/salt.states.augeas.rst | 5 + doc/ref/states/all/salt.states.aws_sqs.rst | 5 + doc/ref/states/all/salt.states.bigip.rst | 5 + .../all/salt.states.boto3_elasticache.rst | 6 + .../all/salt.states.boto3_elasticsearch.rst | 6 + .../states/all/salt.states.boto3_route53.rst | 6 + doc/ref/states/all/salt.states.boto3_sns.rst | 5 + .../all/salt.states.boto_apigateway.rst | 5 + doc/ref/states/all/salt.states.boto_asg.rst | 5 + doc/ref/states/all/salt.states.boto_cfn.rst | 5 + .../all/salt.states.boto_cloudfront.rst | 5 + .../all/salt.states.boto_cloudtrail.rst | 5 + .../all/salt.states.boto_cloudwatch_alarm.rst | 5 + .../all/salt.states.boto_cloudwatch_event.rst | 6 + .../all/salt.states.boto_cognitoidentity.rst | 5 + .../all/salt.states.boto_datapipeline.rst | 5 + .../states/all/salt.states.boto_dynamodb.rst | 5 + doc/ref/states/all/salt.states.boto_ec2.rst | 5 + .../all/salt.states.boto_elasticache.rst | 5 + .../salt.states.boto_elasticsearch_domain.rst | 5 + doc/ref/states/all/salt.states.boto_elb.rst | 5 + doc/ref/states/all/salt.states.boto_elbv2.rst | 6 + doc/ref/states/all/salt.states.boto_iam.rst | 5 + .../states/all/salt.states.boto_iam_role.rst | 5 + doc/ref/states/all/salt.states.boto_iot.rst | 5 + .../states/all/salt.states.boto_kinesis.rst | 6 + doc/ref/states/all/salt.states.boto_kms.rst | 5 + .../states/all/salt.states.boto_lambda.rst | 5 + doc/ref/states/all/salt.states.boto_lc.rst | 5 + doc/ref/states/all/salt.states.boto_rds.rst | 5 + .../states/all/salt.states.boto_route53.rst | 5 + doc/ref/states/all/salt.states.boto_s3.rst | 5 + .../states/all/salt.states.boto_s3_bucket.rst | 5 + .../states/all/salt.states.boto_secgroup.rst | 5 + doc/ref/states/all/salt.states.boto_sns.rst | 5 + doc/ref/states/all/salt.states.boto_sqs.rst | 5 + doc/ref/states/all/salt.states.boto_vpc.rst | 5 + doc/ref/states/all/salt.states.bower.rst | 5 + doc/ref/states/all/salt.states.btrfs.rst | 5 + doc/ref/states/all/salt.states.cabal.rst | 5 + doc/ref/states/all/salt.states.ceph.rst | 6 + doc/ref/states/all/salt.states.chef.rst | 5 + .../states/all/salt.states.chronos_job.rst | 5 + doc/ref/states/all/salt.states.cimc.rst | 5 + doc/ref/states/all/salt.states.cisconso.rst | 6 + doc/ref/states/all/salt.states.composer.rst | 5 + doc/ref/states/all/salt.states.consul.rst | 6 + doc/ref/states/all/salt.states.cryptdev.rst | 5 + doc/ref/states/all/salt.states.csf.rst | 6 + doc/ref/states/all/salt.states.cyg.rst | 5 + doc/ref/states/all/salt.states.ddns.rst | 5 + .../states/all/salt.states.dellchassis.rst | 5 + .../all/salt.states.docker_container.rst | 5 + .../states/all/salt.states.docker_image.rst | 5 + .../states/all/salt.states.docker_network.rst | 5 + .../states/all/salt.states.docker_volume.rst | 5 + doc/ref/states/all/salt.states.drac.rst | 5 + doc/ref/states/all/salt.states.dvs.rst | 5 + .../states/all/salt.states.elasticsearch.rst | 5 + .../all/salt.states.elasticsearch_index.rst | 5 + ...lt.states.elasticsearch_index_template.rst | 5 + doc/ref/states/all/salt.states.eselect.rst | 5 + doc/ref/states/all/salt.states.esxcluster.rst | 5 + .../states/all/salt.states.esxdatacenter.rst | 5 + doc/ref/states/all/salt.states.esxi.rst | 5 + doc/ref/states/all/salt.states.esxvm.rst | 5 + doc/ref/states/all/salt.states.ethtool.rst | 6 + doc/ref/states/all/salt.states.gem.rst | 5 + doc/ref/states/all/salt.states.github.rst | 5 + .../states/all/salt.states.glance_image.rst | 5 + doc/ref/states/all/salt.states.glassfish.rst | 5 + doc/ref/states/all/salt.states.glusterfs.rst | 5 + .../states/all/salt.states.gnomedesktop.rst | 5 + doc/ref/states/all/salt.states.grafana.rst | 5 + .../all/salt.states.grafana4_dashboard.rst | 6 + .../all/salt.states.grafana4_datasource.rst | 6 + .../states/all/salt.states.grafana4_org.rst | 6 + .../states/all/salt.states.grafana4_user.rst | 6 + .../all/salt.states.grafana_dashboard.rst | 5 + .../all/salt.states.grafana_datasource.rst | 5 + doc/ref/states/all/salt.states.heat.rst | 6 + doc/ref/states/all/salt.states.helm.rst | 6 + doc/ref/states/all/salt.states.hg.rst | 5 + doc/ref/states/all/salt.states.icinga2.rst | 6 + doc/ref/states/all/salt.states.ifttt.rst | 5 + doc/ref/states/all/salt.states.incron.rst | 5 + .../all/salt.states.influxdb08_database.rst | 6 + .../all/salt.states.influxdb08_user.rst | 6 + .../salt.states.influxdb_continuous_query.rst | 6 + .../all/salt.states.influxdb_database.rst | 5 + .../salt.states.influxdb_retention_policy.rst | 6 + .../states/all/salt.states.influxdb_user.rst | 5 + doc/ref/states/all/salt.states.infoblox_a.rst | 6 + .../states/all/salt.states.infoblox_cname.rst | 6 + .../all/salt.states.infoblox_host_record.rst | 6 + .../states/all/salt.states.infoblox_range.rst | 6 + doc/ref/states/all/salt.states.ipmi.rst | 5 + doc/ref/states/all/salt.states.jboss7.rst | 5 + doc/ref/states/all/salt.states.jenkins.rst | 5 + doc/ref/states/all/salt.states.junos.rst | 5 + doc/ref/states/all/salt.states.kapacitor.rst | 5 + doc/ref/states/all/salt.states.kernelpkg.rst | 5 + doc/ref/states/all/salt.states.keystone.rst | 5 + .../all/salt.states.keystone_domain.rst | 5 + .../all/salt.states.keystone_endpoint.rst | 5 + .../states/all/salt.states.keystone_group.rst | 5 + .../all/salt.states.keystone_project.rst | 5 + .../states/all/salt.states.keystone_role.rst | 5 + .../all/salt.states.keystone_role_grant.rst | 5 + .../all/salt.states.keystone_service.rst | 5 + .../states/all/salt.states.keystone_user.rst | 5 + doc/ref/states/all/salt.states.keystore.rst | 5 + doc/ref/states/all/salt.states.kubernetes.rst | 5 + doc/ref/states/all/salt.states.layman.rst | 5 + doc/ref/states/all/salt.states.ldap.rst | 5 + .../states/all/salt.states.libcloud_dns.rst | 6 + .../all/salt.states.libcloud_loadbalancer.rst | 6 + .../all/salt.states.libcloud_storage.rst | 6 + doc/ref/states/all/salt.states.logadm.rst | 5 + doc/ref/states/all/salt.states.lvs_server.rst | 5 + .../states/all/salt.states.lvs_service.rst | 5 + doc/ref/states/all/salt.states.lxc.rst | 5 + doc/ref/states/all/salt.states.lxd.rst | 5 + .../states/all/salt.states.lxd_container.rst | 5 + doc/ref/states/all/salt.states.lxd_image.rst | 5 + .../states/all/salt.states.lxd_profile.rst | 5 + .../states/all/salt.states.marathon_app.rst | 5 + doc/ref/states/all/salt.states.memcached.rst | 5 + doc/ref/states/all/salt.states.modjk.rst | 5 + .../states/all/salt.states.modjk_worker.rst | 5 + .../all/salt.states.mongodb_database.rst | 5 + .../states/all/salt.states.mongodb_user.rst | 5 + doc/ref/states/all/salt.states.monit.rst | 5 + .../states/all/salt.states.mssql_database.rst | 5 + .../states/all/salt.states.mssql_login.rst | 5 + doc/ref/states/all/salt.states.mssql_role.rst | 5 + doc/ref/states/all/salt.states.mssql_user.rst | 5 + doc/ref/states/all/salt.states.msteams.rst | 6 + .../states/all/salt.states.mysql_database.rst | 5 + .../states/all/salt.states.mysql_grants.rst | 5 + .../states/all/salt.states.mysql_query.rst | 5 + doc/ref/states/all/salt.states.mysql_user.rst | 5 + .../all/salt.states.net_napalm_yang.rst | 5 + .../all/salt.states.neutron_network.rst | 5 + .../all/salt.states.neutron_secgroup.rst | 5 + .../all/salt.states.neutron_secgroup_rule.rst | 5 + .../states/all/salt.states.neutron_subnet.rst | 5 + doc/ref/states/all/salt.states.nexus.rst | 5 + doc/ref/states/all/salt.states.nfs_export.rst | 5 + doc/ref/states/all/salt.states.npm.rst | 5 + doc/ref/states/all/salt.states.nxos.rst | 5 + .../states/all/salt.states.nxos_upgrade.rst | 5 + .../all/salt.states.openstack_config.rst | 5 + .../all/salt.states.openvswitch_bridge.rst | 5 + .../states/all/salt.states.openvswitch_db.rst | 5 + .../all/salt.states.openvswitch_port.rst | 5 + doc/ref/states/all/salt.states.opsgenie.rst | 5 + doc/ref/states/all/salt.states.pagerduty.rst | 5 + ...alt.states.pagerduty_escalation_policy.rst | 5 + .../all/salt.states.pagerduty_schedule.rst | 5 + .../all/salt.states.pagerduty_service.rst | 5 + .../states/all/salt.states.pagerduty_user.rst | 5 + doc/ref/states/all/salt.states.panos.rst | 5 + doc/ref/states/all/salt.states.pbm.rst | 5 + doc/ref/states/all/salt.states.pcs.rst | 5 + doc/ref/states/all/salt.states.pdbedit.rst | 5 + doc/ref/states/all/salt.states.pecl.rst | 5 + .../states/all/salt.states.portage_config.rst | 5 + doc/ref/states/all/salt.states.ports.rst | 5 + doc/ref/states/all/salt.states.powerpath.rst | 5 + doc/ref/states/all/salt.states.probes.rst | 5 + doc/ref/states/all/salt.states.pushover.rst | 5 + .../states/all/salt.states.pyrax_queues.rst | 5 + .../states/all/salt.states.rbac_solaris.rst | 5 + doc/ref/states/all/salt.states.rbenv.rst | 5 + doc/ref/states/all/salt.states.rdp.rst | 5 + doc/ref/states/all/salt.states.redismod.rst | 5 + doc/ref/states/all/salt.states.restconf.rst | 6 + doc/ref/states/all/salt.states.rsync.rst | 5 + doc/ref/states/all/salt.states.rvm.rst | 5 + .../all/salt.states.serverdensity_device.rst | 5 + doc/ref/states/all/salt.states.slack.rst | 5 + doc/ref/states/all/salt.states.smartos.rst | 5 + doc/ref/states/all/salt.states.smtp.rst | 5 + doc/ref/states/all/salt.states.snapper.rst | 6 + doc/ref/states/all/salt.states.solrcloud.rst | 6 + doc/ref/states/all/salt.states.splunk.rst | 5 + .../states/all/salt.states.splunk_search.rst | 5 + doc/ref/states/all/salt.states.sqlite3.rst | 5 + doc/ref/states/all/salt.states.ssh_pki.rst | 5 - doc/ref/states/all/salt.states.statuspage.rst | 5 + .../states/all/salt.states.supervisord.rst | 5 + doc/ref/states/all/salt.states.svn.rst | 5 + doc/ref/states/all/salt.states.sysrc.rst | 5 + .../all/salt.states.telemetry_alert.rst | 5 + .../states/all/salt.states.testinframod.rst | 6 + doc/ref/states/all/salt.states.tomcat.rst | 5 + .../states/all/salt.states.trafficserver.rst | 5 + doc/ref/states/all/salt.states.tuned.rst | 5 + doc/ref/states/all/salt.states.vagrant.rst | 5 + doc/ref/states/all/salt.states.vault.rst | 6 + doc/ref/states/all/salt.states.vbox_guest.rst | 5 + doc/ref/states/all/salt.states.victorops.rst | 5 + doc/ref/states/all/salt.states.virt.rst | 5 + doc/ref/states/all/salt.states.webutil.rst | 5 + .../all/salt.states.win_dsc_resource.rst | 5 - doc/ref/states/all/salt.states.wordpress.rst | 5 + doc/ref/states/all/salt.states.xml.rst | 5 + doc/ref/states/all/salt.states.xmpp.rst | 5 + .../states/all/salt.states.zabbix_action.rst | 5 + .../states/all/salt.states.zabbix_host.rst | 5 + .../all/salt.states.zabbix_hostgroup.rst | 5 + .../all/salt.states.zabbix_mediatype.rst | 6 + .../all/salt.states.zabbix_template.rst | 5 + .../states/all/salt.states.zabbix_user.rst | 5 + .../all/salt.states.zabbix_usergroup.rst | 5 + .../all/salt.states.zabbix_usermacro.rst | 5 + .../all/salt.states.zabbix_valuemap.rst | 5 + doc/ref/states/all/salt.states.zcbuildout.rst | 5 + doc/ref/states/all/salt.states.zenoss.rst | 5 + doc/ref/states/all/salt.states.zfs.rst | 5 + .../states/all/salt.states.zk_concurrency.rst | 5 + doc/ref/states/all/salt.states.zone.rst | 5 + doc/ref/states/all/salt.states.zookeeper.rst | 5 + doc/ref/states/all/salt.states.zpool.rst | 5 + doc/ref/states/highstate.rst | 3 +- doc/security/index.rst | 122 +- doc/topics/beacons/index.rst | 4 +- doc/topics/cloud/dimensiondata.rst | 6 +- doc/topics/cloud/parallels.rst | 2 +- doc/topics/cloud/vmware.rst | 5 +- doc/topics/cloud/windows.rst | 58 +- doc/topics/development/tests/index.rst | 14 +- doc/topics/event/master_events.rst | 21 +- doc/topics/highavailability/index.rst | 17 +- doc/topics/jobs/index.rst | 28 +- doc/topics/metrics/index.rst | 212 - doc/topics/netapi/netapi-enable-clients.rst | 4 - doc/topics/performance/index.rst | 14 - doc/topics/performance/mmap_cache.rst | 258 - doc/topics/performance/worker_pools.rst | 281 - doc/topics/releases/0.8.9.rst | 2 +- doc/topics/releases/2017.7.0.rst | 15 +- doc/topics/releases/2017.7.3.rst | 2 +- doc/topics/releases/2018.3.0.rst | 6 +- doc/topics/releases/2019.2.1.rst | 2 +- doc/topics/releases/3001.rst | 4 +- doc/topics/releases/3008.0.md | 370 - doc/topics/releases/3008.1.md | 117 - doc/topics/releases/index.rst | 3 +- .../releases/templates/3006.27.md.template | 28 - .../releases/templates/3008.0.md.template | 65 - .../releases/templates/3008.1.md.template | 14 - doc/topics/resources/architecture.rst | 248 - .../resources/authoring/connection_module.rst | 163 - .../resources/authoring/execution_modules.rst | 160 - doc/topics/resources/authoring/index.rst | 100 - doc/topics/resources/authoring/packaging.rst | 113 - doc/topics/resources/authoring/pillar.rst | 131 - .../resources/authoring/state_modules.rst | 145 - doc/topics/resources/configuration.rst | 140 - doc/topics/resources/derived.rst | 221 - doc/topics/resources/index.rst | 153 - doc/topics/resources/operations.rst | 187 - doc/topics/resources/state_authoring.rst | 189 - doc/topics/resources/targeting.rst | 179 - doc/topics/resources/tutorial.rst | 211 - doc/topics/sdb/index.rst | 19 + doc/topics/ssh/roster.rst | 22 - doc/topics/targeting/index.rst | 2 - doc/topics/thorium/index.rst | 268 +- doc/topics/tracing/index.rst | 160 - doc/topics/transports/ssl.rst | 256 - doc/topics/troubleshooting/master.rst | 4 +- doc/topics/tutorials/cloud_controller.rst | 2 +- doc/topics/tutorials/esxi_proxy_minion.rst | 8 +- doc/topics/tutorials/gitfs.rst | 83 +- doc/topics/tutorials/master-cluster.rst | 234 +- doc/topics/tutorials/modules.rst | 2 +- doc/topics/tutorials/multimaster_pki.rst | 6 +- doc/topics/tutorials/quickstart.rst | 6 +- doc/topics/tutorials/starting_states.rst | 2 +- .../windows/windows-package-manager.rst | 11 +- noxfile.py | 59 +- pkg/common/conf/master | 12 - pkg/common/env-cleanup-rules.yml | 4 - pkg/debian/changelog | 1383 --- pkg/debian/salt-master.preinst | 25 +- pkg/debian/salt-minion.preinst | 24 +- pkg/old/shar/build_shar.sh | 4 +- pkg/old/shar/salt.sh | 2 +- pkg/rpm/salt.spec | 164 +- .../nsis/installer/Salt-Minion-Setup.nsi | 89 +- requirements/base.txt | 57 +- requirements/constraints.txt | 34 +- requirements/pytest.txt | 1 - requirements/static/ci/common.txt | 4 - requirements/static/ci/lint.txt | 11 +- requirements/static/ci/linux.txt | 18 +- requirements/static/ci/py3.10/changelog.lock | 2 +- requirements/static/ci/py3.10/cloud.lock | 119 +- requirements/static/ci/py3.10/darwin.lock | 112 +- requirements/static/ci/py3.10/docs.lock | 80 +- requirements/static/ci/py3.10/freebsd.lock | 142 +- requirements/static/ci/py3.10/lint.lock | 118 +- requirements/static/ci/py3.10/linux.lock | 111 +- requirements/static/ci/py3.10/tools.lock | 4 +- requirements/static/ci/py3.10/windows.lock | 100 +- requirements/static/ci/py3.11/changelog.lock | 4 +- requirements/static/ci/py3.11/cloud.lock | 153 +- requirements/static/ci/py3.11/darwin.lock | 138 +- requirements/static/ci/py3.11/docs.lock | 82 +- requirements/static/ci/py3.11/freebsd.lock | 161 +- requirements/static/ci/py3.11/lint.lock | 155 +- requirements/static/ci/py3.11/linux.lock | 144 +- requirements/static/ci/py3.11/tools.lock | 4 +- requirements/static/ci/py3.11/windows.lock | 129 +- requirements/static/ci/py3.12/changelog.lock | 4 +- requirements/static/ci/py3.12/cloud.lock | 119 +- requirements/static/ci/py3.12/darwin.lock | 111 +- requirements/static/ci/py3.12/docs.lock | 82 +- requirements/static/ci/py3.12/freebsd.lock | 136 +- requirements/static/ci/py3.12/lint.lock | 116 +- requirements/static/ci/py3.12/linux.lock | 111 +- requirements/static/ci/py3.12/tools.lock | 4 +- requirements/static/ci/py3.12/windows.lock | 102 +- requirements/static/ci/py3.13/changelog.lock | 4 +- requirements/static/ci/py3.13/cloud.lock | 118 +- requirements/static/ci/py3.13/darwin.lock | 115 +- requirements/static/ci/py3.13/docs.lock | 80 +- requirements/static/ci/py3.13/freebsd.lock | 129 +- requirements/static/ci/py3.13/lint.lock | 118 +- requirements/static/ci/py3.13/linux.lock | 113 +- .../static/ci/py3.13/tools-virustotal.lock | 10 +- requirements/static/ci/py3.13/tools.lock | 47 +- requirements/static/ci/py3.13/windows.lock | 104 +- requirements/static/ci/py3.14/changelog.lock | 4 +- requirements/static/ci/py3.14/cloud.lock | 118 +- requirements/static/ci/py3.14/darwin.lock | 115 +- requirements/static/ci/py3.14/docs.lock | 80 +- requirements/static/ci/py3.14/freebsd.lock | 129 +- requirements/static/ci/py3.14/lint.lock | 118 +- requirements/static/ci/py3.14/linux.lock | 113 +- requirements/static/ci/py3.14/tools.lock | 4 +- requirements/static/ci/py3.14/windows.lock | 104 +- requirements/static/ci/py3.9/changelog.lock | 5 +- requirements/static/ci/py3.9/cloud.lock | 116 +- requirements/static/ci/py3.9/darwin.lock | 104 +- requirements/static/ci/py3.9/docs.lock | 77 +- requirements/static/ci/py3.9/freebsd.lock | 210 +- requirements/static/ci/py3.9/lint.lock | 113 +- requirements/static/ci/py3.9/linux.lock | 106 +- requirements/static/ci/py3.9/tools.lock | 4 +- requirements/static/ci/py3.9/windows.lock | 93 +- requirements/static/ci/tools.txt | 1 + requirements/static/pkg/darwin.txt | 1 + requirements/static/pkg/freebsd.txt | 1 + requirements/static/pkg/py3.10/darwin.lock | 61 +- requirements/static/pkg/py3.10/freebsd.lock | 76 +- requirements/static/pkg/py3.10/linux.lock | 57 +- requirements/static/pkg/py3.10/windows.lock | 67 +- requirements/static/pkg/py3.11/darwin.lock | 65 +- requirements/static/pkg/py3.11/freebsd.lock | 75 +- requirements/static/pkg/py3.11/linux.lock | 59 +- requirements/static/pkg/py3.11/windows.lock | 69 +- requirements/static/pkg/py3.12/darwin.lock | 65 +- requirements/static/pkg/py3.12/freebsd.lock | 75 +- requirements/static/pkg/py3.12/linux.lock | 59 +- requirements/static/pkg/py3.12/windows.lock | 69 +- requirements/static/pkg/py3.13/darwin.lock | 65 +- requirements/static/pkg/py3.13/freebsd.lock | 71 +- requirements/static/pkg/py3.13/linux.lock | 59 +- requirements/static/pkg/py3.13/windows.lock | 73 +- requirements/static/pkg/py3.14/darwin.lock | 65 +- requirements/static/pkg/py3.14/freebsd.lock | 71 +- requirements/static/pkg/py3.14/linux.lock | 59 +- requirements/static/pkg/py3.14/windows.lock | 73 +- requirements/static/pkg/py3.9/darwin.lock | 59 +- requirements/static/pkg/py3.9/freebsd.lock | 106 +- requirements/static/pkg/py3.9/linux.lock | 56 +- requirements/static/pkg/py3.9/windows.lock | 63 +- requirements/static/pkg/windows.txt | 1 + requirements/zeromq.txt | 4 +- salt/__init__.py | 13 - salt/_compat.py | 16 +- salt/_logging/__init__.py | 1 - salt/_logging/impl.py | 33 +- salt/auth/__init__.py | 293 +- salt/auth/django.py | 218 + salt/auth/keystone.py | 42 + salt/auth/mysql.py | 124 + salt/auth/pam.py | 31 +- salt/auth/pki.py | 148 + salt/auth/yubico.py | 95 + salt/beacons/adb.py | 166 + salt/beacons/aix_account.py | 63 + salt/beacons/avahi_announce.py | 264 + salt/beacons/bonjour_announce.py | 246 + salt/beacons/btmp.py | 310 + salt/beacons/cert_info.py | 9 - salt/beacons/glxinfo.py | 81 + salt/beacons/haproxy.py | 102 + salt/beacons/junos_rre_keys.py | 37 + salt/beacons/log_beacon.py | 3 +- salt/beacons/napalm_beacon.py | 354 + salt/beacons/sensehat.py | 100 + salt/beacons/service.py | 15 - salt/beacons/smartos_imgadm.py | 108 + salt/beacons/smartos_vmadm.py | 135 + salt/beacons/status.py | 4 +- salt/beacons/telegram_bot_msg.py | 129 + salt/beacons/twilio_txt_msg.py | 103 + salt/beacons/wtmp.py | 367 + salt/cache/__init__.py | 196 +- salt/cache/localfs.py | 3 +- salt/cache/localfs_key.py | 481 - salt/cache/mmap_cache.py | 334 - salt/cache/mmap_key.py | 437 - salt/cache/mysql_cache.py | 4 +- salt/cache/redis_cache.py | 524 +- salt/channel/client.py | 244 +- salt/channel/server.py | 3831 ++----- salt/cli/batch.py | 678 +- salt/cli/call.py | 60 +- salt/cli/caller.py | 254 - salt/cli/daemons.py | 7 +- salt/cli/salt.py | 151 +- salt/client/__init__.py | 294 +- salt/client/mixins.py | 10 - salt/client/netapi.py | 3 +- salt/client/ssh/__init__.py | 631 +- salt/client/ssh/client.py | 4 - salt/client/ssh/shell.py | 6 +- salt/client/ssh/ssh_py_shim.py | 10 +- salt/client/ssh/wrapper/mine.py | 20 +- salt/client/ssh/wrapper/pillar.py | 16 +- salt/client/ssh/wrapper/publish.py | 20 +- salt/client/ssh/wrapper/slsutil.py | 32 +- salt/client/ssh/wrapper/ssh_pki.py | 680 -- salt/client/ssh/wrapper/state.py | 86 +- salt/client/ssh/wrapper/x509_v2.py | 1001 -- salt/cloud/__init__.py | 1 - salt/cloud/clouds/aliyun.py | 1006 ++ salt/cloud/clouds/clc.py | 441 + salt/cloud/clouds/cloudstack.py | 580 ++ salt/cloud/clouds/digitalocean.py | 1513 +++ salt/cloud/clouds/dimensiondata.py | 616 ++ salt/cloud/clouds/ec2.py | 5238 ++++++++++ salt/cloud/clouds/gce.py | 2590 +++++ salt/cloud/clouds/gogrid.py | 578 ++ salt/cloud/clouds/hetzner.py | 664 ++ salt/cloud/clouds/joyent.py | 1218 +++ salt/cloud/clouds/libvirt.py | 741 ++ salt/cloud/clouds/linode.py | 1605 +++ salt/cloud/clouds/lxc.py | 549 + salt/cloud/clouds/oneandone.py | 901 ++ salt/cloud/clouds/opennebula.py | 4562 +++++++++ salt/cloud/clouds/openstack.py | 922 ++ salt/cloud/clouds/packet.py | 623 ++ salt/cloud/clouds/parallels.py | 606 ++ salt/cloud/clouds/profitbricks.py | 1231 +++ salt/cloud/clouds/proxmox.py | 1371 +++ salt/cloud/clouds/pyrax.py | 106 + salt/cloud/clouds/qingcloud.py | 899 ++ salt/cloud/clouds/scaleway.py | 471 + salt/cloud/clouds/softlayer.py | 659 ++ salt/cloud/clouds/softlayer_hw.py | 661 ++ salt/cloud/clouds/tencentcloud.py | 1042 ++ salt/cloud/clouds/vagrant.py | 361 + salt/cloud/clouds/virtualbox.py | 449 + salt/cloud/clouds/vmware.py | 4958 +++++++++ salt/cloud/clouds/vultrpy.py | 652 ++ salt/cloud/clouds/xen.py | 1305 +++ salt/cluster/__init__.py | 3 - salt/cluster/consensus/__init__.py | 7 - salt/cluster/consensus/peer.py | 458 - salt/cluster/consensus/raft/__init__.py | 79 - salt/cluster/consensus/raft/log.py | 1079 -- salt/cluster/consensus/raft/node.py | 1445 --- salt/cluster/consensus/raft/scheduler.py | 177 - salt/cluster/consensus/raft/util.py | 94 - salt/cluster/consensus/rpc.py | 117 - salt/cluster/consensus/service.py | 1145 --- salt/cluster/consensus/storage.py | 262 - salt/cluster/file_sync.py | 121 - salt/cluster/healthchecks.py | 179 - salt/cluster/migration.py | 228 - salt/cluster/ring.py | 352 - salt/cluster/ring_membership.py | 341 - salt/cluster/state_sync.py | 468 - salt/config/__init__.py | 401 +- salt/config/worker_pools.py | 264 - salt/crypt.py | 919 +- salt/daemons/masterapi.py | 30 +- salt/engines/docker_events.py | 113 + salt/engines/fluent.py | 91 + salt/engines/http_logstash.py | 99 + salt/engines/ircbot.py | 351 + salt/engines/junos_syslog.py | 402 + salt/engines/libvirt_events.py | 759 ++ salt/engines/logentries.py | 219 + salt/engines/logstash_engine.py | 78 + salt/engines/napalm_syslog.py | 357 + salt/engines/redis_sentinel.py | 124 + salt/engines/slack.py | 947 ++ salt/engines/slack_bolt_engine.py | 1090 ++ salt/engines/sqs_events.py | 188 + salt/engines/stalekey.py | 144 + salt/exceptions.py | 6 - salt/executors/docker.py | 59 + salt/executors/transactional_update.py | 132 + salt/features.py | 2 +- salt/fileclient.py | 165 +- salt/fileserver/gitfs.py | 26 +- salt/fileserver/hgfs.py | 963 ++ salt/fileserver/roots.py | 10 +- salt/fileserver/s3fs.py | 890 ++ salt/fileserver/svnfs.py | 787 ++ salt/grains/chronos.py | 35 + salt/grains/cimc.py | 34 + salt/grains/core.py | 58 +- salt/grains/esxi.py | 115 + salt/grains/extra.py | 5 +- salt/grains/fibre_channel.py | 74 + salt/grains/fx2.py | 124 + salt/grains/iscsi.py | 109 + salt/grains/junos.py | 65 + salt/grains/marathon.py | 49 + salt/grains/mdata.py | 154 + salt/grains/metadata.py | 142 + salt/grains/metadata_gce.py | 47 + salt/grains/napalm.py | 445 + salt/grains/nvme.py | 60 + salt/grains/nxos.py | 40 + salt/grains/panos.py | 34 + salt/grains/philips_hue.py | 51 + salt/grains/resources.py | 28 - salt/grains/smartos.py | 215 + salt/grains/ssh_sample.py | 44 + salt/grains/truststore.py | 44 - salt/grains/zfs.py | 83 + salt/key.py | 581 +- salt/loader/__init__.py | 260 +- salt/loader/context.py | 24 +- salt/loader/lazy.py | 66 +- salt/log_handlers/fluent_mod.py | 547 + salt/log_handlers/log4mongo_mod.py | 90 + salt/log_handlers/logstash_mod.py | 461 + salt/log_handlers/sentry_mod.py | 238 + salt/master.py | 1753 +--- salt/matchers/compound_match.py | 2 - salt/matchers/confirm_top.py | 10 +- salt/matchers/managing_minion_match.py | 41 - salt/matchers/pillar_exact_match.py | 11 +- salt/matchers/pillar_match.py | 11 +- salt/matchers/pillar_pcre_match.py | 11 +- salt/matchers/resource_match.py | 56 - salt/metaproxy/deltaproxy.py | 32 +- salt/metaproxy/proxy.py | 18 +- salt/minion.py | 1656 +-- salt/modules/acme.py | 436 + salt/modules/ansiblegate.py | 25 +- salt/modules/apcups.py | 115 + salt/modules/apkpkg.py | 602 ++ salt/modules/aptly.py | 549 + salt/modules/aptpkg.py | 838 +- salt/modules/asymmetric.py | 330 - salt/modules/augeas_cfg.py | 544 + salt/modules/aws_sqs.py | 296 + salt/modules/bamboohr.py | 290 + salt/modules/baredoc.py | 11 +- salt/modules/bigip.py | 2430 +++++ salt/modules/bluez_bluetooth.py | 301 + salt/modules/boto3_elasticache.py | 1285 +++ salt/modules/boto3_elasticsearch.py | 1402 +++ salt/modules/boto3_route53.py | 1208 +++ salt/modules/boto3_sns.py | 437 + salt/modules/boto_apigateway.py | 2120 ++++ salt/modules/boto_asg.py | 1100 ++ salt/modules/boto_cfn.py | 326 + salt/modules/boto_cloudfront.py | 443 + salt/modules/boto_cloudtrail.py | 524 + salt/modules/boto_cloudwatch.py | 362 + salt/modules/boto_cloudwatch_event.py | 333 + salt/modules/boto_cognitoidentity.py | 478 + salt/modules/boto_datapipeline.py | 263 + salt/modules/boto_dynamodb.py | 504 + salt/modules/boto_ec2.py | 2630 +++++ salt/modules/boto_efs.py | 512 + salt/modules/boto_elasticache.py | 837 ++ salt/modules/boto_elasticsearch_domain.py | 530 + salt/modules/boto_elb.py | 1172 +++ salt/modules/boto_elbv2.py | 344 + salt/modules/boto_iam.py | 2572 +++++ salt/modules/boto_iot.py | 926 ++ salt/modules/boto_kinesis.py | 647 ++ salt/modules/boto_kms.py | 692 ++ salt/modules/boto_lambda.py | 1262 +++ salt/modules/boto_rds.py | 1210 +++ salt/modules/boto_route53.py | 1117 ++ salt/modules/boto_s3.py | 163 + salt/modules/boto_s3_bucket.py | 1092 ++ salt/modules/boto_secgroup.py | 940 ++ salt/modules/boto_sns.py | 267 + salt/modules/boto_sqs.py | 244 + salt/modules/boto_ssm.py | 139 + salt/modules/boto_vpc.py | 4147 ++++++++ salt/modules/bower.py | 244 + salt/modules/bsd_shadow.py | 239 + salt/modules/btrfs.py | 1260 +++ salt/modules/cabal.py | 170 + salt/modules/capirca_acl.py | 1285 +++ salt/modules/ceph.py | 752 ++ salt/modules/chassis.py | 52 + salt/modules/chroot.py | 7 +- salt/modules/cimc.py | 1019 ++ salt/modules/ciscoconfparse_mod.py | 452 + salt/modules/cisconso.py | 155 + salt/modules/cmdmod.py | 95 +- salt/modules/composer.py | 435 + salt/modules/consul.py | 2432 +++++ salt/modules/container_resource.py | 401 + salt/modules/cpan.py | 228 + salt/modules/csf.py | 714 ++ salt/modules/cyg.py | 307 + salt/modules/daemontools.py | 259 + salt/modules/datadog_api.py | 261 + salt/modules/ddns.py | 287 + salt/modules/deb_apache.py | 329 + salt/modules/deb_postgres.py | 167 + salt/modules/djangomod.py | 319 + salt/modules/dnsmasq.py | 185 + salt/modules/dockercompose.py | 1078 ++ salt/modules/dockermod.py | 7089 +++++++++++++ salt/modules/drac.py | 467 + salt/modules/dracr.py | 1542 +++ salt/modules/drbd.py | 278 + salt/modules/ebuildpkg.py | 1288 +++ salt/modules/eix.py | 67 + salt/modules/elasticsearch.py | 1742 ++++ salt/modules/eselect.py | 215 + salt/modules/esxcluster.py | 59 + salt/modules/esxdatacenter.py | 59 + salt/modules/esxi.py | 91 + salt/modules/esxvm.py | 60 + salt/modules/file.py | 620 +- salt/modules/freebsd_sysctl.py | 185 + salt/modules/freebsd_update.py | 231 + salt/modules/freebsdjail.py | 254 + salt/modules/freebsdkmod.py | 266 + salt/modules/freebsdpkg.py | 566 ++ salt/modules/freebsdports.py | 471 + salt/modules/freebsdservice.py | 515 + salt/modules/freezer.py | 338 + salt/modules/gcp_addon.py | 140 + salt/modules/gem.py | 388 + salt/modules/genesis.py | 753 ++ salt/modules/gentoo_service.py | 380 + salt/modules/gentoolkitmod.py | 315 + salt/modules/github.py | 1906 ++++ salt/modules/glanceng.py | 203 + salt/modules/glassfish.py | 697 ++ salt/modules/glusterfs.py | 835 ++ salt/modules/gnomedesktop.py | 312 + salt/modules/google_chat.py | 56 + salt/modules/gpg.py | 419 +- salt/modules/grafana4.py | 1236 +++ salt/modules/grains.py | 7 +- salt/modules/grub_legacy.py | 125 + salt/modules/guestfs.py | 96 + salt/modules/hadoop.py | 169 + salt/modules/haproxyconn.py | 445 + salt/modules/heat.py | 880 ++ salt/modules/helm.py | 1557 +++ salt/modules/hg.py | 310 + salt/modules/icinga2.py | 188 + salt/modules/ifttt.py | 91 + salt/modules/ilo.py | 648 ++ salt/modules/influxdb08mod.py | 658 ++ salt/modules/influxdbmod.py | 718 ++ salt/modules/infoblox.py | 669 ++ salt/modules/inspectlib/__init__.py | 51 + salt/modules/inspectlib/collector.py | 601 ++ salt/modules/inspectlib/dbhandle.py | 106 + salt/modules/inspectlib/entities.py | 84 + salt/modules/inspectlib/exceptions.py | 38 + salt/modules/inspectlib/fsdb.py | 341 + salt/modules/inspectlib/kiwiproc.py | 268 + salt/modules/inspectlib/query.py | 558 + salt/modules/inspector.py | 284 + salt/modules/introspect.py | 141 + salt/modules/ipmi.py | 952 ++ salt/modules/jboss7.py | 600 ++ salt/modules/jboss7_cli.py | 416 + salt/modules/jenkinsmod.py | 503 + salt/modules/jira_mod.py | 270 + salt/modules/k8s.py | 838 ++ salt/modules/kapacitor.py | 247 + salt/modules/kerberos.py | 278 + salt/modules/kernelpkg_linux_apt.py | 2 +- salt/modules/keystone.py | 1548 +++ salt/modules/keystoneng.py | 871 ++ salt/modules/keystore.py | 230 + salt/modules/kubeadm.py | 1382 +++ salt/modules/kubernetesmod.py | 1593 +++ salt/modules/launchctl_service.py | 362 + salt/modules/layman.py | 154 + salt/modules/ldap3.py | 600 ++ salt/modules/ldapmod.py | 213 + salt/modules/libcloud_compute.py | 858 ++ salt/modules/libcloud_dns.py | 414 + salt/modules/libcloud_loadbalancer.py | 460 + salt/modules/libcloud_storage.py | 437 + salt/modules/linux_shadow.py | 30 +- salt/modules/localemod.py | 8 +- salt/modules/logadm.py | 339 + salt/modules/lvs.py | 499 + salt/modules/lxc.py | 4859 +++++++++ salt/modules/lxd.py | 3605 +++++++ salt/modules/mac_brew_pkg.py | 118 +- salt/modules/mac_system.py | 2 +- salt/modules/macdefaults.py | 452 +- salt/modules/makeconf.py | 808 ++ salt/modules/mandrill.py | 227 + salt/modules/marathon.py | 220 + salt/modules/mattermost.py | 137 + salt/modules/mdata.py | 177 + salt/modules/memcached.py | 260 + salt/modules/modjk.py | 513 + salt/modules/mongodb.py | 1145 +++ salt/modules/monit.py | 276 + salt/modules/moosefs.py | 164 + salt/modules/mount.py | 1 - salt/modules/mssql.py | 547 + salt/modules/msteams.py | 88 + salt/modules/munin.py | 107 + salt/modules/nagios.py | 269 + salt/modules/nagios_rpc.py | 196 + salt/modules/namecheap_domains.py | 549 + salt/modules/namecheap_domains_dns.py | 232 + salt/modules/namecheap_domains_ns.py | 203 + salt/modules/namecheap_ssl.py | 838 ++ salt/modules/namecheap_users.py | 97 + salt/modules/napalm_network.py | 13 +- salt/modules/netbox.py | 1179 +++ salt/modules/netbsd_sysctl.py | 161 + salt/modules/netbsdservice.py | 312 + salt/modules/netmiko_mod.py | 610 ++ salt/modules/netscaler.py | 1017 ++ salt/modules/network.py | 2 +- salt/modules/neutron.py | 1651 +++ salt/modules/neutronng.py | 571 ++ salt/modules/nexus.py | 758 ++ salt/modules/nginx.py | 173 + salt/modules/nilrt_ip.py | 1110 ++ salt/modules/nix.py | 296 + salt/modules/nixpkg.py | 522 - salt/modules/nova.py | 781 ++ salt/modules/nspawn.py | 1492 +++ salt/modules/nxos.py | 127 + salt/modules/omapi.py | 128 + salt/modules/openbsd_sysctl.py | 145 + salt/modules/openbsdpkg.py | 410 + salt/modules/openbsdrcctl_service.py | 289 + salt/modules/openbsdservice.py | 330 + salt/modules/openscap.py | 235 + salt/modules/openstack_config.py | 143 + salt/modules/openstack_mng.py | 104 + salt/modules/openvswitch.py | 674 ++ salt/modules/opkg.py | 1676 +++ salt/modules/opsgenie.py | 115 + salt/modules/pagerduty.py | 194 + salt/modules/pagerduty_util.py | 483 + salt/modules/panos.py | 2654 +++++ salt/modules/parallels.py | 766 ++ salt/modules/pcs.py | 530 + salt/modules/pdbedit.py | 408 + salt/modules/pecl.py | 161 + salt/modules/peeringdb.py | 307 + salt/modules/pf.py | 366 + salt/modules/philips_hue.py | 68 + salt/modules/pillar.py | 156 +- salt/modules/pip.py | 29 +- salt/modules/pkgin.py | 2 +- salt/modules/pkgng.py | 12 +- salt/modules/portage_config.py | 760 ++ salt/modules/postfix.py | 575 ++ salt/modules/postgres.py | 20 +- salt/modules/poudriere.py | 337 + salt/modules/powerpath.py | 126 + salt/modules/ps.py | 2 +- salt/modules/purefa.py | 1287 +++ salt/modules/purefb.py | 526 + salt/modules/pushbullet.py | 83 + salt/modules/pushover_notify.py | 127 + salt/modules/qemu_img.py | 67 + salt/modules/qemu_nbd.py | 136 + salt/modules/rallydev.py | 272 + salt/modules/random_org.py | 753 ++ salt/modules/rbenv.py | 435 + salt/modules/rebootmgr.py | 360 + salt/modules/redismod.py | 740 ++ salt/modules/restconf.py | 110 + salt/modules/riak.py | 280 + salt/modules/rpm_lowpkg.py | 5 +- salt/modules/runit.py | 728 ++ salt/modules/rvm.py | 456 + salt/modules/s3.py | 445 + salt/modules/s6.py | 192 + salt/modules/saltutil.py | 68 +- salt/modules/seed.py | 23 +- salt/modules/selinux.py | 296 +- salt/modules/sensehat.py | 294 + salt/modules/sensors.py | 52 + salt/modules/serverdensity_device.py | 269 + salt/modules/servicenow.py | 172 + salt/modules/slackware_service.py | 351 + salt/modules/slsutil.py | 8 +- salt/modules/smartos_imgadm.py | 510 + salt/modules/smartos_nictagadm.py | 271 + salt/modules/smartos_virt.py | 239 + salt/modules/smartos_vmadm.py | 879 ++ salt/modules/smtp.py | 178 + salt/modules/solr.py | 1335 +++ salt/modules/solrcloud.py | 570 ++ salt/modules/splunk.py | 339 + salt/modules/splunk_search.py | 305 + salt/modules/ssh_pki.py | 880 -- salt/modules/state.py | 188 +- salt/modules/status.py | 20 +- salt/modules/statuspage.py | 521 + salt/modules/suse_apache.py | 119 + salt/modules/suse_ip.py | 1254 +++ salt/modules/svn.py | 458 + salt/modules/swarm.py | 481 + salt/modules/swift.py | 195 + salt/modules/sysbench.py | 251 + salt/modules/sysrc.py | 150 + salt/modules/system.py | 3 +- salt/modules/system_profiler.py | 142 + salt/modules/telegram.py | 134 + salt/modules/telemetry.py | 464 + salt/modules/test.py | 4 - salt/modules/testinframod.py | 297 + salt/modules/timezone.py | 73 +- salt/modules/tls.py | 81 +- salt/modules/tomcat.py | 732 ++ salt/modules/trafficserver.py | 477 + salt/modules/transactional_update.py | 1151 +++ salt/modules/travisci.py | 85 + salt/modules/tuned.py | 120 + salt/modules/twilio_notify.py | 114 + salt/modules/udev.py | 34 +- salt/modules/uptime.py | 125 + salt/modules/useradd.py | 3 +- salt/modules/uwsgi.py | 45 + salt/modules/varnish.py | 151 + salt/modules/vault.py | 1328 +++ salt/modules/vbox_guest.py | 354 + salt/modules/vboxmanage.py | 589 ++ salt/modules/vcenter.py | 59 + salt/modules/victorops.py | 223 + salt/modules/virt.py | 9017 +++++++++++++++++ salt/modules/virtualenv_mod.py | 24 +- salt/modules/vmctl.py | 395 + salt/modules/vsphere.py | 23 +- salt/modules/win_dsc.py | 2 +- salt/modules/win_dsc_resource.py | 306 - salt/modules/win_file.py | 31 +- salt/modules/win_smtp_server.py | 12 +- salt/modules/win_timezone.py | 1 - salt/modules/win_useradd.py | 5 +- salt/modules/wordpress.py | 209 + salt/modules/x509.py | 14 +- salt/modules/x509_v2.py | 45 +- salt/modules/xapi_virt.py | 914 ++ salt/modules/xbpspkg.py | 607 ++ salt/modules/xmpp.py | 193 + salt/modules/yumpkg.py | 54 +- salt/modules/zabbix.py | 2825 ++++++ salt/modules/zcbuildout.py | 1020 ++ salt/modules/zenoss.py | 214 + salt/modules/zfs.py | 1251 +++ salt/modules/znc.py | 114 + salt/modules/zookeeper.py | 715 ++ salt/modules/zpool.py | 1696 ++++ salt/netapi/rest_cherrypy/app.py | 30 +- salt/netapi/rest_tornado/saltnado.py | 44 +- salt/output/__init__.py | 2 - salt/output/dson.py | 66 + salt/output/key.py | 20 +- salt/output/newline_values_only.py | 109 + salt/output/no_out_quiet.py | 27 + salt/output/overstatestage.py | 33 + salt/output/pony.py | 68 + salt/output/profile.py | 90 + salt/output/virt_query.py | 46 + salt/pillar/__init__.py | 641 +- salt/pillar/cmd_yamlex.py | 26 + salt/pillar/cobbler.py | 63 + salt/pillar/confidant.py | 119 + salt/pillar/consul_pillar.py | 396 + salt/pillar/csvpillar.py | 91 + salt/pillar/digicert.py | 45 + salt/pillar/django_orm.py | 251 + salt/pillar/ec2_pillar.py | 311 + salt/pillar/etcd_pillar.py | 149 + salt/pillar/foreman.py | 137 + salt/pillar/git_pillar.py | 13 +- salt/pillar/hg_pillar.py | 126 + salt/pillar/hiera.py | 36 + salt/pillar/http_json.py | 128 + salt/pillar/http_yaml.py | 119 + salt/pillar/libvirt.py | 175 + salt/pillar/makostack.py | 559 + salt/pillar/mongo.py | 186 + salt/pillar/mysql.py | 146 + salt/pillar/nacl.py | 34 + salt/pillar/netbox.py | 1202 +++ salt/pillar/neutron.py | 100 + salt/pillar/pepa.py | 685 ++ salt/pillar/pillar_ldap.py | 358 + salt/pillar/puppet.py | 26 + salt/pillar/redismod.py | 108 + salt/pillar/rethinkdb_pillar.py | 173 + salt/pillar/s3.py | 483 + salt/pillar/saltclass.py | 63 + salt/pillar/sqlcipher.py | 134 + salt/pillar/sqlite3.py | 111 + salt/pillar/stack.py | 34 +- salt/pillar/svn_pillar.py | 201 + salt/pillar/varstack_pillar.py | 44 + salt/pillar/vault.py | 226 + salt/pillar/venafi.py | 43 + salt/pillar/virtkey.py | 24 + salt/pillar/vmware_pillar.py | 487 + salt/proxy/arista_pyeapi.py | 178 + salt/proxy/chronos.py | 81 + salt/proxy/cimc.py | 342 + salt/proxy/cisconso.py | 349 + salt/proxy/docker.py | 78 + salt/proxy/esxcluster.py | 311 + salt/proxy/esxdatacenter.py | 303 + salt/proxy/esxi.py | 569 ++ salt/proxy/esxvm.py | 289 + salt/proxy/fx2.py | 380 + salt/proxy/junos.py | 286 + salt/proxy/marathon.py | 81 + salt/proxy/napalm.py | 359 + salt/proxy/netmiko_px.py | 402 + salt/proxy/nxos.py | 506 + salt/proxy/nxos_api.py | 208 + salt/proxy/panos.py | 498 + salt/proxy/philips_hue.py | 543 + salt/proxy/rest_sample.py | 256 + salt/proxy/restconf.py | 235 + salt/proxy/ssh_sample.py | 238 + salt/proxy/vcenter.py | 344 + salt/queues/pgjsonb_queue.py | 260 + salt/queues/sqlite_queue.py | 239 + salt/renderers/aws_kms.py | 256 + salt/renderers/cheetah.py | 30 + salt/renderers/dson.py | 47 + salt/renderers/genshi.py | 52 + salt/renderers/gpg.py | 13 +- salt/renderers/hjson.py | 31 + salt/renderers/json5.py | 50 + salt/renderers/pass.py | 185 + salt/renderers/pydsl.py | 391 + salt/renderers/pyobjects.py | 35 +- salt/renderers/stateconf.py | 2 +- salt/renderers/wempy.py | 29 + salt/resources/__init__.py | 3 - salt/resources/dummy/__init__.py | 370 - salt/resources/dummy/modules/__init__.py | 3 - salt/resources/dummy/modules/test.py | 31 - salt/resources/ssh/__init__.py | 521 - salt/resources/ssh/modules/__init__.py | 3 - salt/resources/ssh/modules/cmd.py | 125 - salt/resources/ssh/modules/pkg.py | 180 - salt/resources/ssh/modules/state.py | 499 - salt/resources/ssh/modules/test.py | 32 - salt/returners/appoptics_return.py | 209 + salt/returners/carbon_return.py | 304 + salt/returners/cassandra_cql_return.py | 453 + salt/returners/couchbase_return.py | 354 + salt/returners/couchdb_return.py | 361 + salt/returners/elasticsearch_return.py | 406 + salt/returners/etcd_return.py | 277 + salt/returners/highstate_return.py | 28 +- salt/returners/influxdb_return.py | 320 + salt/returners/kafka_return.py | 91 + salt/returners/librato_return.py | 154 + salt/returners/local_cache.py | 20 +- salt/returners/mattermost_returner.py | 164 + salt/returners/memcache_return.py | 226 + salt/returners/mongo_future_return.py | 390 + salt/returners/mongo_return.py | 241 + salt/returners/mysql.py | 643 ++ salt/returners/nagios_nrdp_return.py | 199 + salt/returners/odbc.py | 322 + salt/returners/pgjsonb.py | 8 +- salt/returners/pushover_returner.py | 262 + salt/returners/redis_return.py | 360 + salt/returners/salt_cache.py | 394 - salt/returners/sentry_return.py | 181 + salt/returners/slack_returner.py | 237 + salt/returners/slack_webhook_return.py | 411 + salt/returners/sms_return.py | 113 + salt/returners/smtp_return.py | 273 + salt/returners/splunk.py | 219 + salt/returners/sqlite3_return.py | 293 + salt/returners/telegram_return.py | 83 + salt/returners/xmpp_return.py | 198 + salt/returners/zabbix_return.py | 99 + salt/roster/cache.py | 2 +- salt/roster/cloud.py | 109 + salt/roster/clustershell.py | 54 + salt/roster/terraform.py | 256 + salt/runners/asam.py | 384 + salt/runners/batch.py | 208 - salt/runners/bgp.py | 426 + salt/runners/cache.py | 131 - salt/runners/cloud.py | 188 + salt/runners/cluster.py | 1160 --- salt/runners/ddns.py | 373 + salt/runners/digicertapi.py | 783 ++ salt/runners/drac.py | 219 + salt/runners/f5.py | 325 + salt/runners/fileserver.py | 6 - salt/runners/launchd.py | 58 + salt/runners/lxc.py | 623 ++ salt/runners/mattermost.py | 174 + salt/runners/mine.py | 4 +- salt/runners/nacl.py | 271 + salt/runners/pagerduty.py | 182 + salt/runners/pillar.py | 43 +- salt/runners/pkg.py | 53 + salt/runners/pki.py | 106 - salt/runners/resource.py | 144 - salt/runners/smartos_vmadm.py | 386 + salt/runners/spacewalk.py | 367 + salt/runners/state.py | 130 - salt/runners/thin.py | 72 + salt/runners/vault.py | 1280 +++ salt/runners/venafiapi.py | 251 + salt/runners/virt.py | 542 + salt/runners/vistara.py | 216 + salt/runners/winrepo.py | 2 +- salt/scripts.py | 57 - salt/sdb/cache.py | 104 + salt/sdb/confidant.py | 125 + salt/sdb/consul.py | 78 + salt/sdb/couchdb.py | 113 + salt/sdb/etcd_db.py | 103 + salt/sdb/keyring_db.py | 95 + salt/sdb/memcached.py | 69 + salt/sdb/redis_sdb.py | 81 + salt/sdb/rest.py | 124 + salt/sdb/sqlite3.py | 146 + salt/sdb/tism.py | 75 + salt/sdb/vault.py | 120 + salt/serializers/keyvalue.py | 121 + salt/serializers/msgpack.py | 92 +- salt/serializers/plist.py | 71 + salt/serializers/python.py | 42 + salt/spm/__init__.py | 8 +- salt/spm/pkgdb/sqlite3.py | 5 +- salt/spm/pkgfiles/local.py | 3 +- salt/state.py | 2271 +++-- salt/states/acme.py | 158 + salt/states/alternatives.py | 248 + salt/states/aptpkg.py | 52 + salt/states/archive.py | 69 +- salt/states/artifactory.py | 172 + salt/states/augeas.py | 305 + salt/states/aws_sqs.py | 100 + salt/states/bigip.py | 3357 ++++++ salt/states/boto3_elasticache.py | 1219 +++ salt/states/boto3_elasticsearch.py | 880 ++ salt/states/boto3_route53.py | 1018 ++ salt/states/boto3_sns.py | 389 + salt/states/boto_apigateway.py | 2387 +++++ salt/states/boto_asg.py | 925 ++ salt/states/boto_cfn.py | 328 + salt/states/boto_cloudfront.py | 226 + salt/states/boto_cloudtrail.py | 412 + salt/states/boto_cloudwatch_alarm.py | 211 + salt/states/boto_cloudwatch_event.py | 409 + salt/states/boto_cognitoidentity.py | 429 + salt/states/boto_datapipeline.py | 614 ++ salt/states/boto_dynamodb.py | 877 ++ salt/states/boto_ec2.py | 2093 ++++ salt/states/boto_elasticache.py | 539 + salt/states/boto_elasticsearch_domain.py | 389 + salt/states/boto_elb.py | 1535 +++ salt/states/boto_elbv2.py | 369 + salt/states/boto_iam.py | 2034 ++++ salt/states/boto_iam_role.py | 737 ++ salt/states/boto_iot.py | 882 ++ salt/states/boto_kinesis.py | 467 + salt/states/boto_kms.py | 368 + salt/states/boto_lambda.py | 1137 +++ salt/states/boto_lc.py | 339 + salt/states/boto_rds.py | 865 ++ salt/states/boto_route53.py | 629 ++ salt/states/boto_s3.py | 306 + salt/states/boto_s3_bucket.py | 754 ++ salt/states/boto_secgroup.py | 989 ++ salt/states/boto_sns.py | 283 + salt/states/boto_sqs.py | 315 + salt/states/boto_vpc.py | 2180 ++++ salt/states/bower.py | 299 + salt/states/btrfs.py | 373 + salt/states/cabal.py | 203 + salt/states/ceph.py | 79 + salt/states/chef.py | 168 + salt/states/chronos_job.py | 144 + salt/states/cimc.py | 503 + salt/states/cisconso.py | 107 + salt/states/cmd.py | 45 - salt/states/composer.py | 302 + salt/states/consul.py | 204 + salt/states/cryptdev.py | 192 + salt/states/csf.py | 408 + salt/states/cyg.py | 258 + salt/states/ddns.py | 140 + salt/states/dellchassis.py | 775 ++ salt/states/docker_container.py | 2537 +++++ salt/states/docker_image.py | 523 + salt/states/docker_network.py | 983 ++ salt/states/docker_volume.py | 229 + salt/states/drac.py | 176 + salt/states/dvs.py | 792 ++ salt/states/elasticsearch.py | 609 ++ salt/states/elasticsearch_index.py | 109 + salt/states/elasticsearch_index_template.py | 119 + salt/states/eselect.py | 79 + salt/states/esxcluster.py | 618 ++ salt/states/esxdatacenter.py | 154 + salt/states/esxi.py | 1792 ++++ salt/states/esxvm.py | 671 ++ salt/states/ethtool.py | 395 + salt/states/file.py | 857 +- salt/states/firewalld.py | 37 - salt/states/gem.py | 251 + salt/states/github.py | 804 ++ salt/states/glance_image.py | 100 + salt/states/glassfish.py | 663 ++ salt/states/glusterfs.py | 448 + salt/states/gnomedesktop.py | 282 + salt/states/gpg.py | 392 +- salt/states/grafana.py | 404 + salt/states/grafana4_dashboard.py | 528 + salt/states/grafana4_datasource.py | 227 + salt/states/grafana4_org.py | 278 + salt/states/grafana4_user.py | 189 + salt/states/grafana_dashboard.py | 553 + salt/states/grafana_datasource.py | 227 + salt/states/grains.py | 67 +- salt/states/heat.py | 351 + salt/states/helm.py | 359 + salt/states/hg.py | 226 + salt/states/icinga2.py | 287 + salt/states/ifttt.py | 87 + salt/states/incron.py | 200 + salt/states/influxdb08_database.py | 106 + salt/states/influxdb08_user.py | 130 + salt/states/influxdb_continuous_query.py | 102 + salt/states/influxdb_database.py | 79 + salt/states/influxdb_retention_policy.py | 158 + salt/states/influxdb_user.py | 161 + salt/states/infoblox_a.py | 144 + salt/states/infoblox_cname.py | 136 + salt/states/infoblox_host_record.py | 183 + salt/states/infoblox_range.py | 205 + salt/states/ipmi.py | 308 + salt/states/jboss7.py | 694 ++ salt/states/jenkins.py | 120 + salt/states/junos.py | 604 ++ salt/states/kapacitor.py | 209 + salt/states/kernelpkg.py | 215 + salt/states/keystone.py | 908 ++ salt/states/keystone_domain.py | 116 + salt/states/keystone_endpoint.py | 179 + salt/states/keystone_group.py | 135 + salt/states/keystone_project.py | 136 + salt/states/keystone_role.py | 101 + salt/states/keystone_role_grant.py | 137 + salt/states/keystone_service.py | 122 + salt/states/keystone_user.py | 148 + salt/states/keystore.py | 158 + salt/states/kubernetes.py | 908 ++ salt/states/layman.py | 89 + salt/states/ldap.py | 542 + salt/states/libcloud_dns.py | 190 + salt/states/libcloud_loadbalancer.py | 191 + salt/states/libcloud_storage.py | 192 + salt/states/linux_acl.py | 31 +- salt/states/logadm.py | 163 + salt/states/lvs_server.py | 203 + salt/states/lvs_service.py | 146 + salt/states/lxc.py | 722 ++ salt/states/lxd.py | 294 + salt/states/lxd_container.py | 832 ++ salt/states/lxd_image.py | 377 + salt/states/lxd_profile.py | 274 + salt/states/macdefaults.py | 110 +- salt/states/marathon_app.py | 147 + salt/states/memcached.py | 159 + salt/states/modjk.py | 130 + salt/states/modjk_worker.py | 225 + salt/states/mongodb_database.py | 61 + salt/states/mongodb_user.py | 233 + salt/states/monit.py | 89 + salt/states/mount.py | 1 - salt/states/mssql_database.py | 107 + salt/states/mssql_login.py | 122 + salt/states/mssql_role.py | 86 + salt/states/mssql_user.py | 123 + salt/states/msteams.py | 93 + salt/states/mysql_database.py | 196 + salt/states/mysql_grants.py | 284 + salt/states/mysql_query.py | 421 + salt/states/mysql_user.py | 304 + salt/states/net_napalm_yang.py | 299 + salt/states/netntp.py | 2 +- salt/states/network.py | 46 +- salt/states/neutron_network.py | 156 + salt/states/neutron_secgroup.py | 152 + salt/states/neutron_secgroup_rule.py | 170 + salt/states/neutron_subnet.py | 163 + salt/states/nexus.py | 157 + salt/states/nfs_export.py | 207 + salt/states/npm.py | 373 + salt/states/nxos.py | 386 + salt/states/nxos_upgrade.py | 118 + salt/states/openstack_config.py | 126 + salt/states/openvswitch_bridge.py | 142 + salt/states/openvswitch_db.py | 71 + salt/states/openvswitch_port.py | 433 + salt/states/opsgenie.py | 147 + salt/states/pagerduty.py | 70 + salt/states/pagerduty_escalation_policy.py | 164 + salt/states/pagerduty_schedule.py | 164 + salt/states/pagerduty_service.py | 119 + salt/states/pagerduty_user.py | 44 + salt/states/panos.py | 1578 +++ salt/states/pbm.py | 561 + salt/states/pcs.py | 1198 +++ salt/states/pdbedit.py | 143 + salt/states/pecl.py | 122 + salt/states/pip_state.py | 8 +- salt/states/pkg.py | 107 +- salt/states/pkgrepo.py | 46 +- salt/states/portage_config.py | 171 + salt/states/ports.py | 188 + salt/states/powerpath.py | 90 + salt/states/probes.py | 437 + salt/states/pushover.py | 142 + salt/states/pyrax_queues.py | 117 + salt/states/rbac_solaris.py | 197 + salt/states/rbenv.py | 255 + salt/states/rdp.py | 57 + salt/states/redismod.py | 189 + salt/states/restconf.py | 198 + salt/states/rsync.py | 185 + salt/states/rvm.py | 254 + salt/states/saltmod.py | 19 +- salt/states/saltutil.py | 14 - salt/states/schedule.py | 4 +- salt/states/selinux.py | 233 +- salt/states/serverdensity_device.py | 230 + salt/states/service.py | 6 - salt/states/slack.py | 174 + salt/states/smartos.py | 1309 +++ salt/states/smtp.py | 93 + salt/states/snapper.py | 228 + salt/states/solrcloud.py | 160 + salt/states/splunk.py | 160 + salt/states/splunk_search.py | 117 + salt/states/sqlite3.py | 472 + salt/states/ssh_pki.py | 911 -- salt/states/statuspage.py | 592 ++ salt/states/supervisord.py | 343 + salt/states/svn.py | 304 + salt/states/sysrc.py | 117 + salt/states/telemetry_alert.py | 220 + salt/states/test.py | 2 +- salt/states/testinframod.py | 52 + salt/states/tomcat.py | 344 + salt/states/trafficserver.py | 355 + salt/states/tuned.py | 149 + salt/states/user.py | 11 +- salt/states/vagrant.py | 364 + salt/states/vault.py | 137 + salt/states/vbox_guest.py | 137 + salt/states/victorops.py | 110 + salt/states/virt.py | 2450 +++++ salt/states/webutil.py | 153 + salt/states/win_dsc_resource.py | 204 - salt/states/wordpress.py | 181 + salt/states/x509.py | 8 +- salt/states/x509_v2.py | 424 +- salt/states/xml.py | 70 + salt/states/xmpp.py | 105 + salt/states/zabbix_action.py | 270 + salt/states/zabbix_host.py | 735 ++ salt/states/zabbix_hostgroup.py | 173 + salt/states/zabbix_mediatype.py | 457 + salt/states/zabbix_template.py | 996 ++ salt/states/zabbix_user.py | 496 + salt/states/zabbix_usergroup.py | 274 + salt/states/zabbix_usermacro.py | 280 + salt/states/zabbix_valuemap.py | 240 + salt/states/zcbuildout.py | 241 + salt/states/zenoss.py | 94 + salt/states/zfs.py | 1091 ++ salt/states/zk_concurrency.py | 222 + salt/states/zone.py | 1272 +++ salt/states/zookeeper.py | 418 + salt/states/zpool.py | 443 + salt/template.py | 2 +- salt/thorium/__init__.py | 5 +- salt/thorium/calc.py | 22 +- salt/thorium/check.py | 25 +- salt/thorium/file.py | 13 +- salt/thorium/key.py | 17 +- salt/thorium/local.py | 21 +- salt/thorium/reg.py | 31 +- salt/thorium/runner.py | 25 +- salt/thorium/status.py | 24 +- salt/thorium/timer.py | 18 +- salt/thorium/wheel.py | 18 +- salt/tops/mongo.py | 27 +- salt/tops/saltclass.py | 62 +- salt/transport/base.py | 70 +- salt/transport/client.py | 76 +- salt/transport/ipc.py | 849 ++ salt/transport/server.py | 4 +- salt/transport/tcp.py | 666 +- salt/transport/tls_util.py | 188 - salt/transport/ws.py | 218 +- salt/transport/zeromq.py | 817 +- salt/utils/ansible.py | 61 +- salt/utils/asymmetric.py | 263 - salt/utils/asynchronous.py | 139 +- salt/utils/atomicfile.py | 47 - salt/utils/aws.py | 10 +- salt/utils/batch_manager.py | 503 - salt/utils/batch_output.py | 360 - salt/utils/batch_state.py | 513 - salt/utils/cache.py | 73 +- salt/utils/channel.py | 96 +- salt/utils/cloud.py | 30 +- salt/utils/data.py | 16 +- salt/utils/decorators/__init__.py | 8 - salt/utils/dictdiffer.py | 96 +- salt/utils/dictupdate.py | 17 +- salt/utils/etcd_util.py | 16 +- salt/utils/event.py | 405 +- salt/utils/extmods.py | 1 - salt/utils/files.py | 3 +- salt/utils/functools.py | 29 +- salt/utils/gitfs.py | 750 +- salt/utils/hashutils.py | 13 - salt/utils/http.py | 9 +- salt/utils/jid.py | 2 +- salt/utils/jinja.py | 6 - salt/utils/job.py | 17 +- salt/utils/master.py | 110 +- salt/utils/metrics.py | 429 - salt/utils/minion.py | 3 +- salt/utils/minions.py | 723 +- salt/utils/mmap_cache.py | 1850 ---- salt/utils/msgpack.py | 87 +- salt/utils/openstack/neutron.py | 2 +- salt/utils/optsdict.py | 1250 --- salt/utils/ostruststore.py | 132 - salt/utils/parsers.py | 112 +- salt/utils/pkg/deb.py | 807 -- salt/utils/pkg/rpm.py | 7 +- salt/utils/platform.py | 7 +- salt/utils/process.py | 71 +- salt/utils/pycrypto.py | 25 +- salt/utils/reactor.py | 31 +- salt/utils/relenv.py | 127 - salt/utils/resource_registry.py | 1108 -- salt/utils/resources.py | 129 - salt/utils/schedule.py | 29 +- salt/utils/secret.py | 321 - salt/utils/sshpki.py | 934 -- salt/utils/state.py | 27 +- salt/utils/stringutils.py | 120 +- salt/utils/tarfileutil.py | 67 - salt/utils/templates.py | 25 +- salt/utils/thin.py | 201 +- salt/utils/timeutil.py | 79 +- salt/utils/tracing.py | 435 - salt/utils/url.py | 20 +- salt/utils/vault/__init__.py | 486 + salt/utils/vault/api.py | 467 + salt/utils/vault/auth.py | 241 + salt/utils/vault/cache.py | 431 + salt/utils/vault/client.py | 526 + salt/utils/vault/exceptions.py | 101 + salt/utils/vault/factory.py | 1052 ++ salt/utils/vault/helpers.py | 156 + salt/utils/vault/kv.py | 259 + salt/utils/vault/leases.py | 603 ++ salt/utils/verify.py | 4 +- salt/utils/versions.py | 189 +- salt/utils/vmware.py | 20 +- salt/utils/win_functions.py | 21 - salt/utils/win_network.py | 14 +- salt/utils/x509.py | 283 +- salt/utils/yamldumper.py | 63 - salt/utils/yamlloader.py | 30 - salt/utils/yamlloader_old.py | 29 - salt/utils/zeromq.py | 2 +- salt/version.py | 28 +- salt/wheel/key.py | 64 +- setup.py | 12 +- tests/conftest.py | 330 +- tests/filename_map.yml | 37 + tests/integration/client/test_standard.py | 20 +- .../cloud/clouds/test_digitalocean.py | 117 + .../cloud/clouds/test_dimensiondata.py | 49 + tests/integration/cloud/clouds/test_ec2.py | 219 + tests/integration/cloud/clouds/test_gce.py | 45 + tests/integration/cloud/clouds/test_gogrid.py | 29 + tests/integration/cloud/clouds/test_linode.py | 81 + .../cloud/clouds/test_oneandone.py | 37 + .../cloud/clouds/test_openstack.py | 226 + .../cloud/clouds/test_profitbricks.py | 127 + .../cloud/clouds/test_tencentcloud.py | 97 + .../cloud/clouds/test_virtualbox.py | 482 + tests/integration/cloud/clouds/test_vmware.py | 122 + .../integration/cloud/clouds/test_vultrpy.py | 93 + tests/integration/conftest.py | 35 +- .../integration/externalapi/test_venafiapi.py | 140 + tests/integration/files/conf/master | 22 +- tests/integration/files/conf/minion | 8 + .../file/base/_modules/runtests_helpers.py | 21 +- .../files/file/base/custom.tar.gz.SHA256.sig | Bin 72 -> 0 bytes .../files/file/base/custom.tar.gz.sig | Bin 72 -> 0 bytes .../files/file/base/grail/scene33.SHA256.sig | Bin 71 -> 0 bytes .../files/file/base/grail/scene33.sig | Bin 71 -> 0 bytes tests/integration/files/file/base/running.sls | 2 +- .../files/vault/policies/salt_master.hcl | 44 + .../files/vault/policies/salt_minion.hcl | 29 + .../files/vault/policies/salt_minion_old.hcl | 29 + tests/integration/modules/test_boto_iam.py | 35 + tests/integration/modules/test_boto_sns.py | 118 + tests/integration/modules/test_cmdmod.py | 713 ++ tests/integration/modules/test_file.py | 7 +- tests/integration/modules/test_gem.py | 188 + .../integration/modules/test_gentoolkitmod.py | 19 + tests/integration/modules/test_lxc.py | 100 + tests/integration/modules/test_mysql.py | 6 +- tests/integration/modules/test_saltcheck.py | 29 +- tests/integration/modules/test_status.py | 20 +- tests/integration/modules/test_sysrc.py | 58 + tests/integration/modules/test_tls.py | 6 - tests/integration/output/test_output.py | 1 - tests/integration/pillar/test_git_pillar.py | 7 - tests/integration/renderers/test_pydsl.py | 72 + .../returners/test_appoptics_return.py | 57 + .../returners/test_librato_return.py | 60 + tests/integration/runners/test_fileserver.py | 40 +- tests/integration/states/test_alternatives.py | 92 + tests/integration/states/test_boto_sns.py | 312 + tests/integration/states/test_bower.py | 72 + tests/integration/states/test_cmd.py | 10 +- tests/integration/states/test_keystone.py | 334 + tests/integration/states/test_lxd.py | 24 + .../integration/states/test_lxd_container.py | 212 + tests/integration/states/test_lxd_image.py | 42 + tests/integration/states/test_lxd_profile.py | 75 + .../integration/states/test_mysql_database.py | 194 + tests/integration/states/test_mysql_grants.py | 275 + tests/integration/states/test_network.py | 7 - tests/integration/states/test_supervisord.py | 281 + tests/integration/states/test_x509.py | 1 - tests/monitoring/analyze_stats.py | 27 +- tests/monitoring/docker-compose.yml | 2 + tests/monitoring/raas.conf | 41 + tests/monitoring/render_panels.py | 384 - tests/pytests/conftest.py | 37 +- tests/pytests/functional/cache/test_consul.py | 11 +- .../pytests/functional/cache/test_localfs.py | 47 - .../functional/cache/test_localfs_key.py | 224 - .../cache/test_mmap_cache_driver.py | 182 - .../pytests/functional/cache/test_mmap_key.py | 375 - tests/pytests/functional/cache/test_mysql.py | 6 +- tests/pytests/functional/cache/test_redis.py | 5 +- .../functional/channel/test_auth_downgrade.py | 76 +- .../functional/channel/test_pool_routing.py | 561 - .../functional/channel/test_req_channel.py | 32 +- .../channel/test_req_server_channel.py | 1 - .../pytests/functional/channel/test_server.py | 64 +- .../channel/test_worker_pool_starvation.py | 314 - tests/pytests/functional/cli/test_batch.py | 236 +- .../pytests/functional/cli/test_salt_cloud.py | 23 - .../pytests/functional/cli/test_salt_run_.py | 19 +- .../functional/cluster/consensus/conftest.py | 139 - .../functional/cluster/consensus/smoke.txt | 234 - .../consensus/test_cluster_ready_scenarios.py | 463 - .../cluster/consensus/test_raft_compaction.py | 403 - .../cluster/consensus/test_raft_learner.py | 266 - .../cluster/consensus/test_raft_scenarios.py | 850 -- .../cluster/consensus/test_raft_service.py | 1345 --- .../cluster/consensus/test_raft_transport.py | 594 -- .../functional/cluster/test_join_crypto.py | 181 - .../functional/cluster/test_master_keys.py | 188 - tests/pytests/functional/cluster/test_ring.py | 262 - .../functional/fileserver/hgfs/test_hgfs.py | 466 + .../functional/fileserver/test_roots.py | 4 +- .../pytests/functional/formulas/test_nginx.py | 4 - .../functional/formulas/test_sudoers.py | 4 - .../pytests/functional/formulas/test_users.py | 4 - tests/pytests/functional/formulas/test_vim.py | 12 +- .../pytests/functional/loader/test_dunder.py | 62 +- .../pytests/functional/loader/test_loader.py | 29 +- .../log_handlers/test_logstash_mod.py | 91 + .../functional/master/test_event_publisher.py | 186 + .../master/test_event_publisher_perms.py | 124 + .../pytests/functional/minion/test_fd_leak.py | 140 +- .../minion/test_per_master_queue.py | 12 +- .../functional/modules/file/test_is_link.py | 22 - .../state/requisites/test_aggregate.py | 123 - .../modules/state/requisites/test_mixed.py | 136 +- .../state/requisites/test_onchanges.py | 21 +- .../modules/state/requisites/test_prereq.py | 177 +- .../modules/state/requisites/test_require.py | 355 +- .../modules/state/test_jinja_filters.py | 26 +- .../functional/modules/state/test_parallel.py | 110 - .../functional/modules/state/test_state.py | 63 +- .../functional/modules/test_asymmetric.py | 834 -- .../functional/modules/test_chocolatey.py | 22 +- .../pytests/functional/modules/test_cmdmod.py | 629 -- .../functional/modules/test_dockermod.py | 101 + .../functional/modules/test_freezer.py | 87 + tests/pytests/functional/modules/test_gpg.py | 172 +- .../modules/test_mac_softwareupdate.py | 2 +- .../functional/modules/test_nilrt_ip.py | 386 + tests/pytests/functional/modules/test_opkg.py | 88 + tests/pytests/functional/modules/test_pip.py | 53 +- tests/pytests/functional/modules/test_pkg.py | 196 +- .../pytests/functional/modules/test_runit.py | 52 + tests/pytests/functional/modules/test_sdb.py | 1 - .../functional/modules/test_ssh_pki.py | 842 -- .../pytests/functional/modules/test_swarm.py | 57 + .../pytests/functional/modules/test_system.py | 17 +- .../pytests/functional/modules/test_vault.py | 305 + .../modules/test_win_dsc_resource.py | 117 - .../functional/modules/test_x509_v2.py | 3 + .../pytests/functional/modules/test_yumpkg.py | 195 +- .../pillar/hg_pillar/test_hg_pillar.py | 69 + .../functional/pillar/test_etcd_pillar.py | 75 + .../functional/pillar/test_pillar_masking.py | 119 - .../functional/returners/test_etcd_return.py | 204 + .../returners/test_salt_cache_integration.py | 168 - .../functional/runners/test_cache_migrate.py | 226 - tests/pytests/functional/sdb/test_etcd_db.py | 49 + .../states/chocolatey/test_post_20.py | 4 - .../functional/states/file/test_append.py | 23 - .../functional/states/file/test_directory.py | 57 - .../functional/states/file/test_keyvalue.py | 93 - .../functional/states/file/test_managed.py | 129 - .../functional/states/file/test_prepend.py | 23 - .../functional/states/file/test_recurse.py | 386 - .../functional/states/file/test_replace.py | 3 +- .../functional/states/file/test_serialize.py | 10 +- .../functional/states/file/test_symlink.py | 34 - .../functional/states/pkgrepo/test_centos.py | 2 +- .../functional/states/pkgrepo/test_debian.py | 334 +- .../functional/states/pkgrepo/test_suse.py | 219 - .../functional/states/rabbitmq/conftest.py | 11 - .../states/rabbitmq/test_cluster.py | 7 +- .../functional/states/rabbitmq/test_plugin.py | 8 +- .../functional/states/rabbitmq/test_policy.py | 7 +- .../states/rabbitmq/test_upstream.py | 7 +- .../functional/states/rabbitmq/test_user.py | 7 +- .../functional/states/rabbitmq/test_vhost.py | 7 +- .../pytests/functional/states/test_archive.py | 124 - .../states/test_docker_container.py | 1207 +++ .../functional/states/test_docker_network.py | 440 + tests/pytests/functional/states/test_gpg.py | 805 +- tests/pytests/functional/states/test_mysql.py | 454 + tests/pytests/functional/states/test_npm.py | 123 + .../functional/states/test_pip_state.py | 64 +- tests/pytests/functional/states/test_pkg.py | 9 +- .../functional/states/test_ssh_auth.py | 11 +- .../pytests/functional/states/test_ssh_pki.py | 1463 --- tests/pytests/functional/states/test_svn.py | 157 + tests/pytests/functional/states/test_user.py | 45 +- .../functional/states/test_virtualenv_mod.py | 78 + .../states/test_win_dsc_resource.py | 152 - .../pytests/functional/states/test_x509_v2.py | 32 +- .../functional/states/test_zookeeper.py | 158 + tests/pytests/functional/test_crypt.py | 20 +- .../functional/test_fileclient_reuse.py | 12 - tests/pytests/functional/test_pip_install.py | 171 +- tests/pytests/functional/test_version.py | 9 - .../functional/transport/ipc/test_client.py | 144 + .../transport/ipc/test_pub_server_channel.py | 167 + .../transport/ipc/test_subscriber.py | 119 + .../functional/transport/server/conftest.py | 86 - .../transport/server/test_ssl_transport.py | 289 - .../functional/transport/tcp/conftest.py | 34 - .../tcp/test_pub_server_stability.py | 111 - .../functional/transport/tcp/test_tcp_ssl.py | 201 - .../transport/tcp/test_tcp_ssl_invalid.py | 226 - .../transport/tcp/test_tcp_ssl_simple.py | 110 - .../functional/transport/ws/__init__.py | 3 - .../functional/transport/ws/conftest.py | 34 - .../functional/transport/ws/test_ws_ssl.py | 194 - .../transport/ws/test_ws_ssl_invalid.py | 226 - .../transport/ws/test_ws_ssl_simple.py | 110 - .../transport/zeromq/test_request_client.py | 106 +- .../functools/test_namespaced_function.py | 43 + .../pytests/functional/utils/pkg/test_deb.py | 111 - tests/pytests/functional/utils/test_http.py | 7 - .../functional/utils/test_mmap_cache.py | 379 - .../functional/utils/test_ostruststore.py | 76 - .../pytests/functional/utils/test_process.py | 153 +- tests/pytests/functional/utils/test_vault.py | 164 + .../integration/_logging/test_jid_logging.py | 134 +- .../test_multiple_processes_logging.py | 2 +- .../integration/cli/test_batch_options.py | 647 -- tests/pytests/integration/cli/test_salt.py | 5 - .../pytests/integration/cli/test_salt_call.py | 216 +- .../pytests/integration/cli/test_salt_key.py | 2 + .../integration/cli/test_salt_minion.py | 3 - .../integration/cli/test_salt_syndic.py | 2 - .../integration/cli/test_syndic_eauth.py | 5 +- .../pytests/integration/client/test_runner.py | 1 + tests/pytests/integration/cluster/conftest.py | 514 +- .../integration/cluster/test_basic_cluster.py | 9 - .../integration/cluster/test_failure_modes.py | 243 - .../integration/cluster/test_health_probes.py | 133 - .../cluster/test_isolated_cluster.py | 650 -- .../cluster/test_jobs_migration.py | 399 - .../integration/cluster/test_raft_cluster.py | 212 - .../cluster/test_ring_lifecycle.py | 368 - .../cluster/test_ring_lifecycle_shared_fs.py | 163 - tests/pytests/integration/conftest.py | 40 +- tests/pytests/integration/events/__init__.py | 0 .../integration/events/test_auth_events.py | 216 - tests/pytests/integration/files/snakeoil.crt | 23 - .../pytests/integration/files/snakeoil.crtkey | 51 - tests/pytests/integration/files/snakeoil.key | 28 - tests/pytests/integration/master/test_peer.py | 4 +- .../pytests/integration/minion/test_reauth.py | 4 +- .../integration/minion/test_return_retries.py | 13 - .../integration/minion/test_startup_states.py | 18 - .../modules/saltutil/test_modules.py | 4 - .../modules/saltutil/test_wheel.py | 42 +- .../modules/state/test_queue_race.py | 31 +- .../state/test_state_queue_concurrent.py | 135 - .../integration/modules/test_mac_sysctl.py | 4 +- .../integration/modules/test_pillar.py | 24 +- .../integration/modules/test_ssh_pki.py | 647 -- .../pytests/integration/modules/test_vault.py | 375 + .../pytests/integration/modules/test_virt.py | 567 ++ .../integration/modules/test_x509_v2.py | 18 +- tests/pytests/integration/netapi/conftest.py | 16 - .../netapi/rest_tornado/conftest.py | 10 - .../rest_tornado/test_minions_api_handler.py | 58 +- .../pytests/integration/netapi/test_client.py | 5 +- .../integration/netapi/test_ssh_client.py | 36 +- .../pillar/cache/test_pillar_cache.py | 2 +- .../integration/pillar/test_fileclient.py | 2 +- .../pillar/test_httpclient_in_pillar.py | 14 +- .../integration/pillar/test_pillar_include.py | 6 +- .../pytests/integration/resources/__init__.py | 0 .../pytests/integration/resources/conftest.py | 470 - ...st_cli_offline_expands_resource_targets.py | 221 - .../resources/test_custom_pillar_key.py | 109 - .../resources/test_dummy_resource.py | 652 -- .../resources/test_dynamic_discovery.py | 152 - .../test_multi_minion_grain_targeting.py | 82 - .../integration/resources_ssh/__init__.py | 1 - .../integration/resources_ssh/conftest.py | 193 - .../test_ssh_resource_integration.py | 66 - .../runners/state/orchestrate/test_events.py | 12 +- .../state/orchestrate/test_orchestrate.py | 5 - .../pytests/integration/runners/test_cache.py | 11 +- .../pytests/integration/runners/test_jobs.py | 20 +- .../pytests/integration/runners/test_nacl.py | 165 + .../pytests/integration/runners/test_vault.py | 1324 +++ tests/pytests/integration/sdb/test_etcd_db.py | 91 + tests/pytests/integration/sdb/test_vault.py | 343 + tests/pytests/integration/ssh/conftest.py | 144 - .../integration/ssh/ssh_pki/conftest.py | 435 - .../test_certificate_managed_wrapper_ssh.py | 570 -- .../ssh_pki/test_create_certificate_ssh.py | 302 - .../integration/ssh/state/test_parallel.py | 10 +- .../ssh/state/test_pillar_override.py | 66 +- .../state/test_pillar_override_template.py | 9 +- ...e_highstate_verification_requisite_fail.py | 4 +- ...e_highstate_verification_structure_fail.py | 9 +- .../test_retcode_pillar_render_exception.py | 9 +- .../state/test_retcode_render_exception.py | 16 +- .../test_retcode_render_module_exception.py | 9 +- .../ssh/state/test_retcode_run_fail.py | 9 +- ...test_retcode_state_run_remote_exception.py | 9 +- .../integration/ssh/state/test_state.py | 33 +- .../ssh/state/test_with_import_dir.py | 30 +- tests/pytests/integration/ssh/test_cmdmod.py | 10 +- tests/pytests/integration/ssh/test_config.py | 16 +- tests/pytests/integration/ssh/test_cp.py | 534 +- .../integration/ssh/test_deploy_relenv.py | 38 - tests/pytests/integration/ssh/test_grains.py | 30 +- .../integration/ssh/test_jinja_filters.py | 6 - .../integration/ssh/test_jinja_mods.py | 6 - tests/pytests/integration/ssh/test_log.py | 13 +- tests/pytests/integration/ssh/test_master.py | 7 +- tests/pytests/integration/ssh/test_mine.py | 6 - tests/pytests/integration/ssh/test_pillar.py | 6 - .../ssh/test_pillar_compilation.py | 8 - .../integration/ssh/test_pre_flight.py | 9 - tests/pytests/integration/ssh/test_publish.py | 6 - .../integration/ssh/test_py_versions.py | 13 +- .../pytests/integration/ssh/test_saltcheck.py | 6 - tests/pytests/integration/ssh/test_saltext.py | 128 - tests/pytests/integration/ssh/test_slsutil.py | 16 +- .../pytests/integration/ssh/test_ssh_setup.py | 7 +- tests/pytests/integration/ssh/test_state.py | 141 +- .../pytests/integration/ssh/test_terraform.py | 92 + .../integration/ssh/x509_v2/conftest.py | 450 - .../test_certificate_managed_wrapper.py | 436 - .../ssh/x509_v2/test_create_certificate.py | 333 - tests/pytests/integration/state/__init__.py | 0 .../integration/state/test_mod_beacon.py | 74 - .../integration/states/test_ansiblegate.py | 10 +- tests/pytests/integration/states/test_cron.py | 1 - tests/pytests/integration/states/test_file.py | 66 +- tests/pytests/integration/states/test_idem.py | 12 +- .../integration/states/test_pip_state.py | 39 +- .../integration/states/test_ssh_pki.py | 735 -- .../integration/states/test_x509_v2.py | 87 +- tests/pytests/integration/tracing/__init__.py | 0 .../tracing/test_tracing_jaeger.py | 254 - .../pytests/integration/wheel/test_client.py | 17 +- tests/pytests/integration/wheel/test_key.py | 5 +- tests/pytests/mmapcache-smoke-tests.txt | 45 - tests/pytests/perf/__init__.py | 0 tests/pytests/perf/test_cache_benchmarks.py | 298 - tests/pytests/pkg/conftest.py | 3 - .../pkg/downgrade/test_salt_downgrade.py | 26 +- .../pkg/integration/test_check_imports.py | 35 +- tests/pytests/pkg/integration/test_pip.py | 28 - .../pytests/pkg/integration/test_pkg_meta.py | 9 +- .../pytests/pkg/integration/test_salt_api.py | 7 +- .../pytests/pkg/integration/test_salt_user.py | 5 + tests/pytests/pkg/integration/test_version.py | 55 +- .../systemd/test_service_preservation.py | 135 - .../pytests/pkg/upgrade/test_salt_upgrade.py | 72 +- tests/pytests/run-mmapcache-smoke-tests.sh | 23 - tests/pytests/run-smoke-tests.sh | 28 - tests/pytests/scenarios/blackout/conftest.py | 33 +- tests/pytests/scenarios/cluster/conftest.py | 47 - .../pytests/scenarios/cluster/test_cluster.py | 144 +- .../scenarios/cluster_kind/__init__.py | 0 .../scenarios/cluster_kind/conftest.py | 592 -- .../cluster_kind/setup-in-container.sh | 88 - .../scenarios/cluster_kind/test_basic.py | 118 - tests/pytests/scenarios/compat/conftest.py | 20 - .../scenarios/compat/test_with_versions.py | 1 - tests/pytests/scenarios/daemons/conftest.py | 20 - .../scenarios/daemons/test_salt_as_daemons.py | 75 +- tests/pytests/scenarios/dns/conftest.py | 40 - .../scenarios/dns/multimaster/conftest.py | 71 +- .../scenarios/dns/multimaster/test_dns.py | 5 +- .../failover/multimaster/conftest.py | 82 +- .../multimaster/test_failover_master.py | 88 +- .../pytests/scenarios/multimaster/conftest.py | 80 - .../multimaster/modules/test_test.py | 20 +- .../scenarios/performance/test_performance.py | 81 - tests/pytests/scenarios/queue/conftest.py | 24 +- .../scenarios/queue/test_queue_fd_leak.py | 353 - .../scenarios/queue/test_queue_load.py | 12 +- tests/pytests/scenarios/reauth/conftest.py | 28 +- tests/pytests/scenarios/reauth/test_reauth.py | 122 +- .../pytests/scenarios/regression/conftest.py | 42 - .../test_fd_leak_asyncgens_executor.py | 89 - .../test_fd_leak_fire_event_async.py | 116 - .../test_fd_leak_ioloop_instance.py | 80 - .../test_fd_leak_syncwrapper_close.py | 75 - .../test_fd_leak_task_cancellation.py | 85 - .../regression/test_fd_threshold_queuing.py | 152 - .../regression/test_resource_runaway_oom.py | 190 - tests/pytests/scenarios/setup/test_install.py | 8 - tests/pytests/scenarios/setup/test_man.py | 6 - tests/pytests/scenarios/swarm/conftest.py | 123 +- .../scenarios/syndic/cluster/conftest.py | 47 - .../scenarios/syndic/cluster/test_syndic.py | 4 +- .../pytests/scenarios/syndic/sync/conftest.py | 47 - .../scenarios/syndic/sync/test_event.py | 2 +- .../scenarios/syndic/sync/test_syndic.py | 2 +- .../transport/test_resource_runaway.py | 66 - .../scenarios/transport/test_zeromq.py | 18 +- tests/pytests/smoke-tests.txt | 277 - tests/pytests/unit/auth/test_auth.py | 134 + tests/pytests/unit/auth/test_pam.py | 97 - tests/pytests/unit/beacons/test_adb.py | 466 + .../unit/beacons/test_avahi_announce.py | 35 + .../unit/beacons/test_bonjour_announce.py | 35 + tests/pytests/unit/beacons/test_btmp.py | 267 + tests/pytests/unit/beacons/test_cert_info.py | 7 - tests/pytests/unit/beacons/test_glxinfo.py | 113 + tests/pytests/unit/beacons/test_haproxy.py | 61 + tests/pytests/unit/beacons/test_log_beacon.py | 51 +- tests/pytests/unit/beacons/test_sensehat.py | 97 + tests/pytests/unit/beacons/test_service.py | 34 - .../unit/beacons/test_smartos_imgadm.py | 237 + .../unit/beacons/test_smartos_vmadm.py | 260 + .../unit/beacons/test_telegram_bot_msg.py | 115 + .../unit/beacons/test_twilio_txt_msg.py | 191 + tests/pytests/unit/beacons/test_wtmp.py | 276 + .../pytests/unit/cache/test_cache_backends.py | 381 - tests/pytests/unit/cache/test_memcache.py | 63 +- tests/pytests/unit/cache/test_mmap_cache.py | 463 - .../unit/cache/test_mmap_cache_errors.py | 184 - tests/pytests/unit/cache/test_mmap_key.py | 399 - tests/pytests/unit/cache/test_redis_cache.py | 614 +- .../unit/channel/test_metrics_propagation.py | 85 - tests/pytests/unit/channel/test_server.py | 443 +- .../unit/channel/test_tracing_propagation.py | 112 - tests/pytests/unit/cli/test_batch.py | 657 +- tests/pytests/unit/cli/test_batch_parity.py | 243 - .../pytests/unit/cli/test_batch_visibility.py | 357 - tests/pytests/unit/cli/test_call.py | 10 +- .../pytests/unit/cli/test_caller_resources.py | 237 - tests/pytests/unit/cli/test_salt_call.py | 32 - tests/pytests/unit/client/ssh/test_shell.py | 71 - tests/pytests/unit/client/ssh/test_single.py | 151 +- tests/pytests/unit/client/ssh/test_ssh.py | 15 +- .../unit/client/ssh/test_ssh_classes.py | 46 - tests/pytests/unit/client/test_init.py | 62 - tests/pytests/unit/client/test_netapi.py | 3 +- .../unit/cloud/clouds/test_digitalocean.py | 88 + .../unit/cloud/clouds/test_dimensiondata.py | 192 + tests/pytests/unit/cloud/clouds/test_ec2.py | 372 + tests/pytests/unit/cloud/clouds/test_gce.py | 387 + .../pytests/unit/cloud/clouds/test_hetzner.py | 587 ++ .../pytests/unit/cloud/clouds/test_joyent.py | 93 + .../pytests/unit/cloud/clouds/test_linode.py | 98 + .../unit/cloud/clouds/test_opennebula.py | 1815 ++++ .../unit/cloud/clouds/test_openstack.py | 370 + .../pytests/unit/cloud/clouds/test_proxmox.py | 595 ++ .../unit/cloud/clouds/test_qingcloud.py | 48 + .../unit/cloud/clouds/test_scaleway.py | 124 + .../pytests/unit/cloud/clouds/test_vultrpy.py | 209 + tests/pytests/unit/cloud/clouds/test_xen.py | 83 + .../clouds/vmware/test_clone_from_snapshot.py | 109 + .../unit/cloud/clouds/vmware/test_vmware.py | 1235 +++ tests/pytests/unit/cloud/test_cloud.py | 49 +- tests/pytests/unit/cloud/test_map.py | 41 - tests/pytests/unit/cluster/__init__.py | 0 .../cluster/consensus/test_cluster_ready.py | 252 - .../consensus/test_multi_ring_fanout.py | 157 - .../unit/cluster/consensus/test_peer.py | 516 - .../unit/cluster/consensus/test_raft_chaos.py | 212 - .../consensus/test_raft_exactly_once.py | 79 - .../unit/cluster/consensus/test_raft_log.py | 2217 ---- .../cluster/consensus/test_raft_membership.py | 731 -- .../unit/cluster/consensus/test_raft_node.py | 1399 --- .../consensus/test_raft_node_safety.py | 538 - .../cluster/consensus/test_raft_scheduler.py | 258 - .../unit/cluster/consensus/test_raft_util.py | 241 - .../unit/cluster/consensus/test_rpc.py | 84 - .../unit/cluster/consensus/test_storage.py | 97 - .../cluster/consensus/test_voter_health.py | 289 - tests/pytests/unit/cluster/test_file_sync.py | 273 - .../pytests/unit/cluster/test_healthchecks.py | 255 - tests/pytests/unit/cluster/test_ring.py | 552 - .../unit/cluster/test_ring_membership.py | 380 - tests/pytests/unit/cluster/test_state_sync.py | 594 -- tests/pytests/unit/config/schemas/test_ssh.py | 3 +- .../pytests/unit/config/test_worker_pools.py | 126 - tests/pytests/unit/conftest.py | 46 +- tests/pytests/unit/crypt/test_crypt.py | 123 +- .../unit/crypt/test_crypt_cryptodome.py | 57 + .../unit/crypt/test_crypt_cryptography.py | 63 +- .../pytests/unit/crypt/test_crypt_m2crypto.py | 35 + .../daemons/masterapi/test_remote_funcs.py | 15 +- tests/pytests/unit/doc/__init__.py | 0 tests/pytests/unit/doc/test_link_audit.py | 192 - .../unit/engines/test_libvirt_events.py | 198 + .../unit/engines/test_redis_sentinel.py | 5 +- tests/pytests/unit/engines/test_slack.py | 94 + .../unit/engines/test_slack_bolt_engine.py | 607 ++ tests/pytests/unit/engines/test_sqs_events.py | 77 + tests/pytests/unit/engines/test_stalekey.py | 78 + .../unit/fileclient/test_fileclient.py | 22 - .../unit/fileserver/gitfs/test_gitfs.py | 4 +- .../fileserver/gitfs/test_gitfs_config.py | 2 - tests/pytests/unit/fileserver/test_s3fs.py | 181 + tests/pytests/unit/fileserver/test_svnfs.py | 33 + tests/pytests/unit/grains/test_core.py | 384 +- tests/pytests/unit/grains/test_esxi.py | 106 + tests/pytests/unit/grains/test_extra.py | 30 - .../pytests/unit/grains/test_fibre_channel.py | 31 + tests/pytests/unit/grains/test_iscsi.py | 107 + tests/pytests/unit/grains/test_mdadm.py | 61 - tests/pytests/unit/grains/test_mdata.py | 164 + tests/pytests/unit/grains/test_metadata.py | 446 + .../pytests/unit/grains/test_metadata_gce.py | 92 + tests/pytests/unit/grains/test_napalm.py | 92 + tests/pytests/unit/grains/test_nvme.py | 57 + tests/pytests/unit/grains/test_smartos.py | 299 + tests/pytests/unit/grains/test_truststore.py | 55 - tests/pytests/unit/loader/test_context.py | 135 - tests/pytests/unit/loader/test_loader.py | 5 +- .../unit/loader/test_loading_modules.py | 10 +- .../loader/test_per_resource_overrides.py | 170 - .../unit/log_handlers/test_sentry_mod.py | 72 + .../pytests/unit/matchers/test_confirm_top.py | 15 - .../unit/matchers/test_resource_matchers.py | 119 - .../unit/modules/dockermod/test_module.py | 1382 +++ .../unit/modules/dockermod/test_trans_tar.py | 85 + .../unit/modules/napalm/test_network.py | 8 +- .../pytests/unit/modules/state/test_state.py | 33 +- tests/pytests/unit/modules/test_acme.py | 368 + .../pytests/unit/modules/test_ansiblegate.py | 152 - tests/pytests/unit/modules/test_aptpkg.py | 333 +- tests/pytests/unit/modules/test_augeas_cfg.py | 181 + tests/pytests/unit/modules/test_baredoc.py | 38 +- tests/pytests/unit/modules/test_bigip.py | 39 + .../unit/modules/test_bluez_bluetooth.py | 202 + .../unit/modules/test_boto_dynamodb.py | 91 + tests/pytests/unit/modules/test_boto_elbv2.py | 28 + tests/pytests/unit/modules/test_boto_ssm.py | 28 + tests/pytests/unit/modules/test_bower.py | 91 + tests/pytests/unit/modules/test_btrfs.py | 777 ++ tests/pytests/unit/modules/test_chroot.py | 1 - tests/pytests/unit/modules/test_cmdmod.py | 42 +- tests/pytests/unit/modules/test_composer.py | 185 + tests/pytests/unit/modules/test_consul.py | 1886 ++++ tests/pytests/unit/modules/test_cpan.py | 139 + .../pytests/unit/modules/test_daemontools.py | 124 + tests/pytests/unit/modules/test_ddns.py | 125 + tests/pytests/unit/modules/test_deb_apache.py | 392 + .../pytests/unit/modules/test_deb_postgres.py | 165 + tests/pytests/unit/modules/test_djangomod.py | 210 + tests/pytests/unit/modules/test_dnsmasq.py | 109 + tests/pytests/unit/modules/test_drac.py | 270 + tests/pytests/unit/modules/test_drbd.py | 185 + tests/pytests/unit/modules/test_esxcluster.py | 22 + .../unit/modules/test_esxdatacenter.py | 24 + .../unit/modules/test_freebsd_sysctl.py | 196 + tests/pytests/unit/modules/test_freezer.py | 41 + tests/pytests/unit/modules/test_gem.py | 155 + tests/pytests/unit/modules/test_genesis.py | 183 + .../unit/modules/test_gentoo_service.py | 506 + tests/pytests/unit/modules/test_glassfish.py | 76 + tests/pytests/unit/modules/test_glusterfs.py | 897 ++ .../pytests/unit/modules/test_gnomedesktop.py | 118 + .../pytests/unit/modules/test_google_chat.py | 47 + tests/pytests/unit/modules/test_gpg.py | 60 +- tests/pytests/unit/modules/test_grains.py | 29 - .../pytests/unit/modules/test_grub_legacy.py | 46 + tests/pytests/unit/modules/test_guestfs.py | 96 + tests/pytests/unit/modules/test_hadoop.py | 64 + .../pytests/unit/modules/test_haproxyconn.py | 213 + tests/pytests/unit/modules/test_helm.py | 638 ++ tests/pytests/unit/modules/test_hg.py | 113 + tests/pytests/unit/modules/test_ilo.py | 398 + tests/pytests/unit/modules/test_introspect.py | 93 + tests/pytests/unit/modules/test_junos.py | 20 +- .../unit/modules/test_junos_yaml_security.py | 45 +- tests/pytests/unit/modules/test_keystone.py | 958 ++ tests/pytests/unit/modules/test_kubeadm.py | 1431 +++ .../unit/modules/test_launchctl_service.py | 118 + tests/pytests/unit/modules/test_ldapmod.py | 57 + .../unit/modules/test_localemod_debian13.py | 70 - tests/pytests/unit/modules/test_logadm.py | 63 + tests/pytests/unit/modules/test_lvs.py | 213 + .../pytests/unit/modules/test_mac_brew_pkg.py | 745 +- .../pytests/unit/modules/test_macdefaults.py | 1012 +- tests/pytests/unit/modules/test_mandrill.py | 76 + tests/pytests/unit/modules/test_modjk.py | 189 + tests/pytests/unit/modules/test_mongodb.py | 750 ++ tests/pytests/unit/modules/test_monit.py | 129 + tests/pytests/unit/modules/test_moosefs.py | 49 + tests/pytests/unit/modules/test_mount.py | 44 - tests/pytests/unit/modules/test_msteams.py | 33 + tests/pytests/unit/modules/test_munin.py | 43 + tests/pytests/unit/modules/test_nacl.py | 19 +- tests/pytests/unit/modules/test_nagios.py | 78 + tests/pytests/unit/modules/test_nexus.py | 155 + tests/pytests/unit/modules/test_nginx.py | 26 + tests/pytests/unit/modules/test_nilrt_ip.py | 71 + tests/pytests/unit/modules/test_nixpkg.py | 393 - .../unit/modules/test_openbsd_sysctl.py | 71 + tests/pytests/unit/modules/test_openbsdpkg.py | 167 + .../unit/modules/test_openbsdrcctl_service.py | 69 + tests/pytests/unit/modules/test_openscap.py | 379 + .../pytests/unit/modules/test_openvswitch.py | 134 + tests/pytests/unit/modules/test_opkg.py | 49 + tests/pytests/unit/modules/test_pagerduty.py | 74 + tests/pytests/unit/modules/test_parallels.py | 625 ++ tests/pytests/unit/modules/test_pcs.py | 304 + tests/pytests/unit/modules/test_pdbedit.py | 157 + tests/pytests/unit/modules/test_pecl.py | 52 + tests/pytests/unit/modules/test_pf.py | 312 + tests/pytests/unit/modules/test_pillar.py | 127 +- tests/pytests/unit/modules/test_pip.py | 198 +- .../unit/modules/test_portage_config.py | 87 + tests/pytests/unit/modules/test_postfix.py | 135 + tests/pytests/unit/modules/test_postgres.py | 193 +- tests/pytests/unit/modules/test_poudriere.py | 252 + tests/pytests/unit/modules/test_powerpath.py | 75 + tests/pytests/unit/modules/test_purefa.py | 115 + tests/pytests/unit/modules/test_purefb.py | 69 + tests/pytests/unit/modules/test_qemu_img.py | 36 + tests/pytests/unit/modules/test_qemu_nbd.py | 94 + tests/pytests/unit/modules/test_rbenv.py | 122 + tests/pytests/unit/modules/test_rebootmgr.py | 310 + tests/pytests/unit/modules/test_redismod.py | 750 ++ tests/pytests/unit/modules/test_restconf.py | 93 + tests/pytests/unit/modules/test_riak.py | 129 + tests/pytests/unit/modules/test_rvm.py | 204 + tests/pytests/unit/modules/test_s3.py | 128 + tests/pytests/unit/modules/test_s6.py | 137 + tests/pytests/unit/modules/test_schedule.py | 6 +- tests/pytests/unit/modules/test_selinux.py | 4 +- tests/pytests/unit/modules/test_sensors.py | 23 + .../unit/modules/test_serverdensity_device.py | 194 + tests/pytests/unit/modules/test_servicenow.py | 65 + .../unit/modules/test_slackware_service.py | 256 + tests/pytests/unit/modules/test_slsutil.py | 4 +- .../unit/modules/test_smartos_imgadm.py | 272 + tests/pytests/unit/modules/test_smtp.py | 327 + tests/pytests/unit/modules/test_solr.py | 501 + .../unit/modules/test_sshresource_state.py | 361 - tests/pytests/unit/modules/test_state.py | 58 - tests/pytests/unit/modules/test_status.py | 31 - tests/pytests/unit/modules/test_suse_ip.py | 692 ++ tests/pytests/unit/modules/test_svn.py | 122 + tests/pytests/unit/modules/test_swarm.py | 76 + tests/pytests/unit/modules/test_swift.py | 54 + tests/pytests/unit/modules/test_sysbench.py | 99 + tests/pytests/unit/modules/test_telegram.py | 78 + tests/pytests/unit/modules/test_timezone.py | 59 +- tests/pytests/unit/modules/test_tls.py | 4 - tests/pytests/unit/modules/test_tomcat.py | 57 + .../unit/modules/test_transactional_update.py | 724 ++ tests/pytests/unit/modules/test_tuned.py | 200 + tests/pytests/unit/modules/test_udev.py | 60 - tests/pytests/unit/modules/test_uptime.py | 82 + tests/pytests/unit/modules/test_uwsgi.py | 26 + tests/pytests/unit/modules/test_varnish.py | 83 + tests/pytests/unit/modules/test_vault.py | 441 + tests/pytests/unit/modules/test_vmctl.py | 264 + .../unit/modules/test_win_dsc_resource.py | 178 - tests/pytests/unit/modules/test_xapi_virt.py | 386 + tests/pytests/unit/modules/test_zabbix.py | 1329 +++ tests/pytests/unit/modules/test_zenoss.py | 52 + tests/pytests/unit/modules/test_zfs.py | 1138 +++ .../unit/modules/test_zfs_solaris10.py | 67 + .../unit/modules/test_zfs_solaris11.py | 67 + tests/pytests/unit/modules/test_znc.py | 80 + tests/pytests/unit/modules/test_zpool.py | 1117 ++ tests/pytests/unit/modules/test_zypperpkg.py | 445 + tests/pytests/unit/modules/virt/conftest.py | 401 + .../pytests/unit/modules/virt/test_domain.py | 2359 +++++ .../pytests/unit/modules/virt/test_helpers.py | 45 + tests/pytests/unit/modules/virt/test_host.py | 239 + .../pytests/unit/modules/virt/test_network.py | 471 + .../unit/modules/win_lgpo/test_netsh.py | 2 + .../netapi/rest_tornado/test_base_handler.py | 24 - tests/pytests/unit/output/test_profile.py | 94 + .../pytests/unit/pillar/test_consul_pillar.py | 152 + tests/pytests/unit/pillar/test_csvpillar.py | 34 + tests/pytests/unit/pillar/test_etcd_pillar.py | 57 + .../test_extra_minion_data_in_pillar.py | 53 + tests/pytests/unit/pillar/test_file_tree.py | 2 - .../unit/pillar/test_http_json_pillar.py | 66 + .../unit/pillar/test_http_yaml_pillar.py | 41 + tests/pytests/unit/pillar/test_mongo.py | 30 + tests/pytests/unit/pillar/test_mysql.py | 916 ++ tests/pytests/unit/pillar/test_nacl.py | 16 + tests/pytests/unit/pillar/test_netbox.py | 2557 +++++ tests/pytests/unit/pillar/test_nodegroups.py | 2 - tests/pytests/unit/pillar/test_pepa.py | 25 + tests/pytests/unit/pillar/test_pillar.py | 226 +- tests/pytests/unit/pillar/test_pillar_ldap.py | 20 + .../unit/pillar/test_reclass_adapter.py | 94 - tests/pytests/unit/pillar/test_s3.py | 83 + tests/pytests/unit/pillar/test_saltclass.py | 290 + tests/pytests/unit/pillar/test_sqlcipher.py | 789 ++ tests/pytests/unit/pillar/test_sqlite3.py | 789 ++ tests/pytests/unit/pillar/test_vault.py | 164 + tests/pytests/unit/pkg/debian/__init__.py | 0 .../unit/pkg/debian/test_preinst_scripts.py | 35 - .../unit/pkg/test_master_scriptlets.py | 262 - .../unit/pkg/test_minion_scriptlets.py | 240 - .../unit/proxy/nxos/test_nxos_nxapi.py | 235 + .../pytests/unit/proxy/nxos/test_nxos_ssh.py | 246 + tests/pytests/unit/proxy/test_cimc.py | 331 + tests/pytests/unit/proxy/test_esxcluster.py | 190 + .../pytests/unit/proxy/test_esxdatacenter.py | 186 + tests/pytests/unit/proxy/test_junos.py | 74 + tests/pytests/unit/proxy/test_napalm.py | 313 + tests/pytests/unit/proxy/test_netmiko_px.py | 239 + tests/pytests/unit/proxy/test_panos.py | 45 + tests/pytests/unit/proxy/test_restconf.py | 88 + tests/pytests/unit/proxy/test_ssh_sample.py | 269 + tests/pytests/unit/renderers/test_aws_kms.py | 234 + tests/pytests/unit/renderers/test_pass.py | 262 + tests/pytests/unit/renderers/test_yamlex.py | 7 +- tests/pytests/unit/resources/__init__.py | 0 .../resources/test_dummy_resource_grains.py | 99 - .../unit/resources/test_ssh_resource.py | 115 - .../returners/local_cache/test_local_cache.py | 135 +- .../returners/test_elasticsearch_return.py | 40 + .../unit/returners/test_etcd_return.py | 365 + .../unit/returners/test_highstate_return.py | 40 +- .../returners/test_mongo_future_return.py | 35 + tests/pytests/unit/returners/test_mysql.py | 37 + .../unit/returners/test_nagios_nrdp_return.py | 41 + .../unit/returners/test_redis_return.py | 530 + .../pytests/unit/returners/test_salt_cache.py | 386 - .../unit/returners/test_sentry_return.py | 18 + .../returners/test_slack_webhook_return.py | 317 + .../unit/returners/test_smtp_return.py | 87 + .../unit/returners/test_splunk_return.py | 118 + .../unit/returners/test_telegram_return.py | 38 + .../pytests/unit/roster/test_clustershell.py | 49 + tests/pytests/unit/roster/test_terraform.py | 174 + tests/pytests/unit/runners/test_asam.py | 114 + tests/pytests/unit/runners/test_batch.py | 164 - tests/pytests/unit/runners/test_bgp.py | 32 + tests/pytests/unit/runners/test_cache.py | 19 +- .../unit/runners/test_cache_migrate.py | 329 - .../unit/runners/test_cluster_runner.py | 1192 --- tests/pytests/unit/runners/test_manage.py | 9 +- tests/pytests/unit/runners/test_nacl.py | 16 + tests/pytests/unit/runners/test_pillar.py | 197 +- tests/pytests/unit/runners/test_pki.py | 96 - tests/pytests/unit/runners/test_resource.py | 124 - tests/pytests/unit/runners/test_spacewalk.py | 50 + .../runners/vault}/__init__.py | 0 .../unit/runners/vault/test_app_role_auth.py | 82 + .../unit/runners/vault/test_token_auth.py | 44 + .../vault/test_token_auth_deprecated.py | 150 + .../pytests/unit/runners/vault/test_vault.py | 1483 +++ tests/pytests/unit/sdb/test_etcd_db.py | 131 + tests/pytests/unit/sdb/test_vault.py | 141 + .../unit/serializers/test_serializers.py | 60 +- .../unit/state/test_reactor_compiler.py | 71 +- tests/pytests/unit/state/test_state_basic.py | 8 +- .../pytests/unit/state/test_state_compiler.py | 213 +- tests/pytests/unit/states/file/test_absent.py | 4 + .../pytests/unit/states/file/test_comment.py | 4 + tests/pytests/unit/states/file/test_copy.py | 4 + .../unit/states/file/test_directory.py | 4 + .../unit/states/file/test_filestate.py | 43 +- .../pytests/unit/states/file/test_hardlink.py | 4 + .../pytests/unit/states/file/test_keyvalue.py | 4 + .../pytests/unit/states/file/test_managed.py | 23 +- .../pytests/unit/states/file/test_prepend.py | 4 + .../pytests/unit/states/file/test_recurse.py | 25 +- tests/pytests/unit/states/file/test_rename.py | 4 + .../states/file/test_retention_schedule.py | 4 + .../unit/states/file/test_serialize.py | 42 - .../pytests/unit/states/file/test_symlink.py | 4 + tests/pytests/unit/states/file/test_tidied.py | 31 +- .../unit/states/mysql/test_database.py | 162 + .../pytests/unit/states/mysql/test_grants.py | 98 + tests/pytests/unit/states/mysql/test_query.py | 178 + tests/pytests/unit/states/mysql/test_user.py | 149 + tests/pytests/unit/states/test_acme.py | 147 + .../pytests/unit/states/test_alternatives.py | 232 + tests/pytests/unit/states/test_aptpkg.py | 31 + tests/pytests/unit/states/test_artifactory.py | 88 + tests/pytests/unit/states/test_augeas.py | 296 + tests/pytests/unit/states/test_aws_sqs.py | 55 + tests/pytests/unit/states/test_boto_asg.py | 112 + .../unit/states/test_boto_cloudfront.py | 226 + .../unit/states/test_boto_cloudtrail.py | 218 + .../unit/states/test_boto_cloudwatch_alarm.py | 77 + .../unit/states/test_boto_cloudwatch_event.py | 466 + .../pytests/unit/states/test_boto_dynamodb.py | 105 + tests/pytests/unit/states/test_boto_ec2.py | 61 + .../unit/states/test_boto_elasticache.py | 94 + .../states/test_boto_elasticsearch_domain.py | 196 + tests/pytests/unit/states/test_boto_elb.py | 176 + .../pytests/unit/states/test_boto_iam_role.py | 192 + tests/pytests/unit/states/test_boto_iot.py | 515 + .../pytests/unit/states/test_boto_kinesis.py | 169 + tests/pytests/unit/states/test_boto_lambda.py | 574 ++ tests/pytests/unit/states/test_boto_lc.py | 64 + .../pytests/unit/states/test_boto_route53.py | 125 + .../unit/states/test_boto_s3_bucket.py | 343 + .../pytests/unit/states/test_boto_secgroup.py | 117 + tests/pytests/unit/states/test_boto_sns.py | 142 + tests/pytests/unit/states/test_boto_sqs.py | 120 + tests/pytests/unit/states/test_bower.py | 257 + tests/pytests/unit/states/test_btrfs.py | 788 ++ tests/pytests/unit/states/test_chef.py | 153 + tests/pytests/unit/states/test_composer.py | 89 + tests/pytests/unit/states/test_consul.py | 160 + tests/pytests/unit/states/test_ddns.py | 60 + .../unit/states/test_docker_container.py | 87 + .../pytests/unit/states/test_docker_image.py | 99 + .../pytests/unit/states/test_docker_volume.py | 182 + tests/pytests/unit/states/test_drac.py | 94 + .../pytests/unit/states/test_elasticsearch.py | 853 ++ tests/pytests/unit/states/test_eselect.py | 29 + tests/pytests/unit/states/test_ethtool.py | 77 + tests/pytests/unit/states/test_file.py | 3 +- tests/pytests/unit/states/test_firewalld.py | 51 - tests/pytests/unit/states/test_gem.py | 136 + tests/pytests/unit/states/test_glusterfs.py | 418 + .../pytests/unit/states/test_gnomedesktop.py | 47 + tests/pytests/unit/states/test_gpg.py | 22 +- tests/pytests/unit/states/test_grafana.py | 130 + .../unit/states/test_grafana_datasource.py | 93 + tests/pytests/unit/states/test_grains.py | 14 +- tests/pytests/unit/states/test_helm.py | 274 + tests/pytests/unit/states/test_hg.py | 140 + tests/pytests/unit/states/test_incron.py | 95 + .../unit/states/test_influxdb08_database.py | 81 + .../unit/states/test_influxdb08_user.py | 91 + .../states/test_influxdb_continuous_query.py | 51 + tests/pytests/unit/states/test_ipmi.py | 166 + tests/pytests/unit/states/test_jboss7.py | 752 ++ tests/pytests/unit/states/test_kapacitor.py | 134 + tests/pytests/unit/states/test_kernelpkg.py | 152 + tests/pytests/unit/states/test_keystone.py | 397 + tests/pytests/unit/states/test_keystore.py | 497 + tests/pytests/unit/states/test_kubernetes.py | 811 ++ tests/pytests/unit/states/test_layman.py | 53 + tests/pytests/unit/states/test_ldap.py | 440 + .../pytests/unit/states/test_libcloud_dns.py | 142 + tests/pytests/unit/states/test_linux_acl.py | 245 - tests/pytests/unit/states/test_lvs_server.py | 130 + tests/pytests/unit/states/test_lvs_service.py | 118 + tests/pytests/unit/states/test_lxc.py | 223 + tests/pytests/unit/states/test_macdefaults.py | 456 +- tests/pytests/unit/states/test_memcached.py | 90 + tests/pytests/unit/states/test_modjk.py | 60 + .../pytests/unit/states/test_modjk_worker.py | 67 + .../unit/states/test_mongodb_database.py | 42 + .../pytests/unit/states/test_mongodb_user.py | 76 + tests/pytests/unit/states/test_mount.py | 6 +- .../unit/states/test_net_napalm_yang.py | 55 + tests/pytests/unit/states/test_network.py | 6 +- tests/pytests/unit/states/test_nexus.py | 44 + tests/pytests/unit/states/test_npm.py | 205 + tests/pytests/unit/states/test_nxos.py | 571 ++ .../unit/states/test_openstack_config.py | 84 + .../unit/states/test_openvswitch_bridge.py | 135 + .../unit/states/test_openvswitch_db.py | 80 + .../unit/states/test_openvswitch_port.py | 99 + tests/pytests/unit/states/test_pagerduty.py | 37 + tests/pytests/unit/states/test_pdbedit.py | 25 + tests/pytests/unit/states/test_pecl.py | 72 + tests/pytests/unit/states/test_pip.py | 1 - tests/pytests/unit/states/test_pkg.py | 54 +- .../unit/states/test_portage_config.py | 59 + tests/pytests/unit/states/test_ports.py | 117 + tests/pytests/unit/states/test_powerpath.py | 107 + .../pytests/unit/states/test_pyrax_queues.py | 69 + tests/pytests/unit/states/test_rbenv.py | 256 + tests/pytests/unit/states/test_rdp.py | 68 + tests/pytests/unit/states/test_redismod.py | 60 + tests/pytests/unit/states/test_restconf.py | 150 + tests/pytests/unit/states/test_rsync.py | 130 + tests/pytests/unit/states/test_rvm.py | 129 + tests/pytests/unit/states/test_selinux.py | 1195 +-- .../unit/states/test_serverdensity_device.py | 55 + tests/pytests/unit/states/test_slack.py | 197 + tests/pytests/unit/states/test_smartos.py | 90 + tests/pytests/unit/states/test_smtp.py | 48 + .../pytests/unit/states/test_splunk_search.py | 62 + tests/pytests/unit/states/test_supervisord.py | 65 + tests/pytests/unit/states/test_svn.py | 142 + tests/pytests/unit/states/test_sysrc.py | 77 + tests/pytests/unit/states/test_tomcat.py | 241 + tests/pytests/unit/states/test_vault.py | 112 + tests/pytests/unit/states/test_vbox_guest.py | 134 + .../unit/states/test_virtualenv_mod.py | 88 + tests/pytests/unit/states/test_webutil.py | 118 + tests/pytests/unit/states/test_win_wua.py | 10 +- tests/pytests/unit/states/test_winrepo.py | 4 +- tests/pytests/unit/states/test_xml.py | 98 + tests/pytests/unit/states/test_xmpp.py | 31 + tests/pytests/unit/states/test_zfs.py | 967 ++ .../unit/states/test_zk_concurrency.py | 76 + tests/pytests/unit/states/test_zpool.py | 534 + tests/pytests/unit/states/virt/test_domain.py | 816 ++ .../pytests/unit/states/virt/test_network.py | 483 + .../pytests/unit/states/zabbix/test_action.py | 301 + tests/pytests/unit/states/zabbix/test_host.py | 1714 ++++ .../unit/states/zabbix/test_template.py | 331 + .../unit/states/zabbix/test_valuemap.py | 235 + .../support/test_macos_salt_onedir_prefix.py | 83 - tests/pytests/unit/test_auth.py | 184 +- tests/pytests/unit/test_auth_creds_event.py | 97 - tests/pytests/unit/test_client.py | 353 +- tests/pytests/unit/test_config.py | 1 + tests/pytests/unit/test_crypt.py | 279 +- .../unit/test_event_monitor_ring_gating.py | 447 - tests/pytests/unit/test_fileserver.py | 3 +- ...test_issue_65317_non_root_publisher_acl.py | 177 + tests/pytests/unit/test_master.py | 739 +- .../unit/test_master_maintenance_batch.py | 113 - .../unit/test_master_requests_metrics.py | 181 - tests/pytests/unit/test_minion.py | 576 +- tests/pytests/unit/test_minion_resources.py | 1219 --- tests/pytests/unit/test_pillar.py | 206 +- .../pytests/unit/test_pool_name_edge_cases.py | 337 - .../pytests/unit/test_pool_name_validation.py | 198 - tests/pytests/unit/test_proxy_minion.py | 29 +- tests/pytests/unit/test_request_router.py | 131 - tests/pytests/unit/test_scripts.py | 59 +- tests/pytests/unit/test_tls_aware_crypt.py | 175 - tests/pytests/unit/test_version.py | 13 +- tests/pytests/unit/thorium/test_examples.py | 256 - tests/pytests/unit/tops/test_mongo.py | 120 - tests/pytests/unit/transport/conftest.py | 21 - tests/pytests/unit/transport/test_base.py | 6 - tests/pytests/unit/transport/test_ipc.py | 54 +- .../unit/transport/test_publish_client.py | 113 +- .../unit/transport/test_ssl_identity.py | 274 - .../unit/transport/test_ssl_transport.py | 150 - tests/pytests/unit/transport/test_tcp.py | 41 +- tests/pytests/unit/transport/test_tls_util.py | 254 - tests/pytests/unit/transport/test_zeromq.py | 279 +- .../unit/transport/test_zeromq_concurrency.py | 179 - .../transport/test_zeromq_pub_stability.py | 90 - .../transport/test_zeromq_worker_pools.py | 139 - .../unit/utils/batch_state/__init__.py | 0 .../batch_state/batch_state_scenarios.py | 477 - .../utils/batch_state/test_conformance.py | 81 - .../unit/utils/batch_state/test_helpers.py | 220 - .../pytests/unit/utils/event/test_tracing.py | 89 - .../utils/jinja/test_custom_extensions.py | 46 +- .../utils/jinja/test_salt_cache_loader.py | 58 - .../utils/parsers/test_salt_cmd_options.py | 94 - .../unit/utils/parsers/test_saltfile_mixin.py | 3 +- .../utils/requisite/test_dependency_graph.py | 572 -- .../pytests/unit/utils/scheduler/test_eval.py | 100 +- .../unit/utils/scheduler/test_schedule.py | 14 +- .../pytests/unit/utils/scheduler/test_skip.py | 14 +- .../utils/templates/test_wrap_tmpl_func.py | 59 +- tests/pytests/unit/utils/test_asynchronous.py | 140 - tests/pytests/unit/utils/test_atomicfile.py | 42 - tests/pytests/unit/utils/test_aws.py | 17 +- .../pytests/unit/utils/test_batch_manager.py | 541 - tests/pytests/unit/utils/test_batch_output.py | 161 - tests/pytests/unit/utils/test_cache.py | 104 - tests/pytests/unit/utils/test_cloud.py | 80 +- tests/pytests/unit/utils/test_data.py | 9 +- tests/pytests/unit/utils/test_dictdiffer.py | 147 +- tests/pytests/unit/utils/test_gitcli.py | 221 - tests/pytests/unit/utils/test_gitfs.py | 63 +- tests/pytests/unit/utils/test_gitfs_locks.py | 113 +- tests/pytests/unit/utils/test_http.py | 34 +- tests/pytests/unit/utils/test_master.py | 178 - tests/pytests/unit/utils/test_metrics.py | 299 - .../unit/utils/test_metrics_console_demo.py | 148 - tests/pytests/unit/utils/test_minions.py | 73 +- .../unit/utils/test_minions_resources.py | 980 -- tests/pytests/unit/utils/test_mmap_cache.py | 463 - .../unit/utils/test_mmap_cache_enterprise.py | 633 -- .../unit/utils/test_mmap_cache_errors.py | 385 - .../unit/utils/test_mmap_cache_segments.py | 725 -- tests/pytests/unit/utils/test_msgpack.py | 406 - tests/pytests/unit/utils/test_nacl.py | 96 +- tests/pytests/unit/utils/test_network.py | 2 +- tests/pytests/unit/utils/test_optsdict.py | 1169 --- tests/pytests/unit/utils/test_ostruststore.py | 113 - tests/pytests/unit/utils/test_pycrypto.py | 29 +- tests/pytests/unit/utils/test_reactor2.py | 5 +- .../unit/utils/test_resource_registry.py | 378 - tests/pytests/unit/utils/test_resources.py | 95 - tests/pytests/unit/utils/test_secret.py | 403 - tests/pytests/unit/utils/test_slack.py | 16 +- tests/pytests/unit/utils/test_sshpki.py | 262 - tests/pytests/unit/utils/test_stringutils.py | 215 +- tests/pytests/unit/utils/test_tarfileutil.py | 65 - tests/pytests/unit/utils/test_thin.py | 186 +- tests/pytests/unit/utils/test_timeutil.py | 37 - tests/pytests/unit/utils/test_tracing.py | 308 - .../unit/utils/test_tracing_console_demo.py | 223 - tests/pytests/unit/utils/test_url_create.py | 77 - tests/pytests/unit/utils/test_versions.py | 199 +- tests/pytests/unit/utils/test_vmware.py | 84 - tests/pytests/unit/utils/test_vt.py | 3 +- tests/pytests/unit/utils/test_x509.py | 7 +- .../pkg => unit/utils/vault}/__init__.py | 0 tests/pytests/unit/utils/vault/conftest.py | 587 ++ tests/pytests/unit/utils/vault/test_api.py | 401 + tests/pytests/unit/utils/vault/test_auth.py | 312 + tests/pytests/unit/utils/vault/test_cache.py | 588 ++ tests/pytests/unit/utils/vault/test_client.py | 668 ++ .../pytests/unit/utils/vault/test_factory.py | 1639 +++ .../pytests/unit/utils/vault/test_helpers.py | 119 + tests/pytests/unit/utils/vault/test_kv.py | 592 ++ tests/pytests/unit/utils/vault/test_leases.py | 363 + .../pytests/unit/utils/verify/test_verify.py | 148 +- .../pytests/unit/utils/win_lgpo/test_netsh.py | 49 +- tests/resources_smoke.txt | 37 - tests/smoke-tests-before-commit.txt | 20 - tests/support/case.py | 11 +- tests/support/gitfs.py | 8 - tests/support/helpers.py | 52 +- tests/support/netapi.py | 5 - tests/support/paths.py | 16 +- tests/support/pkg.py | 355 +- tests/support/pytest/helpers.py | 203 +- tests/support/pytest/mysql.py | 8 - tests/support/pytest/transport_ssl.py | 643 -- tests/support/pytest/vault.py | 342 + tests/support/raft_chaos.py | 116 - tests/support/sshd_runtime.py | 99 - tests/support/unit.py | 35 - .../example_playbooks/playbook1.yaml | 5 - .../unit/modules/inspectlib/test_collector.py | 166 + tests/unit/modules/inspectlib/test_fsdb.py | 555 + tests/unit/modules/nxos/nxos_grains.py | 13 +- .../unit/modules/test_boto3_elasticsearch.py | 1246 +++ tests/unit/modules/test_boto3_route53.py | 158 + tests/unit/modules/test_boto_apigateway.py | 2555 +++++ tests/unit/modules/test_boto_cloudtrail.py | 484 + .../modules/test_boto_cloudwatch_event.py | 330 + .../unit/modules/test_boto_cognitoidentity.py | 736 ++ .../modules/test_boto_elasticsearch_domain.py | 415 + tests/unit/modules/test_boto_elb.py | 293 + tests/unit/modules/test_boto_iot.py | 1031 ++ tests/unit/modules/test_boto_lambda.py | 1044 ++ tests/unit/modules/test_boto_route53.py | 436 + tests/unit/modules/test_boto_s3_bucket.py | 805 ++ tests/unit/modules/test_boto_secgroup.py | 417 + tests/unit/modules/test_boto_vpc.py | 2289 +++++ tests/unit/modules/test_bsd_shadow.py | 115 + tests/unit/modules/test_elasticsearch.py | 2965 ++++++ tests/unit/modules/test_freezer.py | 302 + tests/unit/modules/test_heat.py | 243 + tests/unit/modules/test_influxdb08mod.py | 326 + tests/unit/modules/test_jboss7.py | 288 + tests/unit/modules/test_jboss7_cli.py | 489 + tests/unit/modules/test_k8s.py | 523 + tests/unit/modules/test_kapacitor.py | 81 + .../unit/modules/test_kernelpkg_linux_apt.py | 12 - .../unit/modules/test_kernelpkg_linux_yum.py | 3 +- tests/unit/modules/test_kubernetesmod.py | 308 + tests/unit/modules/test_libcloud_compute.py | 398 + tests/unit/modules/test_libcloud_dns.py | 40 + .../modules/test_libcloud_loadbalancer.py | 167 + tests/unit/modules/test_libcloud_storage.py | 112 + tests/unit/modules/test_memcached.py | 509 + tests/unit/modules/test_netbox.py | 97 + tests/unit/modules/test_netmiko_mod.py | 130 + tests/unit/modules/test_netscaler.py | 1156 +++ tests/unit/modules/test_network.py | 6 +- tests/unit/modules/test_neutron.py | 1108 ++ tests/unit/modules/test_nginx.py | 53 + tests/unit/modules/test_nilrt_ip.py | 102 + tests/unit/modules/test_nova.py | 272 + tests/unit/modules/test_nxos.py | 93 +- tests/unit/modules/test_openstack_config.py | 105 + tests/unit/modules/test_opkg.py | 381 + tests/unit/modules/test_pdbedit.py | 94 + tests/unit/modules/test_random_org.py | 394 + tests/unit/modules/test_saltcheck.py | 13 +- tests/unit/modules/test_swarm.py | 45 + tests/unit/modules/test_twilio_notify.py | 145 + tests/unit/modules/test_virt.py | 7024 +++++++++++++ tests/unit/modules/test_x509.py | 7 +- tests/unit/modules/test_zcbuildout.py | 558 + tests/unit/modules/test_zypperpkg.py | 2 +- tests/unit/states/test_boto_apigateway.py | 2335 +++++ .../unit/states/test_boto_cognitoidentity.py | 618 ++ tests/unit/states/test_boto_vpc.py | 491 + tests/unit/states/test_esxdatacenter.py | 209 + tests/unit/states/test_esxi.py | 72 + tests/unit/states/test_heat.py | 194 + tests/unit/states/test_pip_state.py | 6 +- tests/unit/states/test_virt.py | 1793 ++++ tests/unit/states/test_zcbuildout.py | 87 + tests/unit/test_config.py | 2 - tests/unit/test_module_names.py | 3 - tests/unit/test_zypp_plugins.py | 8 +- tests/unit/transport/test_ipc.py | 157 + tests/unit/utils/test_asynchronous.py | 82 + tests/unit/utils/test_color.py | 2 +- tests/unit/utils/test_dictupdate.py | 15 - tests/unit/utils/test_dockermod.py | 2053 ++++ tests/unit/utils/test_jid.py | 6 - tests/unit/utils/test_minions.py | 12 +- tests/unit/utils/test_msgpack.py | 489 + tests/unit/utils/test_process.py | 182 +- tests/unit/utils/test_pydsl.py | 516 + tests/unit/utils/test_pyobjects.py | 16 - tests/unit/utils/test_schema.py | 105 +- tests/unit/utils/test_sdb.py | 54 + tests/unit/utils/test_url.py | 25 - tests/unit/utils/test_vmware.py | 43 +- tests/unit/utils/test_yamlloader.py | 29 - tools/__main__.py | 4 - tools/audit_doc_links.py | 292 - tools/changelog.py | 14 +- tools/ci.py | 52 +- tools/container.py | 2 +- tools/pkg/build.py | 53 +- tools/pkg/salt_build_backend.py | 12 - tools/precommit/docs.py | 38 +- tools/precommit/docstrings.py | 111 - tools/utils/repo.py | 4 +- 3162 files changed, 522735 insertions(+), 143531 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100755 .github/scripts/hash-files.py delete mode 100755 .github/scripts/verify-draft-signing-manifest.sh delete mode 100644 .github/workflows/doc-linkcheck.yml delete mode 100644 .github/workflows/run-nightly.yml create mode 100644 .github/workflows/triage.yml create mode 100644 FIXED_TESTS.md delete mode 100644 GAP5.md delete mode 100644 MULTI_RING_DESIGN.md delete mode 100644 changelog/62852.added.md create mode 100644 changelog/66603.fixed.md delete mode 100644 changelog/69018.fixed.md delete mode 100644 changelog/69228.fixed.md delete mode 100644 changelog/69303.fixed.md delete mode 100644 changelog/69307.fixed.md delete mode 100644 changelog/69418.fixed.md delete mode 100644 changelog/69448.fixed.md delete mode 100644 changelog/69451.fixed.md delete mode 100644 changelog/69453.added.md delete mode 100644 changelog/69454.fixed.md delete mode 100644 changelog/69472.fixed.md delete mode 100644 changelog/69488.removed.md delete mode 100644 changelog/69494.added.md create mode 100644 doc/ref/auth/all/salt.auth.django.rst create mode 100644 doc/ref/auth/all/salt.auth.keystone.rst create mode 100644 doc/ref/auth/all/salt.auth.mysql.rst create mode 100644 doc/ref/auth/all/salt.auth.pki.rst create mode 100644 doc/ref/auth/all/salt.auth.yubico.rst create mode 100644 doc/ref/beacons/all/salt.beacons.adb.rst create mode 100644 doc/ref/beacons/all/salt.beacons.aix_account.rst create mode 100644 doc/ref/beacons/all/salt.beacons.avahi_announce.rst create mode 100644 doc/ref/beacons/all/salt.beacons.bonjour_announce.rst create mode 100644 doc/ref/beacons/all/salt.beacons.btmp.rst create mode 100644 doc/ref/beacons/all/salt.beacons.glxinfo.rst create mode 100644 doc/ref/beacons/all/salt.beacons.haproxy.rst create mode 100644 doc/ref/beacons/all/salt.beacons.junos_rre_keys.rst create mode 100644 doc/ref/beacons/all/salt.beacons.napalm_beacon.rst create mode 100644 doc/ref/beacons/all/salt.beacons.sensehat.rst create mode 100644 doc/ref/beacons/all/salt.beacons.smartos_imgadm.rst create mode 100644 doc/ref/beacons/all/salt.beacons.smartos_vmadm.rst create mode 100644 doc/ref/beacons/all/salt.beacons.telegram_bot_msg.rst create mode 100644 doc/ref/beacons/all/salt.beacons.twilio_txt_msg.rst create mode 100644 doc/ref/beacons/all/salt.beacons.wtmp.rst delete mode 100644 doc/ref/cache/all/salt.cache.localfs_key.rst delete mode 100644 doc/ref/cache/all/salt.cache.mmap_cache.rst delete mode 100644 doc/ref/cache/all/salt.cache.mmap_key.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.aliyun.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.clc.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.cloudstack.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.digitalocean.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.dimensiondata.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.ec2.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.gce.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.gogrid.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.hetzner.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.joyent.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.libvirt.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.linode.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.lxc.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.oneandone.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.opennebula.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.openstack.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.packet.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.parallels.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.profitbricks.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.proxmox.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.pyrax.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.qingcloud.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.scaleway.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.softlayer.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.softlayer_hw.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.tencentcloud.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.vagrant.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.virtualbox.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.vmware.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.vultrpy.rst create mode 100644 doc/ref/clouds/all/salt.cloud.clouds.xen.rst create mode 100644 doc/ref/configuration/logging/handlers/salt.log_handlers.fluent_mod.rst create mode 100644 doc/ref/configuration/logging/handlers/salt.log_handlers.log4mongo_mod.rst create mode 100644 doc/ref/configuration/logging/handlers/salt.log_handlers.logstash_mod.rst create mode 100644 doc/ref/configuration/logging/handlers/salt.log_handlers.sentry_mod.rst create mode 100644 doc/ref/engines/all/salt.engines.docker_events.rst create mode 100644 doc/ref/engines/all/salt.engines.fluent.rst create mode 100644 doc/ref/engines/all/salt.engines.http_logstash.rst create mode 100644 doc/ref/engines/all/salt.engines.ircbot.rst create mode 100644 doc/ref/engines/all/salt.engines.junos_syslog.rst create mode 100644 doc/ref/engines/all/salt.engines.libvirt_events.rst create mode 100644 doc/ref/engines/all/salt.engines.logentries.rst create mode 100644 doc/ref/engines/all/salt.engines.logstash_engine.rst create mode 100644 doc/ref/engines/all/salt.engines.napalm_syslog.rst create mode 100644 doc/ref/engines/all/salt.engines.redis_sentinel.rst create mode 100644 doc/ref/engines/all/salt.engines.slack.rst create mode 100644 doc/ref/engines/all/salt.engines.slack_bolt_engine.rst create mode 100644 doc/ref/engines/all/salt.engines.sqs_events.rst create mode 100644 doc/ref/engines/all/salt.engines.stalekey.rst create mode 100644 doc/ref/executors/all/salt.executors.docker.rst create mode 100644 doc/ref/executors/all/salt.executors.transactional_update.rst create mode 100644 doc/ref/file_server/all/salt.fileserver.hgfs.rst create mode 100644 doc/ref/file_server/all/salt.fileserver.s3fs.rst create mode 100644 doc/ref/file_server/all/salt.fileserver.svnfs.rst create mode 100644 doc/ref/grains/all/salt.grains.chronos.rst create mode 100644 doc/ref/grains/all/salt.grains.cimc.rst create mode 100644 doc/ref/grains/all/salt.grains.esxi.rst create mode 100644 doc/ref/grains/all/salt.grains.fibre_channel.rst create mode 100644 doc/ref/grains/all/salt.grains.fx2.rst create mode 100644 doc/ref/grains/all/salt.grains.iscsi.rst create mode 100644 doc/ref/grains/all/salt.grains.junos.rst create mode 100644 doc/ref/grains/all/salt.grains.marathon.rst create mode 100644 doc/ref/grains/all/salt.grains.mdata.rst create mode 100644 doc/ref/grains/all/salt.grains.metadata.rst create mode 100644 doc/ref/grains/all/salt.grains.metadata_gce.rst create mode 100644 doc/ref/grains/all/salt.grains.napalm.rst create mode 100644 doc/ref/grains/all/salt.grains.nvme.rst create mode 100644 doc/ref/grains/all/salt.grains.nxos.rst create mode 100644 doc/ref/grains/all/salt.grains.panos.rst create mode 100644 doc/ref/grains/all/salt.grains.philips_hue.rst delete mode 100644 doc/ref/grains/all/salt.grains.resources.rst create mode 100644 doc/ref/grains/all/salt.grains.smartos.rst create mode 100644 doc/ref/grains/all/salt.grains.ssh_sample.rst delete mode 100644 doc/ref/grains/all/salt.grains.truststore.rst create mode 100644 doc/ref/grains/all/salt.grains.zfs.rst create mode 100644 doc/ref/modules/all/salt.modules.acme.rst create mode 100644 doc/ref/modules/all/salt.modules.apcups.rst create mode 100644 doc/ref/modules/all/salt.modules.apkpkg.rst create mode 100644 doc/ref/modules/all/salt.modules.aptly.rst delete mode 100644 doc/ref/modules/all/salt.modules.asymmetric.rst create mode 100644 doc/ref/modules/all/salt.modules.augeas_cfg.rst create mode 100644 doc/ref/modules/all/salt.modules.aws_sqs.rst create mode 100644 doc/ref/modules/all/salt.modules.bamboohr.rst create mode 100644 doc/ref/modules/all/salt.modules.bigip.rst create mode 100644 doc/ref/modules/all/salt.modules.bluez_bluetooth.rst create mode 100644 doc/ref/modules/all/salt.modules.boto3_elasticache.rst create mode 100644 doc/ref/modules/all/salt.modules.boto3_elasticsearch.rst create mode 100644 doc/ref/modules/all/salt.modules.boto3_route53.rst create mode 100644 doc/ref/modules/all/salt.modules.boto3_sns.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_apigateway.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_asg.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_cfn.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_cloudfront.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_cloudtrail.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_cloudwatch.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_cloudwatch_event.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_cognitoidentity.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_datapipeline.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_dynamodb.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_ec2.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_efs.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_elasticache.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_elasticsearch_domain.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_elb.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_elbv2.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_iam.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_iot.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_kinesis.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_kms.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_lambda.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_rds.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_route53.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_s3.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_s3_bucket.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_secgroup.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_sns.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_sqs.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_ssm.rst create mode 100644 doc/ref/modules/all/salt.modules.boto_vpc.rst create mode 100644 doc/ref/modules/all/salt.modules.bower.rst create mode 100644 doc/ref/modules/all/salt.modules.bsd_shadow.rst create mode 100644 doc/ref/modules/all/salt.modules.btrfs.rst create mode 100644 doc/ref/modules/all/salt.modules.cabal.rst create mode 100644 doc/ref/modules/all/salt.modules.capirca_acl.rst create mode 100644 doc/ref/modules/all/salt.modules.ceph.rst create mode 100644 doc/ref/modules/all/salt.modules.chassis.rst create mode 100644 doc/ref/modules/all/salt.modules.cimc.rst create mode 100644 doc/ref/modules/all/salt.modules.ciscoconfparse_mod.rst create mode 100644 doc/ref/modules/all/salt.modules.cisconso.rst create mode 100644 doc/ref/modules/all/salt.modules.composer.rst create mode 100644 doc/ref/modules/all/salt.modules.consul.rst create mode 100644 doc/ref/modules/all/salt.modules.container_resource.rst create mode 100644 doc/ref/modules/all/salt.modules.cpan.rst create mode 100644 doc/ref/modules/all/salt.modules.csf.rst create mode 100644 doc/ref/modules/all/salt.modules.cyg.rst create mode 100644 doc/ref/modules/all/salt.modules.daemontools.rst create mode 100644 doc/ref/modules/all/salt.modules.datadog_api.rst create mode 100644 doc/ref/modules/all/salt.modules.ddns.rst create mode 100644 doc/ref/modules/all/salt.modules.deb_apache.rst create mode 100644 doc/ref/modules/all/salt.modules.deb_postgres.rst create mode 100644 doc/ref/modules/all/salt.modules.djangomod.rst create mode 100644 doc/ref/modules/all/salt.modules.dnsmasq.rst create mode 100644 doc/ref/modules/all/salt.modules.dockercompose.rst create mode 100644 doc/ref/modules/all/salt.modules.dockermod.rst create mode 100644 doc/ref/modules/all/salt.modules.drac.rst create mode 100644 doc/ref/modules/all/salt.modules.dracr.rst create mode 100644 doc/ref/modules/all/salt.modules.drbd.rst create mode 100644 doc/ref/modules/all/salt.modules.ebuildpkg.rst create mode 100644 doc/ref/modules/all/salt.modules.eix.rst create mode 100644 doc/ref/modules/all/salt.modules.elasticsearch.rst create mode 100644 doc/ref/modules/all/salt.modules.eselect.rst create mode 100644 doc/ref/modules/all/salt.modules.esxcluster.rst create mode 100644 doc/ref/modules/all/salt.modules.esxdatacenter.rst create mode 100644 doc/ref/modules/all/salt.modules.esxi.rst create mode 100644 doc/ref/modules/all/salt.modules.esxvm.rst create mode 100644 doc/ref/modules/all/salt.modules.freebsd_sysctl.rst create mode 100644 doc/ref/modules/all/salt.modules.freebsd_update.rst create mode 100644 doc/ref/modules/all/salt.modules.freebsdjail.rst create mode 100644 doc/ref/modules/all/salt.modules.freebsdkmod.rst create mode 100644 doc/ref/modules/all/salt.modules.freebsdpkg.rst create mode 100644 doc/ref/modules/all/salt.modules.freebsdports.rst create mode 100644 doc/ref/modules/all/salt.modules.freebsdservice.rst create mode 100644 doc/ref/modules/all/salt.modules.freezer.rst create mode 100644 doc/ref/modules/all/salt.modules.gcp_addon.rst create mode 100644 doc/ref/modules/all/salt.modules.gem.rst create mode 100644 doc/ref/modules/all/salt.modules.genesis.rst create mode 100644 doc/ref/modules/all/salt.modules.gentoo_service.rst create mode 100644 doc/ref/modules/all/salt.modules.gentoolkitmod.rst create mode 100644 doc/ref/modules/all/salt.modules.github.rst create mode 100644 doc/ref/modules/all/salt.modules.glanceng.rst create mode 100644 doc/ref/modules/all/salt.modules.glassfish.rst create mode 100644 doc/ref/modules/all/salt.modules.glusterfs.rst create mode 100644 doc/ref/modules/all/salt.modules.gnomedesktop.rst create mode 100644 doc/ref/modules/all/salt.modules.google_chat.rst create mode 100644 doc/ref/modules/all/salt.modules.grafana4.rst create mode 100644 doc/ref/modules/all/salt.modules.grub_legacy.rst create mode 100644 doc/ref/modules/all/salt.modules.guestfs.rst create mode 100644 doc/ref/modules/all/salt.modules.hadoop.rst create mode 100644 doc/ref/modules/all/salt.modules.haproxyconn.rst create mode 100644 doc/ref/modules/all/salt.modules.heat.rst create mode 100644 doc/ref/modules/all/salt.modules.helm.rst create mode 100644 doc/ref/modules/all/salt.modules.hg.rst create mode 100644 doc/ref/modules/all/salt.modules.icinga2.rst create mode 100644 doc/ref/modules/all/salt.modules.ifttt.rst create mode 100644 doc/ref/modules/all/salt.modules.ilo.rst create mode 100644 doc/ref/modules/all/salt.modules.influxdb08mod.rst create mode 100644 doc/ref/modules/all/salt.modules.influxdbmod.rst create mode 100644 doc/ref/modules/all/salt.modules.infoblox.rst create mode 100644 doc/ref/modules/all/salt.modules.inspectlib.collector.rst create mode 100644 doc/ref/modules/all/salt.modules.inspectlib.dbhandle.rst create mode 100644 doc/ref/modules/all/salt.modules.inspectlib.entities.rst create mode 100644 doc/ref/modules/all/salt.modules.inspectlib.exceptions.rst create mode 100644 doc/ref/modules/all/salt.modules.inspectlib.fsdb.rst create mode 100644 doc/ref/modules/all/salt.modules.inspectlib.kiwiproc.rst create mode 100644 doc/ref/modules/all/salt.modules.inspectlib.query.rst create mode 100644 doc/ref/modules/all/salt.modules.inspectlib.rst create mode 100644 doc/ref/modules/all/salt.modules.inspector.rst create mode 100644 doc/ref/modules/all/salt.modules.introspect.rst create mode 100644 doc/ref/modules/all/salt.modules.ipmi.rst create mode 100644 doc/ref/modules/all/salt.modules.jboss7.rst create mode 100644 doc/ref/modules/all/salt.modules.jboss7_cli.rst create mode 100644 doc/ref/modules/all/salt.modules.jenkinsmod.rst create mode 100644 doc/ref/modules/all/salt.modules.jira_mod.rst create mode 100644 doc/ref/modules/all/salt.modules.k8s.rst create mode 100644 doc/ref/modules/all/salt.modules.kapacitor.rst create mode 100644 doc/ref/modules/all/salt.modules.kerberos.rst create mode 100644 doc/ref/modules/all/salt.modules.keystone.rst create mode 100644 doc/ref/modules/all/salt.modules.keystoneng.rst create mode 100644 doc/ref/modules/all/salt.modules.keystore.rst create mode 100644 doc/ref/modules/all/salt.modules.kubeadm.rst create mode 100644 doc/ref/modules/all/salt.modules.kubernetesmod.rst create mode 100644 doc/ref/modules/all/salt.modules.launchctl_service.rst create mode 100644 doc/ref/modules/all/salt.modules.layman.rst create mode 100644 doc/ref/modules/all/salt.modules.ldap3.rst create mode 100644 doc/ref/modules/all/salt.modules.ldapmod.rst create mode 100644 doc/ref/modules/all/salt.modules.libcloud_compute.rst create mode 100644 doc/ref/modules/all/salt.modules.libcloud_dns.rst create mode 100644 doc/ref/modules/all/salt.modules.libcloud_loadbalancer.rst create mode 100644 doc/ref/modules/all/salt.modules.libcloud_storage.rst create mode 100644 doc/ref/modules/all/salt.modules.logadm.rst create mode 100644 doc/ref/modules/all/salt.modules.lvs.rst create mode 100644 doc/ref/modules/all/salt.modules.lxc.rst create mode 100644 doc/ref/modules/all/salt.modules.lxd.rst create mode 100644 doc/ref/modules/all/salt.modules.makeconf.rst create mode 100644 doc/ref/modules/all/salt.modules.mandrill.rst create mode 100644 doc/ref/modules/all/salt.modules.marathon.rst create mode 100644 doc/ref/modules/all/salt.modules.mattermost.rst create mode 100644 doc/ref/modules/all/salt.modules.mdata.rst create mode 100644 doc/ref/modules/all/salt.modules.memcached.rst create mode 100644 doc/ref/modules/all/salt.modules.modjk.rst create mode 100644 doc/ref/modules/all/salt.modules.mongodb.rst create mode 100644 doc/ref/modules/all/salt.modules.monit.rst create mode 100644 doc/ref/modules/all/salt.modules.moosefs.rst create mode 100644 doc/ref/modules/all/salt.modules.mssql.rst create mode 100644 doc/ref/modules/all/salt.modules.msteams.rst create mode 100644 doc/ref/modules/all/salt.modules.munin.rst create mode 100644 doc/ref/modules/all/salt.modules.nagios.rst create mode 100644 doc/ref/modules/all/salt.modules.nagios_rpc.rst create mode 100644 doc/ref/modules/all/salt.modules.namecheap_domains.rst create mode 100644 doc/ref/modules/all/salt.modules.namecheap_domains_dns.rst create mode 100644 doc/ref/modules/all/salt.modules.namecheap_domains_ns.rst create mode 100644 doc/ref/modules/all/salt.modules.namecheap_ssl.rst create mode 100644 doc/ref/modules/all/salt.modules.namecheap_users.rst create mode 100644 doc/ref/modules/all/salt.modules.netbox.rst create mode 100644 doc/ref/modules/all/salt.modules.netbsd_sysctl.rst create mode 100644 doc/ref/modules/all/salt.modules.netbsdservice.rst create mode 100644 doc/ref/modules/all/salt.modules.netmiko_mod.rst create mode 100644 doc/ref/modules/all/salt.modules.netscaler.rst create mode 100644 doc/ref/modules/all/salt.modules.neutron.rst create mode 100644 doc/ref/modules/all/salt.modules.neutronng.rst create mode 100644 doc/ref/modules/all/salt.modules.nexus.rst create mode 100644 doc/ref/modules/all/salt.modules.nginx.rst create mode 100644 doc/ref/modules/all/salt.modules.nilrt_ip.rst create mode 100644 doc/ref/modules/all/salt.modules.nix.rst delete mode 100644 doc/ref/modules/all/salt.modules.nixpkg.rst create mode 100644 doc/ref/modules/all/salt.modules.nova.rst create mode 100644 doc/ref/modules/all/salt.modules.nspawn.rst create mode 100644 doc/ref/modules/all/salt.modules.omapi.rst create mode 100644 doc/ref/modules/all/salt.modules.openbsd_sysctl.rst create mode 100644 doc/ref/modules/all/salt.modules.openbsdpkg.rst create mode 100644 doc/ref/modules/all/salt.modules.openbsdrcctl_service.rst create mode 100644 doc/ref/modules/all/salt.modules.openbsdservice.rst create mode 100644 doc/ref/modules/all/salt.modules.openscap.rst create mode 100644 doc/ref/modules/all/salt.modules.openstack_config.rst create mode 100644 doc/ref/modules/all/salt.modules.openstack_mng.rst create mode 100644 doc/ref/modules/all/salt.modules.openvswitch.rst create mode 100644 doc/ref/modules/all/salt.modules.opkg.rst create mode 100644 doc/ref/modules/all/salt.modules.opsgenie.rst create mode 100644 doc/ref/modules/all/salt.modules.pagerduty.rst create mode 100644 doc/ref/modules/all/salt.modules.pagerduty_util.rst create mode 100644 doc/ref/modules/all/salt.modules.panos.rst create mode 100644 doc/ref/modules/all/salt.modules.parallels.rst create mode 100644 doc/ref/modules/all/salt.modules.pcs.rst create mode 100644 doc/ref/modules/all/salt.modules.pdbedit.rst create mode 100644 doc/ref/modules/all/salt.modules.pecl.rst create mode 100644 doc/ref/modules/all/salt.modules.peeringdb.rst create mode 100644 doc/ref/modules/all/salt.modules.pf.rst create mode 100644 doc/ref/modules/all/salt.modules.philips_hue.rst create mode 100644 doc/ref/modules/all/salt.modules.portage_config.rst create mode 100644 doc/ref/modules/all/salt.modules.postfix.rst create mode 100644 doc/ref/modules/all/salt.modules.poudriere.rst create mode 100644 doc/ref/modules/all/salt.modules.powerpath.rst create mode 100644 doc/ref/modules/all/salt.modules.purefa.rst create mode 100644 doc/ref/modules/all/salt.modules.purefb.rst create mode 100644 doc/ref/modules/all/salt.modules.pushbullet.rst create mode 100644 doc/ref/modules/all/salt.modules.pushover_notify.rst create mode 100644 doc/ref/modules/all/salt.modules.qemu_img.rst create mode 100644 doc/ref/modules/all/salt.modules.qemu_nbd.rst create mode 100644 doc/ref/modules/all/salt.modules.rallydev.rst create mode 100644 doc/ref/modules/all/salt.modules.random_org.rst create mode 100644 doc/ref/modules/all/salt.modules.rbenv.rst create mode 100644 doc/ref/modules/all/salt.modules.rebootmgr.rst create mode 100644 doc/ref/modules/all/salt.modules.redismod.rst create mode 100644 doc/ref/modules/all/salt.modules.restconf.rst create mode 100644 doc/ref/modules/all/salt.modules.riak.rst create mode 100644 doc/ref/modules/all/salt.modules.runit.rst create mode 100644 doc/ref/modules/all/salt.modules.rvm.rst create mode 100644 doc/ref/modules/all/salt.modules.s3.rst create mode 100644 doc/ref/modules/all/salt.modules.s6.rst create mode 100644 doc/ref/modules/all/salt.modules.sensehat.rst create mode 100644 doc/ref/modules/all/salt.modules.sensors.rst create mode 100644 doc/ref/modules/all/salt.modules.serverdensity_device.rst create mode 100644 doc/ref/modules/all/salt.modules.servicenow.rst create mode 100644 doc/ref/modules/all/salt.modules.slackware_service.rst create mode 100644 doc/ref/modules/all/salt.modules.smartos_imgadm.rst create mode 100644 doc/ref/modules/all/salt.modules.smartos_nictagadm.rst create mode 100644 doc/ref/modules/all/salt.modules.smartos_virt.rst create mode 100644 doc/ref/modules/all/salt.modules.smartos_vmadm.rst create mode 100644 doc/ref/modules/all/salt.modules.smtp.rst create mode 100644 doc/ref/modules/all/salt.modules.solr.rst create mode 100644 doc/ref/modules/all/salt.modules.solrcloud.rst create mode 100644 doc/ref/modules/all/salt.modules.splunk.rst create mode 100644 doc/ref/modules/all/salt.modules.splunk_search.rst delete mode 100644 doc/ref/modules/all/salt.modules.ssh_pki.rst create mode 100644 doc/ref/modules/all/salt.modules.statuspage.rst create mode 100644 doc/ref/modules/all/salt.modules.suse_apache.rst create mode 100644 doc/ref/modules/all/salt.modules.suse_ip.rst create mode 100644 doc/ref/modules/all/salt.modules.svn.rst create mode 100644 doc/ref/modules/all/salt.modules.swarm.rst create mode 100644 doc/ref/modules/all/salt.modules.swift.rst create mode 100644 doc/ref/modules/all/salt.modules.sysbench.rst create mode 100644 doc/ref/modules/all/salt.modules.sysrc.rst create mode 100644 doc/ref/modules/all/salt.modules.system_profiler.rst create mode 100644 doc/ref/modules/all/salt.modules.telegram.rst create mode 100644 doc/ref/modules/all/salt.modules.telemetry.rst create mode 100644 doc/ref/modules/all/salt.modules.testinframod.rst create mode 100644 doc/ref/modules/all/salt.modules.tomcat.rst create mode 100644 doc/ref/modules/all/salt.modules.trafficserver.rst create mode 100644 doc/ref/modules/all/salt.modules.transactional_update.rst create mode 100644 doc/ref/modules/all/salt.modules.travisci.rst create mode 100644 doc/ref/modules/all/salt.modules.tuned.rst create mode 100644 doc/ref/modules/all/salt.modules.twilio_notify.rst create mode 100644 doc/ref/modules/all/salt.modules.uptime.rst create mode 100644 doc/ref/modules/all/salt.modules.uwsgi.rst create mode 100644 doc/ref/modules/all/salt.modules.varnish.rst create mode 100644 doc/ref/modules/all/salt.modules.vault.rst create mode 100644 doc/ref/modules/all/salt.modules.vbox_guest.rst create mode 100644 doc/ref/modules/all/salt.modules.vboxmanage.rst create mode 100644 doc/ref/modules/all/salt.modules.vcenter.rst create mode 100644 doc/ref/modules/all/salt.modules.victorops.rst create mode 100644 doc/ref/modules/all/salt.modules.virt.rst create mode 100644 doc/ref/modules/all/salt.modules.vmctl.rst delete mode 100644 doc/ref/modules/all/salt.modules.win_dsc_resource.rst create mode 100644 doc/ref/modules/all/salt.modules.wordpress.rst create mode 100644 doc/ref/modules/all/salt.modules.xapi_virt.rst create mode 100644 doc/ref/modules/all/salt.modules.xbpspkg.rst create mode 100644 doc/ref/modules/all/salt.modules.xmpp.rst create mode 100644 doc/ref/modules/all/salt.modules.zabbix.rst create mode 100644 doc/ref/modules/all/salt.modules.zcbuildout.rst create mode 100644 doc/ref/modules/all/salt.modules.zenoss.rst create mode 100644 doc/ref/modules/all/salt.modules.zfs.rst create mode 100644 doc/ref/modules/all/salt.modules.znc.rst create mode 100644 doc/ref/modules/all/salt.modules.zookeeper.rst create mode 100644 doc/ref/modules/all/salt.modules.zpool.rst create mode 100644 doc/ref/output/all/salt.output.dson.rst create mode 100644 doc/ref/output/all/salt.output.newline_values_only.rst create mode 100644 doc/ref/output/all/salt.output.no_out_quiet.rst create mode 100644 doc/ref/output/all/salt.output.overstatestage.rst create mode 100644 doc/ref/output/all/salt.output.pony.rst create mode 100644 doc/ref/output/all/salt.output.profile.rst create mode 100644 doc/ref/output/all/salt.output.virt_query.rst create mode 100644 doc/ref/pillar/all/salt.pillar.cmd_yamlex.rst create mode 100644 doc/ref/pillar/all/salt.pillar.cobbler.rst create mode 100644 doc/ref/pillar/all/salt.pillar.confidant.rst create mode 100644 doc/ref/pillar/all/salt.pillar.consul_pillar.rst create mode 100644 doc/ref/pillar/all/salt.pillar.csvpillar.rst create mode 100644 doc/ref/pillar/all/salt.pillar.digicert.rst create mode 100644 doc/ref/pillar/all/salt.pillar.django_orm.rst create mode 100644 doc/ref/pillar/all/salt.pillar.ec2_pillar.rst create mode 100644 doc/ref/pillar/all/salt.pillar.etcd_pillar.rst create mode 100644 doc/ref/pillar/all/salt.pillar.foreman.rst create mode 100644 doc/ref/pillar/all/salt.pillar.hg_pillar.rst create mode 100644 doc/ref/pillar/all/salt.pillar.hiera.rst create mode 100644 doc/ref/pillar/all/salt.pillar.http_json.rst create mode 100644 doc/ref/pillar/all/salt.pillar.http_yaml.rst create mode 100644 doc/ref/pillar/all/salt.pillar.libvirt.rst create mode 100644 doc/ref/pillar/all/salt.pillar.makostack.rst create mode 100644 doc/ref/pillar/all/salt.pillar.mongo.rst create mode 100644 doc/ref/pillar/all/salt.pillar.mysql.rst create mode 100644 doc/ref/pillar/all/salt.pillar.nacl.rst create mode 100644 doc/ref/pillar/all/salt.pillar.netbox.rst create mode 100644 doc/ref/pillar/all/salt.pillar.neutron.rst create mode 100644 doc/ref/pillar/all/salt.pillar.pepa.rst create mode 100644 doc/ref/pillar/all/salt.pillar.pillar_ldap.rst create mode 100644 doc/ref/pillar/all/salt.pillar.puppet.rst create mode 100644 doc/ref/pillar/all/salt.pillar.redismod.rst create mode 100644 doc/ref/pillar/all/salt.pillar.rethinkdb_pillar.rst create mode 100644 doc/ref/pillar/all/salt.pillar.s3.rst create mode 100644 doc/ref/pillar/all/salt.pillar.saltclass.rst create mode 100644 doc/ref/pillar/all/salt.pillar.sqlcipher.rst create mode 100644 doc/ref/pillar/all/salt.pillar.sqlite3.rst create mode 100644 doc/ref/pillar/all/salt.pillar.svn_pillar.rst create mode 100644 doc/ref/pillar/all/salt.pillar.varstack_pillar.rst create mode 100644 doc/ref/pillar/all/salt.pillar.vault.rst create mode 100644 doc/ref/pillar/all/salt.pillar.venafi.rst create mode 100644 doc/ref/pillar/all/salt.pillar.virtkey.rst create mode 100644 doc/ref/pillar/all/salt.pillar.vmware_pillar.rst create mode 100644 doc/ref/proxy/all/salt.proxy.arista_pyeapi.rst create mode 100644 doc/ref/proxy/all/salt.proxy.chronos.rst create mode 100644 doc/ref/proxy/all/salt.proxy.cimc.rst create mode 100644 doc/ref/proxy/all/salt.proxy.cisconso.rst create mode 100644 doc/ref/proxy/all/salt.proxy.docker.rst create mode 100644 doc/ref/proxy/all/salt.proxy.esxcluster.rst create mode 100644 doc/ref/proxy/all/salt.proxy.esxdatacenter.rst create mode 100644 doc/ref/proxy/all/salt.proxy.esxi.rst create mode 100644 doc/ref/proxy/all/salt.proxy.esxvm.rst create mode 100644 doc/ref/proxy/all/salt.proxy.fx2.rst create mode 100644 doc/ref/proxy/all/salt.proxy.junos.rst create mode 100644 doc/ref/proxy/all/salt.proxy.marathon.rst create mode 100644 doc/ref/proxy/all/salt.proxy.napalm.rst create mode 100644 doc/ref/proxy/all/salt.proxy.netmiko_px.rst create mode 100644 doc/ref/proxy/all/salt.proxy.nxos.rst create mode 100644 doc/ref/proxy/all/salt.proxy.nxos_api.rst create mode 100644 doc/ref/proxy/all/salt.proxy.panos.rst create mode 100644 doc/ref/proxy/all/salt.proxy.philips_hue.rst create mode 100644 doc/ref/proxy/all/salt.proxy.rest_sample.rst create mode 100644 doc/ref/proxy/all/salt.proxy.restconf.rst create mode 100644 doc/ref/proxy/all/salt.proxy.ssh_sample.rst create mode 100644 doc/ref/proxy/all/salt.proxy.vcenter.rst create mode 100644 doc/ref/queues/all/salt.queues.pgjsonb_queue.rst create mode 100644 doc/ref/queues/all/salt.queues.sqlite_queue.rst create mode 100644 doc/ref/renderers/all/salt.renderers.aws_kms.rst create mode 100644 doc/ref/renderers/all/salt.renderers.cheetah.rst create mode 100644 doc/ref/renderers/all/salt.renderers.dson.rst create mode 100644 doc/ref/renderers/all/salt.renderers.genshi.rst create mode 100644 doc/ref/renderers/all/salt.renderers.hjson.rst create mode 100644 doc/ref/renderers/all/salt.renderers.json5.rst create mode 100644 doc/ref/renderers/all/salt.renderers.pass.rst create mode 100644 doc/ref/renderers/all/salt.renderers.pydsl.rst create mode 100644 doc/ref/renderers/all/salt.renderers.wempy.rst delete mode 100644 doc/ref/resources/all/index.rst delete mode 100644 doc/ref/resources/all/salt.resources.dummy.modules.test.rst delete mode 100644 doc/ref/resources/all/salt.resources.dummy.rst delete mode 100644 doc/ref/resources/all/salt.resources.ssh.modules.cmd.rst delete mode 100644 doc/ref/resources/all/salt.resources.ssh.modules.pkg.rst delete mode 100644 doc/ref/resources/all/salt.resources.ssh.modules.state.rst delete mode 100644 doc/ref/resources/all/salt.resources.ssh.modules.test.rst delete mode 100644 doc/ref/resources/all/salt.resources.ssh.rst delete mode 100644 doc/ref/resources/index.rst create mode 100644 doc/ref/returners/all/salt.returners.appoptics_return.rst create mode 100644 doc/ref/returners/all/salt.returners.carbon_return.rst create mode 100644 doc/ref/returners/all/salt.returners.cassandra_cql_return.rst create mode 100644 doc/ref/returners/all/salt.returners.couchbase_return.rst create mode 100644 doc/ref/returners/all/salt.returners.couchdb_return.rst create mode 100644 doc/ref/returners/all/salt.returners.elasticsearch_return.rst create mode 100644 doc/ref/returners/all/salt.returners.etcd_return.rst create mode 100644 doc/ref/returners/all/salt.returners.influxdb_return.rst create mode 100644 doc/ref/returners/all/salt.returners.kafka_return.rst create mode 100644 doc/ref/returners/all/salt.returners.librato_return.rst create mode 100644 doc/ref/returners/all/salt.returners.mattermost_returner.rst create mode 100644 doc/ref/returners/all/salt.returners.memcache_return.rst create mode 100644 doc/ref/returners/all/salt.returners.mongo_future_return.rst create mode 100644 doc/ref/returners/all/salt.returners.mongo_return.rst create mode 100644 doc/ref/returners/all/salt.returners.mysql.rst create mode 100644 doc/ref/returners/all/salt.returners.nagios_nrdp_return.rst create mode 100644 doc/ref/returners/all/salt.returners.odbc.rst create mode 100644 doc/ref/returners/all/salt.returners.pushover_returner.rst create mode 100644 doc/ref/returners/all/salt.returners.redis_return.rst delete mode 100644 doc/ref/returners/all/salt.returners.salt_cache.rst create mode 100644 doc/ref/returners/all/salt.returners.sentry_return.rst create mode 100644 doc/ref/returners/all/salt.returners.slack_returner.rst create mode 100644 doc/ref/returners/all/salt.returners.slack_webhook_return.rst create mode 100644 doc/ref/returners/all/salt.returners.sms_return.rst create mode 100644 doc/ref/returners/all/salt.returners.smtp_return.rst create mode 100644 doc/ref/returners/all/salt.returners.splunk.rst create mode 100644 doc/ref/returners/all/salt.returners.sqlite3_return.rst create mode 100644 doc/ref/returners/all/salt.returners.telegram_return.rst create mode 100644 doc/ref/returners/all/salt.returners.xmpp_return.rst create mode 100644 doc/ref/returners/all/salt.returners.zabbix_return.rst create mode 100644 doc/ref/roster/all/salt.roster.cloud.rst create mode 100644 doc/ref/roster/all/salt.roster.clustershell.rst create mode 100644 doc/ref/roster/all/salt.roster.terraform.rst create mode 100644 doc/ref/runners/all/salt.runners.asam.rst delete mode 100644 doc/ref/runners/all/salt.runners.batch.rst create mode 100644 doc/ref/runners/all/salt.runners.bgp.rst create mode 100644 doc/ref/runners/all/salt.runners.cloud.rst delete mode 100644 doc/ref/runners/all/salt.runners.cluster.rst create mode 100644 doc/ref/runners/all/salt.runners.ddns.rst create mode 100644 doc/ref/runners/all/salt.runners.digicertapi.rst create mode 100644 doc/ref/runners/all/salt.runners.drac.rst create mode 100644 doc/ref/runners/all/salt.runners.f5.rst create mode 100644 doc/ref/runners/all/salt.runners.launchd.rst create mode 100644 doc/ref/runners/all/salt.runners.lxc.rst create mode 100644 doc/ref/runners/all/salt.runners.mattermost.rst create mode 100644 doc/ref/runners/all/salt.runners.nacl.rst create mode 100644 doc/ref/runners/all/salt.runners.pagerduty.rst create mode 100644 doc/ref/runners/all/salt.runners.pkg.rst delete mode 100644 doc/ref/runners/all/salt.runners.pki.rst delete mode 100644 doc/ref/runners/all/salt.runners.resource.rst create mode 100644 doc/ref/runners/all/salt.runners.smartos_vmadm.rst create mode 100644 doc/ref/runners/all/salt.runners.spacewalk.rst create mode 100644 doc/ref/runners/all/salt.runners.thin.rst create mode 100644 doc/ref/runners/all/salt.runners.vault.rst create mode 100644 doc/ref/runners/all/salt.runners.venafiapi.rst create mode 100644 doc/ref/runners/all/salt.runners.virt.rst create mode 100644 doc/ref/runners/all/salt.runners.vistara.rst create mode 100644 doc/ref/sdb/all/salt.sdb.cache.rst create mode 100644 doc/ref/sdb/all/salt.sdb.confidant.rst create mode 100644 doc/ref/sdb/all/salt.sdb.consul.rst create mode 100644 doc/ref/sdb/all/salt.sdb.couchdb.rst create mode 100644 doc/ref/sdb/all/salt.sdb.etcd_db.rst create mode 100644 doc/ref/sdb/all/salt.sdb.keyring_db.rst create mode 100644 doc/ref/sdb/all/salt.sdb.memcached.rst create mode 100644 doc/ref/sdb/all/salt.sdb.redis_sdb.rst create mode 100644 doc/ref/sdb/all/salt.sdb.rest.rst create mode 100644 doc/ref/sdb/all/salt.sdb.sqlite3.rst create mode 100644 doc/ref/sdb/all/salt.sdb.tism.rst create mode 100644 doc/ref/sdb/all/salt.sdb.vault.rst create mode 100644 doc/ref/serializers/all/salt.serializers.keyvalue.rst create mode 100644 doc/ref/serializers/all/salt.serializers.plist.rst create mode 100644 doc/ref/serializers/all/salt.serializers.python.rst create mode 100644 doc/ref/states/all/salt.states.acme.rst create mode 100644 doc/ref/states/all/salt.states.alternatives.rst create mode 100644 doc/ref/states/all/salt.states.aptpkg.rst create mode 100644 doc/ref/states/all/salt.states.artifactory.rst create mode 100644 doc/ref/states/all/salt.states.augeas.rst create mode 100644 doc/ref/states/all/salt.states.aws_sqs.rst create mode 100644 doc/ref/states/all/salt.states.bigip.rst create mode 100644 doc/ref/states/all/salt.states.boto3_elasticache.rst create mode 100644 doc/ref/states/all/salt.states.boto3_elasticsearch.rst create mode 100644 doc/ref/states/all/salt.states.boto3_route53.rst create mode 100644 doc/ref/states/all/salt.states.boto3_sns.rst create mode 100644 doc/ref/states/all/salt.states.boto_apigateway.rst create mode 100644 doc/ref/states/all/salt.states.boto_asg.rst create mode 100644 doc/ref/states/all/salt.states.boto_cfn.rst create mode 100644 doc/ref/states/all/salt.states.boto_cloudfront.rst create mode 100644 doc/ref/states/all/salt.states.boto_cloudtrail.rst create mode 100644 doc/ref/states/all/salt.states.boto_cloudwatch_alarm.rst create mode 100644 doc/ref/states/all/salt.states.boto_cloudwatch_event.rst create mode 100644 doc/ref/states/all/salt.states.boto_cognitoidentity.rst create mode 100644 doc/ref/states/all/salt.states.boto_datapipeline.rst create mode 100644 doc/ref/states/all/salt.states.boto_dynamodb.rst create mode 100644 doc/ref/states/all/salt.states.boto_ec2.rst create mode 100644 doc/ref/states/all/salt.states.boto_elasticache.rst create mode 100644 doc/ref/states/all/salt.states.boto_elasticsearch_domain.rst create mode 100644 doc/ref/states/all/salt.states.boto_elb.rst create mode 100644 doc/ref/states/all/salt.states.boto_elbv2.rst create mode 100644 doc/ref/states/all/salt.states.boto_iam.rst create mode 100644 doc/ref/states/all/salt.states.boto_iam_role.rst create mode 100644 doc/ref/states/all/salt.states.boto_iot.rst create mode 100644 doc/ref/states/all/salt.states.boto_kinesis.rst create mode 100644 doc/ref/states/all/salt.states.boto_kms.rst create mode 100644 doc/ref/states/all/salt.states.boto_lambda.rst create mode 100644 doc/ref/states/all/salt.states.boto_lc.rst create mode 100644 doc/ref/states/all/salt.states.boto_rds.rst create mode 100644 doc/ref/states/all/salt.states.boto_route53.rst create mode 100644 doc/ref/states/all/salt.states.boto_s3.rst create mode 100644 doc/ref/states/all/salt.states.boto_s3_bucket.rst create mode 100644 doc/ref/states/all/salt.states.boto_secgroup.rst create mode 100644 doc/ref/states/all/salt.states.boto_sns.rst create mode 100644 doc/ref/states/all/salt.states.boto_sqs.rst create mode 100644 doc/ref/states/all/salt.states.boto_vpc.rst create mode 100644 doc/ref/states/all/salt.states.bower.rst create mode 100644 doc/ref/states/all/salt.states.btrfs.rst create mode 100644 doc/ref/states/all/salt.states.cabal.rst create mode 100644 doc/ref/states/all/salt.states.ceph.rst create mode 100644 doc/ref/states/all/salt.states.chef.rst create mode 100644 doc/ref/states/all/salt.states.chronos_job.rst create mode 100644 doc/ref/states/all/salt.states.cimc.rst create mode 100644 doc/ref/states/all/salt.states.cisconso.rst create mode 100644 doc/ref/states/all/salt.states.composer.rst create mode 100644 doc/ref/states/all/salt.states.consul.rst create mode 100644 doc/ref/states/all/salt.states.cryptdev.rst create mode 100644 doc/ref/states/all/salt.states.csf.rst create mode 100644 doc/ref/states/all/salt.states.cyg.rst create mode 100644 doc/ref/states/all/salt.states.ddns.rst create mode 100644 doc/ref/states/all/salt.states.dellchassis.rst create mode 100644 doc/ref/states/all/salt.states.docker_container.rst create mode 100644 doc/ref/states/all/salt.states.docker_image.rst create mode 100644 doc/ref/states/all/salt.states.docker_network.rst create mode 100644 doc/ref/states/all/salt.states.docker_volume.rst create mode 100644 doc/ref/states/all/salt.states.drac.rst create mode 100644 doc/ref/states/all/salt.states.dvs.rst create mode 100644 doc/ref/states/all/salt.states.elasticsearch.rst create mode 100644 doc/ref/states/all/salt.states.elasticsearch_index.rst create mode 100644 doc/ref/states/all/salt.states.elasticsearch_index_template.rst create mode 100644 doc/ref/states/all/salt.states.eselect.rst create mode 100644 doc/ref/states/all/salt.states.esxcluster.rst create mode 100644 doc/ref/states/all/salt.states.esxdatacenter.rst create mode 100644 doc/ref/states/all/salt.states.esxi.rst create mode 100644 doc/ref/states/all/salt.states.esxvm.rst create mode 100644 doc/ref/states/all/salt.states.ethtool.rst create mode 100644 doc/ref/states/all/salt.states.gem.rst create mode 100644 doc/ref/states/all/salt.states.github.rst create mode 100644 doc/ref/states/all/salt.states.glance_image.rst create mode 100644 doc/ref/states/all/salt.states.glassfish.rst create mode 100644 doc/ref/states/all/salt.states.glusterfs.rst create mode 100644 doc/ref/states/all/salt.states.gnomedesktop.rst create mode 100644 doc/ref/states/all/salt.states.grafana.rst create mode 100644 doc/ref/states/all/salt.states.grafana4_dashboard.rst create mode 100644 doc/ref/states/all/salt.states.grafana4_datasource.rst create mode 100644 doc/ref/states/all/salt.states.grafana4_org.rst create mode 100644 doc/ref/states/all/salt.states.grafana4_user.rst create mode 100644 doc/ref/states/all/salt.states.grafana_dashboard.rst create mode 100644 doc/ref/states/all/salt.states.grafana_datasource.rst create mode 100644 doc/ref/states/all/salt.states.heat.rst create mode 100644 doc/ref/states/all/salt.states.helm.rst create mode 100644 doc/ref/states/all/salt.states.hg.rst create mode 100644 doc/ref/states/all/salt.states.icinga2.rst create mode 100644 doc/ref/states/all/salt.states.ifttt.rst create mode 100644 doc/ref/states/all/salt.states.incron.rst create mode 100644 doc/ref/states/all/salt.states.influxdb08_database.rst create mode 100644 doc/ref/states/all/salt.states.influxdb08_user.rst create mode 100644 doc/ref/states/all/salt.states.influxdb_continuous_query.rst create mode 100644 doc/ref/states/all/salt.states.influxdb_database.rst create mode 100644 doc/ref/states/all/salt.states.influxdb_retention_policy.rst create mode 100644 doc/ref/states/all/salt.states.influxdb_user.rst create mode 100644 doc/ref/states/all/salt.states.infoblox_a.rst create mode 100644 doc/ref/states/all/salt.states.infoblox_cname.rst create mode 100644 doc/ref/states/all/salt.states.infoblox_host_record.rst create mode 100644 doc/ref/states/all/salt.states.infoblox_range.rst create mode 100644 doc/ref/states/all/salt.states.ipmi.rst create mode 100644 doc/ref/states/all/salt.states.jboss7.rst create mode 100644 doc/ref/states/all/salt.states.jenkins.rst create mode 100644 doc/ref/states/all/salt.states.junos.rst create mode 100644 doc/ref/states/all/salt.states.kapacitor.rst create mode 100644 doc/ref/states/all/salt.states.kernelpkg.rst create mode 100644 doc/ref/states/all/salt.states.keystone.rst create mode 100644 doc/ref/states/all/salt.states.keystone_domain.rst create mode 100644 doc/ref/states/all/salt.states.keystone_endpoint.rst create mode 100644 doc/ref/states/all/salt.states.keystone_group.rst create mode 100644 doc/ref/states/all/salt.states.keystone_project.rst create mode 100644 doc/ref/states/all/salt.states.keystone_role.rst create mode 100644 doc/ref/states/all/salt.states.keystone_role_grant.rst create mode 100644 doc/ref/states/all/salt.states.keystone_service.rst create mode 100644 doc/ref/states/all/salt.states.keystone_user.rst create mode 100644 doc/ref/states/all/salt.states.keystore.rst create mode 100644 doc/ref/states/all/salt.states.kubernetes.rst create mode 100644 doc/ref/states/all/salt.states.layman.rst create mode 100644 doc/ref/states/all/salt.states.ldap.rst create mode 100644 doc/ref/states/all/salt.states.libcloud_dns.rst create mode 100644 doc/ref/states/all/salt.states.libcloud_loadbalancer.rst create mode 100644 doc/ref/states/all/salt.states.libcloud_storage.rst create mode 100644 doc/ref/states/all/salt.states.logadm.rst create mode 100644 doc/ref/states/all/salt.states.lvs_server.rst create mode 100644 doc/ref/states/all/salt.states.lvs_service.rst create mode 100644 doc/ref/states/all/salt.states.lxc.rst create mode 100644 doc/ref/states/all/salt.states.lxd.rst create mode 100644 doc/ref/states/all/salt.states.lxd_container.rst create mode 100644 doc/ref/states/all/salt.states.lxd_image.rst create mode 100644 doc/ref/states/all/salt.states.lxd_profile.rst create mode 100644 doc/ref/states/all/salt.states.marathon_app.rst create mode 100644 doc/ref/states/all/salt.states.memcached.rst create mode 100644 doc/ref/states/all/salt.states.modjk.rst create mode 100644 doc/ref/states/all/salt.states.modjk_worker.rst create mode 100644 doc/ref/states/all/salt.states.mongodb_database.rst create mode 100644 doc/ref/states/all/salt.states.mongodb_user.rst create mode 100644 doc/ref/states/all/salt.states.monit.rst create mode 100644 doc/ref/states/all/salt.states.mssql_database.rst create mode 100644 doc/ref/states/all/salt.states.mssql_login.rst create mode 100644 doc/ref/states/all/salt.states.mssql_role.rst create mode 100644 doc/ref/states/all/salt.states.mssql_user.rst create mode 100644 doc/ref/states/all/salt.states.msteams.rst create mode 100644 doc/ref/states/all/salt.states.mysql_database.rst create mode 100644 doc/ref/states/all/salt.states.mysql_grants.rst create mode 100644 doc/ref/states/all/salt.states.mysql_query.rst create mode 100644 doc/ref/states/all/salt.states.mysql_user.rst create mode 100644 doc/ref/states/all/salt.states.net_napalm_yang.rst create mode 100644 doc/ref/states/all/salt.states.neutron_network.rst create mode 100644 doc/ref/states/all/salt.states.neutron_secgroup.rst create mode 100644 doc/ref/states/all/salt.states.neutron_secgroup_rule.rst create mode 100644 doc/ref/states/all/salt.states.neutron_subnet.rst create mode 100644 doc/ref/states/all/salt.states.nexus.rst create mode 100644 doc/ref/states/all/salt.states.nfs_export.rst create mode 100644 doc/ref/states/all/salt.states.npm.rst create mode 100644 doc/ref/states/all/salt.states.nxos.rst create mode 100644 doc/ref/states/all/salt.states.nxos_upgrade.rst create mode 100644 doc/ref/states/all/salt.states.openstack_config.rst create mode 100644 doc/ref/states/all/salt.states.openvswitch_bridge.rst create mode 100644 doc/ref/states/all/salt.states.openvswitch_db.rst create mode 100644 doc/ref/states/all/salt.states.openvswitch_port.rst create mode 100644 doc/ref/states/all/salt.states.opsgenie.rst create mode 100644 doc/ref/states/all/salt.states.pagerduty.rst create mode 100644 doc/ref/states/all/salt.states.pagerduty_escalation_policy.rst create mode 100644 doc/ref/states/all/salt.states.pagerduty_schedule.rst create mode 100644 doc/ref/states/all/salt.states.pagerduty_service.rst create mode 100644 doc/ref/states/all/salt.states.pagerduty_user.rst create mode 100644 doc/ref/states/all/salt.states.panos.rst create mode 100644 doc/ref/states/all/salt.states.pbm.rst create mode 100644 doc/ref/states/all/salt.states.pcs.rst create mode 100644 doc/ref/states/all/salt.states.pdbedit.rst create mode 100644 doc/ref/states/all/salt.states.pecl.rst create mode 100644 doc/ref/states/all/salt.states.portage_config.rst create mode 100644 doc/ref/states/all/salt.states.ports.rst create mode 100644 doc/ref/states/all/salt.states.powerpath.rst create mode 100644 doc/ref/states/all/salt.states.probes.rst create mode 100644 doc/ref/states/all/salt.states.pushover.rst create mode 100644 doc/ref/states/all/salt.states.pyrax_queues.rst create mode 100644 doc/ref/states/all/salt.states.rbac_solaris.rst create mode 100644 doc/ref/states/all/salt.states.rbenv.rst create mode 100644 doc/ref/states/all/salt.states.rdp.rst create mode 100644 doc/ref/states/all/salt.states.redismod.rst create mode 100644 doc/ref/states/all/salt.states.restconf.rst create mode 100644 doc/ref/states/all/salt.states.rsync.rst create mode 100644 doc/ref/states/all/salt.states.rvm.rst create mode 100644 doc/ref/states/all/salt.states.serverdensity_device.rst create mode 100644 doc/ref/states/all/salt.states.slack.rst create mode 100644 doc/ref/states/all/salt.states.smartos.rst create mode 100644 doc/ref/states/all/salt.states.smtp.rst create mode 100644 doc/ref/states/all/salt.states.snapper.rst create mode 100644 doc/ref/states/all/salt.states.solrcloud.rst create mode 100644 doc/ref/states/all/salt.states.splunk.rst create mode 100644 doc/ref/states/all/salt.states.splunk_search.rst create mode 100644 doc/ref/states/all/salt.states.sqlite3.rst delete mode 100644 doc/ref/states/all/salt.states.ssh_pki.rst create mode 100644 doc/ref/states/all/salt.states.statuspage.rst create mode 100644 doc/ref/states/all/salt.states.supervisord.rst create mode 100644 doc/ref/states/all/salt.states.svn.rst create mode 100644 doc/ref/states/all/salt.states.sysrc.rst create mode 100644 doc/ref/states/all/salt.states.telemetry_alert.rst create mode 100644 doc/ref/states/all/salt.states.testinframod.rst create mode 100644 doc/ref/states/all/salt.states.tomcat.rst create mode 100644 doc/ref/states/all/salt.states.trafficserver.rst create mode 100644 doc/ref/states/all/salt.states.tuned.rst create mode 100644 doc/ref/states/all/salt.states.vagrant.rst create mode 100644 doc/ref/states/all/salt.states.vault.rst create mode 100644 doc/ref/states/all/salt.states.vbox_guest.rst create mode 100644 doc/ref/states/all/salt.states.victorops.rst create mode 100644 doc/ref/states/all/salt.states.virt.rst create mode 100644 doc/ref/states/all/salt.states.webutil.rst delete mode 100644 doc/ref/states/all/salt.states.win_dsc_resource.rst create mode 100644 doc/ref/states/all/salt.states.wordpress.rst create mode 100644 doc/ref/states/all/salt.states.xml.rst create mode 100644 doc/ref/states/all/salt.states.xmpp.rst create mode 100644 doc/ref/states/all/salt.states.zabbix_action.rst create mode 100644 doc/ref/states/all/salt.states.zabbix_host.rst create mode 100644 doc/ref/states/all/salt.states.zabbix_hostgroup.rst create mode 100644 doc/ref/states/all/salt.states.zabbix_mediatype.rst create mode 100644 doc/ref/states/all/salt.states.zabbix_template.rst create mode 100644 doc/ref/states/all/salt.states.zabbix_user.rst create mode 100644 doc/ref/states/all/salt.states.zabbix_usergroup.rst create mode 100644 doc/ref/states/all/salt.states.zabbix_usermacro.rst create mode 100644 doc/ref/states/all/salt.states.zabbix_valuemap.rst create mode 100644 doc/ref/states/all/salt.states.zcbuildout.rst create mode 100644 doc/ref/states/all/salt.states.zenoss.rst create mode 100644 doc/ref/states/all/salt.states.zfs.rst create mode 100644 doc/ref/states/all/salt.states.zk_concurrency.rst create mode 100644 doc/ref/states/all/salt.states.zone.rst create mode 100644 doc/ref/states/all/salt.states.zookeeper.rst create mode 100644 doc/ref/states/all/salt.states.zpool.rst delete mode 100644 doc/topics/metrics/index.rst delete mode 100644 doc/topics/performance/index.rst delete mode 100644 doc/topics/performance/mmap_cache.rst delete mode 100644 doc/topics/performance/worker_pools.rst delete mode 100644 doc/topics/releases/3008.0.md delete mode 100644 doc/topics/releases/3008.1.md delete mode 100644 doc/topics/releases/templates/3006.27.md.template delete mode 100644 doc/topics/releases/templates/3008.0.md.template delete mode 100644 doc/topics/releases/templates/3008.1.md.template delete mode 100644 doc/topics/resources/architecture.rst delete mode 100644 doc/topics/resources/authoring/connection_module.rst delete mode 100644 doc/topics/resources/authoring/execution_modules.rst delete mode 100644 doc/topics/resources/authoring/index.rst delete mode 100644 doc/topics/resources/authoring/packaging.rst delete mode 100644 doc/topics/resources/authoring/pillar.rst delete mode 100644 doc/topics/resources/authoring/state_modules.rst delete mode 100644 doc/topics/resources/configuration.rst delete mode 100644 doc/topics/resources/derived.rst delete mode 100644 doc/topics/resources/index.rst delete mode 100644 doc/topics/resources/operations.rst delete mode 100644 doc/topics/resources/state_authoring.rst delete mode 100644 doc/topics/resources/targeting.rst delete mode 100644 doc/topics/resources/tutorial.rst delete mode 100644 doc/topics/tracing/index.rst create mode 100644 salt/auth/django.py create mode 100644 salt/auth/keystone.py create mode 100644 salt/auth/mysql.py create mode 100644 salt/auth/pki.py create mode 100644 salt/auth/yubico.py create mode 100644 salt/beacons/adb.py create mode 100644 salt/beacons/aix_account.py create mode 100644 salt/beacons/avahi_announce.py create mode 100644 salt/beacons/bonjour_announce.py create mode 100644 salt/beacons/btmp.py create mode 100644 salt/beacons/glxinfo.py create mode 100644 salt/beacons/haproxy.py create mode 100644 salt/beacons/junos_rre_keys.py create mode 100644 salt/beacons/napalm_beacon.py create mode 100644 salt/beacons/sensehat.py create mode 100644 salt/beacons/smartos_imgadm.py create mode 100644 salt/beacons/smartos_vmadm.py create mode 100644 salt/beacons/telegram_bot_msg.py create mode 100644 salt/beacons/twilio_txt_msg.py create mode 100644 salt/beacons/wtmp.py delete mode 100644 salt/cache/localfs_key.py delete mode 100644 salt/cache/mmap_cache.py delete mode 100644 salt/cache/mmap_key.py delete mode 100644 salt/client/ssh/wrapper/ssh_pki.py delete mode 100644 salt/client/ssh/wrapper/x509_v2.py create mode 100644 salt/cloud/clouds/aliyun.py create mode 100644 salt/cloud/clouds/clc.py create mode 100644 salt/cloud/clouds/cloudstack.py create mode 100644 salt/cloud/clouds/digitalocean.py create mode 100644 salt/cloud/clouds/dimensiondata.py create mode 100644 salt/cloud/clouds/ec2.py create mode 100644 salt/cloud/clouds/gce.py create mode 100644 salt/cloud/clouds/gogrid.py create mode 100644 salt/cloud/clouds/hetzner.py create mode 100644 salt/cloud/clouds/joyent.py create mode 100644 salt/cloud/clouds/libvirt.py create mode 100644 salt/cloud/clouds/linode.py create mode 100644 salt/cloud/clouds/lxc.py create mode 100644 salt/cloud/clouds/oneandone.py create mode 100644 salt/cloud/clouds/opennebula.py create mode 100644 salt/cloud/clouds/openstack.py create mode 100644 salt/cloud/clouds/packet.py create mode 100644 salt/cloud/clouds/parallels.py create mode 100644 salt/cloud/clouds/profitbricks.py create mode 100644 salt/cloud/clouds/proxmox.py create mode 100644 salt/cloud/clouds/pyrax.py create mode 100644 salt/cloud/clouds/qingcloud.py create mode 100644 salt/cloud/clouds/scaleway.py create mode 100644 salt/cloud/clouds/softlayer.py create mode 100644 salt/cloud/clouds/softlayer_hw.py create mode 100644 salt/cloud/clouds/tencentcloud.py create mode 100644 salt/cloud/clouds/vagrant.py create mode 100644 salt/cloud/clouds/virtualbox.py create mode 100644 salt/cloud/clouds/vmware.py create mode 100644 salt/cloud/clouds/vultrpy.py create mode 100644 salt/cloud/clouds/xen.py delete mode 100644 salt/cluster/__init__.py delete mode 100644 salt/cluster/consensus/__init__.py delete mode 100644 salt/cluster/consensus/peer.py delete mode 100644 salt/cluster/consensus/raft/__init__.py delete mode 100644 salt/cluster/consensus/raft/log.py delete mode 100644 salt/cluster/consensus/raft/node.py delete mode 100644 salt/cluster/consensus/raft/scheduler.py delete mode 100644 salt/cluster/consensus/raft/util.py delete mode 100644 salt/cluster/consensus/rpc.py delete mode 100644 salt/cluster/consensus/service.py delete mode 100644 salt/cluster/consensus/storage.py delete mode 100644 salt/cluster/file_sync.py delete mode 100644 salt/cluster/healthchecks.py delete mode 100644 salt/cluster/migration.py delete mode 100644 salt/cluster/ring.py delete mode 100644 salt/cluster/ring_membership.py delete mode 100644 salt/cluster/state_sync.py delete mode 100644 salt/config/worker_pools.py create mode 100644 salt/engines/docker_events.py create mode 100644 salt/engines/fluent.py create mode 100644 salt/engines/http_logstash.py create mode 100644 salt/engines/ircbot.py create mode 100644 salt/engines/junos_syslog.py create mode 100644 salt/engines/libvirt_events.py create mode 100644 salt/engines/logentries.py create mode 100644 salt/engines/logstash_engine.py create mode 100644 salt/engines/napalm_syslog.py create mode 100644 salt/engines/redis_sentinel.py create mode 100644 salt/engines/slack.py create mode 100644 salt/engines/slack_bolt_engine.py create mode 100644 salt/engines/sqs_events.py create mode 100644 salt/engines/stalekey.py create mode 100644 salt/executors/docker.py create mode 100644 salt/executors/transactional_update.py create mode 100644 salt/fileserver/hgfs.py create mode 100644 salt/fileserver/s3fs.py create mode 100644 salt/fileserver/svnfs.py create mode 100644 salt/grains/chronos.py create mode 100644 salt/grains/cimc.py create mode 100644 salt/grains/esxi.py create mode 100644 salt/grains/fibre_channel.py create mode 100644 salt/grains/fx2.py create mode 100644 salt/grains/iscsi.py create mode 100644 salt/grains/junos.py create mode 100644 salt/grains/marathon.py create mode 100644 salt/grains/mdata.py create mode 100644 salt/grains/metadata.py create mode 100644 salt/grains/metadata_gce.py create mode 100644 salt/grains/napalm.py create mode 100644 salt/grains/nvme.py create mode 100644 salt/grains/nxos.py create mode 100644 salt/grains/panos.py create mode 100644 salt/grains/philips_hue.py delete mode 100644 salt/grains/resources.py create mode 100644 salt/grains/smartos.py create mode 100644 salt/grains/ssh_sample.py delete mode 100644 salt/grains/truststore.py create mode 100644 salt/grains/zfs.py create mode 100644 salt/log_handlers/fluent_mod.py create mode 100644 salt/log_handlers/log4mongo_mod.py create mode 100644 salt/log_handlers/logstash_mod.py create mode 100644 salt/log_handlers/sentry_mod.py delete mode 100644 salt/matchers/managing_minion_match.py delete mode 100644 salt/matchers/resource_match.py create mode 100644 salt/modules/acme.py create mode 100644 salt/modules/apcups.py create mode 100644 salt/modules/apkpkg.py create mode 100644 salt/modules/aptly.py delete mode 100644 salt/modules/asymmetric.py create mode 100644 salt/modules/augeas_cfg.py create mode 100644 salt/modules/aws_sqs.py create mode 100644 salt/modules/bamboohr.py create mode 100644 salt/modules/bigip.py create mode 100644 salt/modules/bluez_bluetooth.py create mode 100644 salt/modules/boto3_elasticache.py create mode 100644 salt/modules/boto3_elasticsearch.py create mode 100644 salt/modules/boto3_route53.py create mode 100644 salt/modules/boto3_sns.py create mode 100644 salt/modules/boto_apigateway.py create mode 100644 salt/modules/boto_asg.py create mode 100644 salt/modules/boto_cfn.py create mode 100644 salt/modules/boto_cloudfront.py create mode 100644 salt/modules/boto_cloudtrail.py create mode 100644 salt/modules/boto_cloudwatch.py create mode 100644 salt/modules/boto_cloudwatch_event.py create mode 100644 salt/modules/boto_cognitoidentity.py create mode 100644 salt/modules/boto_datapipeline.py create mode 100644 salt/modules/boto_dynamodb.py create mode 100644 salt/modules/boto_ec2.py create mode 100644 salt/modules/boto_efs.py create mode 100644 salt/modules/boto_elasticache.py create mode 100644 salt/modules/boto_elasticsearch_domain.py create mode 100644 salt/modules/boto_elb.py create mode 100644 salt/modules/boto_elbv2.py create mode 100644 salt/modules/boto_iam.py create mode 100644 salt/modules/boto_iot.py create mode 100644 salt/modules/boto_kinesis.py create mode 100644 salt/modules/boto_kms.py create mode 100644 salt/modules/boto_lambda.py create mode 100644 salt/modules/boto_rds.py create mode 100644 salt/modules/boto_route53.py create mode 100644 salt/modules/boto_s3.py create mode 100644 salt/modules/boto_s3_bucket.py create mode 100644 salt/modules/boto_secgroup.py create mode 100644 salt/modules/boto_sns.py create mode 100644 salt/modules/boto_sqs.py create mode 100644 salt/modules/boto_ssm.py create mode 100644 salt/modules/boto_vpc.py create mode 100644 salt/modules/bower.py create mode 100644 salt/modules/bsd_shadow.py create mode 100644 salt/modules/btrfs.py create mode 100644 salt/modules/cabal.py create mode 100644 salt/modules/capirca_acl.py create mode 100644 salt/modules/ceph.py create mode 100644 salt/modules/chassis.py create mode 100644 salt/modules/cimc.py create mode 100644 salt/modules/ciscoconfparse_mod.py create mode 100644 salt/modules/cisconso.py create mode 100644 salt/modules/composer.py create mode 100644 salt/modules/consul.py create mode 100644 salt/modules/container_resource.py create mode 100644 salt/modules/cpan.py create mode 100644 salt/modules/csf.py create mode 100644 salt/modules/cyg.py create mode 100644 salt/modules/daemontools.py create mode 100644 salt/modules/datadog_api.py create mode 100644 salt/modules/ddns.py create mode 100644 salt/modules/deb_apache.py create mode 100644 salt/modules/deb_postgres.py create mode 100644 salt/modules/djangomod.py create mode 100644 salt/modules/dnsmasq.py create mode 100644 salt/modules/dockercompose.py create mode 100644 salt/modules/dockermod.py create mode 100644 salt/modules/drac.py create mode 100644 salt/modules/dracr.py create mode 100644 salt/modules/drbd.py create mode 100644 salt/modules/ebuildpkg.py create mode 100644 salt/modules/eix.py create mode 100644 salt/modules/elasticsearch.py create mode 100644 salt/modules/eselect.py create mode 100644 salt/modules/esxcluster.py create mode 100644 salt/modules/esxdatacenter.py create mode 100644 salt/modules/esxi.py create mode 100644 salt/modules/esxvm.py create mode 100644 salt/modules/freebsd_sysctl.py create mode 100644 salt/modules/freebsd_update.py create mode 100644 salt/modules/freebsdjail.py create mode 100644 salt/modules/freebsdkmod.py create mode 100644 salt/modules/freebsdpkg.py create mode 100644 salt/modules/freebsdports.py create mode 100644 salt/modules/freebsdservice.py create mode 100644 salt/modules/freezer.py create mode 100644 salt/modules/gcp_addon.py create mode 100644 salt/modules/gem.py create mode 100644 salt/modules/genesis.py create mode 100644 salt/modules/gentoo_service.py create mode 100644 salt/modules/gentoolkitmod.py create mode 100644 salt/modules/github.py create mode 100644 salt/modules/glanceng.py create mode 100644 salt/modules/glassfish.py create mode 100644 salt/modules/glusterfs.py create mode 100644 salt/modules/gnomedesktop.py create mode 100644 salt/modules/google_chat.py create mode 100644 salt/modules/grafana4.py create mode 100644 salt/modules/grub_legacy.py create mode 100644 salt/modules/guestfs.py create mode 100644 salt/modules/hadoop.py create mode 100644 salt/modules/haproxyconn.py create mode 100644 salt/modules/heat.py create mode 100644 salt/modules/helm.py create mode 100644 salt/modules/hg.py create mode 100644 salt/modules/icinga2.py create mode 100644 salt/modules/ifttt.py create mode 100644 salt/modules/ilo.py create mode 100644 salt/modules/influxdb08mod.py create mode 100644 salt/modules/influxdbmod.py create mode 100644 salt/modules/infoblox.py create mode 100644 salt/modules/inspectlib/__init__.py create mode 100644 salt/modules/inspectlib/collector.py create mode 100644 salt/modules/inspectlib/dbhandle.py create mode 100644 salt/modules/inspectlib/entities.py create mode 100644 salt/modules/inspectlib/exceptions.py create mode 100644 salt/modules/inspectlib/fsdb.py create mode 100644 salt/modules/inspectlib/kiwiproc.py create mode 100644 salt/modules/inspectlib/query.py create mode 100644 salt/modules/inspector.py create mode 100644 salt/modules/introspect.py create mode 100644 salt/modules/ipmi.py create mode 100644 salt/modules/jboss7.py create mode 100644 salt/modules/jboss7_cli.py create mode 100644 salt/modules/jenkinsmod.py create mode 100644 salt/modules/jira_mod.py create mode 100644 salt/modules/k8s.py create mode 100644 salt/modules/kapacitor.py create mode 100644 salt/modules/kerberos.py create mode 100644 salt/modules/keystone.py create mode 100644 salt/modules/keystoneng.py create mode 100644 salt/modules/keystore.py create mode 100644 salt/modules/kubeadm.py create mode 100644 salt/modules/kubernetesmod.py create mode 100644 salt/modules/launchctl_service.py create mode 100644 salt/modules/layman.py create mode 100644 salt/modules/ldap3.py create mode 100644 salt/modules/ldapmod.py create mode 100644 salt/modules/libcloud_compute.py create mode 100644 salt/modules/libcloud_dns.py create mode 100644 salt/modules/libcloud_loadbalancer.py create mode 100644 salt/modules/libcloud_storage.py create mode 100644 salt/modules/logadm.py create mode 100644 salt/modules/lvs.py create mode 100644 salt/modules/lxc.py create mode 100644 salt/modules/lxd.py create mode 100644 salt/modules/makeconf.py create mode 100644 salt/modules/mandrill.py create mode 100644 salt/modules/marathon.py create mode 100644 salt/modules/mattermost.py create mode 100644 salt/modules/mdata.py create mode 100644 salt/modules/memcached.py create mode 100644 salt/modules/modjk.py create mode 100644 salt/modules/mongodb.py create mode 100644 salt/modules/monit.py create mode 100644 salt/modules/moosefs.py create mode 100644 salt/modules/mssql.py create mode 100644 salt/modules/msteams.py create mode 100644 salt/modules/munin.py create mode 100644 salt/modules/nagios.py create mode 100644 salt/modules/nagios_rpc.py create mode 100644 salt/modules/namecheap_domains.py create mode 100644 salt/modules/namecheap_domains_dns.py create mode 100644 salt/modules/namecheap_domains_ns.py create mode 100644 salt/modules/namecheap_ssl.py create mode 100644 salt/modules/namecheap_users.py create mode 100644 salt/modules/netbox.py create mode 100644 salt/modules/netbsd_sysctl.py create mode 100644 salt/modules/netbsdservice.py create mode 100644 salt/modules/netmiko_mod.py create mode 100644 salt/modules/netscaler.py create mode 100644 salt/modules/neutron.py create mode 100644 salt/modules/neutronng.py create mode 100644 salt/modules/nexus.py create mode 100644 salt/modules/nginx.py create mode 100644 salt/modules/nilrt_ip.py create mode 100644 salt/modules/nix.py delete mode 100644 salt/modules/nixpkg.py create mode 100644 salt/modules/nova.py create mode 100644 salt/modules/nspawn.py create mode 100644 salt/modules/omapi.py create mode 100644 salt/modules/openbsd_sysctl.py create mode 100644 salt/modules/openbsdpkg.py create mode 100644 salt/modules/openbsdrcctl_service.py create mode 100644 salt/modules/openbsdservice.py create mode 100644 salt/modules/openscap.py create mode 100644 salt/modules/openstack_config.py create mode 100644 salt/modules/openstack_mng.py create mode 100644 salt/modules/openvswitch.py create mode 100644 salt/modules/opkg.py create mode 100644 salt/modules/opsgenie.py create mode 100644 salt/modules/pagerduty.py create mode 100644 salt/modules/pagerduty_util.py create mode 100644 salt/modules/panos.py create mode 100644 salt/modules/parallels.py create mode 100644 salt/modules/pcs.py create mode 100644 salt/modules/pdbedit.py create mode 100644 salt/modules/pecl.py create mode 100644 salt/modules/peeringdb.py create mode 100644 salt/modules/pf.py create mode 100644 salt/modules/philips_hue.py create mode 100644 salt/modules/portage_config.py create mode 100644 salt/modules/postfix.py create mode 100644 salt/modules/poudriere.py create mode 100644 salt/modules/powerpath.py create mode 100644 salt/modules/purefa.py create mode 100644 salt/modules/purefb.py create mode 100644 salt/modules/pushbullet.py create mode 100644 salt/modules/pushover_notify.py create mode 100644 salt/modules/qemu_img.py create mode 100644 salt/modules/qemu_nbd.py create mode 100644 salt/modules/rallydev.py create mode 100644 salt/modules/random_org.py create mode 100644 salt/modules/rbenv.py create mode 100644 salt/modules/rebootmgr.py create mode 100644 salt/modules/redismod.py create mode 100644 salt/modules/restconf.py create mode 100644 salt/modules/riak.py create mode 100644 salt/modules/runit.py create mode 100644 salt/modules/rvm.py create mode 100644 salt/modules/s3.py create mode 100644 salt/modules/s6.py create mode 100644 salt/modules/sensehat.py create mode 100644 salt/modules/sensors.py create mode 100644 salt/modules/serverdensity_device.py create mode 100644 salt/modules/servicenow.py create mode 100644 salt/modules/slackware_service.py create mode 100644 salt/modules/smartos_imgadm.py create mode 100644 salt/modules/smartos_nictagadm.py create mode 100644 salt/modules/smartos_virt.py create mode 100644 salt/modules/smartos_vmadm.py create mode 100644 salt/modules/smtp.py create mode 100644 salt/modules/solr.py create mode 100644 salt/modules/solrcloud.py create mode 100644 salt/modules/splunk.py create mode 100644 salt/modules/splunk_search.py delete mode 100644 salt/modules/ssh_pki.py create mode 100644 salt/modules/statuspage.py create mode 100644 salt/modules/suse_apache.py create mode 100644 salt/modules/suse_ip.py create mode 100644 salt/modules/svn.py create mode 100644 salt/modules/swarm.py create mode 100644 salt/modules/swift.py create mode 100644 salt/modules/sysbench.py create mode 100644 salt/modules/sysrc.py create mode 100644 salt/modules/system_profiler.py create mode 100644 salt/modules/telegram.py create mode 100644 salt/modules/telemetry.py create mode 100644 salt/modules/testinframod.py create mode 100644 salt/modules/tomcat.py create mode 100644 salt/modules/trafficserver.py create mode 100644 salt/modules/transactional_update.py create mode 100644 salt/modules/travisci.py create mode 100644 salt/modules/tuned.py create mode 100644 salt/modules/twilio_notify.py create mode 100644 salt/modules/uptime.py create mode 100644 salt/modules/uwsgi.py create mode 100644 salt/modules/varnish.py create mode 100644 salt/modules/vault.py create mode 100644 salt/modules/vbox_guest.py create mode 100644 salt/modules/vboxmanage.py create mode 100644 salt/modules/vcenter.py create mode 100644 salt/modules/victorops.py create mode 100644 salt/modules/virt.py create mode 100644 salt/modules/vmctl.py delete mode 100644 salt/modules/win_dsc_resource.py create mode 100644 salt/modules/wordpress.py create mode 100644 salt/modules/xapi_virt.py create mode 100644 salt/modules/xbpspkg.py create mode 100644 salt/modules/xmpp.py create mode 100644 salt/modules/zabbix.py create mode 100644 salt/modules/zcbuildout.py create mode 100644 salt/modules/zenoss.py create mode 100644 salt/modules/zfs.py create mode 100644 salt/modules/znc.py create mode 100644 salt/modules/zookeeper.py create mode 100644 salt/modules/zpool.py create mode 100644 salt/output/dson.py create mode 100644 salt/output/newline_values_only.py create mode 100644 salt/output/no_out_quiet.py create mode 100644 salt/output/overstatestage.py create mode 100644 salt/output/pony.py create mode 100644 salt/output/profile.py create mode 100644 salt/output/virt_query.py create mode 100644 salt/pillar/cmd_yamlex.py create mode 100644 salt/pillar/cobbler.py create mode 100644 salt/pillar/confidant.py create mode 100644 salt/pillar/consul_pillar.py create mode 100644 salt/pillar/csvpillar.py create mode 100644 salt/pillar/digicert.py create mode 100644 salt/pillar/django_orm.py create mode 100644 salt/pillar/ec2_pillar.py create mode 100644 salt/pillar/etcd_pillar.py create mode 100644 salt/pillar/foreman.py create mode 100644 salt/pillar/hg_pillar.py create mode 100644 salt/pillar/hiera.py create mode 100644 salt/pillar/http_json.py create mode 100644 salt/pillar/http_yaml.py create mode 100644 salt/pillar/libvirt.py create mode 100644 salt/pillar/makostack.py create mode 100644 salt/pillar/mongo.py create mode 100644 salt/pillar/mysql.py create mode 100644 salt/pillar/nacl.py create mode 100644 salt/pillar/netbox.py create mode 100644 salt/pillar/neutron.py create mode 100644 salt/pillar/pepa.py create mode 100644 salt/pillar/pillar_ldap.py create mode 100644 salt/pillar/puppet.py create mode 100644 salt/pillar/redismod.py create mode 100644 salt/pillar/rethinkdb_pillar.py create mode 100644 salt/pillar/s3.py create mode 100644 salt/pillar/saltclass.py create mode 100644 salt/pillar/sqlcipher.py create mode 100644 salt/pillar/sqlite3.py create mode 100644 salt/pillar/svn_pillar.py create mode 100644 salt/pillar/varstack_pillar.py create mode 100644 salt/pillar/vault.py create mode 100644 salt/pillar/venafi.py create mode 100644 salt/pillar/virtkey.py create mode 100644 salt/pillar/vmware_pillar.py create mode 100644 salt/proxy/arista_pyeapi.py create mode 100644 salt/proxy/chronos.py create mode 100644 salt/proxy/cimc.py create mode 100644 salt/proxy/cisconso.py create mode 100644 salt/proxy/docker.py create mode 100644 salt/proxy/esxcluster.py create mode 100644 salt/proxy/esxdatacenter.py create mode 100644 salt/proxy/esxi.py create mode 100644 salt/proxy/esxvm.py create mode 100644 salt/proxy/fx2.py create mode 100644 salt/proxy/junos.py create mode 100644 salt/proxy/marathon.py create mode 100644 salt/proxy/napalm.py create mode 100644 salt/proxy/netmiko_px.py create mode 100644 salt/proxy/nxos.py create mode 100644 salt/proxy/nxos_api.py create mode 100644 salt/proxy/panos.py create mode 100644 salt/proxy/philips_hue.py create mode 100644 salt/proxy/rest_sample.py create mode 100644 salt/proxy/restconf.py create mode 100644 salt/proxy/ssh_sample.py create mode 100644 salt/proxy/vcenter.py create mode 100644 salt/queues/pgjsonb_queue.py create mode 100644 salt/queues/sqlite_queue.py create mode 100644 salt/renderers/aws_kms.py create mode 100644 salt/renderers/cheetah.py create mode 100644 salt/renderers/dson.py create mode 100644 salt/renderers/genshi.py create mode 100644 salt/renderers/hjson.py create mode 100644 salt/renderers/json5.py create mode 100644 salt/renderers/pass.py create mode 100644 salt/renderers/pydsl.py create mode 100644 salt/renderers/wempy.py delete mode 100644 salt/resources/__init__.py delete mode 100644 salt/resources/dummy/__init__.py delete mode 100644 salt/resources/dummy/modules/__init__.py delete mode 100644 salt/resources/dummy/modules/test.py delete mode 100644 salt/resources/ssh/__init__.py delete mode 100644 salt/resources/ssh/modules/__init__.py delete mode 100644 salt/resources/ssh/modules/cmd.py delete mode 100644 salt/resources/ssh/modules/pkg.py delete mode 100644 salt/resources/ssh/modules/state.py delete mode 100644 salt/resources/ssh/modules/test.py create mode 100644 salt/returners/appoptics_return.py create mode 100644 salt/returners/carbon_return.py create mode 100644 salt/returners/cassandra_cql_return.py create mode 100644 salt/returners/couchbase_return.py create mode 100644 salt/returners/couchdb_return.py create mode 100644 salt/returners/elasticsearch_return.py create mode 100644 salt/returners/etcd_return.py create mode 100644 salt/returners/influxdb_return.py create mode 100644 salt/returners/kafka_return.py create mode 100644 salt/returners/librato_return.py create mode 100644 salt/returners/mattermost_returner.py create mode 100644 salt/returners/memcache_return.py create mode 100644 salt/returners/mongo_future_return.py create mode 100644 salt/returners/mongo_return.py create mode 100644 salt/returners/mysql.py create mode 100644 salt/returners/nagios_nrdp_return.py create mode 100644 salt/returners/odbc.py create mode 100644 salt/returners/pushover_returner.py create mode 100644 salt/returners/redis_return.py delete mode 100644 salt/returners/salt_cache.py create mode 100644 salt/returners/sentry_return.py create mode 100644 salt/returners/slack_returner.py create mode 100644 salt/returners/slack_webhook_return.py create mode 100644 salt/returners/sms_return.py create mode 100644 salt/returners/smtp_return.py create mode 100644 salt/returners/splunk.py create mode 100644 salt/returners/sqlite3_return.py create mode 100644 salt/returners/telegram_return.py create mode 100644 salt/returners/xmpp_return.py create mode 100644 salt/returners/zabbix_return.py create mode 100644 salt/roster/cloud.py create mode 100644 salt/roster/clustershell.py create mode 100644 salt/roster/terraform.py create mode 100644 salt/runners/asam.py delete mode 100644 salt/runners/batch.py create mode 100644 salt/runners/bgp.py create mode 100644 salt/runners/cloud.py delete mode 100644 salt/runners/cluster.py create mode 100644 salt/runners/ddns.py create mode 100644 salt/runners/digicertapi.py create mode 100644 salt/runners/drac.py create mode 100644 salt/runners/f5.py create mode 100644 salt/runners/launchd.py create mode 100644 salt/runners/lxc.py create mode 100644 salt/runners/mattermost.py create mode 100644 salt/runners/nacl.py create mode 100644 salt/runners/pagerduty.py create mode 100644 salt/runners/pkg.py delete mode 100644 salt/runners/pki.py delete mode 100644 salt/runners/resource.py create mode 100644 salt/runners/smartos_vmadm.py create mode 100644 salt/runners/spacewalk.py create mode 100644 salt/runners/thin.py create mode 100644 salt/runners/vault.py create mode 100644 salt/runners/venafiapi.py create mode 100644 salt/runners/virt.py create mode 100644 salt/runners/vistara.py create mode 100644 salt/sdb/cache.py create mode 100644 salt/sdb/confidant.py create mode 100644 salt/sdb/consul.py create mode 100644 salt/sdb/couchdb.py create mode 100644 salt/sdb/etcd_db.py create mode 100644 salt/sdb/keyring_db.py create mode 100644 salt/sdb/memcached.py create mode 100644 salt/sdb/redis_sdb.py create mode 100644 salt/sdb/rest.py create mode 100644 salt/sdb/sqlite3.py create mode 100644 salt/sdb/tism.py create mode 100644 salt/sdb/vault.py create mode 100644 salt/serializers/keyvalue.py create mode 100644 salt/serializers/plist.py create mode 100644 salt/serializers/python.py create mode 100644 salt/states/acme.py create mode 100644 salt/states/alternatives.py create mode 100644 salt/states/aptpkg.py create mode 100644 salt/states/artifactory.py create mode 100644 salt/states/augeas.py create mode 100644 salt/states/aws_sqs.py create mode 100644 salt/states/bigip.py create mode 100644 salt/states/boto3_elasticache.py create mode 100644 salt/states/boto3_elasticsearch.py create mode 100644 salt/states/boto3_route53.py create mode 100644 salt/states/boto3_sns.py create mode 100644 salt/states/boto_apigateway.py create mode 100644 salt/states/boto_asg.py create mode 100644 salt/states/boto_cfn.py create mode 100644 salt/states/boto_cloudfront.py create mode 100644 salt/states/boto_cloudtrail.py create mode 100644 salt/states/boto_cloudwatch_alarm.py create mode 100644 salt/states/boto_cloudwatch_event.py create mode 100644 salt/states/boto_cognitoidentity.py create mode 100644 salt/states/boto_datapipeline.py create mode 100644 salt/states/boto_dynamodb.py create mode 100644 salt/states/boto_ec2.py create mode 100644 salt/states/boto_elasticache.py create mode 100644 salt/states/boto_elasticsearch_domain.py create mode 100644 salt/states/boto_elb.py create mode 100644 salt/states/boto_elbv2.py create mode 100644 salt/states/boto_iam.py create mode 100644 salt/states/boto_iam_role.py create mode 100644 salt/states/boto_iot.py create mode 100644 salt/states/boto_kinesis.py create mode 100644 salt/states/boto_kms.py create mode 100644 salt/states/boto_lambda.py create mode 100644 salt/states/boto_lc.py create mode 100644 salt/states/boto_rds.py create mode 100644 salt/states/boto_route53.py create mode 100644 salt/states/boto_s3.py create mode 100644 salt/states/boto_s3_bucket.py create mode 100644 salt/states/boto_secgroup.py create mode 100644 salt/states/boto_sns.py create mode 100644 salt/states/boto_sqs.py create mode 100644 salt/states/boto_vpc.py create mode 100644 salt/states/bower.py create mode 100644 salt/states/btrfs.py create mode 100644 salt/states/cabal.py create mode 100644 salt/states/ceph.py create mode 100644 salt/states/chef.py create mode 100644 salt/states/chronos_job.py create mode 100644 salt/states/cimc.py create mode 100644 salt/states/cisconso.py create mode 100644 salt/states/composer.py create mode 100644 salt/states/consul.py create mode 100644 salt/states/cryptdev.py create mode 100644 salt/states/csf.py create mode 100644 salt/states/cyg.py create mode 100644 salt/states/ddns.py create mode 100644 salt/states/dellchassis.py create mode 100644 salt/states/docker_container.py create mode 100644 salt/states/docker_image.py create mode 100644 salt/states/docker_network.py create mode 100644 salt/states/docker_volume.py create mode 100644 salt/states/drac.py create mode 100644 salt/states/dvs.py create mode 100644 salt/states/elasticsearch.py create mode 100644 salt/states/elasticsearch_index.py create mode 100644 salt/states/elasticsearch_index_template.py create mode 100644 salt/states/eselect.py create mode 100644 salt/states/esxcluster.py create mode 100644 salt/states/esxdatacenter.py create mode 100644 salt/states/esxi.py create mode 100644 salt/states/esxvm.py create mode 100644 salt/states/ethtool.py create mode 100644 salt/states/gem.py create mode 100644 salt/states/github.py create mode 100644 salt/states/glance_image.py create mode 100644 salt/states/glassfish.py create mode 100644 salt/states/glusterfs.py create mode 100644 salt/states/gnomedesktop.py create mode 100644 salt/states/grafana.py create mode 100644 salt/states/grafana4_dashboard.py create mode 100644 salt/states/grafana4_datasource.py create mode 100644 salt/states/grafana4_org.py create mode 100644 salt/states/grafana4_user.py create mode 100644 salt/states/grafana_dashboard.py create mode 100644 salt/states/grafana_datasource.py create mode 100644 salt/states/heat.py create mode 100644 salt/states/helm.py create mode 100644 salt/states/hg.py create mode 100644 salt/states/icinga2.py create mode 100644 salt/states/ifttt.py create mode 100644 salt/states/incron.py create mode 100644 salt/states/influxdb08_database.py create mode 100644 salt/states/influxdb08_user.py create mode 100644 salt/states/influxdb_continuous_query.py create mode 100644 salt/states/influxdb_database.py create mode 100644 salt/states/influxdb_retention_policy.py create mode 100644 salt/states/influxdb_user.py create mode 100644 salt/states/infoblox_a.py create mode 100644 salt/states/infoblox_cname.py create mode 100644 salt/states/infoblox_host_record.py create mode 100644 salt/states/infoblox_range.py create mode 100644 salt/states/ipmi.py create mode 100644 salt/states/jboss7.py create mode 100644 salt/states/jenkins.py create mode 100644 salt/states/junos.py create mode 100644 salt/states/kapacitor.py create mode 100644 salt/states/kernelpkg.py create mode 100644 salt/states/keystone.py create mode 100644 salt/states/keystone_domain.py create mode 100644 salt/states/keystone_endpoint.py create mode 100644 salt/states/keystone_group.py create mode 100644 salt/states/keystone_project.py create mode 100644 salt/states/keystone_role.py create mode 100644 salt/states/keystone_role_grant.py create mode 100644 salt/states/keystone_service.py create mode 100644 salt/states/keystone_user.py create mode 100644 salt/states/keystore.py create mode 100644 salt/states/kubernetes.py create mode 100644 salt/states/layman.py create mode 100644 salt/states/ldap.py create mode 100644 salt/states/libcloud_dns.py create mode 100644 salt/states/libcloud_loadbalancer.py create mode 100644 salt/states/libcloud_storage.py create mode 100644 salt/states/logadm.py create mode 100644 salt/states/lvs_server.py create mode 100644 salt/states/lvs_service.py create mode 100644 salt/states/lxc.py create mode 100644 salt/states/lxd.py create mode 100644 salt/states/lxd_container.py create mode 100644 salt/states/lxd_image.py create mode 100644 salt/states/lxd_profile.py create mode 100644 salt/states/marathon_app.py create mode 100644 salt/states/memcached.py create mode 100644 salt/states/modjk.py create mode 100644 salt/states/modjk_worker.py create mode 100644 salt/states/mongodb_database.py create mode 100644 salt/states/mongodb_user.py create mode 100644 salt/states/monit.py create mode 100644 salt/states/mssql_database.py create mode 100644 salt/states/mssql_login.py create mode 100644 salt/states/mssql_role.py create mode 100644 salt/states/mssql_user.py create mode 100644 salt/states/msteams.py create mode 100644 salt/states/mysql_database.py create mode 100644 salt/states/mysql_grants.py create mode 100644 salt/states/mysql_query.py create mode 100644 salt/states/mysql_user.py create mode 100644 salt/states/net_napalm_yang.py create mode 100644 salt/states/neutron_network.py create mode 100644 salt/states/neutron_secgroup.py create mode 100644 salt/states/neutron_secgroup_rule.py create mode 100644 salt/states/neutron_subnet.py create mode 100644 salt/states/nexus.py create mode 100644 salt/states/nfs_export.py create mode 100644 salt/states/npm.py create mode 100644 salt/states/nxos.py create mode 100644 salt/states/nxos_upgrade.py create mode 100644 salt/states/openstack_config.py create mode 100644 salt/states/openvswitch_bridge.py create mode 100644 salt/states/openvswitch_db.py create mode 100644 salt/states/openvswitch_port.py create mode 100644 salt/states/opsgenie.py create mode 100644 salt/states/pagerduty.py create mode 100644 salt/states/pagerduty_escalation_policy.py create mode 100644 salt/states/pagerduty_schedule.py create mode 100644 salt/states/pagerduty_service.py create mode 100644 salt/states/pagerduty_user.py create mode 100644 salt/states/panos.py create mode 100644 salt/states/pbm.py create mode 100644 salt/states/pcs.py create mode 100644 salt/states/pdbedit.py create mode 100644 salt/states/pecl.py create mode 100644 salt/states/portage_config.py create mode 100644 salt/states/ports.py create mode 100644 salt/states/powerpath.py create mode 100644 salt/states/probes.py create mode 100644 salt/states/pushover.py create mode 100644 salt/states/pyrax_queues.py create mode 100644 salt/states/rbac_solaris.py create mode 100644 salt/states/rbenv.py create mode 100644 salt/states/rdp.py create mode 100644 salt/states/redismod.py create mode 100644 salt/states/restconf.py create mode 100644 salt/states/rsync.py create mode 100644 salt/states/rvm.py create mode 100644 salt/states/serverdensity_device.py create mode 100644 salt/states/slack.py create mode 100644 salt/states/smartos.py create mode 100644 salt/states/smtp.py create mode 100644 salt/states/snapper.py create mode 100644 salt/states/solrcloud.py create mode 100644 salt/states/splunk.py create mode 100644 salt/states/splunk_search.py create mode 100644 salt/states/sqlite3.py delete mode 100644 salt/states/ssh_pki.py create mode 100644 salt/states/statuspage.py create mode 100644 salt/states/supervisord.py create mode 100644 salt/states/svn.py create mode 100644 salt/states/sysrc.py create mode 100644 salt/states/telemetry_alert.py create mode 100644 salt/states/testinframod.py create mode 100644 salt/states/tomcat.py create mode 100644 salt/states/trafficserver.py create mode 100644 salt/states/tuned.py create mode 100644 salt/states/vagrant.py create mode 100644 salt/states/vault.py create mode 100644 salt/states/vbox_guest.py create mode 100644 salt/states/victorops.py create mode 100644 salt/states/virt.py create mode 100644 salt/states/webutil.py delete mode 100644 salt/states/win_dsc_resource.py create mode 100644 salt/states/wordpress.py create mode 100644 salt/states/xml.py create mode 100644 salt/states/xmpp.py create mode 100644 salt/states/zabbix_action.py create mode 100644 salt/states/zabbix_host.py create mode 100644 salt/states/zabbix_hostgroup.py create mode 100644 salt/states/zabbix_mediatype.py create mode 100644 salt/states/zabbix_template.py create mode 100644 salt/states/zabbix_user.py create mode 100644 salt/states/zabbix_usergroup.py create mode 100644 salt/states/zabbix_usermacro.py create mode 100644 salt/states/zabbix_valuemap.py create mode 100644 salt/states/zcbuildout.py create mode 100644 salt/states/zenoss.py create mode 100644 salt/states/zfs.py create mode 100644 salt/states/zk_concurrency.py create mode 100644 salt/states/zone.py create mode 100644 salt/states/zookeeper.py create mode 100644 salt/states/zpool.py create mode 100644 salt/transport/ipc.py delete mode 100644 salt/transport/tls_util.py delete mode 100644 salt/utils/asymmetric.py delete mode 100644 salt/utils/batch_manager.py delete mode 100644 salt/utils/batch_output.py delete mode 100644 salt/utils/batch_state.py delete mode 100644 salt/utils/metrics.py delete mode 100644 salt/utils/mmap_cache.py delete mode 100644 salt/utils/optsdict.py delete mode 100644 salt/utils/ostruststore.py delete mode 100644 salt/utils/relenv.py delete mode 100644 salt/utils/resource_registry.py delete mode 100644 salt/utils/resources.py delete mode 100644 salt/utils/secret.py delete mode 100644 salt/utils/sshpki.py delete mode 100644 salt/utils/tarfileutil.py delete mode 100644 salt/utils/tracing.py create mode 100644 salt/utils/vault/__init__.py create mode 100644 salt/utils/vault/api.py create mode 100644 salt/utils/vault/auth.py create mode 100644 salt/utils/vault/cache.py create mode 100644 salt/utils/vault/client.py create mode 100644 salt/utils/vault/exceptions.py create mode 100644 salt/utils/vault/factory.py create mode 100644 salt/utils/vault/helpers.py create mode 100644 salt/utils/vault/kv.py create mode 100644 salt/utils/vault/leases.py create mode 100644 tests/integration/cloud/clouds/test_digitalocean.py create mode 100644 tests/integration/cloud/clouds/test_dimensiondata.py create mode 100644 tests/integration/cloud/clouds/test_ec2.py create mode 100644 tests/integration/cloud/clouds/test_gce.py create mode 100644 tests/integration/cloud/clouds/test_gogrid.py create mode 100644 tests/integration/cloud/clouds/test_linode.py create mode 100644 tests/integration/cloud/clouds/test_oneandone.py create mode 100644 tests/integration/cloud/clouds/test_openstack.py create mode 100644 tests/integration/cloud/clouds/test_profitbricks.py create mode 100644 tests/integration/cloud/clouds/test_tencentcloud.py create mode 100644 tests/integration/cloud/clouds/test_virtualbox.py create mode 100644 tests/integration/cloud/clouds/test_vmware.py create mode 100644 tests/integration/cloud/clouds/test_vultrpy.py create mode 100644 tests/integration/externalapi/test_venafiapi.py delete mode 100644 tests/integration/files/file/base/custom.tar.gz.SHA256.sig delete mode 100644 tests/integration/files/file/base/custom.tar.gz.sig delete mode 100644 tests/integration/files/file/base/grail/scene33.SHA256.sig delete mode 100644 tests/integration/files/file/base/grail/scene33.sig create mode 100644 tests/integration/files/vault/policies/salt_master.hcl create mode 100644 tests/integration/files/vault/policies/salt_minion.hcl create mode 100644 tests/integration/files/vault/policies/salt_minion_old.hcl create mode 100644 tests/integration/modules/test_boto_iam.py create mode 100644 tests/integration/modules/test_boto_sns.py create mode 100644 tests/integration/modules/test_cmdmod.py create mode 100644 tests/integration/modules/test_gem.py create mode 100644 tests/integration/modules/test_gentoolkitmod.py create mode 100644 tests/integration/modules/test_lxc.py create mode 100644 tests/integration/modules/test_sysrc.py create mode 100644 tests/integration/renderers/test_pydsl.py create mode 100644 tests/integration/returners/test_appoptics_return.py create mode 100644 tests/integration/returners/test_librato_return.py create mode 100644 tests/integration/states/test_alternatives.py create mode 100644 tests/integration/states/test_boto_sns.py create mode 100644 tests/integration/states/test_bower.py create mode 100644 tests/integration/states/test_keystone.py create mode 100644 tests/integration/states/test_lxd.py create mode 100644 tests/integration/states/test_lxd_container.py create mode 100644 tests/integration/states/test_lxd_image.py create mode 100644 tests/integration/states/test_lxd_profile.py create mode 100644 tests/integration/states/test_mysql_database.py create mode 100644 tests/integration/states/test_mysql_grants.py create mode 100644 tests/integration/states/test_supervisord.py create mode 100644 tests/monitoring/raas.conf delete mode 100644 tests/monitoring/render_panels.py delete mode 100644 tests/pytests/functional/cache/test_localfs_key.py delete mode 100644 tests/pytests/functional/cache/test_mmap_cache_driver.py delete mode 100644 tests/pytests/functional/cache/test_mmap_key.py delete mode 100644 tests/pytests/functional/channel/test_pool_routing.py delete mode 100644 tests/pytests/functional/channel/test_worker_pool_starvation.py delete mode 100644 tests/pytests/functional/cluster/consensus/conftest.py delete mode 100644 tests/pytests/functional/cluster/consensus/smoke.txt delete mode 100644 tests/pytests/functional/cluster/consensus/test_cluster_ready_scenarios.py delete mode 100644 tests/pytests/functional/cluster/consensus/test_raft_compaction.py delete mode 100644 tests/pytests/functional/cluster/consensus/test_raft_learner.py delete mode 100644 tests/pytests/functional/cluster/consensus/test_raft_scenarios.py delete mode 100644 tests/pytests/functional/cluster/consensus/test_raft_service.py delete mode 100644 tests/pytests/functional/cluster/consensus/test_raft_transport.py delete mode 100644 tests/pytests/functional/cluster/test_join_crypto.py delete mode 100644 tests/pytests/functional/cluster/test_master_keys.py delete mode 100644 tests/pytests/functional/cluster/test_ring.py create mode 100644 tests/pytests/functional/fileserver/hgfs/test_hgfs.py create mode 100644 tests/pytests/functional/log_handlers/test_logstash_mod.py create mode 100644 tests/pytests/functional/master/test_event_publisher.py create mode 100644 tests/pytests/functional/master/test_event_publisher_perms.py delete mode 100644 tests/pytests/functional/modules/file/test_is_link.py delete mode 100644 tests/pytests/functional/modules/state/requisites/test_aggregate.py delete mode 100644 tests/pytests/functional/modules/state/test_parallel.py delete mode 100644 tests/pytests/functional/modules/test_asymmetric.py delete mode 100644 tests/pytests/functional/modules/test_cmdmod.py create mode 100644 tests/pytests/functional/modules/test_dockermod.py create mode 100644 tests/pytests/functional/modules/test_freezer.py create mode 100644 tests/pytests/functional/modules/test_nilrt_ip.py create mode 100644 tests/pytests/functional/modules/test_opkg.py create mode 100644 tests/pytests/functional/modules/test_runit.py delete mode 100644 tests/pytests/functional/modules/test_ssh_pki.py create mode 100644 tests/pytests/functional/modules/test_swarm.py create mode 100644 tests/pytests/functional/modules/test_vault.py delete mode 100644 tests/pytests/functional/modules/test_win_dsc_resource.py create mode 100644 tests/pytests/functional/pillar/hg_pillar/test_hg_pillar.py create mode 100644 tests/pytests/functional/pillar/test_etcd_pillar.py delete mode 100644 tests/pytests/functional/pillar/test_pillar_masking.py create mode 100644 tests/pytests/functional/returners/test_etcd_return.py delete mode 100644 tests/pytests/functional/returners/test_salt_cache_integration.py delete mode 100644 tests/pytests/functional/runners/test_cache_migrate.py create mode 100644 tests/pytests/functional/sdb/test_etcd_db.py delete mode 100644 tests/pytests/functional/states/pkgrepo/test_suse.py create mode 100644 tests/pytests/functional/states/test_docker_container.py create mode 100644 tests/pytests/functional/states/test_docker_network.py create mode 100644 tests/pytests/functional/states/test_mysql.py create mode 100644 tests/pytests/functional/states/test_npm.py delete mode 100644 tests/pytests/functional/states/test_ssh_pki.py create mode 100644 tests/pytests/functional/states/test_svn.py create mode 100644 tests/pytests/functional/states/test_virtualenv_mod.py delete mode 100644 tests/pytests/functional/states/test_win_dsc_resource.py create mode 100644 tests/pytests/functional/states/test_zookeeper.py create mode 100644 tests/pytests/functional/transport/ipc/test_client.py create mode 100644 tests/pytests/functional/transport/ipc/test_pub_server_channel.py create mode 100644 tests/pytests/functional/transport/ipc/test_subscriber.py delete mode 100644 tests/pytests/functional/transport/server/test_ssl_transport.py delete mode 100644 tests/pytests/functional/transport/tcp/conftest.py delete mode 100644 tests/pytests/functional/transport/tcp/test_pub_server_stability.py delete mode 100644 tests/pytests/functional/transport/tcp/test_tcp_ssl.py delete mode 100644 tests/pytests/functional/transport/tcp/test_tcp_ssl_invalid.py delete mode 100644 tests/pytests/functional/transport/tcp/test_tcp_ssl_simple.py delete mode 100644 tests/pytests/functional/transport/ws/__init__.py delete mode 100644 tests/pytests/functional/transport/ws/conftest.py delete mode 100644 tests/pytests/functional/transport/ws/test_ws_ssl.py delete mode 100644 tests/pytests/functional/transport/ws/test_ws_ssl_invalid.py delete mode 100644 tests/pytests/functional/transport/ws/test_ws_ssl_simple.py delete mode 100644 tests/pytests/functional/utils/pkg/test_deb.py delete mode 100644 tests/pytests/functional/utils/test_mmap_cache.py delete mode 100644 tests/pytests/functional/utils/test_ostruststore.py create mode 100644 tests/pytests/functional/utils/test_vault.py delete mode 100644 tests/pytests/integration/cli/test_batch_options.py delete mode 100644 tests/pytests/integration/cluster/test_failure_modes.py delete mode 100644 tests/pytests/integration/cluster/test_health_probes.py delete mode 100644 tests/pytests/integration/cluster/test_isolated_cluster.py delete mode 100644 tests/pytests/integration/cluster/test_jobs_migration.py delete mode 100644 tests/pytests/integration/cluster/test_raft_cluster.py delete mode 100644 tests/pytests/integration/cluster/test_ring_lifecycle.py delete mode 100644 tests/pytests/integration/cluster/test_ring_lifecycle_shared_fs.py delete mode 100644 tests/pytests/integration/events/__init__.py delete mode 100644 tests/pytests/integration/events/test_auth_events.py delete mode 100644 tests/pytests/integration/files/snakeoil.crt delete mode 100644 tests/pytests/integration/files/snakeoil.crtkey delete mode 100644 tests/pytests/integration/files/snakeoil.key delete mode 100644 tests/pytests/integration/modules/state/test_state_queue_concurrent.py delete mode 100644 tests/pytests/integration/modules/test_ssh_pki.py create mode 100644 tests/pytests/integration/modules/test_vault.py create mode 100644 tests/pytests/integration/modules/test_virt.py delete mode 100644 tests/pytests/integration/resources/__init__.py delete mode 100644 tests/pytests/integration/resources/conftest.py delete mode 100644 tests/pytests/integration/resources/test_cli_offline_expands_resource_targets.py delete mode 100644 tests/pytests/integration/resources/test_custom_pillar_key.py delete mode 100644 tests/pytests/integration/resources/test_dummy_resource.py delete mode 100644 tests/pytests/integration/resources/test_dynamic_discovery.py delete mode 100644 tests/pytests/integration/resources/test_multi_minion_grain_targeting.py delete mode 100644 tests/pytests/integration/resources_ssh/__init__.py delete mode 100644 tests/pytests/integration/resources_ssh/conftest.py delete mode 100644 tests/pytests/integration/resources_ssh/test_ssh_resource_integration.py create mode 100644 tests/pytests/integration/runners/test_nacl.py create mode 100644 tests/pytests/integration/runners/test_vault.py create mode 100644 tests/pytests/integration/sdb/test_etcd_db.py create mode 100644 tests/pytests/integration/sdb/test_vault.py delete mode 100644 tests/pytests/integration/ssh/ssh_pki/conftest.py delete mode 100644 tests/pytests/integration/ssh/ssh_pki/test_certificate_managed_wrapper_ssh.py delete mode 100644 tests/pytests/integration/ssh/ssh_pki/test_create_certificate_ssh.py delete mode 100644 tests/pytests/integration/ssh/test_deploy_relenv.py delete mode 100644 tests/pytests/integration/ssh/test_saltext.py create mode 100644 tests/pytests/integration/ssh/test_terraform.py delete mode 100644 tests/pytests/integration/ssh/x509_v2/conftest.py delete mode 100644 tests/pytests/integration/ssh/x509_v2/test_certificate_managed_wrapper.py delete mode 100644 tests/pytests/integration/ssh/x509_v2/test_create_certificate.py delete mode 100644 tests/pytests/integration/state/__init__.py delete mode 100644 tests/pytests/integration/state/test_mod_beacon.py delete mode 100644 tests/pytests/integration/states/test_ssh_pki.py delete mode 100644 tests/pytests/integration/tracing/__init__.py delete mode 100644 tests/pytests/integration/tracing/test_tracing_jaeger.py delete mode 100644 tests/pytests/mmapcache-smoke-tests.txt delete mode 100644 tests/pytests/perf/__init__.py delete mode 100644 tests/pytests/perf/test_cache_benchmarks.py delete mode 100755 tests/pytests/run-mmapcache-smoke-tests.sh delete mode 100755 tests/pytests/run-smoke-tests.sh delete mode 100644 tests/pytests/scenarios/cluster_kind/__init__.py delete mode 100644 tests/pytests/scenarios/cluster_kind/conftest.py delete mode 100755 tests/pytests/scenarios/cluster_kind/setup-in-container.sh delete mode 100644 tests/pytests/scenarios/cluster_kind/test_basic.py delete mode 100644 tests/pytests/scenarios/queue/test_queue_fd_leak.py delete mode 100644 tests/pytests/scenarios/regression/conftest.py delete mode 100644 tests/pytests/scenarios/regression/test_fd_leak_asyncgens_executor.py delete mode 100644 tests/pytests/scenarios/regression/test_fd_leak_fire_event_async.py delete mode 100644 tests/pytests/scenarios/regression/test_fd_leak_ioloop_instance.py delete mode 100644 tests/pytests/scenarios/regression/test_fd_leak_syncwrapper_close.py delete mode 100644 tests/pytests/scenarios/regression/test_fd_leak_task_cancellation.py delete mode 100644 tests/pytests/scenarios/regression/test_fd_threshold_queuing.py delete mode 100644 tests/pytests/scenarios/regression/test_resource_runaway_oom.py delete mode 100644 tests/pytests/scenarios/transport/test_resource_runaway.py delete mode 100644 tests/pytests/smoke-tests.txt create mode 100644 tests/pytests/unit/auth/test_auth.py create mode 100644 tests/pytests/unit/beacons/test_adb.py create mode 100644 tests/pytests/unit/beacons/test_avahi_announce.py create mode 100644 tests/pytests/unit/beacons/test_bonjour_announce.py create mode 100644 tests/pytests/unit/beacons/test_btmp.py create mode 100644 tests/pytests/unit/beacons/test_glxinfo.py create mode 100644 tests/pytests/unit/beacons/test_haproxy.py create mode 100644 tests/pytests/unit/beacons/test_sensehat.py create mode 100644 tests/pytests/unit/beacons/test_smartos_imgadm.py create mode 100644 tests/pytests/unit/beacons/test_smartos_vmadm.py create mode 100644 tests/pytests/unit/beacons/test_telegram_bot_msg.py create mode 100644 tests/pytests/unit/beacons/test_twilio_txt_msg.py create mode 100644 tests/pytests/unit/beacons/test_wtmp.py delete mode 100644 tests/pytests/unit/cache/test_cache_backends.py delete mode 100644 tests/pytests/unit/cache/test_mmap_cache.py delete mode 100644 tests/pytests/unit/cache/test_mmap_cache_errors.py delete mode 100644 tests/pytests/unit/cache/test_mmap_key.py delete mode 100644 tests/pytests/unit/channel/test_metrics_propagation.py delete mode 100644 tests/pytests/unit/channel/test_tracing_propagation.py delete mode 100644 tests/pytests/unit/cli/test_batch_parity.py delete mode 100644 tests/pytests/unit/cli/test_batch_visibility.py delete mode 100644 tests/pytests/unit/cli/test_caller_resources.py delete mode 100644 tests/pytests/unit/cli/test_salt_call.py create mode 100644 tests/pytests/unit/cloud/clouds/test_digitalocean.py create mode 100644 tests/pytests/unit/cloud/clouds/test_dimensiondata.py create mode 100644 tests/pytests/unit/cloud/clouds/test_ec2.py create mode 100644 tests/pytests/unit/cloud/clouds/test_gce.py create mode 100644 tests/pytests/unit/cloud/clouds/test_hetzner.py create mode 100644 tests/pytests/unit/cloud/clouds/test_joyent.py create mode 100644 tests/pytests/unit/cloud/clouds/test_linode.py create mode 100644 tests/pytests/unit/cloud/clouds/test_opennebula.py create mode 100644 tests/pytests/unit/cloud/clouds/test_openstack.py create mode 100644 tests/pytests/unit/cloud/clouds/test_proxmox.py create mode 100644 tests/pytests/unit/cloud/clouds/test_qingcloud.py create mode 100644 tests/pytests/unit/cloud/clouds/test_scaleway.py create mode 100644 tests/pytests/unit/cloud/clouds/test_vultrpy.py create mode 100644 tests/pytests/unit/cloud/clouds/test_xen.py create mode 100644 tests/pytests/unit/cloud/clouds/vmware/test_clone_from_snapshot.py create mode 100644 tests/pytests/unit/cloud/clouds/vmware/test_vmware.py delete mode 100644 tests/pytests/unit/cluster/__init__.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_cluster_ready.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_multi_ring_fanout.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_peer.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_raft_chaos.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_raft_exactly_once.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_raft_log.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_raft_membership.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_raft_node.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_raft_node_safety.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_raft_scheduler.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_raft_util.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_rpc.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_storage.py delete mode 100644 tests/pytests/unit/cluster/consensus/test_voter_health.py delete mode 100644 tests/pytests/unit/cluster/test_file_sync.py delete mode 100644 tests/pytests/unit/cluster/test_healthchecks.py delete mode 100644 tests/pytests/unit/cluster/test_ring.py delete mode 100644 tests/pytests/unit/cluster/test_ring_membership.py delete mode 100644 tests/pytests/unit/cluster/test_state_sync.py delete mode 100644 tests/pytests/unit/config/test_worker_pools.py create mode 100644 tests/pytests/unit/crypt/test_crypt_cryptodome.py delete mode 100644 tests/pytests/unit/doc/__init__.py delete mode 100644 tests/pytests/unit/doc/test_link_audit.py create mode 100644 tests/pytests/unit/engines/test_libvirt_events.py create mode 100644 tests/pytests/unit/engines/test_slack.py create mode 100644 tests/pytests/unit/engines/test_slack_bolt_engine.py create mode 100644 tests/pytests/unit/engines/test_sqs_events.py create mode 100644 tests/pytests/unit/engines/test_stalekey.py create mode 100644 tests/pytests/unit/fileserver/test_s3fs.py create mode 100644 tests/pytests/unit/fileserver/test_svnfs.py create mode 100644 tests/pytests/unit/grains/test_esxi.py delete mode 100644 tests/pytests/unit/grains/test_extra.py create mode 100644 tests/pytests/unit/grains/test_fibre_channel.py create mode 100644 tests/pytests/unit/grains/test_iscsi.py delete mode 100644 tests/pytests/unit/grains/test_mdadm.py create mode 100644 tests/pytests/unit/grains/test_mdata.py create mode 100644 tests/pytests/unit/grains/test_metadata.py create mode 100644 tests/pytests/unit/grains/test_metadata_gce.py create mode 100644 tests/pytests/unit/grains/test_napalm.py create mode 100644 tests/pytests/unit/grains/test_nvme.py create mode 100644 tests/pytests/unit/grains/test_smartos.py delete mode 100644 tests/pytests/unit/grains/test_truststore.py delete mode 100644 tests/pytests/unit/loader/test_per_resource_overrides.py create mode 100644 tests/pytests/unit/log_handlers/test_sentry_mod.py delete mode 100644 tests/pytests/unit/matchers/test_resource_matchers.py create mode 100644 tests/pytests/unit/modules/dockermod/test_module.py create mode 100644 tests/pytests/unit/modules/dockermod/test_trans_tar.py create mode 100644 tests/pytests/unit/modules/test_acme.py create mode 100644 tests/pytests/unit/modules/test_augeas_cfg.py create mode 100644 tests/pytests/unit/modules/test_bigip.py create mode 100644 tests/pytests/unit/modules/test_bluez_bluetooth.py create mode 100644 tests/pytests/unit/modules/test_boto_dynamodb.py create mode 100644 tests/pytests/unit/modules/test_boto_elbv2.py create mode 100644 tests/pytests/unit/modules/test_boto_ssm.py create mode 100644 tests/pytests/unit/modules/test_bower.py create mode 100644 tests/pytests/unit/modules/test_btrfs.py create mode 100644 tests/pytests/unit/modules/test_composer.py create mode 100644 tests/pytests/unit/modules/test_consul.py create mode 100644 tests/pytests/unit/modules/test_cpan.py create mode 100644 tests/pytests/unit/modules/test_daemontools.py create mode 100644 tests/pytests/unit/modules/test_ddns.py create mode 100644 tests/pytests/unit/modules/test_deb_apache.py create mode 100644 tests/pytests/unit/modules/test_deb_postgres.py create mode 100644 tests/pytests/unit/modules/test_djangomod.py create mode 100644 tests/pytests/unit/modules/test_dnsmasq.py create mode 100644 tests/pytests/unit/modules/test_drac.py create mode 100644 tests/pytests/unit/modules/test_drbd.py create mode 100644 tests/pytests/unit/modules/test_esxcluster.py create mode 100644 tests/pytests/unit/modules/test_esxdatacenter.py create mode 100644 tests/pytests/unit/modules/test_freebsd_sysctl.py create mode 100644 tests/pytests/unit/modules/test_freezer.py create mode 100644 tests/pytests/unit/modules/test_gem.py create mode 100644 tests/pytests/unit/modules/test_genesis.py create mode 100644 tests/pytests/unit/modules/test_gentoo_service.py create mode 100644 tests/pytests/unit/modules/test_glassfish.py create mode 100644 tests/pytests/unit/modules/test_glusterfs.py create mode 100644 tests/pytests/unit/modules/test_gnomedesktop.py create mode 100644 tests/pytests/unit/modules/test_google_chat.py create mode 100644 tests/pytests/unit/modules/test_grub_legacy.py create mode 100644 tests/pytests/unit/modules/test_guestfs.py create mode 100644 tests/pytests/unit/modules/test_hadoop.py create mode 100644 tests/pytests/unit/modules/test_haproxyconn.py create mode 100644 tests/pytests/unit/modules/test_helm.py create mode 100644 tests/pytests/unit/modules/test_hg.py create mode 100644 tests/pytests/unit/modules/test_ilo.py create mode 100644 tests/pytests/unit/modules/test_introspect.py create mode 100644 tests/pytests/unit/modules/test_keystone.py create mode 100644 tests/pytests/unit/modules/test_kubeadm.py create mode 100644 tests/pytests/unit/modules/test_launchctl_service.py create mode 100644 tests/pytests/unit/modules/test_ldapmod.py delete mode 100644 tests/pytests/unit/modules/test_localemod_debian13.py create mode 100644 tests/pytests/unit/modules/test_logadm.py create mode 100644 tests/pytests/unit/modules/test_lvs.py create mode 100644 tests/pytests/unit/modules/test_mandrill.py create mode 100644 tests/pytests/unit/modules/test_modjk.py create mode 100644 tests/pytests/unit/modules/test_mongodb.py create mode 100644 tests/pytests/unit/modules/test_monit.py create mode 100644 tests/pytests/unit/modules/test_moosefs.py create mode 100644 tests/pytests/unit/modules/test_msteams.py create mode 100644 tests/pytests/unit/modules/test_munin.py create mode 100644 tests/pytests/unit/modules/test_nagios.py create mode 100644 tests/pytests/unit/modules/test_nexus.py create mode 100644 tests/pytests/unit/modules/test_nginx.py create mode 100644 tests/pytests/unit/modules/test_nilrt_ip.py delete mode 100644 tests/pytests/unit/modules/test_nixpkg.py create mode 100644 tests/pytests/unit/modules/test_openbsd_sysctl.py create mode 100644 tests/pytests/unit/modules/test_openbsdpkg.py create mode 100644 tests/pytests/unit/modules/test_openbsdrcctl_service.py create mode 100644 tests/pytests/unit/modules/test_openscap.py create mode 100644 tests/pytests/unit/modules/test_openvswitch.py create mode 100644 tests/pytests/unit/modules/test_opkg.py create mode 100644 tests/pytests/unit/modules/test_pagerduty.py create mode 100644 tests/pytests/unit/modules/test_parallels.py create mode 100644 tests/pytests/unit/modules/test_pcs.py create mode 100644 tests/pytests/unit/modules/test_pdbedit.py create mode 100644 tests/pytests/unit/modules/test_pecl.py create mode 100644 tests/pytests/unit/modules/test_pf.py create mode 100644 tests/pytests/unit/modules/test_portage_config.py create mode 100644 tests/pytests/unit/modules/test_postfix.py create mode 100644 tests/pytests/unit/modules/test_poudriere.py create mode 100644 tests/pytests/unit/modules/test_powerpath.py create mode 100644 tests/pytests/unit/modules/test_purefa.py create mode 100644 tests/pytests/unit/modules/test_purefb.py create mode 100644 tests/pytests/unit/modules/test_qemu_img.py create mode 100644 tests/pytests/unit/modules/test_qemu_nbd.py create mode 100644 tests/pytests/unit/modules/test_rbenv.py create mode 100644 tests/pytests/unit/modules/test_rebootmgr.py create mode 100644 tests/pytests/unit/modules/test_redismod.py create mode 100644 tests/pytests/unit/modules/test_restconf.py create mode 100644 tests/pytests/unit/modules/test_riak.py create mode 100644 tests/pytests/unit/modules/test_rvm.py create mode 100644 tests/pytests/unit/modules/test_s3.py create mode 100644 tests/pytests/unit/modules/test_s6.py create mode 100644 tests/pytests/unit/modules/test_sensors.py create mode 100644 tests/pytests/unit/modules/test_serverdensity_device.py create mode 100644 tests/pytests/unit/modules/test_servicenow.py create mode 100644 tests/pytests/unit/modules/test_slackware_service.py create mode 100644 tests/pytests/unit/modules/test_smartos_imgadm.py create mode 100644 tests/pytests/unit/modules/test_smtp.py create mode 100644 tests/pytests/unit/modules/test_solr.py delete mode 100644 tests/pytests/unit/modules/test_sshresource_state.py create mode 100644 tests/pytests/unit/modules/test_suse_ip.py create mode 100644 tests/pytests/unit/modules/test_svn.py create mode 100644 tests/pytests/unit/modules/test_swarm.py create mode 100644 tests/pytests/unit/modules/test_swift.py create mode 100644 tests/pytests/unit/modules/test_sysbench.py create mode 100644 tests/pytests/unit/modules/test_telegram.py create mode 100644 tests/pytests/unit/modules/test_tomcat.py create mode 100644 tests/pytests/unit/modules/test_transactional_update.py create mode 100644 tests/pytests/unit/modules/test_tuned.py create mode 100644 tests/pytests/unit/modules/test_uptime.py create mode 100644 tests/pytests/unit/modules/test_uwsgi.py create mode 100644 tests/pytests/unit/modules/test_varnish.py create mode 100644 tests/pytests/unit/modules/test_vault.py create mode 100644 tests/pytests/unit/modules/test_vmctl.py delete mode 100644 tests/pytests/unit/modules/test_win_dsc_resource.py create mode 100644 tests/pytests/unit/modules/test_xapi_virt.py create mode 100644 tests/pytests/unit/modules/test_zabbix.py create mode 100644 tests/pytests/unit/modules/test_zenoss.py create mode 100644 tests/pytests/unit/modules/test_zfs.py create mode 100644 tests/pytests/unit/modules/test_zfs_solaris10.py create mode 100644 tests/pytests/unit/modules/test_zfs_solaris11.py create mode 100644 tests/pytests/unit/modules/test_znc.py create mode 100644 tests/pytests/unit/modules/test_zpool.py create mode 100644 tests/pytests/unit/modules/test_zypperpkg.py create mode 100644 tests/pytests/unit/modules/virt/conftest.py create mode 100644 tests/pytests/unit/modules/virt/test_domain.py create mode 100644 tests/pytests/unit/modules/virt/test_helpers.py create mode 100644 tests/pytests/unit/modules/virt/test_host.py create mode 100644 tests/pytests/unit/modules/virt/test_network.py create mode 100644 tests/pytests/unit/output/test_profile.py create mode 100644 tests/pytests/unit/pillar/test_consul_pillar.py create mode 100644 tests/pytests/unit/pillar/test_csvpillar.py create mode 100644 tests/pytests/unit/pillar/test_etcd_pillar.py create mode 100644 tests/pytests/unit/pillar/test_extra_minion_data_in_pillar.py create mode 100644 tests/pytests/unit/pillar/test_http_json_pillar.py create mode 100644 tests/pytests/unit/pillar/test_http_yaml_pillar.py create mode 100644 tests/pytests/unit/pillar/test_mongo.py create mode 100644 tests/pytests/unit/pillar/test_mysql.py create mode 100644 tests/pytests/unit/pillar/test_nacl.py create mode 100644 tests/pytests/unit/pillar/test_netbox.py create mode 100644 tests/pytests/unit/pillar/test_pepa.py create mode 100644 tests/pytests/unit/pillar/test_pillar_ldap.py delete mode 100644 tests/pytests/unit/pillar/test_reclass_adapter.py create mode 100644 tests/pytests/unit/pillar/test_s3.py create mode 100644 tests/pytests/unit/pillar/test_saltclass.py create mode 100644 tests/pytests/unit/pillar/test_sqlcipher.py create mode 100644 tests/pytests/unit/pillar/test_sqlite3.py create mode 100644 tests/pytests/unit/pillar/test_vault.py delete mode 100644 tests/pytests/unit/pkg/debian/__init__.py delete mode 100644 tests/pytests/unit/pkg/debian/test_preinst_scripts.py delete mode 100644 tests/pytests/unit/pkg/test_master_scriptlets.py delete mode 100644 tests/pytests/unit/pkg/test_minion_scriptlets.py create mode 100644 tests/pytests/unit/proxy/nxos/test_nxos_nxapi.py create mode 100644 tests/pytests/unit/proxy/nxos/test_nxos_ssh.py create mode 100644 tests/pytests/unit/proxy/test_cimc.py create mode 100644 tests/pytests/unit/proxy/test_esxcluster.py create mode 100644 tests/pytests/unit/proxy/test_esxdatacenter.py create mode 100644 tests/pytests/unit/proxy/test_junos.py create mode 100644 tests/pytests/unit/proxy/test_napalm.py create mode 100644 tests/pytests/unit/proxy/test_netmiko_px.py create mode 100644 tests/pytests/unit/proxy/test_panos.py create mode 100644 tests/pytests/unit/proxy/test_restconf.py create mode 100644 tests/pytests/unit/proxy/test_ssh_sample.py create mode 100644 tests/pytests/unit/renderers/test_aws_kms.py create mode 100644 tests/pytests/unit/renderers/test_pass.py delete mode 100644 tests/pytests/unit/resources/__init__.py delete mode 100644 tests/pytests/unit/resources/test_dummy_resource_grains.py delete mode 100644 tests/pytests/unit/resources/test_ssh_resource.py create mode 100644 tests/pytests/unit/returners/test_elasticsearch_return.py create mode 100644 tests/pytests/unit/returners/test_etcd_return.py create mode 100644 tests/pytests/unit/returners/test_mongo_future_return.py create mode 100644 tests/pytests/unit/returners/test_mysql.py create mode 100644 tests/pytests/unit/returners/test_nagios_nrdp_return.py create mode 100644 tests/pytests/unit/returners/test_redis_return.py delete mode 100644 tests/pytests/unit/returners/test_salt_cache.py create mode 100644 tests/pytests/unit/returners/test_sentry_return.py create mode 100644 tests/pytests/unit/returners/test_slack_webhook_return.py create mode 100644 tests/pytests/unit/returners/test_smtp_return.py create mode 100644 tests/pytests/unit/returners/test_splunk_return.py create mode 100644 tests/pytests/unit/returners/test_telegram_return.py create mode 100644 tests/pytests/unit/roster/test_clustershell.py create mode 100644 tests/pytests/unit/roster/test_terraform.py create mode 100644 tests/pytests/unit/runners/test_asam.py delete mode 100644 tests/pytests/unit/runners/test_batch.py create mode 100644 tests/pytests/unit/runners/test_bgp.py delete mode 100644 tests/pytests/unit/runners/test_cache_migrate.py delete mode 100644 tests/pytests/unit/runners/test_cluster_runner.py create mode 100644 tests/pytests/unit/runners/test_nacl.py delete mode 100644 tests/pytests/unit/runners/test_pki.py delete mode 100644 tests/pytests/unit/runners/test_resource.py create mode 100644 tests/pytests/unit/runners/test_spacewalk.py rename tests/pytests/{functional/cluster => unit/runners/vault}/__init__.py (100%) create mode 100644 tests/pytests/unit/runners/vault/test_app_role_auth.py create mode 100644 tests/pytests/unit/runners/vault/test_token_auth.py create mode 100644 tests/pytests/unit/runners/vault/test_token_auth_deprecated.py create mode 100644 tests/pytests/unit/runners/vault/test_vault.py create mode 100644 tests/pytests/unit/sdb/test_etcd_db.py create mode 100644 tests/pytests/unit/sdb/test_vault.py delete mode 100644 tests/pytests/unit/states/file/test_serialize.py create mode 100644 tests/pytests/unit/states/mysql/test_database.py create mode 100644 tests/pytests/unit/states/mysql/test_grants.py create mode 100644 tests/pytests/unit/states/mysql/test_query.py create mode 100644 tests/pytests/unit/states/mysql/test_user.py create mode 100644 tests/pytests/unit/states/test_acme.py create mode 100644 tests/pytests/unit/states/test_alternatives.py create mode 100644 tests/pytests/unit/states/test_aptpkg.py create mode 100644 tests/pytests/unit/states/test_artifactory.py create mode 100644 tests/pytests/unit/states/test_augeas.py create mode 100644 tests/pytests/unit/states/test_aws_sqs.py create mode 100644 tests/pytests/unit/states/test_boto_asg.py create mode 100644 tests/pytests/unit/states/test_boto_cloudfront.py create mode 100644 tests/pytests/unit/states/test_boto_cloudtrail.py create mode 100644 tests/pytests/unit/states/test_boto_cloudwatch_alarm.py create mode 100644 tests/pytests/unit/states/test_boto_cloudwatch_event.py create mode 100644 tests/pytests/unit/states/test_boto_dynamodb.py create mode 100644 tests/pytests/unit/states/test_boto_ec2.py create mode 100644 tests/pytests/unit/states/test_boto_elasticache.py create mode 100644 tests/pytests/unit/states/test_boto_elasticsearch_domain.py create mode 100644 tests/pytests/unit/states/test_boto_elb.py create mode 100644 tests/pytests/unit/states/test_boto_iam_role.py create mode 100644 tests/pytests/unit/states/test_boto_iot.py create mode 100644 tests/pytests/unit/states/test_boto_kinesis.py create mode 100644 tests/pytests/unit/states/test_boto_lambda.py create mode 100644 tests/pytests/unit/states/test_boto_lc.py create mode 100644 tests/pytests/unit/states/test_boto_route53.py create mode 100644 tests/pytests/unit/states/test_boto_s3_bucket.py create mode 100644 tests/pytests/unit/states/test_boto_secgroup.py create mode 100644 tests/pytests/unit/states/test_boto_sns.py create mode 100644 tests/pytests/unit/states/test_boto_sqs.py create mode 100644 tests/pytests/unit/states/test_bower.py create mode 100644 tests/pytests/unit/states/test_btrfs.py create mode 100644 tests/pytests/unit/states/test_chef.py create mode 100644 tests/pytests/unit/states/test_composer.py create mode 100644 tests/pytests/unit/states/test_consul.py create mode 100644 tests/pytests/unit/states/test_ddns.py create mode 100644 tests/pytests/unit/states/test_docker_container.py create mode 100644 tests/pytests/unit/states/test_docker_image.py create mode 100644 tests/pytests/unit/states/test_docker_volume.py create mode 100644 tests/pytests/unit/states/test_drac.py create mode 100644 tests/pytests/unit/states/test_elasticsearch.py create mode 100644 tests/pytests/unit/states/test_eselect.py create mode 100644 tests/pytests/unit/states/test_ethtool.py create mode 100644 tests/pytests/unit/states/test_gem.py create mode 100644 tests/pytests/unit/states/test_glusterfs.py create mode 100644 tests/pytests/unit/states/test_gnomedesktop.py create mode 100644 tests/pytests/unit/states/test_grafana.py create mode 100644 tests/pytests/unit/states/test_grafana_datasource.py create mode 100644 tests/pytests/unit/states/test_helm.py create mode 100644 tests/pytests/unit/states/test_hg.py create mode 100644 tests/pytests/unit/states/test_incron.py create mode 100644 tests/pytests/unit/states/test_influxdb08_database.py create mode 100644 tests/pytests/unit/states/test_influxdb08_user.py create mode 100644 tests/pytests/unit/states/test_influxdb_continuous_query.py create mode 100644 tests/pytests/unit/states/test_ipmi.py create mode 100644 tests/pytests/unit/states/test_jboss7.py create mode 100644 tests/pytests/unit/states/test_kapacitor.py create mode 100644 tests/pytests/unit/states/test_kernelpkg.py create mode 100644 tests/pytests/unit/states/test_keystone.py create mode 100644 tests/pytests/unit/states/test_keystore.py create mode 100644 tests/pytests/unit/states/test_kubernetes.py create mode 100644 tests/pytests/unit/states/test_layman.py create mode 100644 tests/pytests/unit/states/test_ldap.py create mode 100644 tests/pytests/unit/states/test_libcloud_dns.py create mode 100644 tests/pytests/unit/states/test_lvs_server.py create mode 100644 tests/pytests/unit/states/test_lvs_service.py create mode 100644 tests/pytests/unit/states/test_lxc.py create mode 100644 tests/pytests/unit/states/test_memcached.py create mode 100644 tests/pytests/unit/states/test_modjk.py create mode 100644 tests/pytests/unit/states/test_modjk_worker.py create mode 100644 tests/pytests/unit/states/test_mongodb_database.py create mode 100644 tests/pytests/unit/states/test_mongodb_user.py create mode 100644 tests/pytests/unit/states/test_net_napalm_yang.py create mode 100644 tests/pytests/unit/states/test_nexus.py create mode 100644 tests/pytests/unit/states/test_npm.py create mode 100644 tests/pytests/unit/states/test_nxos.py create mode 100644 tests/pytests/unit/states/test_openstack_config.py create mode 100644 tests/pytests/unit/states/test_openvswitch_bridge.py create mode 100644 tests/pytests/unit/states/test_openvswitch_db.py create mode 100644 tests/pytests/unit/states/test_openvswitch_port.py create mode 100644 tests/pytests/unit/states/test_pagerduty.py create mode 100644 tests/pytests/unit/states/test_pdbedit.py create mode 100644 tests/pytests/unit/states/test_pecl.py create mode 100644 tests/pytests/unit/states/test_portage_config.py create mode 100644 tests/pytests/unit/states/test_ports.py create mode 100644 tests/pytests/unit/states/test_powerpath.py create mode 100644 tests/pytests/unit/states/test_pyrax_queues.py create mode 100644 tests/pytests/unit/states/test_rbenv.py create mode 100644 tests/pytests/unit/states/test_rdp.py create mode 100644 tests/pytests/unit/states/test_redismod.py create mode 100644 tests/pytests/unit/states/test_restconf.py create mode 100644 tests/pytests/unit/states/test_rsync.py create mode 100644 tests/pytests/unit/states/test_rvm.py create mode 100644 tests/pytests/unit/states/test_serverdensity_device.py create mode 100644 tests/pytests/unit/states/test_slack.py create mode 100644 tests/pytests/unit/states/test_smartos.py create mode 100644 tests/pytests/unit/states/test_smtp.py create mode 100644 tests/pytests/unit/states/test_splunk_search.py create mode 100644 tests/pytests/unit/states/test_supervisord.py create mode 100644 tests/pytests/unit/states/test_svn.py create mode 100644 tests/pytests/unit/states/test_sysrc.py create mode 100644 tests/pytests/unit/states/test_tomcat.py create mode 100644 tests/pytests/unit/states/test_vault.py create mode 100644 tests/pytests/unit/states/test_vbox_guest.py create mode 100644 tests/pytests/unit/states/test_virtualenv_mod.py create mode 100644 tests/pytests/unit/states/test_webutil.py create mode 100644 tests/pytests/unit/states/test_xml.py create mode 100644 tests/pytests/unit/states/test_xmpp.py create mode 100644 tests/pytests/unit/states/test_zfs.py create mode 100644 tests/pytests/unit/states/test_zk_concurrency.py create mode 100644 tests/pytests/unit/states/test_zpool.py create mode 100644 tests/pytests/unit/states/virt/test_domain.py create mode 100644 tests/pytests/unit/states/virt/test_network.py create mode 100644 tests/pytests/unit/states/zabbix/test_action.py create mode 100644 tests/pytests/unit/states/zabbix/test_host.py create mode 100644 tests/pytests/unit/states/zabbix/test_template.py create mode 100644 tests/pytests/unit/states/zabbix/test_valuemap.py delete mode 100644 tests/pytests/unit/support/test_macos_salt_onedir_prefix.py delete mode 100644 tests/pytests/unit/test_auth_creds_event.py delete mode 100644 tests/pytests/unit/test_event_monitor_ring_gating.py create mode 100644 tests/pytests/unit/test_issue_65317_non_root_publisher_acl.py delete mode 100644 tests/pytests/unit/test_master_maintenance_batch.py delete mode 100644 tests/pytests/unit/test_master_requests_metrics.py delete mode 100644 tests/pytests/unit/test_minion_resources.py delete mode 100644 tests/pytests/unit/test_pool_name_edge_cases.py delete mode 100644 tests/pytests/unit/test_pool_name_validation.py delete mode 100644 tests/pytests/unit/test_request_router.py delete mode 100644 tests/pytests/unit/test_tls_aware_crypt.py delete mode 100644 tests/pytests/unit/thorium/test_examples.py delete mode 100644 tests/pytests/unit/tops/test_mongo.py delete mode 100644 tests/pytests/unit/transport/conftest.py delete mode 100644 tests/pytests/unit/transport/test_ssl_identity.py delete mode 100644 tests/pytests/unit/transport/test_ssl_transport.py delete mode 100644 tests/pytests/unit/transport/test_tls_util.py delete mode 100644 tests/pytests/unit/transport/test_zeromq_concurrency.py delete mode 100644 tests/pytests/unit/transport/test_zeromq_pub_stability.py delete mode 100644 tests/pytests/unit/transport/test_zeromq_worker_pools.py delete mode 100644 tests/pytests/unit/utils/batch_state/__init__.py delete mode 100644 tests/pytests/unit/utils/batch_state/batch_state_scenarios.py delete mode 100644 tests/pytests/unit/utils/batch_state/test_conformance.py delete mode 100644 tests/pytests/unit/utils/batch_state/test_helpers.py delete mode 100644 tests/pytests/unit/utils/event/test_tracing.py delete mode 100644 tests/pytests/unit/utils/parsers/test_salt_cmd_options.py delete mode 100644 tests/pytests/unit/utils/requisite/test_dependency_graph.py delete mode 100644 tests/pytests/unit/utils/test_asynchronous.py delete mode 100644 tests/pytests/unit/utils/test_batch_manager.py delete mode 100644 tests/pytests/unit/utils/test_batch_output.py delete mode 100644 tests/pytests/unit/utils/test_gitcli.py delete mode 100644 tests/pytests/unit/utils/test_master.py delete mode 100644 tests/pytests/unit/utils/test_metrics.py delete mode 100644 tests/pytests/unit/utils/test_metrics_console_demo.py delete mode 100644 tests/pytests/unit/utils/test_minions_resources.py delete mode 100644 tests/pytests/unit/utils/test_mmap_cache.py delete mode 100644 tests/pytests/unit/utils/test_mmap_cache_enterprise.py delete mode 100644 tests/pytests/unit/utils/test_mmap_cache_errors.py delete mode 100644 tests/pytests/unit/utils/test_mmap_cache_segments.py delete mode 100644 tests/pytests/unit/utils/test_optsdict.py delete mode 100644 tests/pytests/unit/utils/test_ostruststore.py delete mode 100644 tests/pytests/unit/utils/test_resource_registry.py delete mode 100644 tests/pytests/unit/utils/test_resources.py delete mode 100644 tests/pytests/unit/utils/test_secret.py delete mode 100644 tests/pytests/unit/utils/test_sshpki.py delete mode 100644 tests/pytests/unit/utils/test_tarfileutil.py delete mode 100644 tests/pytests/unit/utils/test_timeutil.py delete mode 100644 tests/pytests/unit/utils/test_tracing.py delete mode 100644 tests/pytests/unit/utils/test_tracing_console_demo.py delete mode 100644 tests/pytests/unit/utils/test_url_create.py rename tests/pytests/{functional/utils/pkg => unit/utils/vault}/__init__.py (100%) create mode 100644 tests/pytests/unit/utils/vault/conftest.py create mode 100644 tests/pytests/unit/utils/vault/test_api.py create mode 100644 tests/pytests/unit/utils/vault/test_auth.py create mode 100644 tests/pytests/unit/utils/vault/test_cache.py create mode 100644 tests/pytests/unit/utils/vault/test_client.py create mode 100644 tests/pytests/unit/utils/vault/test_factory.py create mode 100644 tests/pytests/unit/utils/vault/test_helpers.py create mode 100644 tests/pytests/unit/utils/vault/test_kv.py create mode 100644 tests/pytests/unit/utils/vault/test_leases.py delete mode 100644 tests/resources_smoke.txt delete mode 100644 tests/smoke-tests-before-commit.txt delete mode 100644 tests/support/pytest/transport_ssl.py create mode 100644 tests/support/pytest/vault.py delete mode 100644 tests/support/raft_chaos.py delete mode 100644 tests/support/sshd_runtime.py delete mode 100644 tests/unit/files/playbooks/example_playbooks/playbook1.yaml create mode 100644 tests/unit/modules/inspectlib/test_collector.py create mode 100644 tests/unit/modules/inspectlib/test_fsdb.py create mode 100644 tests/unit/modules/test_boto3_elasticsearch.py create mode 100644 tests/unit/modules/test_boto3_route53.py create mode 100644 tests/unit/modules/test_boto_apigateway.py create mode 100644 tests/unit/modules/test_boto_cloudtrail.py create mode 100644 tests/unit/modules/test_boto_cloudwatch_event.py create mode 100644 tests/unit/modules/test_boto_cognitoidentity.py create mode 100644 tests/unit/modules/test_boto_elasticsearch_domain.py create mode 100644 tests/unit/modules/test_boto_elb.py create mode 100644 tests/unit/modules/test_boto_iot.py create mode 100644 tests/unit/modules/test_boto_lambda.py create mode 100644 tests/unit/modules/test_boto_route53.py create mode 100644 tests/unit/modules/test_boto_s3_bucket.py create mode 100644 tests/unit/modules/test_boto_secgroup.py create mode 100644 tests/unit/modules/test_boto_vpc.py create mode 100644 tests/unit/modules/test_bsd_shadow.py create mode 100644 tests/unit/modules/test_elasticsearch.py create mode 100644 tests/unit/modules/test_freezer.py create mode 100644 tests/unit/modules/test_heat.py create mode 100644 tests/unit/modules/test_influxdb08mod.py create mode 100644 tests/unit/modules/test_jboss7.py create mode 100644 tests/unit/modules/test_jboss7_cli.py create mode 100644 tests/unit/modules/test_k8s.py create mode 100644 tests/unit/modules/test_kapacitor.py create mode 100644 tests/unit/modules/test_kubernetesmod.py create mode 100644 tests/unit/modules/test_libcloud_compute.py create mode 100644 tests/unit/modules/test_libcloud_dns.py create mode 100644 tests/unit/modules/test_libcloud_loadbalancer.py create mode 100644 tests/unit/modules/test_libcloud_storage.py create mode 100644 tests/unit/modules/test_memcached.py create mode 100644 tests/unit/modules/test_netbox.py create mode 100644 tests/unit/modules/test_netmiko_mod.py create mode 100644 tests/unit/modules/test_netscaler.py create mode 100644 tests/unit/modules/test_neutron.py create mode 100644 tests/unit/modules/test_nginx.py create mode 100644 tests/unit/modules/test_nilrt_ip.py create mode 100644 tests/unit/modules/test_nova.py create mode 100644 tests/unit/modules/test_openstack_config.py create mode 100644 tests/unit/modules/test_opkg.py create mode 100644 tests/unit/modules/test_pdbedit.py create mode 100644 tests/unit/modules/test_random_org.py create mode 100644 tests/unit/modules/test_swarm.py create mode 100644 tests/unit/modules/test_twilio_notify.py create mode 100644 tests/unit/modules/test_virt.py create mode 100644 tests/unit/modules/test_zcbuildout.py create mode 100644 tests/unit/states/test_boto_apigateway.py create mode 100644 tests/unit/states/test_boto_cognitoidentity.py create mode 100644 tests/unit/states/test_boto_vpc.py create mode 100644 tests/unit/states/test_esxdatacenter.py create mode 100644 tests/unit/states/test_esxi.py create mode 100644 tests/unit/states/test_heat.py create mode 100644 tests/unit/states/test_virt.py create mode 100644 tests/unit/states/test_zcbuildout.py create mode 100644 tests/unit/transport/test_ipc.py create mode 100644 tests/unit/utils/test_asynchronous.py create mode 100644 tests/unit/utils/test_dockermod.py create mode 100644 tests/unit/utils/test_msgpack.py create mode 100644 tests/unit/utils/test_pydsl.py create mode 100644 tests/unit/utils/test_sdb.py delete mode 100644 tools/__main__.py delete mode 100644 tools/audit_doc_links.py diff --git a/.coveragerc b/.coveragerc index 3b82a5e05791..6960eb453577 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,38 +4,7 @@ cover_pylib = False parallel = True concurrency = multiprocessing relative_files = True -# dynamic_context = test_function -# -# Temporarily disabled while the Salt onedir is on Python 3.14. -# -# ``dynamic_context = test_function`` tells coverage.py to tag every -# recorded ``(file, line)`` hit with the name of the test function -# that drove it, so HTML reports can answer "which tests touched this -# line?". The cost is that the setting forces coverage off the new -# sys.monitoring ("sysmon") core — sysmon does not implement dynamic -# contexts. On Python 3.14 sysmon is the default and is dramatically -# faster than the PyTracer / CTracer fallbacks; falling back to -# CTracer adds modest overhead, falling back to PyTracer (the only -# option on the coverage version we currently pin, 7.3.1, which -# ships no CTracer wheel for 3.14) adds catastrophic overhead. -# -# In Salt CI the catastrophic-overhead path showed up as the -# functional zeromq 4 shard taking ~80 min instead of ~12 min and -# 12 subprocess-heavy tests (test_publsh_server, -# test_zeromq_filtering_*, test_concurrent_writers_no_data_loss, …) -# blowing past their internal asyncio / queue / loop timeouts. The -# slow ``cov.start()`` in each forked subprocess also leaked -# non-daemon child processes that blocked Python interpreter exit -# and triggered the GHA 3-hour step timeout (the "test session -# hangs at the end" symptom). -# -# Re-enable once Salt's coverage pin is bumped to a version that -# either ships a CTracer wheel for Python 3.14 (>=7.10) or makes -# sysmon usable with dynamic contexts (no version available as of -# 2026-05-21). See: -# * https://github.com/coveragepy/coveragepy/issues/2082 -# * https://coverage.readthedocs.io/en/latest/contexts.html -# * https://coverage.readthedocs.io/en/latest/faq.html +dynamic_context = test_function omit = setup.py .nox/* diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml deleted file mode 100644 index 7bc057961b3e..000000000000 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: Bug Report -description: File a bug report -title: "[Bug]: " -labels: ["bug", "needs-triage"] -projects: ["saltstack/51"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this bug report! - - type: textarea - id: what-happened - validations: - required: true - attributes: - label: What happened? - description: A clear and concise description of what the bug is. Please provide relevant configs and/or SLS files (be sure to remove sensitive info. There is no general set-up of Salt.) Please be as specific as possible and give set-up details, and a concise description of what you expected to happen. - value: "A bug happened!" - - type: dropdown - id: salt-type - validations: - required: true - attributes: - label: Type of salt install - multiple: false - description: What type of Salt installation is this? - options: - - Official deb - - Official rpm - - Official exe - - Official msi - - Official pkg - - pip (pypi) - - pip (git) - - other (please specify in bug details) - - type: dropdown - id: salt-version - validations: - required: true - attributes: - label: Major version - multiple: true - description: What major version(s) of Salt are you running? Can select multiple. - options: - - 3006.x - - 3007.x - - type: dropdown - id: operating-systems - validations: - required: true - attributes: - label: What supported OS are you seeing the problem on? Can select multiple. (If bug appears on an unsupported OS, please open a GitHub Discussion instead) - multiple: true - options: - - almalinux-8 - - almalinux-9 - - almalinux-10 - - amazonlinux-2 - - amazonlinux-2023 - - centos-stream-9 - - debian-11 - - debian-12 - - fedora-42 - - macos-13 - - macos-14 - - macos-15 - - opensuse-leap-15.5 - - oraclelinux-8 - - oraclelinux-9 - - photon-4 - - photon-5 - - rhel-8 - - rhel-9 - - rhel-10 - - rockylinux-8 - - rockylinux-9 - - rockylinux-10 - - sles-12-sp5 - - sles-15-sp5 - - ubuntu-22.04 - - ubuntu-24.04 - - windows-desktop-10 - - windows-desktop-11 - - windows-2016 - - windows-2019 - - windows-2022 - - windows-2025 - - type: textarea - id: salt-versions-reports - validations: - required: true - attributes: - label: salt --versions-report output - description: Please copy and paste the output of "salt --versions-report". This will be automatically formatted into code, so no need for backticks. - render: shell diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000000..6f61f0333369 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: Bug, needs-triage +assignees: '' + +--- + +**Description** +A clear and concise description of what the bug is. + +**Setup** +(Please provide relevant configs and/or SLS files (be sure to remove sensitive info. There is no general set-up of Salt.) + +Please be as specific as possible and give set-up details. + +- [ ] on-prem machine +- [ ] VM (Virtualbox, KVM, etc. please specify) +- [ ] VM running on a cloud service, please be explicit and add details +- [ ] container (Kubernetes, Docker, containerd, etc. please specify) +- [ ] or a combination, please be explicit +- [ ] jails if it is FreeBSD +- [ ] classic packaging +- [ ] onedir packaging +- [ ] used bootstrap to install + + +**Steps to Reproduce the behavior** +(Include debug logs if possible and relevant) + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Versions Report** +
salt --versions-report +(Provided by running salt --versions-report. Please also mention any differences in master/minion versions.) + +```yaml +PASTE HERE +``` +
+ +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 95de21f3b446..c5fcbf50fca3 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,8 @@ blank_issues_enabled: true contact_links: - - name: Feature Requests and Q&A - url: https://github.com/saltstack/salt/discussions - about: Please see GitHub Discussions for Feature Requests and Q&A - name: Salt Community Discord url: https://discord.com/invite/J7b7EscrAs - about: Please ask and answer questions here + about: Please ask and answer questions here. - name: Salt-Users Forum url: https://groups.google.com/forum/#!forum/salt-users - about: Prefer email list for Q&A? Check out the Google groups newsletter + about: Please ask and answer questions here. diff --git a/.github/ISSUE_TEMPLATE/docs.md b/.github/ISSUE_TEMPLATE/docs.md index 555fcc87b759..311e931619cb 100644 --- a/.github/ISSUE_TEMPLATE/docs.md +++ b/.github/ISSUE_TEMPLATE/docs.md @@ -2,7 +2,7 @@ name: Docs about: Issue related to Salt Documentation title: "[DOCS]" -labels: documentation, needs-triage +labels: Documentation, needs-triage assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000000..e35351f11a4b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE REQUEST]" +labels: Feature, needs-triage +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. + +**Please Note** +If this feature request would be considered a substantial change or addition, this should go through a SEP process here https://github.com/saltstack/salt-enhancement-proposals, instead of a feature request. diff --git a/.github/ISSUE_TEMPLATE/test-failure.md b/.github/ISSUE_TEMPLATE/test-failure.md index c19d9bfb1d0d..6eec4274e26a 100644 --- a/.github/ISSUE_TEMPLATE/test-failure.md +++ b/.github/ISSUE_TEMPLATE/test-failure.md @@ -1,8 +1,8 @@ --- name: Test Failure -about: GitHub Actions Test Failure issues +about: Jenkins Test Failure issues title: "[TEST FAILURE]" -labels: 'test-failure' +labels: 'Test-Failure' assignees: '' --- diff --git a/.github/actions/build-onedir-deps/action.yml b/.github/actions/build-onedir-deps/action.yml index e3ff009b5aa8..fe4735c0bc49 100644 --- a/.github/actions/build-onedir-deps/action.yml +++ b/.github/actions/build-onedir-deps/action.yml @@ -25,25 +25,22 @@ runs: using: composite steps: - - name: Get Hash For Onedir Deps Cache - id: onedir-deps-hash - shell: bash - run: | - HASH=$(python3 .github/scripts/hash-files.py \ - '.relenv/**/*.xz' \ - 'requirements/static/pkg/*/*.lock' \ - 'tools/pkg/build.py' \ - '.github/actions/build-onedir-deps/action.yml' \ - '.github/workflows/build-deps-onedir-*.yml' \ - 'cicd/shared-gh-workflows-context.yml') - echo "hash=${HASH}" | tee -a "$GITHUB_OUTPUT" - - name: Cache Deps Onedir Package Directory id: onedir-pkg-cache uses: ./.github/actions/cache with: path: artifacts/${{ inputs.package-name }} - key: ${{ inputs.cache-prefix }}|${{ inputs.python-version }}|deps|${{ inputs.platform }}|${{ inputs.arch }}|${{ inputs.package-name }}|${{ steps.onedir-deps-hash.outputs.hash }} + key: > + ${{ inputs.cache-prefix }}|${{ inputs.python-version }}|deps|${{ inputs.platform }}|${{ inputs.arch }}|${{ inputs.package-name }}|${{ + hashFiles( + format('{0}/.relenv/**/*.xz', github.workspace), + 'requirements/static/pkg/*/*.lock', + 'tools/pkg/build.py', + '.github/actions/build-onedir-deps/action.yml', + '.github/workflows/build-deps-onedir-*.yml', + 'cicd/shared-gh-workflows-context.yml' + ) + }} - name: Install Salt Onedir Package Dependencies shell: bash diff --git a/.github/actions/build-onedir-salt/action.yml b/.github/actions/build-onedir-salt/action.yml index 639825b57bc3..5f682d4e31ce 100644 --- a/.github/actions/build-onedir-salt/action.yml +++ b/.github/actions/build-onedir-salt/action.yml @@ -22,6 +22,13 @@ inputs: salt-version: required: true description: The Salt version to set prior to building packages. + upload-artifact: + required: false + description: Whether to upload the resulting onedir tarball as the + canonical artifact. With a multi-python matrix only the canonical + python build should upload to avoid clobbering downstream consumers + that fetch the unsuffixed artifact name. + default: "true" runs: @@ -35,7 +42,7 @@ runs: platform: ${{ inputs.platform }} arch: ${{ inputs.arch }} python-version: "${{ inputs.python-version }}" - cache-prefix: ${{ inputs.cache-prefix }}|relenv|${{ inputs.salt-version }} + cache-prefix: ${{ inputs.cache-prefix }}|relenv|${{ inputs.python-version }}|${{ inputs.salt-version }} - name: Download Source Tarball uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -73,6 +80,7 @@ runs: tools pkg generate-hashes artifacts/${{ inputs.package-name }}-${{ inputs.salt-version }}-onedir-${{ inputs.platform }}-${{ inputs.arch }}.* - name: Upload Onedir Tarball as an Artifact + if: ${{ inputs.upload-artifact == 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ inputs.package-name }}-${{ inputs.salt-version }}-onedir-${{ inputs.platform }}-${{ inputs.arch }}.tar.xz @@ -81,7 +89,7 @@ runs: if-no-files-found: error - name: Upload Onedir Zipfile as an Artifact - if: ${{ inputs.platform == 'windows' }} + if: ${{ inputs.upload-artifact == 'true' && inputs.platform == 'windows' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ inputs.package-name }}-${{ inputs.salt-version }}-onedir-${{ inputs.platform }}-${{ inputs.arch }}.zip diff --git a/.github/actions/setup-python-tools-scripts/action.yml b/.github/actions/setup-python-tools-scripts/action.yml index b31449c0b085..de10117a48c4 100644 --- a/.github/actions/setup-python-tools-scripts/action.yml +++ b/.github/actions/setup-python-tools-scripts/action.yml @@ -31,9 +31,9 @@ runs: id: venv-hash shell: bash run: | - FILES_HASH=$(python3 .github/scripts/hash-files.py 'requirements/**/*.txt' 'requirements/**/*.lock' 'tools/**/*.py') VENV_NAME_HASH=$(echo "${{ inputs.cache-prefix }}|${{ github.workflow }}|${{ - steps.get-python-version.outputs.version-sha256sum }}|${FILES_HASH}" | sha256sum | cut -d ' ' -f 1) + steps.get-python-version.outputs.version-sha256sum }}|${{ + hashFiles('requirements/**/*.txt', 'requirements/**/*.lock', 'tools/**/*.py') }}" | sha256sum | cut -d ' ' -f 1) echo "TOOLS_VIRTUALENV_CACHE_SEED=$VENV_NAME_HASH" | tee -a "${GITHUB_ENV}" echo "venv-hash=$VENV_NAME_HASH" | tee -a "${GITHUB_OUTPUT}" diff --git a/.github/config.yml b/.github/config.yml index bbb921cc6989..1d916579c6ce 100644 --- a/.github/config.yml +++ b/.github/config.yml @@ -4,48 +4,48 @@ newIssueWelcomeComment: > Hi there! Welcome to the Salt Community! Thank you for making your first contribution. We have a lengthy process for issues and PRs. Someone from the Core Team will follow up as soon as - possible. In the meantime, here's some information that may help as you continue your Salt + possible. In the meantime, here’s some information that may help as you continue your Salt journey. Please be sure to review our [Code of Conduct](https://github.com/saltstack/salt/blob/master/CODE_OF_CONDUCT.md). Also, check out some of our community resources including: - - [Salt's Contributor Guide](https://docs.saltproject.io/en/master/topics/development/contributing.html) + - [Salt’s Contributor Guide](https://docs.saltproject.io/en/master/topics/development/contributing.html) - [Join our Community Discord](https://discord.com/invite/J7b7EscrAs) - [Salt Project YouTube channel](https://www.youtube.com/channel/UCpveTIucFx9ljGelW63-BWg) - - [GitHub Discussions](https://github.com/saltstack/salt/discussions) + - [Community Wiki](https://github.com/saltstack/community/wiki) There are lots of ways to get involved in our community. Every month, there are around a dozen opportunities to meet with other contributors and the Salt Core team and collaborate in real time. The best way to keep track is by subscribing to the Salt Community Events Calendar. - If you have additional questions, email us at saltproject.pdl@broadcom.com. We're glad - you've joined our community and look forward to doing awesome things with + If you have additional questions, email us at saltproject.pdl@broadcom.com. We’re glad + you’ve joined our community and look forward to doing awesome things with you! # Comment to be posted to on PRs from first time contributors in your repository newPRWelcomeComment: > Hi there! Welcome to the Salt Community! Thank you for making your first contribution. We have a lengthy process for issues and PRs. Someone from the Core Team will follow up as soon as - possible. In the meantime, here's some information that may help as you continue your Salt + possible. In the meantime, here’s some information that may help as you continue your Salt journey. Please be sure to review our [Code of Conduct](https://github.com/saltstack/salt/blob/master/CODE_OF_CONDUCT.md). Also, check out some of our community resources including: - - [Salt's Contributor Guide](https://docs.saltproject.io/en/master/topics/development/contributing.html) + - [Salt’s Contributor Guide](https://docs.saltproject.io/en/master/topics/development/contributing.html) - [Join our Community Discord](https://discord.com/invite/J7b7EscrAs) - [Salt Project YouTube channel](https://www.youtube.com/channel/UCpveTIucFx9ljGelW63-BWg) - - [GitHub Discussions](https://github.com/saltstack/salt/discussions) + - [Community Wiki](https://github.com/saltstack/community/wiki) There are lots of ways to get involved in our community. Every month, there are around a dozen opportunities to meet with other contributors and the Salt Core team and collaborate in real time. The best way to keep track is by subscribing to the Salt Community Events Calendar. - If you have additional questions, email us at saltproject.pdl@broadcom.com. We're glad - you've joined our community and look forward to doing awesome things with + If you have additional questions, email us at saltproject.pdl@broadcom.com. We’re glad + you’ve joined our community and look forward to doing awesome things with you! # Comment to be posted to on pull requests merged by a first time user diff --git a/.github/scripts/hash-files.py b/.github/scripts/hash-files.py deleted file mode 100755 index a1894c7698cc..000000000000 --- a/.github/scripts/hash-files.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -""" -Cross-platform replacement for GitHub Actions hashFiles() function. - -This script computes a hash of files matching the given glob patterns, -compatible with Linux, macOS, and Windows runners. - -Usage: - python hash-files.py 'pattern1' 'pattern2' ... - -Example: - python hash-files.py 'requirements/**/*.txt' 'noxfile.py' -""" -import hashlib -import sys -from pathlib import Path - - -def find_files(patterns): - """ - Find all files matching the given glob patterns. - - Args: - patterns: List of glob patterns (e.g., 'requirements/**/*.txt') - - Returns: - Sorted list of Path objects for matching files - """ - files = set() - repo_root = Path.cwd() - - for pattern in patterns: - # Handle both absolute and relative patterns - pattern = pattern.strip() - if not pattern: - continue - - # Check if pattern is absolute - pattern_path = Path(pattern) - if pattern_path.is_absolute(): - # For absolute paths, extract the pattern relative to repo root - # e.g., /home/runner/work/salt/salt/.relenv/**/*.xz -> .relenv/**/*.xz - try: - # Try to make it relative to repo root - relative_pattern = pattern_path.relative_to(repo_root) - pattern = str(relative_pattern) - except ValueError: - # Pattern is outside repo root, use as-is - # Try to glob from root - if "**" in pattern or "*" in pattern or "?" in pattern: - # It's a glob pattern with absolute base - # Extract the base directory and the glob part - parts = pattern.split("/") - # Find the first part with a glob character - for i, part in enumerate(parts): - if "*" in part or "?" in part: - base = Path("/".join(parts[:i])) - glob_pattern = "/".join(parts[i:]) - matching_paths = base.glob(glob_pattern) - for path in matching_paths: - if path.is_file(): - files.add(path) - break - continue - else: - # It's an absolute path to a single file - if pattern_path.is_file(): - files.add(pattern_path) - continue - - # Use glob for patterns - matching_paths = repo_root.glob(pattern) - - # Add only files (not directories) - for path in matching_paths: - if path.is_file(): - files.add(path) - - # Sort for consistent ordering across platforms - return sorted(files) - - -def hash_files(file_paths): - """ - Compute SHA256 hash of the contents of all files. - - Args: - file_paths: List of Path objects to hash - - Returns: - Hexadecimal hash string - """ - hasher = hashlib.sha256() - - for file_path in file_paths: - try: - # Add the relative path to the hash for consistency - # Try to make it relative to cwd, otherwise use the full path - try: - rel_path = file_path.relative_to(Path.cwd()) - except ValueError: - # File is outside cwd, use absolute path - rel_path = file_path - hasher.update(str(rel_path).encode("utf-8")) - - # Read and hash file contents in binary mode - with open(file_path, "rb") as f: - # Read in chunks to handle large files efficiently - while chunk := f.read(8192): - hasher.update(chunk) - except OSError as e: - # Print warning but continue with other files - print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr) - continue - - return hasher.hexdigest() - - -def main(): - """Main entry point.""" - if len(sys.argv) < 2: - print("Usage: python hash-files.py 'pattern1' 'pattern2' ...", file=sys.stderr) - print("", file=sys.stderr) - print( - "Example: python hash-files.py 'requirements/**/*.txt' 'noxfile.py'", - file=sys.stderr, - ) - sys.exit(1) - - patterns = sys.argv[1:] - - # Find all matching files - files = find_files(patterns) - - if not files: - # Return empty hash if no files found (mimics hashFiles behavior) - print("") - return - - # Compute and print hash - file_hash = hash_files(files) - print(file_hash) - - -if __name__ == "__main__": - main() diff --git a/.github/scripts/verify-draft-signing-manifest.sh b/.github/scripts/verify-draft-signing-manifest.sh deleted file mode 100755 index ff5d3d9c8abe..000000000000 --- a/.github/scripts/verify-draft-signing-manifest.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -# Verify salt-release-signing-manifest.json on a draft release before publishing. -# Usage: verify-draft-signing-manifest.sh -set -euo pipefail - -REPO="${1:?repository owner/name required}" -TAG="${2:?release tag required}" - -WORKDIR=$(mktemp -d) -trap 'rm -rf "${WORKDIR}"' EXIT - -echo "Fetching signing manifest for ${REPO} ${TAG} ..." -gh release download "${TAG}" -R "${REPO}" -p 'salt-release-signing-manifest.json' -D "${WORKDIR}" - -MANIFEST="${WORKDIR}/salt-release-signing-manifest.json" -test -f "${MANIFEST}" - -jq -e '.schema_version == 1' "${MANIFEST}" >/dev/null -jq -e --arg t "${TAG}" '.release_tag == $t' "${MANIFEST}" >/dev/null - -if [[ "$(jq '.artifacts | length' "${MANIFEST}")" -lt 1 ]]; then - echo "Signing manifest has no artifacts." >&2 - exit 1 -fi - -DL="${WORKDIR}/files" -mkdir -p "${DL}" - -echo "Verifying digests for signed package entries ..." -while IFS=$'\t' read -r name want; do - [[ -n "${name}" ]] || continue - gh release download "${TAG}" -R "${REPO}" -p "${name}" -D "${DL}" - got=$(sha256sum "${DL}/${name}" | awk '{print $1}') - if [[ "${got}" != "${want}" ]]; then - echo "SHA256 mismatch for ${name} (expected ${want}, got ${got})" >&2 - exit 1 - fi -done < <(jq -r '.artifacts[] | [.name, .sha256] | @tsv' "${MANIFEST}") - -echo "Checking every package asset on the release is listed in the manifest ..." -mapfile -t release_assets < <(gh api "repos/${REPO}/releases/tags/${TAG}" --jq '.assets[].name') - -for aname in "${release_assets[@]}"; do - if [[ "${aname}" == salt-release-signing-manifest.json ]] || [[ "${aname}" == SHA256SUMS ]] || [[ "${aname}" == CHECKSUMS ]]; then - continue - fi - if [[ "${aname}" =~ \.(deb|rpm|msi|exe|pkg)$ ]]; then - if ! jq -e --arg n "${aname}" '.artifacts | map(.name) | index($n) != null' "${MANIFEST}" >/dev/null; then - echo "Release contains package asset not covered by signing manifest: ${aname}" >&2 - exit 1 - fi - fi -done - -echo "Signing manifest checks passed." diff --git a/.github/workflows/build-deps-ci-action.yml b/.github/workflows/build-deps-ci-action.yml index a86772ec9adf..67f8a0c96011 100644 --- a/.github/workflows/build-deps-ci-action.yml +++ b/.github/workflows/build-deps-ci-action.yml @@ -47,10 +47,6 @@ on: required: true type: string description: Json job matrix config - raise-deprecations-runtime-errors: - required: true - type: string - description: Whether to raise RuntimeError on deprecation warnings ("1" or "0") env: @@ -62,7 +58,7 @@ env: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" jobs: @@ -327,7 +323,7 @@ jobs: if: steps.nox-dependencies-cache.outputs.cache-hit != 'true' uses: ./.github/actions/setup-python-tools-scripts with: - cache-prefix: ${{ inputs.cache-prefix }}-build-deps-ci + cache-prefix: ${{ inputs.cache-prefix }}-build-deps-ci-${{ matrix.arch }} - name: Install System Dependencies if: steps.nox-dependencies-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 9a252819e6aa..f83881f92a41 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -69,19 +69,12 @@ jobs: run: | tools pkg apply-release-patch salt-${{ inputs.salt-version }}.patch --delete - - name: Get Hash For Docs Requirements - id: docs-requirements-hash - shell: bash - run: | - HASH=$(python3 .github/scripts/hash-files.py 'requirements/**/docs.lock') - echo "hash=${HASH}" | tee -a "$GITHUB_OUTPUT" - - name: Cache Python Tools Docs Virtualenv id: tools-venvs-dependencies-cache uses: ./.github/actions/cache with: path: .tools-venvs/docs - key: ${{ inputs.cache-seed }}|${{ github.workflow }}|${{ github.job }}|tools-venvs|${{ steps.python-tools-scripts.outputs.version }}|docs|${{ steps.get-python-version.outputs.version }}|${{ steps.docs-requirements-hash.outputs.hash }} + key: ${{ inputs.cache-seed }}|${{ github.workflow }}|${{ github.job }}|tools-venvs|${{ steps.python-tools-scripts.outputs.version }}|docs|${{ steps.get-python-version.outputs.version }}|${{ hashFiles('requirements/**/docs.txt') }} - name: Prepare Docs Build run: | diff --git a/.github/workflows/build-salt-onedir.yml b/.github/workflows/build-salt-onedir.yml index ddb79f641dc7..d70840b9a01d 100644 --- a/.github/workflows/build-salt-onedir.yml +++ b/.github/workflows/build-salt-onedir.yml @@ -89,7 +89,7 @@ jobs: arch: ${{ matrix.arch == 'arm64' && 'aarch64' || matrix.arch }} version: ${{ inputs.relenv-version }} cache-seed: ${{ inputs.cache-seed }} - python-version: ${{ inputs.python-version }} + python-version: ${{ matrix.python }} - name: Install Salt into Relenv Onedir uses: ./.github/actions/build-onedir-salt @@ -97,8 +97,9 @@ jobs: platform: linux arch: ${{ matrix.arch }} salt-version: "${{ inputs.salt-version }}" - python-version: "${{ inputs.python-version }}" + python-version: "${{ matrix.python }}" cache-prefix: ${{ inputs.cache-seed }}|relenv|${{ steps.setup-relenv.outputs.version }} + upload-artifact: ${{ matrix.python == inputs.python-version }} build-salt-macos: name: macOS @@ -148,7 +149,7 @@ jobs: arch: ${{ matrix.arch }} version: ${{ inputs.relenv-version }} cache-seed: ${{ inputs.cache-seed }} - python-version: ${{ inputs.python-version }} + python-version: ${{ matrix.python }} - name: Install Salt into Relenv Onedir uses: ./.github/actions/build-onedir-salt @@ -156,8 +157,9 @@ jobs: platform: macos arch: ${{ matrix.arch }} salt-version: "${{ inputs.salt-version }}" - python-version: "${{ inputs.python-version }}" + python-version: "${{ matrix.python }}" cache-prefix: ${{ inputs.cache-seed }}|relenv|${{ steps.setup-relenv.outputs.version }} + upload-artifact: ${{ matrix.python == inputs.python-version }} build-salt-windows: name: Windows @@ -204,7 +206,7 @@ jobs: arch: ${{ matrix.arch }} version: ${{ inputs.relenv-version }} cache-seed: ${{ inputs.cache-seed }} - python-version: ${{ inputs.python-version }} + python-version: ${{ matrix.python }} - name: Install Salt into Relenv Onedir uses: ./.github/actions/build-onedir-salt @@ -212,5 +214,6 @@ jobs: platform: windows arch: ${{ matrix.arch }} salt-version: "${{ inputs.salt-version }}" - python-version: "${{ inputs.python-version }}" + python-version: "${{ matrix.python }}" cache-prefix: ${{ inputs.cache-seed }}|relenv|${{ steps.setup-relenv.outputs.version }} + upload-artifact: ${{ matrix.python == inputs.python-version }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dca4587f6f1..25383a4d79ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,6 @@ on: branches: - 3006.x - 3007.x - - 3008.x - master pull_request: types: @@ -173,17 +172,8 @@ jobs: - name: Get Hash For Nox Tarball Cache id: nox-archive-hash - shell: bash run: | - HASH=$(python3 .github/scripts/hash-files.py \ - 'requirements/**/*.txt' \ - 'requirements/**/*.in' \ - 'requirements/**/*.lock' \ - 'cicd/golden-images.json' \ - 'noxfile.py' \ - 'pkg/common/env-cleanup-rules.yml' \ - '.github/workflows/build-deps-ci-action.yml') - echo "nox-archive-hash=${HASH}" | tee -a "$GITHUB_OUTPUT" + echo "nox-archive-hash=${{ hashFiles('requirements/**/*.txt', 'requirements/**/*.lock', 'cicd/golden-images.json', 'noxfile.py', 'pkg/common/env-cleanup-rules.yml', '.github/workflows/build-deps-ci-action.yml') }}" | tee -a "$GITHUB_OUTPUT" - name: Write Changed Files To A Local File run: @@ -473,9 +463,9 @@ jobs: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" - matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} + matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['onedir-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} build-pkgs-onedir: @@ -490,7 +480,7 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" source: "onedir" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -507,7 +497,7 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" source: "src" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -522,14 +512,13 @@ jobs: with: nox-session: ci-test-onedir nox-version: 2022.8.7 - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 nox-archive-hash: "${{ needs.prepare-workflow.outputs.nox-archive-hash }}" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" test-packages: name: Test Package if: ${{ fromJSON(needs.prepare-workflow.outputs.config)['jobs']['test-pkg'] }} @@ -543,12 +532,11 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" nox-version: 2022.8.7 ci-python-version: "3.14" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 skip-code-coverage: ${{ fromJSON(needs.prepare-workflow.outputs.config)['skip_code_coverage'] }} testing-releases: ${{ needs.prepare-workflow.outputs.testing-releases }} matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['pkg-test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" test: name: Test Salt if: ${{ fromJSON(needs.prepare-workflow.outputs.config)['jobs']['test'] }} @@ -562,13 +550,12 @@ jobs: ci-python-version: "3.14" testrun: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['testrun']) }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 skip-code-coverage: ${{ fromJSON(needs.prepare-workflow.outputs.config)['skip_code_coverage'] }} workflow-slug: ci default-timeout: 180 matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" combine-all-code-coverage: name: Combine Code Coverage @@ -605,10 +592,7 @@ jobs: # We can't yet use tokenless uploads with the codecov CLI # python3 -m pip install codecov-cli # - # Codecov retired the ``codecovsecurity`` keybase user; the - # uploader signing key now lives under ``codecovsecops``. - # Fingerprint unchanged: 2703 4E7F DB85 0E0B BC2C 62FF 806B B28A ED77 9869. - curl https://keybase.io/codecovsecops/pgp_keys.asc | gpg --no-default-keyring --import + curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --import curl -Os https://uploader.codecov.io/latest/linux/codecov curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig diff --git a/.github/workflows/depcheck.yml b/.github/workflows/depcheck.yml index 418a8cf982ae..fa118a9872ae 100644 --- a/.github/workflows/depcheck.yml +++ b/.github/workflows/depcheck.yml @@ -169,17 +169,8 @@ jobs: - name: Get Hash For Nox Tarball Cache id: nox-archive-hash - shell: bash run: | - HASH=$(python3 .github/scripts/hash-files.py \ - 'requirements/**/*.txt' \ - 'requirements/**/*.in' \ - 'requirements/**/*.lock' \ - 'cicd/golden-images.json' \ - 'noxfile.py' \ - 'pkg/common/env-cleanup-rules.yml' \ - '.github/workflows/build-deps-ci-action.yml') - echo "nox-archive-hash=${HASH}" | tee -a "$GITHUB_OUTPUT" + echo "nox-archive-hash=${{ hashFiles('requirements/**/*.txt', 'requirements/**/*.lock', 'cicd/golden-images.json', 'noxfile.py', 'pkg/common/env-cleanup-rules.yml', '.github/workflows/build-deps-ci-action.yml') }}" | tee -a "$GITHUB_OUTPUT" - name: Write Changed Files To A Local File run: @@ -485,7 +476,6 @@ jobs: nox-archive-hash: "${{ needs.prepare-workflow.outputs.nox-archive-hash }}" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" test-packages: name: Test Package @@ -505,7 +495,6 @@ jobs: testing-releases: ${{ needs.prepare-workflow.outputs.testing-releases }} matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['pkg-test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" test: name: Test Salt if: ${{ fromJSON(needs.prepare-workflow.outputs.config)['jobs']['test'] }} @@ -525,7 +514,6 @@ jobs: default-timeout: 180 matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" combine-all-code-coverage: name: Combine Code Coverage @@ -581,10 +569,7 @@ jobs: # We can't yet use tokenless uploads with the codecov CLI # python3 -m pip install codecov-cli # - # Codecov retired the ``codecovsecurity`` keybase user; the - # uploader signing key now lives under ``codecovsecops``. - # Fingerprint unchanged: 2703 4E7F DB85 0E0B BC2C 62FF 806B B28A ED77 9869. - curl https://keybase.io/codecovsecops/pgp_keys.asc | gpg --no-default-keyring --import + curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --import curl -Os https://uploader.codecov.io/latest/linux/codecov curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig diff --git a/.github/workflows/doc-linkcheck.yml b/.github/workflows/doc-linkcheck.yml deleted file mode 100644 index a8b0269ec170..000000000000 --- a/.github/workflows/doc-linkcheck.yml +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: doc-linkcheck - -# Informational only. This workflow runs the wrapped Sphinx linkcheck -# (see ``tools/audit_doc_links.py``) on a weekly cadence and opens a -# tracking issue if any URLs are reported as broken. It is NOT wired -# into PR CI -- regressions in external URLs are not the PR author's -# responsibility. - -on: - schedule: - # Every Monday at 06:00 UTC. - - cron: "0 6 * * 1" - workflow_dispatch: {} - -permissions: - contents: read - issues: write - -jobs: - audit: - name: Audit doc URLs - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install Sphinx dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements/static/ci/py3.11/docs.lock - - - name: Run link audit - id: audit - run: | - python tools/audit_doc_links.py \ - --doc-dir doc \ - --build-dir doc/_build/linkcheck-audit \ - --csv doc/_build/linkcheck-audit/report.csv - echo "csv=doc/_build/linkcheck-audit/report.csv" >> "$GITHUB_OUTPUT" - broken=$(awk -F, 'NR>1 && $3 == "broken"' \ - doc/_build/linkcheck-audit/report.csv | wc -l) - echo "broken=${broken}" >> "$GITHUB_OUTPUT" - - - name: Upload audit CSV - if: always() - uses: actions/upload-artifact@v4 - with: - name: doc-linkcheck-report - path: doc/_build/linkcheck-audit/report.csv - if-no-files-found: warn - - - name: Open tracking issue on regression - if: steps.audit.outputs.broken != '0' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const broken = '${{ steps.audit.outputs.broken }}'; - const title = `doc-linkcheck: ${broken} broken URL(s) detected`; - const existing = await github.rest.search.issuesAndPullRequests({ - q: `repo:${context.repo.owner}/${context.repo.repo} is:open is:issue in:title "doc-linkcheck:"`, - }); - if (existing.data.total_count > 0) { - core.info(`Existing tracking issue: ${existing.data.items[0].html_url}`); - return; - } - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title, - body: [ - `Weekly doc URL audit found ${broken} broken URL(s).`, - ``, - `Download the artifact \`doc-linkcheck-report\` from the run:`, - `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, - ``, - `This workflow is informational; PR CI is not gated on it.`, - ].join('\n'), - labels: ['Documentation', 'needs-triage'], - }); diff --git a/.github/workflows/nightly-stress-test.yml b/.github/workflows/nightly-stress-test.yml index c2dce9415675..21098a54872f 100644 --- a/.github/workflows/nightly-stress-test.yml +++ b/.github/workflows/nightly-stress-test.yml @@ -13,11 +13,6 @@ on: jobs: stress-test: runs-on: ubuntu-latest - # ``contents: write`` lets the ``Publish panels to stress-snapshots - # branch`` step push the rendered PNGs to an orphan branch so the - # step summary can embed them via raw.githubusercontent.com URLs. - permissions: - contents: write steps: - uses: actions/checkout@v4 @@ -35,56 +30,13 @@ jobs: - name: Build and Start Environment run: | cd tests/monitoring - # The prometheus container runs as ``nobody`` (uid 65534) and - # writes its TSDB to ``/prometheus``. Without pre-creating the - # bind-mount source with that ownership, Docker auto-creates - # it owned by root and prometheus fails to start with - # "permission denied" on /prometheus -- which then surfaces - # downstream as a bare ConnectionRefusedError when - # analyze_stats.py tries to query http://localhost:19090. - mkdir -p prometheus_data - sudo chown -R 65534:65534 prometheus_data docker compose build docker compose up -d sleep 30 # Wait for initialization - name: Verify Connections - # The salt CLI returns exit 0 even when the master returns an - # error string (the legacy ``'str' object has no attribute - # 'pop'`` failure path surfaces this way), so we have to inspect - # the JSON output ourselves to fail the step. run: | - out=$(docker exec salt-master salt --out=json '*' test.ping) - echo "$out" - python3 - "$out" <<'PY' - import json, sys - payload = sys.argv[1].strip() - decoder = json.JSONDecoder() - idx = 0 - bad = [] - while idx < len(payload): - while idx < len(payload) and payload[idx].isspace(): - idx += 1 - if idx >= len(payload): - break - try: - obj, end = decoder.raw_decode(payload, idx) - except ValueError as exc: - bad.append(f" non-JSON at offset {idx}: {exc}") - break - idx = end - if isinstance(obj, dict): - for mid, val in obj.items(): - if val is not True: - bad.append(f" minion {mid!r} returned {val!r}") - else: - bad.append(f" unexpected payload type: {type(obj).__name__}={obj!r}") - if bad: - print("Verify Connections failed:", file=sys.stderr) - for b in bad: - print(b, file=sys.stderr) - sys.exit(1) - PY + docker exec salt-master salt '*' test.ping - name: Run Aggressive Stress Test run: | @@ -112,19 +64,6 @@ jobs: sleep 30 python3 analyze_stats.py - - name: Render Dashboard Panels - # Render BEFORE Snapshot Metrics stops prometheus. We *only* - # produce the PNGs here; the markdown step-summary that links - # to them runs after the upload so it can embed a real URL. - # Runs even if Analyze Results failed -- the graphs are usually - # the most useful diagnostic for a failed run. - if: always() - run: | - cd tests/monitoring - python3 -m pip install --quiet matplotlib - PANELS_DIR="${GITHUB_WORKSPACE}/artifacts/panels" \ - python3 render_panels.py - - name: Snapshot Metrics if: always() run: | @@ -139,14 +78,9 @@ jobs: mkdir -p artifacts docker logs salt-master > artifacts/salt-master.log docker logs salt-minion-1 > artifacts/salt-minion-1.log - cp tests/monitoring/event_log.txt artifacts/ || true - # Always grab prometheus' own log so we can tell whether the - # connection refused was a startup issue (bind mount permissions - # etc.) vs. a runtime crash. - docker logs prometheus > artifacts/prometheus.log 2>&1 || true + cp monitoring/event_log.txt artifacts/ || true - name: Upload Artifacts - id: upload-artifacts if: always() uses: actions/upload-artifact@v4 with: @@ -154,78 +88,3 @@ jobs: path: | artifacts/ prometheus-data.tar.gz - - - name: Publish panels to stress-snapshots branch - # GitHub's step-summary sanitizer strips ``data:`` URIs but - # allows real ``https:`` image URLs, so we push the rendered - # PNGs to a dedicated ``stress-snapshots`` branch and let the - # summary embed them via ``raw.githubusercontent.com``. The - # branch is orphan-style: it never merges anywhere and is - # auto-pruned 14 days back so it stays small. Skipped silently - # when no PNGs exist (e.g. early-stage failures). - id: publish-snapshots - if: always() && hashFiles('artifacts/panels/*.png') != '' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -e - REPO_URL="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" - BRANCH=stress-snapshots - RUN_DIR="runs/${{ github.run_id }}" - - if git ls-remote --exit-code --heads "$REPO_URL" "$BRANCH" >/dev/null 2>&1; then - git clone --depth=1 --branch="$BRANCH" "$REPO_URL" snaps - else - git clone --depth=1 "$REPO_URL" snaps - cd snaps - git switch --orphan "$BRANCH" - git rm -rf . >/dev/null 2>&1 || true - cd .. - fi - - mkdir -p "snaps/$RUN_DIR" - cp artifacts/panels/*.png "snaps/$RUN_DIR/" - - cd snaps - # Drop any run directory older than 14 days so the branch - # never accumulates unbounded. - find runs -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + 2>/dev/null || true - - git config user.email "actions@github.com" - git config user.name "github-actions[bot]" - git add -A - if ! git commit -m "Stress run ${{ github.run_id }}"; then - echo "nothing to commit" - else - # Tiny retry loop in case two runs raced. Cron is nightly - # so this is essentially defensive. - for attempt in 1 2 3; do - if git push origin "$BRANCH"; then - break - fi - echo "push attempt $attempt failed; rebasing" - git fetch origin "$BRANCH" - git rebase "origin/$BRANCH" - done - fi - - URL_PREFIX="https://raw.githubusercontent.com/${{ github.repository }}/${BRANCH}/${RUN_DIR}/" - echo "url-prefix=${URL_PREFIX}" >> "$GITHUB_OUTPUT" - echo "Published panels under ${URL_PREFIX}" - - - name: Panel Summary - # Build the workflow step summary from the PNGs the Render - # step already produced, NOT by re-querying prometheus -- by - # this point ``Snapshot Metrics`` has stopped the prometheus - # container, so every range query would return no data. - # ``--image-url-prefix`` points at the stress-snapshots branch - # so each panel renders inline; without it the summary falls - # back to listing the artifact bundle. - if: always() - run: | - cd tests/monitoring - PANELS_DIR="${GITHUB_WORKSPACE}/artifacts/panels" \ - python3 render_panels.py --summary --from-existing \ - --artifact-name stress-test-results \ - --artifact-url "${{ steps.upload-artifacts.outputs.artifact-url }}" \ - --image-url-prefix "${{ steps.publish-snapshots.outputs.url-prefix }}" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c176a4e788e2..792796f32c02 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -16,6 +16,9 @@ on: type: boolean default: false description: Skip running the Salt packages test suite. + schedule: + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onschedule + - cron: '0 0 * * *' # Every day at 0AM env: COLUMNS: 190 @@ -35,10 +38,71 @@ concurrency: jobs: + workflow-requirements: + name: Check Workflow Requirements + runs-on: ubuntu-22.04 + outputs: + requirements-met: ${{ steps.check-requirements.outputs.requirements-met }} + steps: + - name: Check Requirements + id: check-requirements + run: | + if [ "${{ vars.RUN_SCHEDULED_BUILDS }}" = "1" ]; then + MSG="Running workflow because RUN_SCHEDULED_BUILDS=1" + echo "${MSG}" + echo "${MSG}" >> "${GITHUB_STEP_SUMMARY}" + echo "requirements-met=true" >> "${GITHUB_OUTPUT}" + elif [ "${{ github.event.repository.fork }}" = "true" ]; then + MSG="Not running workflow because ${{ github.repository }} is a fork" + echo "${MSG}" + echo "${MSG}" >> "${GITHUB_STEP_SUMMARY}" + echo "requirements-met=false" >> "${GITHUB_OUTPUT}" + elif [ "${{ github.event.repository.private }}" = "true" ]; then + MSG="Not running workflow because ${{ github.repository }} is a private repository" + echo "${MSG}" + echo "${MSG}" >> "${GITHUB_STEP_SUMMARY}" + echo "requirements-met=false" >> "${GITHUB_OUTPUT}" + else + MSG="Running workflow because ${{ github.repository }} is not a fork" + echo "${MSG}" + echo "${MSG}" >> "${GITHUB_STEP_SUMMARY}" + echo "requirements-met=true" >> "${GITHUB_OUTPUT}" + fi + + trigger-branch-nightly-builds: + name: Trigger Branch Workflows + if: ${{ github.event_name == 'schedule' && fromJSON(needs.workflow-requirements.outputs.requirements-met) }} + runs-on: ubuntu-22.04 + needs: + - workflow-requirements + + steps: + + - name: Trigger 3006.x branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh workflow run nightly.yml --repo ${{ github.repository }} --ref 3006.x + + - name: Trigger 3007.x branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh workflow run nightly.yml --repo ${{ github.repository }} --ref 3007.x + + - name: Trigger 3008.x branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh workflow run nightly.yml --repo ${{ github.repository }} --ref 3008.x + prepare-workflow: name: Prepare Workflow Run runs-on: ubuntu-22.04 environment: ci + if: ${{ fromJSON(needs.workflow-requirements.outputs.requirements-met) }} + needs: + - workflow-requirements outputs: changed-files: ${{ steps.process-changed-files.outputs.changed-files }} salt-version: ${{ steps.setup-salt-version.outputs.salt-version }} @@ -167,17 +231,8 @@ jobs: - name: Get Hash For Nox Tarball Cache id: nox-archive-hash - shell: bash run: | - HASH=$(python3 .github/scripts/hash-files.py \ - 'requirements/**/*.txt' \ - 'requirements/**/*.in' \ - 'requirements/**/*.lock' \ - 'cicd/golden-images.json' \ - 'noxfile.py' \ - 'pkg/common/env-cleanup-rules.yml' \ - '.github/workflows/build-deps-ci-action.yml') - echo "nox-archive-hash=${HASH}" | tee -a "$GITHUB_OUTPUT" + echo "nox-archive-hash=${{ hashFiles('requirements/**/*.txt', 'requirements/**/*.lock', 'cicd/golden-images.json', 'noxfile.py', 'pkg/common/env-cleanup-rules.yml', '.github/workflows/build-deps-ci-action.yml') }}" | tee -a "$GITHUB_OUTPUT" - name: Write Changed Files To A Local File run: @@ -467,9 +522,9 @@ jobs: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" - matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} + matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['onedir-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} build-pkgs-onedir: @@ -484,7 +539,7 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" source: "onedir" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -505,7 +560,7 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" source: "src" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -524,14 +579,13 @@ jobs: with: nox-session: ci-test-onedir nox-version: 2022.8.7 - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 nox-archive-hash: "${{ needs.prepare-workflow.outputs.nox-archive-hash }}" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" test-packages: name: Test Package if: ${{ fromJSON(needs.prepare-workflow.outputs.config)['jobs']['test-pkg'] }} @@ -545,12 +599,11 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" nox-version: 2022.8.7 ci-python-version: "3.14" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 skip-code-coverage: true testing-releases: ${{ needs.prepare-workflow.outputs.testing-releases }} matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['pkg-test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" test: name: Test Salt if: ${{ fromJSON(needs.prepare-workflow.outputs.config)['jobs']['test'] }} @@ -564,13 +617,12 @@ jobs: ci-python-version: "3.14" testrun: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['testrun']) }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 skip-code-coverage: true workflow-slug: nightly default-timeout: 360 matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" set-pipeline-exit-status: # This step is just so we can make github require this step, to pass checks # on a pull request instead of requiring all @@ -579,6 +631,8 @@ jobs: runs-on: ubuntu-22.04 environment: nightly needs: + - workflow-requirements + - trigger-branch-nightly-builds - prepare-workflow - pre-commit - lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dbc254bfbbe..8fd2e412adb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,18 +9,7 @@ on: required: true description: > The Salt version to get from staging to publish the release. - DO NOT prefix the version with a "v" (use 3006.0, not v3006.0). - For prereleases use the PEP 440 form WITHOUT a hyphen - (use 3008.0rc1, not 3008.0-rc1). - The Python sdist/wheel and the GitHub tag/release will use this - string verbatim (e.g. "salt-3008.0rc1.tar.gz" / "v3008.0rc1"). - The RPM "Version:" and the Debian changelog stanza substitute - "rc" for "~rc" so prereleases sort before the GA version - (e.g. "3008.0~rc1" < "3008.0"). - skip-salt-pkg-download-test-suite: - type: boolean - default: false - description: Skip running the Salt packages download test suite. + Good: 3006.0, 3008.0rc1. Bad: v3006.0, 3008.0-rc1, 3008.0~rc1. env: COLUMNS: 190 @@ -105,17 +94,8 @@ jobs: - name: Get Hash For Nox Tarball Cache id: nox-archive-hash - shell: bash run: | - HASH=$(python3 .github/scripts/hash-files.py \ - 'requirements/**/*.txt' \ - 'requirements/**/*.in' \ - 'requirements/**/*.lock' \ - 'cicd/golden-images.json' \ - 'noxfile.py' \ - 'pkg/common/env-cleanup-rules.yml' \ - '.github/workflows/build-deps-ci-action.yml') - echo "nox-archive-hash=${HASH}" | tee -a "$GITHUB_OUTPUT" + echo "nox-archive-hash=${{ hashFiles('requirements/**/*.txt', 'requirements/**/*.lock', 'cicd/golden-images.json', 'noxfile.py', 'pkg/common/env-cleanup-rules.yml', '.github/workflows/build-deps-ci-action.yml') }}" | tee -a "$GITHUB_OUTPUT" release: name: Release v${{ needs.prepare-workflow.outputs.salt-version }} diff --git a/.github/workflows/run-nightly.yml b/.github/workflows/run-nightly.yml deleted file mode 100644 index bf832ef27f66..000000000000 --- a/.github/workflows/run-nightly.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Run Nightly Builds - -on: - workflow_dispatch: {} - schedule: - # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onschedule - - cron: '0 0 * * *' # Every day at 0AM - -permissions: - contents: read # for dorny/paths-filter to fetch a list of changed files - pull-requests: read # for dorny/paths-filter to read pull requests - actions: write # to trigger branch nightly builds - -jobs: - - workflow-requirements: - name: Check Workflow Requirements - runs-on: ubuntu-22.04 - outputs: - requirements-met: ${{ steps.check-requirements.outputs.requirements-met }} - steps: - - name: Check Requirements - id: check-requirements - run: | - if [ "${{ vars.RUN_SCHEDULED_BUILDS }}" = "1" ]; then - MSG="Running workflow because RUN_SCHEDULED_BUILDS=1" - echo "${MSG}" - echo "${MSG}" >> "${GITHUB_STEP_SUMMARY}" - echo "requirements-met=true" >> "${GITHUB_OUTPUT}" - elif [ "${{ github.event.repository.fork }}" = "true" ]; then - MSG="Not running workflow because ${{ github.repository }} is a fork" - echo "${MSG}" - echo "${MSG}" >> "${GITHUB_STEP_SUMMARY}" - echo "requirements-met=false" >> "${GITHUB_OUTPUT}" - elif [ "${{ github.event.repository.private }}" = "true" ]; then - MSG="Not running workflow because ${{ github.repository }} is a private repository" - echo "${MSG}" - echo "${MSG}" >> "${GITHUB_STEP_SUMMARY}" - echo "requirements-met=false" >> "${GITHUB_OUTPUT}" - else - MSG="Running workflow because ${{ github.repository }} is not a fork" - echo "${MSG}" - echo "${MSG}" >> "${GITHUB_STEP_SUMMARY}" - echo "requirements-met=true" >> "${GITHUB_OUTPUT}" - fi - - trigger-branch-nightly-builds: - name: Trigger Branch Workflows - if: ${{ fromJSON(needs.workflow-requirements.outputs.requirements-met) }} - runs-on: ubuntu-24.04 - needs: - - workflow-requirements - environment: workflow-restart - strategy: - matrix: - branch: [3006.x, 3007.x, master] - steps: - - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - name: Trigger ${{ matrix.branch }} branch - env: - GH_TOKEN: ${{ steps.generate-token.outputs.token }} - run: | - gh workflow run nightly.yml --repo ${{ github.repository }} --ref ${{ matrix.branch }} diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml index 83cd30775666..994dfa74031c 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/scheduled.yml @@ -221,17 +221,8 @@ jobs: - name: Get Hash For Nox Tarball Cache id: nox-archive-hash - shell: bash run: | - HASH=$(python3 .github/scripts/hash-files.py \ - 'requirements/**/*.txt' \ - 'requirements/**/*.in' \ - 'requirements/**/*.lock' \ - 'cicd/golden-images.json' \ - 'noxfile.py' \ - 'pkg/common/env-cleanup-rules.yml' \ - '.github/workflows/build-deps-ci-action.yml') - echo "nox-archive-hash=${HASH}" | tee -a "$GITHUB_OUTPUT" + echo "nox-archive-hash=${{ hashFiles('requirements/**/*.txt', 'requirements/**/*.lock', 'cicd/golden-images.json', 'noxfile.py', 'pkg/common/env-cleanup-rules.yml', '.github/workflows/build-deps-ci-action.yml') }}" | tee -a "$GITHUB_OUTPUT" - name: Write Changed Files To A Local File run: @@ -521,9 +512,9 @@ jobs: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" - matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} + matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['onedir-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} build-pkgs-onedir: @@ -538,7 +529,7 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" source: "onedir" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -555,7 +546,7 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" source: "src" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -570,14 +561,13 @@ jobs: with: nox-session: ci-test-onedir nox-version: 2022.8.7 - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 nox-archive-hash: "${{ needs.prepare-workflow.outputs.nox-archive-hash }}" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" test-packages: name: Test Package if: ${{ fromJSON(needs.prepare-workflow.outputs.config)['jobs']['test-pkg'] }} @@ -591,12 +581,11 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" nox-version: 2022.8.7 ci-python-version: "3.14" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 skip-code-coverage: true testing-releases: ${{ needs.prepare-workflow.outputs.testing-releases }} matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['pkg-test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" test: name: Test Salt if: ${{ fromJSON(needs.prepare-workflow.outputs.config)['jobs']['test'] }} @@ -610,13 +599,12 @@ jobs: ci-python-version: "3.14" testrun: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['testrun']) }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 skip-code-coverage: true workflow-slug: scheduled default-timeout: 360 matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" set-pipeline-exit-status: # This step is just so we can make github require this step, to pass checks # on a pull request instead of requiring all diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 646e5cb5fe5a..845320755aa9 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -194,17 +194,8 @@ jobs: - name: Get Hash For Nox Tarball Cache id: nox-archive-hash - shell: bash run: | - HASH=$(python3 .github/scripts/hash-files.py \ - 'requirements/**/*.txt' \ - 'requirements/**/*.in' \ - 'requirements/**/*.lock' \ - 'cicd/golden-images.json' \ - 'noxfile.py' \ - 'pkg/common/env-cleanup-rules.yml' \ - '.github/workflows/build-deps-ci-action.yml') - echo "nox-archive-hash=${HASH}" | tee -a "$GITHUB_OUTPUT" + echo "nox-archive-hash=${{ hashFiles('requirements/**/*.txt', 'requirements/**/*.lock', 'cicd/golden-images.json', 'noxfile.py', 'pkg/common/env-cleanup-rules.yml', '.github/workflows/build-deps-ci-action.yml') }}" | tee -a "$GITHUB_OUTPUT" - name: Write Changed Files To A Local File run: @@ -495,9 +486,9 @@ jobs: cache-seed: ${{ needs.prepare-workflow.outputs.cache-seed }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" - matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} + matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['onedir-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} build-pkgs-onedir: @@ -513,7 +504,7 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" source: "onedir" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -535,7 +526,7 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }} relenv-version: "0.22.14" - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" source: "src" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} @@ -554,14 +545,13 @@ jobs: with: nox-session: ci-test-onedir nox-version: 2022.8.7 - python-version: "3.14.6" + python-version: "3.10.20" ci-python-version: "3.14" salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 nox-archive-hash: "${{ needs.prepare-workflow.outputs.nox-archive-hash }}" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" test-packages: name: Test Package if: ${{ fromJSON(needs.prepare-workflow.outputs.config)['jobs']['test-pkg'] }} @@ -575,12 +565,11 @@ jobs: salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" nox-version: 2022.8.7 ci-python-version: "3.14" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 skip-code-coverage: true testing-releases: ${{ needs.prepare-workflow.outputs.testing-releases }} matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['pkg-test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" test: name: Test Salt if: ${{ fromJSON(needs.prepare-workflow.outputs.config)['jobs']['test'] }} @@ -594,13 +583,12 @@ jobs: ci-python-version: "3.14" testrun: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['testrun']) }} salt-version: "${{ needs.prepare-workflow.outputs.salt-version }}" - cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.14.6 + cache-prefix: ${{ needs.prepare-workflow.outputs.cache-seed }}|3.10.20 skip-code-coverage: true workflow-slug: staging default-timeout: 180 matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" draft-release: name: Draft Github Release diff --git a/.github/workflows/templates/build-ci-deps.yml.jinja b/.github/workflows/templates/build-ci-deps.yml.jinja index ac248b69fc12..871bcb3329d9 100644 --- a/.github/workflows/templates/build-ci-deps.yml.jinja +++ b/.github/workflows/templates/build-ci-deps.yml.jinja @@ -24,4 +24,3 @@ nox-archive-hash: "${{ needs.prepare-workflow.outputs.nox-archive-hash }}" matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" diff --git a/.github/workflows/templates/ci.yml.jinja b/.github/workflows/templates/ci.yml.jinja index a18a43bc9cf5..178e1d4b6cbe 100644 --- a/.github/workflows/templates/ci.yml.jinja +++ b/.github/workflows/templates/ci.yml.jinja @@ -284,7 +284,7 @@ relenv-version: "<{ relenv_version }>" python-version: "<{ python_version }>" ci-python-version: "<{ gh_actions_workflows_python_version }>" - matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }} + matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['onedir-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} <%- endif %> @@ -360,10 +360,7 @@ # We can't yet use tokenless uploads with the codecov CLI # python3 -m pip install codecov-cli # - # Codecov retired the ``codecovsecurity`` keybase user; the - # uploader signing key now lives under ``codecovsecops``. - # Fingerprint unchanged: 2703 4E7F DB85 0E0B BC2C 62FF 806B B28A ED77 9869. - curl https://keybase.io/codecovsecops/pgp_keys.asc | gpg --no-default-keyring --import + curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --import curl -Os https://uploader.codecov.io/latest/linux/codecov curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig diff --git a/.github/workflows/templates/layout.yml.jinja b/.github/workflows/templates/layout.yml.jinja index 9e025ebc6104..dc488ebd8783 100644 --- a/.github/workflows/templates/layout.yml.jinja +++ b/.github/workflows/templates/layout.yml.jinja @@ -9,6 +9,7 @@ <%- set gpg_key_id = "64CBBC8173D76B3F" %> <%- set prepare_actual_release = prepare_actual_release | default(False) %> <%- set gh_actions_workflows_python_version = "3.14" %> +<%- set nox_archive_hashfiles = "${{ hashFiles('requirements/**/*.txt', 'requirements/**/*.lock', 'cicd/golden-images.json', 'noxfile.py', 'pkg/common/env-cleanup-rules.yml', '.github/workflows/build-deps-ci-action.yml') }}" %> --- <%- block name %> name: <{ workflow_name }> @@ -22,7 +23,6 @@ on: branches: - 3006.x - 3007.x - - 3008.x - master pull_request: types: @@ -54,7 +54,6 @@ permissions: actions: read # for technote-space/workflow-conclusion-action to get the job statuses <%- endif %> - <%- endblock permissions %> <%- block concurrency %> @@ -221,17 +220,8 @@ jobs: - name: Get Hash For Nox Tarball Cache id: nox-archive-hash - shell: bash run: | - HASH=$(python3 .github/scripts/hash-files.py \ - 'requirements/**/*.txt' \ - 'requirements/**/*.in' \ - 'requirements/**/*.lock' \ - 'cicd/golden-images.json' \ - 'noxfile.py' \ - 'pkg/common/env-cleanup-rules.yml' \ - '.github/workflows/build-deps-ci-action.yml') - echo "nox-archive-hash=${HASH}" | tee -a "$GITHUB_OUTPUT" + echo "nox-archive-hash=<{ nox_archive_hashfiles }>" | tee -a "$GITHUB_OUTPUT" - name: Write Changed Files To A Local File run: diff --git a/.github/workflows/templates/nightly.yml.jinja b/.github/workflows/templates/nightly.yml.jinja index f49d8e484f15..3bf88c07a952 100644 --- a/.github/workflows/templates/nightly.yml.jinja +++ b/.github/workflows/templates/nightly.yml.jinja @@ -2,6 +2,7 @@ <%- set skip_test_coverage_check = skip_test_coverage_check|default("true") %> <%- set prepare_workflow_skip_test_suite = "${{ inputs.skip-salt-test-suite && ' --skip-tests' || '' }}" %> <%- set prepare_workflow_skip_pkg_test_suite = "${{ inputs.skip-salt-pkg-test-suite && ' --skip-pkg-tests' || '' }}" %> +<%- set prepare_workflow_if_check = prepare_workflow_if_check|default("${{ fromJSON(needs.workflow-requirements.outputs.requirements-met) }}") %> <%- extends 'ci.yml.jinja' %> <%- block name %> @@ -24,6 +25,9 @@ on: type: boolean default: false description: Skip running the Salt packages test suite. + schedule: + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onschedule + - cron: '0 0 * * *' # Every day at 0AM <%- endblock on %> @@ -44,6 +48,9 @@ concurrency: <%- block pre_jobs %> + <%- include "workflow-requirements-check.yml.jinja" %> + <%- include "trigger-branch-workflows.yml.jinja" %> + <%- endblock pre_jobs %> <%- block jobs %> diff --git a/.github/workflows/templates/test-salt-pkg.yml.jinja b/.github/workflows/templates/test-salt-pkg.yml.jinja index 4b177f725c0d..397a8be2be74 100644 --- a/.github/workflows/templates/test-salt-pkg.yml.jinja +++ b/.github/workflows/templates/test-salt-pkg.yml.jinja @@ -17,4 +17,3 @@ testing-releases: ${{ needs.prepare-workflow.outputs.testing-releases }} matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['pkg-test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" diff --git a/.github/workflows/templates/test-salt.yml.jinja b/.github/workflows/templates/test-salt.yml.jinja index 098af3d2e68e..999138a7aab7 100644 --- a/.github/workflows/templates/test-salt.yml.jinja +++ b/.github/workflows/templates/test-salt.yml.jinja @@ -22,4 +22,3 @@ default-timeout: <{ timeout_value }> matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['test-matrix']) }} linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }} - raise-deprecations-runtime-errors: "1" diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index d96d0b120390..aa0510660229 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -56,21 +56,14 @@ on: required: true type: string description: Json job matrix config - raise-deprecations-runtime-errors: - required: true - type: string - description: Whether to raise RuntimeError on deprecation warnings ("1" or "0") env: COLUMNS: 190 - RELENV_DATA: "${{ github.workspace }}/.relenv" PIP_INDEX_URL: ${{ vars.PIP_INDEX_URL }} PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} - # Line-buffer Python on the runner; docker exec still passes this into the test container explicitly. - PYTHONUNBUFFERED: "1" + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" jobs: @@ -154,22 +147,7 @@ jobs: - name: "Create docker network" run: | - # Use ULA prefix (fd00::/8) for proper IPv6 support with NAT - docker network create --ipv6 \ - --subnet="fd00:db8::/64" \ - -o "com.docker.network.driver.mtu=1500" \ - ip6net - - # Enable IPv6 forwarding and NAT for internet connectivity - sudo sysctl -w net.ipv6.conf.all.forwarding=1 - sudo sysctl -w net.ipv6.conf.default.forwarding=1 - - # Accept router advertisements even with forwarding enabled - sudo sysctl -w net.ipv6.conf.all.accept_ra=2 - sudo sysctl -w net.ipv6.conf.default.accept_ra=2 - - # Add masquerading for ULA to reach internet (fixes Python 3.11+ IPv6 timeouts) - sudo ip6tables -t nat -A POSTROUTING -s fd00:db8::/64 ! -d fd00:db8::/64 -j MASQUERADE + docker network create -o "com.docker.network.driver.mtu=1500" --ipv6 --subnet 2001:db8::/64 ip6net - name: "Host network config" run: | @@ -199,7 +177,7 @@ jobs: PIP_TRUSTED_HOST: "${{ vars.PIP_TRUSTED_HOST }}" PIP_EXTRA_INDEX_URL: "${{ vars.PIP_EXTRA_INDEX_URL }}" PIP_DISABLE_PIP_VERSION_CHECK: 1 - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: 1 SALT_TRANSPORT: ${{ matrix.transport }} FIPS_TESTRUN: ${{ matrix.fips && '1' || '0' }} run: | @@ -249,14 +227,7 @@ jobs: - name: Decompress .nox Directory run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test python3 -m nox --force-color -e decompress-dependencies -- linux ${{ matrix.arch }} - - - name: Create relenv toolchain symlink - run: | - # Python's sysconfig expects toolchain at ~/.local/relenv/toolchain but - # relenv extracts to ~/.cache/relenv/toolchains - create symlink - docker exec ${{ github.run_id}}_salt-test bash -c \ - 'mkdir -p ~/.local/relenv && ln -sf ~/.cache/relenv/toolchains ~/.local/relenv/toolchain' + docker exec ${{ github.run_id}}_salt-test python3 -m nox --force-color -e decompress-dependencies -- linux ${{ matrix.arch }} - name: Download testrun-changed-files.txt if: ${{ fromJSON(inputs.testrun)['type'] != 'full' }} @@ -328,14 +299,14 @@ jobs: id: run-fast-changed-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['type'] != 'full' }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ + docker exec ${{ github.run_id}}_salt-test python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --core-tests --slow-tests --suppress-no-test-exit-code --from-filenames=testrun-changed-files.txt - name: Run Fast Tests id: run-fast-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['type'] != 'full' && fromJSON(inputs.testrun)['selected_tests']['fast'] }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test \ + docker exec ${{ github.run_id}}_salt-test \ python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --suppress-no-test-exit-code @@ -343,7 +314,7 @@ jobs: id: run-slow-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['type'] != 'full' && fromJSON(inputs.testrun)['selected_tests']['slow'] }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test \ + docker exec ${{ github.run_id}}_salt-test \ python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --suppress-no-test-exit-code --no-fast-tests --slow-tests @@ -351,7 +322,7 @@ jobs: id: run-core-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['type'] != 'full' && fromJSON(inputs.testrun)['selected_tests']['core'] }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test \ + docker exec ${{ github.run_id}}_salt-test \ python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --suppress-no-test-exit-code --no-fast-tests --core-tests @@ -359,7 +330,7 @@ jobs: id: run-flaky-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['selected_tests']['flaky'] }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test \ + docker exec ${{ github.run_id}}_salt-test \ python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --suppress-no-test-exit-code --no-fast-tests --flaky-jail @@ -367,32 +338,23 @@ jobs: id: run-full-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['type'] == 'full' }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test \ + docker exec ${{ github.run_id}}_salt-test \ python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --slow-tests --core-tests --test-group-count=${{ matrix.test-group-count || 1 }} --test-group=${{ matrix.test-group || 1 }} - - name: Sync filesystem in test container - if: always() - run: | - docker exec ${{ github.run_id}}_salt-test sync 2>/dev/null || true - - name: Stop Container - if: always() run: | - docker container stop ${{ github.run_id}}_salt-test || true + docker container stop ${{ github.run_id}}_salt-test - name: Remove Container - if: always() run: | - docker container rm ${{ github.run_id}}_salt-test || true + docker container rm ${{ github.run_id}}_salt-test - name: Remove Container Image - if: always() run: | - docker image rm ${{ matrix.container }} || true + docker image rm ${{ matrix.container }} - name: Fix file ownership - if: always() run: | sudo chown -R "$(id -un)" . @@ -403,7 +365,6 @@ jobs: # Delete the salt onedir, we won't need it anymore and it will prevent # from it showing in the tree command below sudo rm -rf artifacts/salt* - sync || true tree -a artifacts - name: Combine Code Coverage @@ -529,22 +490,7 @@ jobs: - name: "Create docker network" run: | - # Use ULA prefix (fd00::/8) for proper IPv6 support with NAT - docker network create --ipv6 \ - --subnet="fd00:db8::/64" \ - -o "com.docker.network.driver.mtu=1500" \ - ip6net - - # Enable IPv6 forwarding and NAT for internet connectivity - sudo sysctl -w net.ipv6.conf.all.forwarding=1 - sudo sysctl -w net.ipv6.conf.default.forwarding=1 - - # Accept router advertisements even with forwarding enabled - sudo sysctl -w net.ipv6.conf.all.accept_ra=2 - sudo sysctl -w net.ipv6.conf.default.accept_ra=2 - - # Add masquerading for ULA to reach internet (fixes Python 3.11+ IPv6 timeouts) - sudo ip6tables -t nat -A POSTROUTING -s fd00:db8::/64 ! -d fd00:db8::/64 -j MASQUERADE + docker network create -o "com.docker.network.driver.mtu=1500" --ipv6 --subnet 2001:db8::/64 ip6net - name: "Host network config" run: | @@ -574,7 +520,7 @@ jobs: PIP_TRUSTED_HOST: "${{ vars.PIP_TRUSTED_HOST }}" PIP_EXTRA_INDEX_URL: "${{ vars.PIP_EXTRA_INDEX_URL }}" PIP_DISABLE_PIP_VERSION_CHECK: 1 - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: 1 SALT_TRANSPORT: ${{ matrix.transport }} FIPS_TESTRUN: ${{ matrix.fips && '1' || '0' }} run: | @@ -624,14 +570,7 @@ jobs: - name: Decompress .nox Directory run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test python3 -m nox --force-color -e decompress-dependencies -- linux ${{ matrix.arch }} - - - name: Create relenv toolchain symlink - run: | - # Python's sysconfig expects toolchain at ~/.local/relenv/toolchain but - # relenv extracts to ~/.cache/relenv/toolchains - create symlink - docker exec ${{ github.run_id}}_salt-test bash -c \ - 'mkdir -p ~/.local/relenv && ln -sf ~/.cache/relenv/toolchains ~/.local/relenv/toolchain' + docker exec ${{ github.run_id}}_salt-test python3 -m nox --force-color -e decompress-dependencies -- linux ${{ matrix.arch }} - name: Download testrun-changed-files.txt if: ${{ fromJSON(inputs.testrun)['type'] != 'full' }} @@ -703,14 +642,14 @@ jobs: id: run-fast-changed-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['type'] != 'full' }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ + docker exec ${{ github.run_id}}_salt-test python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --core-tests --slow-tests --suppress-no-test-exit-code --from-filenames=testrun-changed-files.txt - name: Run Fast Tests id: run-fast-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['type'] != 'full' && fromJSON(inputs.testrun)['selected_tests']['fast'] }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test \ + docker exec ${{ github.run_id}}_salt-test \ python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --suppress-no-test-exit-code @@ -718,7 +657,7 @@ jobs: id: run-slow-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['type'] != 'full' && fromJSON(inputs.testrun)['selected_tests']['slow'] }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test \ + docker exec ${{ github.run_id}}_salt-test \ python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --suppress-no-test-exit-code --no-fast-tests --slow-tests @@ -726,7 +665,7 @@ jobs: id: run-core-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['type'] != 'full' && fromJSON(inputs.testrun)['selected_tests']['core'] }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test \ + docker exec ${{ github.run_id}}_salt-test \ python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --suppress-no-test-exit-code --no-fast-tests --core-tests @@ -734,7 +673,7 @@ jobs: id: run-flaky-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['selected_tests']['flaky'] }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test \ + docker exec ${{ github.run_id}}_salt-test \ python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --suppress-no-test-exit-code --no-fast-tests --flaky-jail @@ -742,32 +681,23 @@ jobs: id: run-full-tests if: ${{ !cancelled() && fromJSON(inputs.testrun)['type'] == 'full' }} run: | - docker exec -e PYTHONUNBUFFERED=1 ${{ github.run_id}}_salt-test \ + docker exec ${{ github.run_id}}_salt-test \ python3 -m nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ --slow-tests --core-tests --test-group-count=${{ matrix.test-group-count || 1 }} --test-group=${{ matrix.test-group || 1 }} - - name: Sync filesystem in test container - if: always() - run: | - docker exec ${{ github.run_id}}_salt-test sync 2>/dev/null || true - - name: Stop Container - if: always() run: | - docker container stop ${{ github.run_id}}_salt-test || true + docker container stop ${{ github.run_id}}_salt-test - name: Remove Container - if: always() run: | - docker container rm ${{ github.run_id}}_salt-test || true + docker container rm ${{ github.run_id}}_salt-test - name: Remove Container Image - if: always() run: | - docker image rm ${{ matrix.container }} || true + docker image rm ${{ matrix.container }} - name: Fix file ownership - if: always() run: | sudo chown -R "$(id -un)" . @@ -778,7 +708,6 @@ jobs: # Delete the salt onedir, we won't need it anymore and it will prevent # from it showing in the tree command below sudo rm -rf artifacts/salt* - sync || true tree -a artifacts - name: Combine Code Coverage @@ -916,7 +845,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} run: | sudo -E nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ @@ -942,7 +871,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} run: | sudo -E nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ @@ -967,7 +896,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} run: | sudo -E nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ @@ -992,7 +921,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} run: | sudo -E nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ @@ -1017,7 +946,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} run: | sudo -E nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ @@ -1042,7 +971,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} run: | sudo -E nox --force-color -e ${{ inputs.nox-session }} -- ${{ matrix.tests-chunk }} -- \ @@ -1050,7 +979,6 @@ jobs: - name: Fix file ownership - if: always() run: | sudo chown -R "$(id -un)" . @@ -1061,7 +989,6 @@ jobs: # Delete the salt onedir, we won't need it anymore and it will prevent # from it showing in the tree command below rm -rf artifacts/salt* - sync || true tree -a artifacts - name: Combine Code Coverage @@ -1224,7 +1151,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} TMPDIR: ${{ runner.temp }} shell: powershell @@ -1252,7 +1179,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} TMPDIR: ${{ runner.temp }} shell: powershell @@ -1279,7 +1206,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} TMPDIR: ${{ runner.temp }} shell: powershell @@ -1306,7 +1233,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} TMPDIR: ${{ runner.temp }} shell: powershell @@ -1333,7 +1260,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} TMPDIR: ${{ runner.temp }} shell: powershell @@ -1360,7 +1287,7 @@ jobs: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" SALT_TRANSPORT: ${{ matrix.transport }} TMPDIR: ${{ runner.temp }} shell: powershell @@ -1377,7 +1304,6 @@ jobs: # Delete the salt onedir, we won't need it anymore and it will prevent # from it showing in the tree command below rm -rf artifacts/salt* - sync || true - name: Combine Code Coverage if: ${{ !cancelled() && !inputs.skip-code-coverage }} diff --git a/.github/workflows/test-packages-action.yml b/.github/workflows/test-packages-action.yml index 236286d1c4d2..7020bad4202d 100644 --- a/.github/workflows/test-packages-action.yml +++ b/.github/workflows/test-packages-action.yml @@ -47,10 +47,6 @@ on: required: true type: string description: Json job matrix config - raise-deprecations-runtime-errors: - required: true - type: string - description: Whether to raise RuntimeError on deprecation warnings ("1" or "0") env: COLUMNS: 190 @@ -60,7 +56,7 @@ env: PIP_TRUSTED_HOST: ${{ vars.PIP_TRUSTED_HOST }} PIP_EXTRA_INDEX_URL: ${{ vars.PIP_EXTRA_INDEX_URL }} PIP_DISABLE_PIP_VERSION_CHECK: "1" - RAISE_DEPRECATIONS_RUNTIME_ERRORS: ${{ inputs.raise-deprecations-runtime-errors }} + RAISE_DEPRECATIONS_RUNTIME_ERRORS: "1" USE_S3_CACHE: 'false' jobs: diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 000000000000..091e49991172 --- /dev/null +++ b/.github/workflows/triage.yml @@ -0,0 +1,63 @@ +--- +name: New Issues Triage Assignment +concurrency: 1 +on: + issues: + types: [opened] + + +env: + PIP_INDEX_URL: https://pypi-proxy.saltstack.net/root/local/+simple/ + PIP_EXTRA_INDEX_URL: https://pypi.org/simple + + +permissions: + contents: read + +jobs: + label-and-assign: + permissions: + actions: read # for dawidd6/action-download-artifact to query and download artifacts + contents: read # for actions/checkout to fetch code + issues: write + pull-requests: read # for dawidd6/action-download-artifact to query commit hash + name: Triage New Issue + runs-on: ubuntu-latest + steps: + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: 3.8 + + - name: Install Dependencies + run: | + pip install pygithub + + - name: Download last assignment cache + continue-on-error: true + uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe # v3 + with: + workflow: triage.yml + name: last-assignment + path: .cache + + - name: Label And Assign + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + READ_ORG_TOKEN: ${{ secrets.READ_ORG_TEAM_MEMBERS_ISSUE_TRIAGE }} + run: | + python .github/workflows/scripts/label-and-assign.py \ + --org ${{ github.repository_owner }} \ + --repo ${{ github.event.repository.name }} \ + --team team-triage \ + --label needs-triage \ + --issue ${{ github.event.issue.number }} + + - name: Upload last assignment cache + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: last-assignment + path: .cache diff --git a/.github/workflows/workflow-finished.yml b/.github/workflows/workflow-finished.yml index 9badf210a8d1..65216aab515b 100644 --- a/.github/workflows/workflow-finished.yml +++ b/.github/workflows/workflow-finished.yml @@ -3,7 +3,8 @@ run-name: Workflow Finished ${{ github.event.workflow_run.display_title }} (${{ on: workflow_run: - workflows: [Nightly, Scheduled, Stage Release] + workflows: ["Nightly", "Scheduled", "Stage Release"] + branches: ["3006.x", "3007.x", "master"] types: - completed diff --git a/.gitignore b/.gitignore index 21257ad4d499..03c8a6e971fa 100644 --- a/.gitignore +++ b/.gitignore @@ -24,13 +24,10 @@ Pipfile.lock # top of salt such as # - /some/path$ git clone https://github.com/thatch45/salt.git # - /some/path$ virtualenv --python=/usr/bin/python2.6 salt -/.?env/ +/env/ +/.env/ /bin/ /etc/ -# Allow repo-local Salt dev configs (parent must be un-ignored first). -!/etc/ -!/etc/salt/ -!/etc/salt/** /include/ /lib/ /lib64/ @@ -41,7 +38,6 @@ Pipfile.lock /tests/cachedir/ /tests/unit/templates/roots/ /var/ -/.?venv/ /venv/ /doc/man/* @@ -99,7 +95,6 @@ tests/unit/templates/roots # Pycharm .idea venv/ -venv311/ .venv/ # VS Code @@ -164,5 +159,3 @@ nox.*.tar.xz /.aiderignore /aider.conf.yml /.gemini -venv311/ -venv312/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3a49e927324c..b4eadfcd9d00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,7 @@ --- +default_language_version: + python: python3 + exclude: ^(doc/_static/.*|doc/_themes/.*)$ repos: @@ -128,26 +131,6 @@ repos: - docstrings - check - - id: tools - alias: check-cp1252-docstrings - name: Check docstrings encode in cp1252 (Windows stdout) - files: salt/.*\.py$ - exclude: > - (?x)^( - templates/.*| - salt/ext/.*| - )$ - additional_dependencies: - - boto3 - - pyyaml - - jinja2 - - MarkupSafe<3.0.0 - - packaging - args: - - pre-commit - - docstrings - - check-cp1252 - - id: tools alias: check-known-missing-docstrings name: Check Known Missing Docstrings @@ -790,7 +773,7 @@ repos: - id: pip-compile alias: compile-ci-linux-crypto-3.9-requirements name: Linux CI Py3.9 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.9/linux-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.9/linux-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -805,7 +788,7 @@ repos: - id: pip-compile alias: compile-ci-linux-crypto-3.10-requirements name: Linux CI Py3.10 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.10/linux-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.10/linux-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -820,7 +803,7 @@ repos: - id: pip-compile alias: compile-ci-linux-crypto-3.11-requirements name: Linux CI Py3.11 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.11/linux-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.11/linux-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -835,7 +818,7 @@ repos: - id: pip-compile alias: compile-ci-linux-crypto-3.12-requirements name: Linux CI Py3.12 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.12/linux-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.12/linux-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -850,7 +833,7 @@ repos: - id: pip-compile alias: compile-ci-linux-crypto-3.14-requirements name: Linux CI Py3.14 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.14/linux-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.14/linux-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -865,7 +848,7 @@ repos: - id: pip-compile alias: compile-ci-linux-crypto-3.13-requirements name: Linux CI Py3.13 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.13/linux-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.13/linux-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1014,8 +997,8 @@ repos: - id: pip-compile alias: compile-ci-freebsd-crypto-3.9-requirements name: FreeBSD CI Py3.9 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/crypto\.txt)$ - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.9/freebsd-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/crypto\.txt)$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.9/freebsd-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1030,8 +1013,8 @@ repos: - id: pip-compile alias: compile-ci-freebsd-crypto-3.10-requirements name: FreeBSD CI Py3.10 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/crypto\.txt)$ - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.10/freebsd-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/crypto\.txt)$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.10/freebsd-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1046,7 +1029,7 @@ repos: - id: pip-compile alias: compile-ci-freebsd-crypto-3.11-requirements name: FreeBSD CI Py3.11 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.11/freebsd-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.11/freebsd-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1061,7 +1044,7 @@ repos: - id: pip-compile alias: compile-ci-freebsd-crypto-3.12-requirements name: FreeBSD CI Py3.12 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.12/freebsd-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.12/freebsd-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1076,7 +1059,7 @@ repos: - id: pip-compile alias: compile-ci-freebsd-crypto-3.14-requirements name: FreeBSD CI Py3.14 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.14/freebsd-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.14/freebsd-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1091,7 +1074,7 @@ repos: - id: pip-compile alias: compile-ci-freebsd-crypto-3.13-requirements name: FreeBSD CI Py3.13 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.13/freebsd-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.13/freebsd-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1240,7 +1223,7 @@ repos: - id: pip-compile alias: compile-ci-darwin-crypto-3.9-requirements name: Darwin CI Py3.9 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.9/darwin-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.9/darwin-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1255,7 +1238,7 @@ repos: - id: pip-compile alias: compile-ci-darwin-crypto-3.10-requirements name: Darwin CI Py3.10 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.10/darwin-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.10/darwin-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1270,7 +1253,7 @@ repos: - id: pip-compile alias: compile-ci-darwin-crypto-3.11-requirements name: Darwin CI Py3.11 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.11/darwin-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.11/darwin-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1285,7 +1268,7 @@ repos: - id: pip-compile alias: compile-ci-darwin-crypto-3.12-requirements name: Darwin CI Py3.12 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.12/darwin-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.12/darwin-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1300,7 +1283,7 @@ repos: - id: pip-compile alias: compile-ci-darwin-crypto-3.14-requirements name: Darwin CI Py3.14 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.14/darwin-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.14/darwin-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1315,7 +1298,7 @@ repos: - id: pip-compile alias: compile-ci-darwin-crypto-3.13-requirements name: Darwin CI Py3.13 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.13/darwin-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.13/darwin-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1464,7 +1447,7 @@ repos: - id: pip-compile alias: compile-ci-windows-crypto-3.9-requirements name: Windows CI Py3.9 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.9/windows-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.9/windows-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1479,7 +1462,7 @@ repos: - id: pip-compile alias: compile-ci-windows-crypto-3.10-requirements name: Windows CI Py3.10 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.10/windows-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.10/windows-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1494,7 +1477,7 @@ repos: - id: pip-compile alias: compile-ci-windows-crypto-3.11-requirements name: Windows CI Py3.11 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.11/windows-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.11/windows-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1509,7 +1492,7 @@ repos: - id: pip-compile alias: compile-ci-windows-crypto-3.12-requirements name: Windows CI Py3.12 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.12/windows-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.12/windows-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1524,7 +1507,7 @@ repos: - id: pip-compile alias: compile-ci-windows-crypto-3.14-requirements name: Windows CI Py3.14 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.14/windows-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.14/windows-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -1539,7 +1522,7 @@ repos: - id: pip-compile alias: compile-ci-windows-crypto-3.13-requirements name: Windows CI Py3.13 Crypto Requirements - files: ^requirements/(constraints\.txt|crypto\.txt|static/ci/(crypto\.txt|py3\.13/windows-crypto\.lock))$ + files: ^requirements/(constraints\.lock|crypto\.lock|static/ci/(crypto\.txt|py3\.13/windows-crypto\.lock))$ pass_filenames: false additional_dependencies: ["pip<26.0"] args: @@ -2305,6 +2288,8 @@ repos: alias: rewrite-tests name: Rewrite Salt's Test Suite files: ^tests/.*\.py$ + # Exclude fix_tornado_imports to prevent rewriting tornado -> salt.ext.tornado + args: [--silent, -E, fix_asserts, -E, fix_docstrings, -E, fix_tornado_imports] # Inhibited to prevent global rewrites entry: echo "Inhibited rewrite-tests" language: python @@ -2320,6 +2305,9 @@ repos: hooks: - id: enforce-tornado-imports name: Enforce Tornado Imports + # Replace salt.ext.tornado with tornado + entry: bash -c 'sed -i "s/salt\.ext\.tornado/tornado/g" "$@"' -- + language: system # Inhibited to prevent global rewrites entry: echo "Inhibited enforce-tornado-imports" language: python diff --git a/.pylintrc b/.pylintrc index 4cc3fcbe822e..3353cb66bba6 100644 --- a/.pylintrc +++ b/.pylintrc @@ -452,7 +452,6 @@ disable=R, line-too-long, locally-disabled, logging-format-interpolation, - logging-fstring-interpolation, missing-docstring, no-member, protected-access, @@ -731,7 +730,7 @@ blacklisted-functions=posix.umask=salt.utils.files.set_umask or get_umask, [3RD-PARTY-IMPORTS] # Known 3rd-party modules which don' require being gated, separated by a comma -allowed-3rd-party-modules=msgpack,xxhash, +allowed-3rd-party-modules=msgpack, tornado, yaml, jinja2, @@ -762,8 +761,6 @@ allowed-3rd-party-modules=msgpack,xxhash, cryptography, aiohttp, pytest_timeout, - urllib3, - idna, salt, tests, backports diff --git a/CHANGELOG.md b/CHANGELOG.md index 805cff30e922..1bec657e1de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1594,185 +1594,6 @@ Versions are `MAJOR.PATCH`. ### Fixed -- Fixed recursive prereq requisites to report recursive requisite error. [#8210](https://github.com/saltstack/salt/issues/8210) -- Fixed erroneous recursive requisite error when a prereq is used in combination with onchanges_any. [#47154](https://github.com/saltstack/salt/issues/47154) -- Fixed an infinite loop in `requisite_any` when a requisite state was not found. [#50436](https://github.com/saltstack/salt/issues/50436) -- Fixed dependency resolution to not be quadratic. [#59123](https://github.com/saltstack/salt/issues/59123) -- Fix regex cache exception during sort in sweep function [#59437](https://github.com/saltstack/salt/issues/59437) -- Fixed requisites by parallel states on parallel states being evaluated synchronously (blocking state execution for other parallel states) [#59959](https://github.com/saltstack/salt/issues/59959) -- Fix bug when specifying template_source using net.load_template [#60515](https://github.com/saltstack/salt/issues/60515) -- firewalld: normalize new rich rules before comparing to old ones [#61235](https://github.com/saltstack/salt/issues/61235) -- Fix regression that prevented salt-minion from running interval-based jobs on startup by default. [#61964](https://github.com/saltstack/salt/issues/61964) -- Fixed performance when state_aggregate is enabled. [#62439](https://github.com/saltstack/salt/issues/62439) -- Fixed issue with salt-ssh hanging due to non-exposed host key acceptance prompt [#62782](https://github.com/saltstack/salt/issues/62782) -- Repaired zypper repositories being reconfigured without changes [#63402](https://github.com/saltstack/salt/issues/63402) -- Fix calculation of SLS context vars when trailing dots on targetted state [#63411](https://github.com/saltstack/salt/issues/63411) -- Put default `optimization_order` to LazyLoader to prevent possible fails on testing [#65266](https://github.com/saltstack/salt/issues/65266) -- Fixed aggregation to correctly honor requisites. [#65304](https://github.com/saltstack/salt/issues/65304) -- Fixed some instances of deprecated datetime.datetime.utcnow() [#65604](https://github.com/saltstack/salt/issues/65604) -- Introduce pruning option in file.keyvalue [#65631](https://github.com/saltstack/salt/issues/65631) -- fix 65703 by using OrderedDict instead of a index that breaks. . [#65703](https://github.com/saltstack/salt/issues/65703) -- Simplify timezone.compare_zone to primarily rely get_zone() [#65719](https://github.com/saltstack/salt/issues/65719) -- Handle regular expressions which do not not use grouping [#65722](https://github.com/saltstack/salt/issues/65722) -- fix consul.acl_create rule creation [#65788](https://github.com/saltstack/salt/issues/65788) -- Fix salt-cloud get_cloud_config_value for list objects [#65789](https://github.com/saltstack/salt/issues/65789) -- Prevent exceptions with fileserver.update when called via state [#65819](https://github.com/saltstack/salt/issues/65819) -- Fix granting of privileges on Postgres functions [#65839](https://github.com/saltstack/salt/issues/65839) -- Made Salt Cloud Hetzner module detect image architecture from instance type [#65888](https://github.com/saltstack/salt/issues/65888) -- Optimize async calls with using async wrapped method in thread only if io loop is already running [#65983](https://github.com/saltstack/salt/issues/65983) -- salt.auth.pam: fallback to use running Python in case /usr/bin/python3 is not found [#66035](https://github.com/saltstack/salt/issues/66035) -- Fix file.is_link hangs on paths that are hung mounts [#66096](https://github.com/saltstack/salt/issues/66096) -- Fix file.managed and file.serialize default tmp_dir to relative path [#66098](https://github.com/saltstack/salt/issues/66098) -- Make win_timezone recognize Qyzylorda timezone [#66176](https://github.com/saltstack/salt/issues/66176) -- Remove firing useless events with JID as a tag [#66279](https://github.com/saltstack/salt/issues/66279) -- Made gpg modules create GNUPGHOME if it does not exist [#66312](https://github.com/saltstack/salt/issues/66312) -- Fixed an issue where conflicting top level keys in the static grains file - (usually `/etc/salt/grains`) would break all grains states, and prevent static - grains from being loaded. [#66445](https://github.com/saltstack/salt/issues/66445) -- Fixed beacon delete not calling the beacon's close function, causing resource - leaks (e.g. inotify file descriptors) and CPU spin after deleting beacons at - runtime via ``beacons.delete``. Also fixed inotify file descriptor leak during - beacon refresh when the Beacon instance is replaced. [#66449](https://github.com/saltstack/salt/issues/66449) -- Make "status.diskusage" more robust and prevent crashes when stats cannot be obtained [#66646](https://github.com/saltstack/salt/issues/66646) -- Use `--cachedir` parameter for setting `extension_modules` with salt-call. [#66742](https://github.com/saltstack/salt/issues/66742) -- Don't schedule `__master_alive` jobs if `master_alive_interval` is not specified [#66757](https://github.com/saltstack/salt/issues/66757) -- Make x509 module compatible with `cryptography` module newer than `43.0.0` [#66818](https://github.com/saltstack/salt/issues/66818) -- Fixed Python 3.13 compatibility regarding urllib.parse module [#66898](https://github.com/saltstack/salt/issues/66898) -- make salt.channel.server.handle_message codepath more defensive [#66909](https://github.com/saltstack/salt/issues/66909) -- Fix the installation of pip modules with special characters in the module name [#66988](https://github.com/saltstack/salt/issues/66988) -- Repaired mount.fstab_present always returning pending changes [#67065](https://github.com/saltstack/salt/issues/67065) -- dictupdate.update: throw a TypeError when trying to merge a list with a mapping when ``merge_lists=True``. [#67092](https://github.com/saltstack/salt/issues/67092) -- Remove usage of spwd [#67119](https://github.com/saltstack/salt/issues/67119) -- Fixed order chunks not handling a state with both require and order first or last [#67120](https://github.com/saltstack/salt/issues/67120) -- Fixed pkg.install in test mode would not detect FreeBSD packages installed by their origin name [#67126](https://github.com/saltstack/salt/issues/67126) -- Fix virtual grains for VMs running on Nutanix AHV [#67180](https://github.com/saltstack/salt/issues/67180) -- Fixed creating relative directory symlinks on Windows, ensured listing targets of symlinks in file_roots always produces POSIX-style paths [#67766](https://github.com/saltstack/salt/issues/67766) -- Avoid loading `salt.utils.crypt` module instead of `crypt` if it's missing in Python as it was deprecated and removed in Python 3.13. [#67797](https://github.com/saltstack/salt/issues/67797) -- Fixed docstring error in salt/modules/file.py that misnamed an option "user" when it should have been "owner". [#67911](https://github.com/saltstack/salt/issues/67911) -- salt.key: check_minion_cache performance optimization [#68030](https://github.com/saltstack/salt/issues/68030) -- when a file is managed, and the same file is cleaned, an incorrect message is displayed saying "removed: Removed due to clean" when the file isn't actually removed. Now the correct message is returned. [#68052](https://github.com/saltstack/salt/issues/68052) -- log_beacon - remove verbose minion log output [#68055](https://github.com/saltstack/salt/issues/68055) -- Fix that the state `saltmod.state` can be used on a masterless minion with salt-ssh like `saltmod.function` currently does. [#68116](https://github.com/saltstack/salt/issues/68116) -- Fixed ssh_known_hosts.present failure when ssh host keys changed [#68132](https://github.com/saltstack/salt/issues/68132) -- grains.disks: fix exception with incompatible output of Get-PhysicalDisk [#68184](https://github.com/saltstack/salt/issues/68184) -- Made osfinger report major&minor version for NixOS [#68230](https://github.com/saltstack/salt/issues/68230) -- Fix tests failing on AlmaLinux 10 and other clones [#68246](https://github.com/saltstack/salt/issues/68246) -- Speedup wheel key.finger call by removing redundant processing calls. [#68251](https://github.com/saltstack/salt/issues/68251) -- Fixed cp.cache_file when using Tornado > 6.4 [#68328](https://github.com/saltstack/salt/issues/68328) -- Stop mutating locals, which is unsupported in Py >=3.13 [#68445](https://github.com/saltstack/salt/issues/68445) -- Add `blockdev` state module back in to core - - Adds the `blockdev` state module back into the core Salt repo as it is critical functionality that shouldn't have been pulled out in the module migration [#68465](https://github.com/saltstack/salt/issues/68465) -- Adds `mdadm` and `lvm` grains modules back in to core. - - Restores the modules that had been removed as part of the community module - migration. They are core bits of functionality and the associated execution and - states modules had not been removed. [#68470](https://github.com/saltstack/salt/issues/68470) -- Fixed grains.list_present state to correctly handle multiple calls within the same state run. - Fixed `salt.utils.platform` to properly handle `__salt_system_encoding__` when synced as an extension module. - Improved `network.traceroute` parsing to be more robust across different traceroute versions. - Added retry logic to `saltutil.wheel` integration test to improve reliability in CI. - Improved architecture detection in `salt-ssh` to better support ARM64 platforms. - Fixed `salt-ssh` extension module syncing to avoid accidentally bundling core Salt modules and to correctly load wrapper modules. - Ensured `salt-ssh` relenv tests skip gracefully if the relenv tarball is unavailable in the test environment. - Fixed `mine.get` runner to correctly handle master's ID when ACLs are enabled. - Fixed `win_useradd.get_user_sid` to correctly handle non-string input. - Improved reliability of `state.running` integration test for `salt-ssh`. - Fixed high CPU usage in minion asynchronous authentication loop when masters are unreachable. - Added support for running Salt tools using `python -m tools`. [#68520](https://github.com/saltstack/salt/issues/68520) -- Adds `alias` state module back in to core. - - Restores the module that had been removed as part of the - community module migration. The associated execution module - had not been migrated. [#68574](https://github.com/saltstack/salt/issues/68574) -- Fixed mongodb tops module authentication to be compatible with pymongo v4+ by passing credentials directly to MongoClient instead of using the deprecated authenticate() method [#68659](https://github.com/saltstack/salt/issues/68659) -- Improved the rejected authentication warning message to include the minion ID, - making it easier for administrators to identify which minions need upgrading. [#68671](https://github.com/saltstack/salt/issues/68671) -- This PR fixes a bug where corrupted grains cache files cause unhandled - `SaltDeserializationError` exceptions, resulting in CRITICAL errors. - The fix adds proper exception handling to gracefully recover from corrupted - cache by regenerating grains. [#68678](https://github.com/saltstack/salt/issues/68678) -- Fix `mac_brew_pkg.list_pkgs` crashing or producing incorrect results when - Homebrew returns `null` values for cask metadata: - - - When the installed version of a cask is `null` (e.g. Homebrew cannot - determine the installed version), it is now reported as `"unknown"` - instead of raising an error. - - When `full_token` is `null`, it is now filtered out so that `None` - is never used as a package name key in the returned dictionary. [#68763](https://github.com/saltstack/salt/issues/68763) -- Fix ansible.playbooks extra_vars quoting to prevent passing broken variables to ansible-playbook. [#68787](https://github.com/saltstack/salt/issues/68787) -- Make `x86_64_v2` to be handled properly with `salt.modules.yumpkg` module as a possible package architecture. [#68789](https://github.com/saltstack/salt/issues/68789) -- Make `salt-ssh` work without issues using `domain\user` notation for remote user with SSH. [#68790](https://github.com/saltstack/salt/issues/68790) -- Fixed source package builds (DEB/RPM) failing with ``LookupError: hatchling is already being built`` by adding ``hatchling`` to the ``--only-binary`` allow-list so pip uses its universal wheel instead of attempting a circular source build. [#68858](https://github.com/saltstack/salt/issues/68858) -- Use a 30 second ``salt`` CLI timeout in the reauth scenario tests so Windows CI does not time out on ``test.ping`` after master/minion restart (default was often 5s). [#68924](https://github.com/saltstack/salt/issues/68924) -- Fix logging in potentially dead process in reap_stray_processes fixture [#68927](https://github.com/saltstack/salt/issues/68927) -- Fix dynamic version discovery on a new release branch before the first ``v*`` tag exists: ``git describe`` still anchored on the previous line (e.g. ``v3007.13``) is lifted to the unreleased codename baseline (e.g. ``3008.0``) while keeping the commit offset and SHA. [#68964](https://github.com/saltstack/salt/issues/68964) -- Remove deprecations. - - salt/auth/pki.py (removed) - - salt/features.py (removed) - - salt/modules/nxos.py (modified) [#68985](https://github.com/saltstack/salt/issues/68985) - - -### Added - -- Added proxy option to `gitfs`, `git_pillar` and `winrepo` for specifying a proxy server used to connect to git repositories [#30990](https://github.com/saltstack/salt/issues/30990) -- Added support for limiting the number of parallel states executing at the same time via `state_max_parallel` [#49301](https://github.com/saltstack/salt/issues/49301) -- Added metalink to mod_repo in yumpkg and documented in pkgrepo state [#58931](https://github.com/saltstack/salt/issues/58931) -- Added ssl and verify_ssl arguments to mongodb module and states. [#59927](https://github.com/saltstack/salt/issues/59927) -- Added two new options, ``win_delay_start`` and ``win_install_dir``, to pass to - the Windows installer in salt-cloud [#61318](https://github.com/saltstack/salt/issues/61318) -- Add context aware change handling for file state module [#63328](https://github.com/saltstack/salt/issues/63328) -- Added the ability to access already compiled pillar data during the pillar rendering process via the `__pillar__` global in templates and matchers. [#64043](https://github.com/saltstack/salt/issues/64043) -- Allow salt-call arguments --file-root, --pillar-root and --states-dir to be specified multiple times [#64486](https://github.com/saltstack/salt/issues/64486) -- Adds documentation notes to clarify that Salt's file module only supports numeric mode specifications and does not support symbolic modes. [#64624](https://github.com/saltstack/salt/issues/64624) -- Added management of SSH keys and certificates [#65197](https://github.com/saltstack/salt/issues/65197) -- Add option (auth_events_autosign_grains) to add autosign_grains to auth events [#65426](https://github.com/saltstack/salt/issues/65426) -- Enable "KeepAlive" probes for Salt SSH executions [#65488](https://github.com/saltstack/salt/issues/65488) -- Add ability to show diff for new files in file.managed [#65546](https://github.com/saltstack/salt/issues/65546) -- Added Virtuozzo Linux to Redhat os_family [#65600](https://github.com/saltstack/salt/issues/65600) -- Pillar dunder is now available in extension modules during pillar render. [#65724](https://github.com/saltstack/salt/issues/65724) -- Added x509_v2 SSH wrapper module. In addition to the regular calls, it provides a function for statefully managing remote certificates, even when access to the event bus is required [#65728](https://github.com/saltstack/salt/issues/65728) -- Introduce fibre_channel_host grain [#65750](https://github.com/saltstack/salt/issues/65750) -- Make `salt-run jobs.master` return runner jobs that are currently running on a master. [#66007](https://github.com/saltstack/salt/issues/66007) -- Added file and plaintext sources to `gpg.present`, allowed to skip keyserver queries [#66173](https://github.com/saltstack/salt/issues/66173) -- added pkg.which to aptpkg, for finding which package installed a file. [#66201](https://github.com/saltstack/salt/issues/66201) -- Allow pre-connection scripts to be run on host before any ssh commands [#66210](https://github.com/saltstack/salt/issues/66210) -- Added port, tls, username and password to the `smtp` configuration of the highstate returner. [#66251](https://github.com/saltstack/salt/issues/66251) -- Improve macOS defaults support [#66466](https://github.com/saltstack/salt/issues/66466) -- Added support for specifying different signature verification backends in `file.managed`/`archive.extracted` [#66527](https://github.com/saltstack/salt/issues/66527) -- Added an `asymmetric` execution module for signing/verifying data using raw asymmetric algorithms [#66528](https://github.com/saltstack/salt/issues/66528) -- Added support in service Beacon for only fire matching configured running state [#66809](https://github.com/saltstack/salt/issues/66809) -- Add --relenv Option to salt-ssh for Using a Onedir Bundled Salt+Python [#66877](https://github.com/saltstack/salt/issues/66877) -- Add support for state.sls_exists when using salt-ssh [#66894](https://github.com/saltstack/salt/issues/66894) -- Add detection for OS grains when running in [AlmaLinux Kitten](https://wiki.almalinux.org/release-notes/kitten-10.html) [#66991](https://github.com/saltstack/salt/issues/66991) -- Added a `merge` option to `file.recurse`, which merges subpaths from all existing `source`s before managing the directory. Handy when using different saltenvs or the TOFS pattern. [#67072](https://github.com/saltstack/salt/issues/67072) -- Add `_auth` calls to the master stats [#67746](https://github.com/saltstack/salt/issues/67746) -- Added possibility to load data from multiple inventories with `ansible.targets`. [#67776](https://github.com/saltstack/salt/issues/67776) -- Detect openEuler as RedHat family OS. [#67796](https://github.com/saltstack/salt/issues/67796) -- refactored server-side PKI to support cache interface - optimization: check_compound_minions: defer _pki_minions fetch - refactor: push salt.utils.minions bits into salt.key / optimize matching [#67799](https://github.com/saltstack/salt/issues/67799) -- Add deb822 apt source format support to aptpkg module [#67956](https://github.com/saltstack/salt/issues/67956) -- Add subsystem filter to "udev.exportdb" execution module function [#68047](https://github.com/saltstack/salt/issues/68047) -- Implement SL Micro 6.2 detection to fill the grains with proper values. [#68247](https://github.com/saltstack/salt/issues/68247) -- Added booleans argument to selinux.booleans - Added mod_aggregate to selinux to combine boolean - Added some type hints to selinux module and made some minor changes to improve readability and performance slightly [#68323](https://github.com/saltstack/salt/issues/68323) -- Add support for minion_id in log formats - - Adds support for including `%(minion_id)s` in log formats. Where id is available log messages on the master will have that data added to allow easier correlation of messages to minions. [#68410](https://github.com/saltstack/salt/issues/68410) -- Added feature parity for relenv and thin dir with salt-ssh. All salt-ssh tests pass with both thin dir and relenv. [#68531](https://github.com/saltstack/salt/issues/68531) -- Added tunable worker pools: partition the master's MWorkers into named pools - and route specific commands (for example `_auth`) to dedicated pools so a - slow workload cannot starve time-critical traffic. Controlled by the new - `worker_pools` and `worker_pools_enabled` master settings; see the "Tunable - Worker Pools" topic guide for details. Existing `worker_threads` - configurations remain fully backward compatible. [#68532](https://github.com/saltstack/salt/issues/68532) -- Added TLS encryption optimization via disable_aes_with_tls config option that eliminates redundant AES encryption when TLS with mutual authentication is active, improving performance while maintaining security through certificate identity verification. [#68536](https://github.com/saltstack/salt/issues/68536) -- utils.dictdiffer: support diffing of dicts in lists [#68726](https://github.com/saltstack/salt/issues/68726) -- Add support for nix package manager. [#68752](https://github.com/saltstack/salt/issues/68752) -- Added a centralized, declarative system for managing Salt's optional dependencies and their version-specific requirements in ``salt/utils/versions.py``. [#68894](https://github.com/saltstack/salt/issues/68894) -- Implemented an O(1) memory-mapped PKI index to optimize minion public key lookups. This optimization substantially reduces master disk I/O and publication overhead in large-scale environments by replacing linear directory scans with constant-time hash table lookups. The feature is opt-in via the `pki_index_enabled` master configuration setting. [#68936](https://github.com/saltstack/salt/issues/68936) - Fix `mac_brew_pkg.list_pkgs` crashing or producing incorrect results when Homebrew returns `null` values for cask metadata: diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c1c5dba7b382..8cb45984f129 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -588,12 +588,12 @@ But that advice is backwards for the changelog. We follow the `keepachangelog `__ approach for our changelog, and use towncrier to generate it for each release. As a contributor, all that means is that you need to add a file to the -``salt/changelog`` directory, using the ``..md`` format. For +``salt/changelog`` directory, using the ``.`` format. For instance, if you fixed issue 123, you would do: :: - echo "Made sys.doc inform when no minions return" > changelog/123.fixed.md + echo "Made sys.doc inform when no minions return" > changelog/123.fixed And that's all that would go into your file. When it comes to your commit message, it's usually a good idea to add other information, such as diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 42a7ebaabf17..849b1ed5e336 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -71,6 +71,7 @@ | natsort | 7.0.1 | MIT/X11 | https://pypi.org/project/natsort/ | Seth M. Morton | 2012-2020 | | ncclient | 0.6.7 | Apache License, V2.0 | https://pypi.org/project/ncclient/ | Shikhar Bhushan, Leonidas Poulopoulos, Ebben Aries, Einar Nilsen-Nygaard | 2004 | | netaddr | 0.8.0 | BSD | https://pypi.org/project/netaddr/ | Author: David P. D. Moss, Stefan Nordhausen et al | 2008 | +| networkx | 2.4 | BSD | https://pypi.org/project/networkx/ | Aric Hagberg | 2004-2020 | | ntc-templates | 1.5.0 | Apache License, V2.0 | https://pypi.org/project/ntc-templates/ | Jason Edelman | 2015 | | oauthlib | 3.1.0 | BSD | https://pypi.org/project/oauthlib/ | The OAuthlib Community | 2019 | | oscrypto | 1.2.0 | MIT/X11 | https://pypi.org/project/oscrypto/ | wbond | 2015-2019 | diff --git a/FIXED_TESTS.md b/FIXED_TESTS.md new file mode 100644 index 000000000000..c98b36fd7ee6 --- /dev/null +++ b/FIXED_TESTS.md @@ -0,0 +1,71 @@ +# FIXED_TESTS.md: Salt Merge-Forward CI Regressions (3006.x -> 3007.x) + +This document tracks the test regressions and CI failures resolved during the merge of Salt 3006.x into 3007.x (PR #68929). + +## 1. Package Lifecycle Tests (Downgrade/Upgrade) +* **Files**: + * `tests/pytests/pkg/downgrade/test_salt_downgrade.py` + * `tests/pytests/pkg/upgrade/test_salt_upgrade.py` +* **Symptom**: `AssertionError` where `3007.13` was incorrectly evaluated as equal to `3007.13+187.g813a978cff` due to `.base_version` usage. +* **Fix**: Switched to full `packaging.version.Version` objects for comparison, correctly identifying that dev/git versions are "greater than" the base stable version. Also initialized `original_py_version = None` to resolve pylint warnings. + +## 2. Salt-SSH Unit Tests +* **Files**: + * `tests/pytests/unit/client/ssh/test_ssh.py` + * `tests/pytests/unit/client/ssh/test_password.py` +* **Symptom**: `ValueError` (too many values to unpack) and `AttributeError` after refactoring. +* **Fix**: + * Refactored tests to match the renamed `_handle_routine_thread` method. + * Updated mocks to handle the new 3-tuple return format (`stdout`, `stderr`, `retcode`). + * Added robust `retcode = None` handling. + * Switched to `ANY` for `opts` in `display_output` mocks to accommodate merge-added internal configuration keys. + +## 3. Salt-Mine Integration & Runner Tests +* **Files**: + * `tests/integration/modules/test_mine.py` + * `tests/pytests/integration/runners/test_mine.py` +* **Symptom**: Flaky failures and race conditions where Mine data was not available immediately after being sent. +* **Fix**: Ported 30-second polling logic and `mine.update` patterns from `master` to ensure data consistency before assertions. + +## 4. Async Client Unit Tests +* **File**: `tests/pytests/unit/test_client.py` +* **Symptom**: `RuntimeError: Event loop is closed` and JID nesting errors in `pub_async`. +* **Fix**: Ported the `async def` test pattern from `master`, ensuring Tornado/Asyncio loops are properly managed and that `jid` and `timeout` are correctly extracted from nested return structures. + +## 5. Loader/Grains Cleanup Tests +* **File**: `tests/pytests/unit/loader/test_grains_cleanup.py` +* **Symptom**: Failures in grain provider cleanup due to stub module interference. +* **Fix**: Aligned module filtering logic with `master` to correctly handle (and ignore) stub modules that were causing cleanup failures. + +## 6. System Verification Unit Tests +* **File**: `tests/pytests/unit/utils/verify/test_verify.py` +* **Symptom**: **Hard Crash/Hang** of the unit test shard (specifically Unit 4 on Linux). +* **Fix**: Patched `resource.getrlimit` and `resource.setrlimit` (and Windows equivalents) to prevent the test from actually lowering the process file descriptor limit to 256. Previously, hitting this limit caused Salt's logging and master processes to crash recursively without a summary. + +## 7. Package Ownership Integration Tests +* **File**: `tests/pytests/pkg/integration/test_salt_user.py` +* **Symptom**: `AssertionError: assert 'salt' == 'root'` at various paths (e.g., `/etc/salt/pki/minion/minion.pub`, `/var/cache/salt/master/proc`). +* **Fix**: Refactored `test_pkg_paths` to use a non-recursive, explicit path check for `salt` user ownership. This correctly aligns the test with Salt's 3006.x+ multi-user security model, where `root`-owned subdirectories often exist within `salt`-managed parent directories, and avoids the cascading failures caused by the previous recursive logic. + +## 8. Integration Shard 1 (Widespread Collision) +* **Symptom**: 169+ failures in Ubuntu 24.04 (and other Linux) integration shards. +* **Error**: `salt.loader.lazy: ERROR Module/package collision: '.../salt/utils/vault.py' and '.../salt/utils/vault'`. +* **Fix**: Deleted the redundant `salt/utils/vault.py` (which was accidentally restored from 3006.x) in favor of the `salt/utils/vault/` directory structure required by 3007.x. Also removed redundant `tests/pytests/unit/utils/test_vault.py`. + +## 9. GPG Key Download Failures +* **File**: `tests/support/pytest/helpers.py` +* **Symptom**: `requests.exceptions.ConnectionError` in restricted/air-gapped CI environments when downloading Broadcom GPG keys. +* **Fix**: Added a local PGP public key fallback to the `download_file` helper, allowing tests to proceed even when the Broadcom artifactory is unreachable. + +## 10. Systemd Masked Service Hangs +* **File**: `tests/pytests/pkg/upgrade/systemd/test_service_preservation.py` +* **Symptom**: **5-hour Hang** in package upgrade tests. +* **Fix**: Disabled automated service stopping for masked units during the `install(upgrade=True)` call. `systemctl stop` can block indefinitely on masked services in certain environments. + +--- + +## Core Supporting Fixes (Verified) +The following core changes were required to enable the test fixes above: +- **`salt/client/ssh/__init__.py`**: Fixed `SSH._expand_target` to preserve user prefixes (e.g., `user@host`). +- **`salt/pillar/__init__.py`**: Added `deepcopy(opts)` for Pillar renderer isolation. +- **`pkg/windows/nsis/installer/Salt-Minion-Setup.nsi`**: Restored PR-original Windows MSI fix. diff --git a/GAP5.md b/GAP5.md deleted file mode 100644 index cc26e9bfdf8d..000000000000 --- a/GAP5.md +++ /dev/null @@ -1,153 +0,0 @@ -# Gap 5 — `state.py __virtual__` guard is too broad: resource types without a `state.*` override lose `state.sls` - -Discovered: 2026-05-10, during `salt-call -r --tgt dwozniak-91-ss state.sls starting_state.vcf-91-dev` -after adding a second resource type (`ssh`) to the Pillar alongside `starting_state`. - -**Status: fixed via the per-type directory layout** (`salt/resources//modules/`). -The broad `__virtual__` guard in `salt/modules/state.py` was deleted entirely; -the standard ``state.py`` now loads in every context. Per-type ``state.py`` -overrides under ``salt/resources//modules/`` (or under any -extension's ``saltext//resources//modules/``) win their slot -via directory-order priority in :func:`salt.loader._module_dirs`. See -``RESOURCE_STATE_GAPS.md`` for the full picture. - ---- - -## What happened - -The following command failed immediately — before any state even ran — with: - -``` -Function 'state.sls' is not supported for resource type 'starting_state'. -``` - -```bash -sudo salt-call -r --tgt dwozniak-91-ss --local \ - --config-dir .../saltcall \ - state.sls starting_state.vcf-91-dev -``` - -The same command had worked on the two prior runs (when only the `starting_state` -resource type was active). The only thing that changed between the working and -failing run was that `pillar/ssh_resources.sls` now contained a real SSH host -(`jumphost-dwozniak-91-ss`), causing the minion to discover and build a loader -for the `ssh` resource type in addition to `starting_state`. - ---- - -## Root cause - -### 1. The Gap 2 fix introduced a broad `__virtual__` guard in `salt/modules/state.py` - -Commit `5fd6da6810a` ("state: call resource init() in State.load_modules") changed -`State.load_modules` to build `self.functions` via `salt.loader.resource_modules` -instead of `minion_mods` when `opts["resource_type"]` is set. As a companion -change, `salt/modules/state.py` was given a `__virtual__` guard that returns -`False` for **all** resource types: - -```python -# salt/modules/state.py -def __virtual__(): - if __opts__.get("resource_type"): - return False, "state: not loaded in resource-type loaders" - ... - return __virtualname__ -``` - -The intent was to yield the `"state"` virtualname slot to resource-specific -override modules like `sshresource_state` (which provides `state.sls`, -`state.highstate`, and `state.apply` for SSH resources). - -### 2. The guard is too broad — it fires for ALL resource types, not just `ssh` - -`sshresource_state` only overrides `state.*` when `resource_type == "ssh"`: - -```python -# salt/modules/sshresource_state.py -def __virtual__(): - if __opts__.get("resource_type") == "ssh": - return __virtualname__ # "state" - return False, "sshresource_state: only loads in an ssh-resource-type loader." -``` - -For resource types that have **no** override module (e.g. `starting_state`, -`dummy`, any future custom type), the outcome is: - -- `salt/modules/state.py` → returns `False` (blocked by the broad guard) -- `sshresource_state.py` → returns `False` (wrong resource type) -- Result: **the `state` virtualname slot is empty** in the resource loader for - that type. - -### 3. The caller-level check catches the empty slot before `State.load_modules` is reached - -`salt/cli/caller.py` checks `if fun not in loader` against the minion's -per-type `resource_loaders[rtype]` loader **before** running the function: - -```python -# salt/cli/caller.py ~387 -loader = getattr(self.minion, "resource_loaders", {}).get(rtype) -if fun not in loader: - results[rid] = ( - f"Function '{fun}' is not supported for resource " - f"type '{rtype}'." - ) - continue -``` - -Because `state.sls` is absent from `resource_loaders["starting_state"]`, -the call is rejected here and never reaches `State.load_modules` at all. - -### 4. Why it only appeared when the `ssh` resource type was added - -When only `starting_state` was in the Pillar, only a `starting_state` -resource loader was built. `state.sls` was absent from it (same root cause), -but the two successful runs that preceded this failure had a warm Pillar cache -that still showed `hosts: {}` for the SSH resource — the minion had not yet -reloaded the updated Pillar. Once the cache expired and the full Pillar -(including a real SSH host) was compiled, the minion built a loader for `ssh` -too. The `ssh` loader's presence is what finally caused the lazy evaluation -of `resource_loaders["starting_state"]` to reveal the missing `state.sls`. - -(The `starting_state` loader was always broken post–Gap 2; the Pillar cache -masked the failure for two runs.) - ---- - -## How it was fixed - -**Option B** was implemented in commit `d28f8d2e981`: a narrow -`_RESOURCE_TYPES_WITH_STATE_OVERRIDE = frozenset({"ssh"})` sentinel is -defined in `salt/modules/state.py`. The `__virtual__` guard only returns -`False` for resource types *in* that set. For all other resource types -(e.g. `starting_state`) the standard `state.sls` slot is available. - -For resource types that need customised state execution (e.g. different -transport, merged module loaders), a per-type override module can be provided -in a Salt extension: - -- **`starting_state`** → `saltext-opsdev` ships - `src/saltext/opsdev/modules/starting_stateresource_state.py` which: - 1. Claims the `state` virtualname ahead of `salt.modules.state` - (loads first alphabetically). - 2. Builds a local `HighState` (manages states on the same machine, - not via SSH like `sshresource_state`). - 3. Applies the Gap 4 local workaround: after `HighState` initialises - its `resource_modules` loader, the standard `minion_mods` set is - merged in for any slot not already claimed by a resource-specific - module — making `cmd.*`, `pkg.*`, `file.*`, etc. available alongside - `ss_env.*` in the same SLS. - ---- - -## Relationship to Gap 4 - -Gap 4 documents that `State.load_modules` using `resource_modules` instead of -`minion_mods` causes standard modules (`cmd`, `pkg`, `file`, etc.) to be -absent from the state's `__salt__`. - -Gap 5 was a **prerequisite failure**: the caller rejected the function call -entirely before `State.load_modules` was reached. The Gap 5 fix unblocked -dispatch; `starting_stateresource_state` then resolves Gap 4 locally inside -the extension without requiring a Salt core change. - -Both gaps were introduced by the same commit (`fccc5263638`). diff --git a/MULTI_RING_DESIGN.md b/MULTI_RING_DESIGN.md deleted file mode 100644 index 41a49a42bcf0..000000000000 --- a/MULTI_RING_DESIGN.md +++ /dev/null @@ -1,446 +0,0 @@ -# Multi-Ring / Multi-Raft Cluster Design - -Working notes for the evolution from "single global ring, single Raft -group, every master votes" to "named rings per cache type, per-ring -Raft groups, capped voter and ring-member counts". - -The original sketch (preserved below as historical context) is now -mostly **landed**. This top section describes the shipping shape; -read it first if you want operational context, and consult the -"Original design sketch" section if you're tracing decisions. - -## Shipping shape (2026-05-16) - -Multi-ring landed across seven slices. All 722 tests in the cluster -suites pass. Operator surface and on-the-wire shape are stable from -here. - -### Architecture - -``` - cluster Raft log (single group, "cluster" id) - ┌──────────────────────────────────────────┐ - │ MembershipStateMachine (CONFIG) │ - │ RingRegistryStateMachine (RING_REGISTRY) │ - │ RoutingStateMachine (ROUTE) │ - │ RingConfigStateMachine (RING_CONFIG, legacy) │ - └──────────────────────────────────────────┘ - │ - spawns per-founder - v - ring_X Raft log (own group, own log/term/leader) - ┌─────────────────────────────────────┐ - │ MembershipStateMachine (per-ring CONFIG) │ - │ RingConfigStateMachine (per-ring policy) │ - └─────────────────────────────────────┘ -``` - -The cluster log is the only consensus state every master needs to -agree on; per-ring logs handle their own membership and policy -churn so a ring outage doesn't take down other rings or the cluster. - -### Substrate - -* `SaltStorage(node_id, opts, ring_id="cluster")` — on-disk path - scheme: `cachedir/cluster/consensus///`. -* RPC envelope carries `raft_group_id` (default `"cluster"`). - `salt/cluster/consensus/rpc.py:pack/unpack` handle it; pre-multi-ring - envelopes default to the cluster group on decode. -* `salt/cluster/consensus/peer.py:RaftDispatcher` accepts either a - single `Node` (treated as cluster) or `dict[str, Node]`; routes - inbound RPCs by `raft_group_id`. `register_node`/`unregister_node` - let `RaftService` mutate the routing table at runtime. -* `salt/cluster/consensus/service.py:RaftService` keeps `self._node` - for backward compat and `self._nodes = {"cluster": self._node}` as - the multi-ring registry. `_heartbeat_tick` iterates all groups. - -### Cluster-log state machines - -* `RingRegistryStateMachine` (`raft/log.py`) — registry of named - rings. Each entry: `{ring_id, founding_voters, status}`. Snapshot - round-trips; `on_change` fires per commit. -* `RoutingStateMachine` (`raft/log.py`) — data-type → ring mapping - (or `None` for broadcast). Snapshot round-trips; `on_change` - populates the per-process routing snapshot. -* New `LogEntryType.RING_REGISTRY = 4` and `LogEntryType.ROUTE = 5`; - the legacy `RING_CONFIG = 3` continues to drive the single-ring - fallback that pre-multi-ring callers use. - -### Per-ring lifecycle - -`RaftService._on_ring_registry_change` brings up a per-ring `Node` -(with its own `SaltStorage(ring_id=...)`, `MembershipStateMachine`, -and `RingConfigStateMachine`) whenever a registry entry commits and -this master is in the founder list. `status="destroyed"` tears down -the local `Node` and drops it from the dispatcher; on-disk state is -preserved so re-create with the same id recovers state. - -### Ring registry / routing surface - -`salt/cluster/ring_membership.py` is now a registry of named rings. - -* `get_ring(name)` lazily creates an empty `HashRing` per name. -* `rebuild(name, voters, replicas=1)` keyed by name; legacy - `rebuild(voters)` keeps targeting the `"cluster"` ring. -* `owns_for(opts, data_type, key)` consults `_ROUTING` first: no - route ⇒ broadcast (True); routed to an unknown/empty ring ⇒ False; - routed to a populated ring ⇒ defers to `ring.owns()`. -* `set_route(data_type, ring_id)` / `drop_ring(name)` are called by - `RaftService` on commits to keep the per-process snapshot in sync. - -### Gate sites - -`salt/master.py:1171,1187` (job submission + job return mirroring) -call `ring_membership.owns_for(self.opts, "jobs", jid)`. No routing -entry for `"jobs"` keeps today's broadcast behaviour; an operator -flips it to a ring with `cluster.route_set`. - -### Operator runners - -All in `salt/runners/cluster.py`: - -| Runner | Purpose | -|---|---| -| `cluster.ring_create name=X voters=[…]` | Propose `RING_REGISTRY` entry creating ring X | -| `cluster.ring_destroy name=X` | Propose destroy (status="destroyed") | -| `cluster.route_set data_type=… ring=…` | Propose `ROUTE` entry binding data_type to ring | -| `cluster.route_clear data_type=…` | Propose route → `None` (back to broadcast) | -| `cluster.ring_set name=X members=voters replicas=N` | Propose `RING_CONFIG` on ring X's *own* log (per-ring policy) | -| `cluster.shed_unowned ring=X banks=[…] dry_run=…` | Local: drop cache entries this master no longer owns | -| `cluster.collect_from_peers channels=[…]` | Pull keys/denied_keys from every peer via the existing state-sync chunk transport | -| `cluster.members` | Read-only membership + leader + health | -| `cluster.ring_info` | Read-only ring snapshot | -| `cluster.sync_roots` | Pre-existing: push file_roots/pillar_roots to peers | - -Each runner that proposes a Raft entry fires a `cluster/runner/*` -local event; `salt/channel/server.py:publish_payload` intercepts and -dispatches to the `RaftService` propose helpers in the publish -daemon. Same pattern as `cluster.sync_roots`. - -### Reversible migration flow - -Going in (broadcast → ring=jobs): - -1. `salt-run cluster.ring_create name=jobs voters='[m1,m2,m3]'` -2. (Registry commits; founders spin up the ring.) -3. `salt-run cluster.route_set data_type=jobs ring=jobs` -4. (Routing commits; gates start filtering writes.) -5. `salt-run cluster.shed_unowned ring=jobs dry_run=True` (preview) -6. `salt-run cluster.shed_unowned ring=jobs` (commit drops) - -Going out (ring=jobs → broadcast): - -1. `salt-run cluster.collect_from_peers` (each master gathers full set) -2. (Operator confirms every master succeeded.) -3. `salt-run cluster.route_clear data_type=jobs` -4. (Routing commits; gates broadcast again.) -5. (Optional) `salt-run cluster.ring_destroy name=jobs` - -The asymmetry — drop **after** policy flip going in, collect -**before** policy flip going out — is what keeps the window safe. - -### Recommended production opts - -A fresh cluster that wants the multi-ring job-cache sharding from -day one sets: - -```yaml -# salt/master.d/cluster.conf — same on every master - -cluster_id: my-cluster -cluster_peers: - - 10.0.0.1 - - 10.0.0.2 - - 10.0.0.3 -interface: 10.0.0.1 # this master's address; differs per master - -# Job cache through salt.cache.Cache so the ring gate can shard it. -master_job_cache: salt_cache -cache: mmap_cache # or localfs if mmap_cache is unavailable - -# Optional but recommended: cap the cluster's voter pool and let the -# watchdog auto-replace failed voters. Defaults are -# unlimited/disabled to preserve pre-multi-ring behaviour. -cluster_max_voters: 5 -cluster_min_voters: 3 -cluster_auto_replace_voters: true -cluster_voter_timeout: 10.0 -``` - -After the master daemon is running with these opts, create a ring -and route the jobs data type to it from any master: - -```bash -salt-run cluster.ring_create name=jobs \ - voters='["10.0.0.1","10.0.0.2","10.0.0.3"]' -salt-run cluster.route_set data_type=jobs ring=jobs -``` - -Operators upgrading an existing ``master_job_cache: local_cache`` -cluster should: - -```bash -# 1. Drain incoming jobs (operator-specific). -# 2. Stop every master. -# 3. On each master, migrate the on-disk job cache into the salt_cache -# bank layout. --dry-run first to preview the count. -salt-run cluster.migrate_jobs_to_cache dry_run=True -salt-run cluster.migrate_jobs_to_cache - -# 4. Flip master_job_cache + cache opts as shown above. -# 5. Restart every master. -# 6. Verify with cluster.rings / cluster.routes / cluster.members. -``` - -### Known limitations / follow-ups - -* **`cluster.collect_from_peers` v1 covers the four state-sync - channels and any ``bank:`` channel.** The default targets - the four ``jobs/*`` banks the salt_cache returner writes through; - operators routing other caches name them explicitly via the - ``banks=`` parameter. PKI keys (``keys`` / ``denied_keys``) stay - broadcast — see the ``master.py:1195-1208`` comment. -* **Non-member writes are no-ops in v1.** A master that is not a - ring member but receives a job event for that ring's data type - drops the write rather than delegating to a ring member over RPC. - ``ring_membership.drop_stats`` records ``not_a_member`` counts so - operators can spot a misconfigured load balancer; the rate-limited - WARN log line is the loud signal. Delegate-on-miss is a future - RPC. -* **Legacy `RingConfigStateMachine` still lives on the cluster log.** - Functionally inert in multi-ring deployments — per-ring policy is - on per-ring logs. Removing the cluster-log registration is a - cleanup follow-up, not a blocker. - ---- - -## Original design sketch - -The rest of this document is the pre-implementation design sketch. -It's preserved for context on the decisions that shaped the shipped -code. Open questions noted in the sketch have all been resolved: - -* Q1 (ring lifecycle): dynamic via `cluster.ring_create` / `ring_destroy`. -* Q2 (default rings): pre-multi-ring callers default to the `"cluster"` - ring; new code uses named rings via `route_set`. -* Q3 / Q9 (voter selection): operator-specified in the create call. -* Q4 (decommissioning): tear down local Raft group; on-disk state - preserved for recovery; routes that pointed there are operator's - responsibility to clear via `route_clear` first. -* Q5 (snapshot scope): each ring's `SaltStorage(ring_id=…)` snapshots - independently into its own on-disk path. -* Q6 / Q7 / Q8 (voter caps, operator overrides, auto-replacement): - landed in earlier slices (`cluster_max_voters`, - `cluster.promote`/`demote`, voter-health watchdog). -* Q10 (persistence of voter-vs-ring-member status): per-ring - `MembershipStateMachine` persisted via the multi-SM envelope, same - treatment as the cluster log. - -(Working notes for the proposed evolution from "single global ring, -single Raft group, every master votes" to "named rings per cache type, -per-ring Raft groups, capped voter and ring-member counts".) - -## Today (baseline) - -* **One Raft group per cluster.** Every master in `cluster_peers` - starts as a voter (`voting=True` is the default in - `salt/cluster/consensus/raft/node.py:62` and - `salt/cluster/consensus/service.py:69`). -* **Late joiners become non-voting learners** via - `RaftService.notify_peer_joined` (`service.py:359`). The leader - replicates the log to them; once `match_index >= log.index` - (`node.py:703-722`) it proposes a CONFIG entry promoting them to - voter. No permanent observer/learner role. -* **One global ring.** `_on_membership_change` calls - `salt.cluster.ring_membership.rebuild(voters)` at `service.py:218`. - The ring is a singleton (`salt/cluster/ring_membership.py`), so every - cache type that wants ring routing shares the same node set. -* **Ring config entries already exist.** - `LogEntryType.RING_CONFIG = 3` and `RingConfigStateMachine` were - added in `212c6d97bb2`. Today they ride the single cluster Raft log; - the runner `cluster.ring_set` raises `NotImplementedError` because - the runner→master propose path was deferred (see `GAPS.md`). -* **Heap segment cap.** `mmap_cache.DEFAULT_MAX_SEGMENT_BYTES = 1 - GiB` (`salt/utils/mmap_cache.py:79`). Tunable via - `mmap_cache_max_segment_bytes` / `mmap_key_max_segment_bytes`. Not - driven by ring config. - -## Decisions locked in - -1. **Voter count is bounded** per Raft group. Default unlimited - (preserve current behaviour); operator opts in via a cap. -2. **Ring-member count is bounded** per ring. Independent cap. -3. **Voter set and ring-member set are decoupled.** A master can be a - voter without owning ring work, or a ring member without voting. - They are not subset-related. -4. **First-pass behaviour on member loss may halt.** Auto-replacement - of a dead voter / dead ring node by promoting a learner / catching- - up peer is a follow-up. Acceptable because log replication reaches - non-voting peers, so eventual promotion is always possible. -5. **A separate Raft log per ring.** Multi-Raft architecture — each - ring is its own Raft group with its own log, term, leader, commit - index, and voter set. -6. **Multiple rings per cluster, one per cache type.** Examples: a - `minion-keys` ring, a `jobs` ring, a `pillars` ring. Each cache - backend declares which ring it uses. -7. **Ring-group voters can be any cluster node.** Not constrained to - cluster-Raft voters. - -## Architecture that falls out - -### Cluster Raft (singular) — control plane - -Owns everything cluster-wide that isn't per-ring: - -* Cluster-wide voter/learner membership (today's - `MembershipStateMachine`). -* **Ring definitions.** A registry of - `{ring_name → {voter_cap, node_cap, cache_types, initial_voters}}`. - Either a new entry type or a new SM that every node replays so - every node learns which rings exist. - -### Ring Raft groups (N) — data-plane routers - -One per logical ring / cache type. Each one owns: - -* Its own `LogStorage` file (independent snapshot / compaction). -* Its own term, leader, commit index, voted-for. -* Its own voter set (subset of cluster nodes, bounded by `voter_cap`). -* Its own ring-member set (the masters that actually own work for - this cache; bounded by `node_cap`). Independent of voters. -* A `RingMembershipSM` whose `on_change` hook calls - `ring_membership[ring_name].rebuild(members)` locally. - -### Cache → ring binding - -Each cache driver declares its ring name in config: - -```yaml -keys.cache_driver_ring: minion-keys -jobs.cache_driver_ring: jobs -pillars.cache_driver_ring: pillars -``` - -Ownership queries become -`ring_membership.get_ring(name).owns(opts, key)`. The -`salt/cluster/ring_membership.py` singleton becomes a registry keyed -by ring name. Cache types that don't opt into a named ring use a -`default` ring for backwards compatibility. - -### Bootstrap order - -1. Cluster Raft commits a `RING_DEFINITION` entry: "create ring X - with these caps and initial voters". -2. On apply, every node instantiates a local Raft `Node` for ring X - bound to a new log file, peers configured from the entry's voter - list. -3. Multiplexed transport tags each AppendEntries / RequestVote / - InstallSnapshot with `raft_group_id` (cluster-id or ring-name); - `SaltPeer` dispatches to the right group. -4. Ring X's leader emits its own membership entries; each apply - fires `ring_membership["X"].rebuild`. - -### Cost shape - -N ring groups × per-group heartbeats, timers, log files. With ~5 -cache types and 3-voter rings, well below what etcd / CockroachDB -live with. Standard mitigations (group ticking, batched AppendEntries -over the shared transport) available later if N grows. - -## Implementation surface (where it touches today's code) - -* `salt/cluster/consensus/service.py` — `RaftService` becomes a - manager of multiple `Node` instances rather than owning one. - `_on_membership_change` for the cluster group still updates - cluster membership; new per-ring callbacks update each ring's - membership. -* `salt/cluster/consensus/raft/node.py` — gains a `raft_group_id` - field on every RPC. `notify_peer_joined`'s promotion gate at - `node.py:703-722` reads its group's `voter_cap` before proposing - promotion. -* `salt/cluster/consensus/peer.py` / transport — dispatches incoming - RPCs to the addressed group. -* `salt/cluster/ring_membership.py` — becomes a registry of named - rings; `get_ring(name)` replaces the singleton accessor; `rebuild` - takes a ring name. -* `salt/cluster/consensus/storage.py` — supports multiple - `SaltStorage` instances, one per group, each with its own - persistent path. -* `salt/runners/cluster.py` — `ring_set` becomes a per-ring propose - call; new `ring_create` / `ring_drop` runners for the cluster Raft. -* `salt/master.py` — gate sites use `ring_membership.get_ring(name)` - with the cache's declared ring name instead of the global ring. - -## Open questions - -1. **Ring definition lifecycle.** Are rings created at cluster init - via static config, or dynamically via a runner - (`cluster.ring_create name=jobs voter_cap=3 node_cap=10 - cache_types=[jobs]`)? Static is simpler. Dynamic matches how - `ring_set` is shaped today. - -2. **Default rings.** Ship with a default `default` ring that all - cache types use unless they opt into a named ring? Keeps the - migration path from "today's one ring" to "many rings" trivial. - -3. **Voter selection for new ring groups.** When the cluster commits - "create ring X", who are X's *initial* voters? Operator-specified - in the create call, lowest-N-by-node-id from current cluster - voters, or random-N? - -4. **Decommissioning a ring.** Drop a `RING_DEFINITION` entry → - every node tears down that local Raft group, deletes its log - file. Cache callers that were routing via it fall back to… - what? Single-node ownership? Reject? Worth a contract - decision. - -5. **Snapshot scope.** Each ring group snapshots independently. - Cluster Raft snapshots independently. The snapshot envelope - `raft.snapshot.v1` already does multi-SM serialisation, so this - works — but the envelope writer per group becomes one-of-its-own- - SMs, not the global multi-SM bundle. - -6. **Voter cap enforcement at static startup.** Today every address - in `cluster_peers` becomes a voter. With a cap, if - `len(cluster_peers) + 1 > max_voters` we need a deterministic - voter-subset selection rule (lowest-id-wins, or first N in the - configured list) and the rest start as learners. - -7. **Operator override.** A runner like `cluster.promote ` / - `cluster.demote ` so ops can pick who votes when the - deterministic rule picks wrong. - -8. **Auto-replacement on member loss.** Today a dead voter stays in - `voters` until manually removed. With caps, the leader should - want to demote a missing voter / ring node and promote a healthy - learner — otherwise a single death stalls the group forever. New - state machine. Locked as a follow-up, not first-pass. - -9. **Voter set of each ring group.** Decided: "any node in the - cluster, bounded by per-ring cap". How are they chosen at ring- - create time? Same answer as Q3. - -10. **Persistence of voter-vs-ring-member status.** The membership - state machine is already snapshot-persisted across log - compaction (`c53e9bec3dd`). Each ring group's SM needs the same - treatment so restart doesn't re-promote everyone. - -## Smallest shippable subset - -If the above is the long-term shape, a sensible first slice that -preserves today's behaviour by default: - -* `cluster_max_voters` opt (default unlimited). Gate - `notify_peer_joined` promotion (`raft/node.py:708`) on it. Don't - touch static `cluster_peers` startup — operator's responsibility to - keep that ≤ cap. -* `cluster_max_ring_nodes` opt (default unlimited). - `_on_membership_change` clamps `voters` → first N (sorted node id) - when calling `ring_membership.rebuild`. -* No multi-Raft yet; no per-cache-type rings yet. Single global - ring continues to ride the cluster Raft via `RING_CONFIG` entries. -* Auto-replacement deferred; halting on member loss is the - first-pass contract. - -Multi-Raft and named rings come in follow-ups, gated on the open -questions above. diff --git a/README.rst b/README.rst index 68da809e2145..63d4bf775687 100644 --- a/README.rst +++ b/README.rst @@ -28,11 +28,6 @@ * `Latest Salt Documentation`_ * `Open an issue`_ (bug report, feature request, etc.) -.. note:: - When using ``salt-cloud -p`` with a profile, pass only the VM name on the - command line. Specify VM attributes (memory, cpu, vcpu, etc.) in the profile - configuration, not as command-line arguments. - *Salt is the world's fastest, most intelligent and scalable automation* *engine.* diff --git a/SECURITY.md b/SECURITY.md index ad7e974639f8..eaf22ee8b5ef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,52 +66,22 @@ xJLUpltwXLZSrJEXYjtJtnh0om71NXes0OyWE1cL4+U6WA9Hho6xedjk2bai -----END PGP PUBLIC KEY BLOCK----- ``` -## Reporting a Vulnerability - Private Disclosure Process - -The Salt Project Security Team is available at -saltproject-security.pdl@broadcom.com for security-related bug reports or -questions. Emails will be addressed within 3 business days. +The SaltStack Security Team is available at saltproject-security.pdl@broadcom.com for +security-related bug reports or questions. We request the disclosure of any security-related bugs or issues be reported non-publicly until such time as the issue can be resolved and a security-fix release can be prepared. At that time we will release the fix and make a public announcement with upgrade instructions and download locations. -**IMPORTANT: Do not file public issues on GitHub for security vulnerabilities** - -## Proposed Email Content - -Provide a descriptive subject line and in the body of the email include the -following information: - -* Basic identity information, such as your name and your affiliation or company. -* Detailed steps to reproduce the vulnerability (POC scripts, screenshots, and - logs are all helpful to us). -* Description of the effects of the vulnerability on Salt and the related - hardware and software configurations, so that the VMware Security Team can - reproduce it. -* How the vulnerability affects Salt usage and an estimation of the attack - surface, if there is one. -* List other projects or dependencies that were used in conjunction with Salt to - produce the vulnerability. - - -## When to report a vulnerability - -* When you think Salt has a potential security vulnerability. -* When you suspect a potential vulnerability but you are unsure that it impacts - Salt. -* When you know of or suspect a potential vulnerability on another project that - is used by Salt. - ## Security response procedure -We take security and the trust of our customers and users very seriously. Our -disclosure policy is intended to resolve security issues as quickly and safely -as is possible. +SaltStack takes security and the trust of our customers and users very +seriously. Our disclosure policy is intended to resolve security issues as +quickly and safely as is possible. -1. A security report sent to saltproject-security.pdl@broadcom.com is assigned - to a team member. This person is the primary contact for questions and will +1. A security report sent to saltproject-security.pdl@broadcom.com is assigned to a team + member. This person is the primary contact for questions and will coordinate the fix, release, and announcement. 2. The reported issue is reproduced and confirmed. A list of affected projects @@ -121,46 +91,21 @@ as is possible. actively supported. Back-ports of the fix are made to any old releases that are actively supported. -4. A new release is created and pushed to all affected repositories. The +4. Packagers are notified via the [salt-packagers](https://groups.google.com/forum/#!forum/salt-packagers) mailing list that an issue + was reported and resolved, and that an announcement is incoming. + +5. A new release is created and pushed to all affected repositories. The release documentation provides a full description of the issue, plus any upgrade instructions or other relevant details. -5. An announcement is made to the - [salt-users](https://groups.google.com/forum/#!forum/salt-users) and - [salt-announce](https://groups.google.com/forum/#!forum/salt-announce) - mailing lists. The announcement contains a description of the issue and a - link to the full release documentation and download locations. +6. An announcement is made to the [salt-users](https://groups.google.com/forum/#!forum/salt-users) and [salt-announce](https://groups.google.com/forum/#!forum/salt-announce) mailing + lists. The announcement contains a description of the issue and a link to + the full release documentation and download locations. ## Receiving security announcements -Keep an eye on the -[Salt Project Security Announcements](https://saltproject.io/security-announcements/) -landing page. Salt Project recommends subscribing to the -[Salt Project Security RSS feed](https://saltproject.io/security-announcements/index.xml) -to receive notification when new information is available regarding security -announcements. - -Other channels to receive security announcements include the -[Salt Project GitHub Discussions](https://github.com/saltstack/salt/discussions) -and the -[Salt Project Community Discord](https://discord.gg/J7b7EscrAs). - -## Confidentiality, integrity and availability - -We consider vulnerabilities leading to the compromise of data confidentiality, -elevation of privilege, or integrity to be our highest priority concerns. -Availability, in particular in areas relating to DoS and resource exhaustion, is -also a serious security concern. The Salt Project Security Team takes all -vulnerabilities, potential vulnerabilities, and suspected vulnerabilities -seriously and will investigate them in an urgent and expeditious manner. - -Note that we do not currently consider the default settings for Salt to be -secure-by-default. It is necessary for operators to explicitly configure -settings, role based access control, and other resource related features in -Salt to provide a hardened Salt environment. We will not act on any security -disclosure that relates to a lack of safe defaults. Over time, we will work -towards improved safe-by-default configuration, taking into account backwards -compatibility. +The fastest place to receive security announcements is via the [salt-announce](https://groups.google.com/forum/#!forum/salt-announce) +mailing list. This list is low-traffic. ## Guidance on Salt and security best practices diff --git a/agents/CLAUDE.md b/agents/CLAUDE.md index ff53741e8a81..01d64ddc3831 100644 --- a/agents/CLAUDE.md +++ b/agents/CLAUDE.md @@ -205,7 +205,7 @@ from salt.exceptions import ( **Essential guides in `agents/docs/`:** -- **[agents/docs/development-setup.md](agents/docs/development-setup.md)** - Virtual environment setup (venv310 and venv312), platform-specific dependencies, installation verification +- **[agents/docs/development-setup.md](agents/docs/development-setup.md)** - Virtual environment setup (venv310 and venv311), platform-specific dependencies, installation verification - **[agents/docs/architecture.md](agents/docs/architecture.md)** - Complete Salt architecture, all module types, loader system, event bus - **[agents/docs/module-templates.md](agents/docs/module-templates.md)** - Complete templates for execution and state modules, `__virtual__()` patterns, decorators - **[agents/docs/testing.md](agents/docs/testing.md)** - Test structure, unit test templates, mocking patterns, running tests diff --git a/agents/COPILOT.md b/agents/COPILOT.md index f5ce82addea9..874078b3cc14 100644 --- a/agents/COPILOT.md +++ b/agents/COPILOT.md @@ -186,7 +186,7 @@ def __virtual__(): **Two environments required:** - **venv310**: Testing 3006.x/3007.x branches -- **venv312**: Testing master branch + pre-commit +- **venv311**: Testing master branch + pre-commit ```bash # venv310 @@ -196,15 +196,15 @@ pip install -r requirements/static/pkg/py3.10/linux.lock # or darwin.lock/windo pip install -r requirements/pytest.txt -r requirements/static/ci/py3.10/tools.lock pip install pre-commit python-tools-scripts && pip install -e . && deactivate -# venv312 -python3.12 -m venv venv312 && source venv312/bin/activate +# venv311 +python3.11 -m venv venv311 && source venv311/bin/activate pip install --upgrade pip setuptools wheel pip install -r requirements/static/pkg/py3.11/linux.lock # or darwin.lock/windows.lock pip install -r requirements/pytest.txt -r requirements/static/ci/py3.11/tools.lock pip install pre-commit python-tools-scripts && pip install -e . && pre-commit install && deactivate ``` -**Always use full paths:** `./venv312/bin/pytest` (master); `./venv310/bin/pytest` (3006.x/3007.x) +**Always use full paths:** `./venv310/bin/pytest` **See [agents/docs/development-setup.md](agents/docs/development-setup.md) for complete setup.** @@ -239,7 +239,7 @@ nox -e test-3 -- tests/pytests/unit/test_loader.py nox -e test-3 -- --lf # Last failed # Direct (faster) -./venv312/bin/pytest tests/pytests/unit/test_foo.py -v +./venv310/bin/pytest tests/pytests/unit/test_foo.py -v ``` **See [agents/docs/testing.md](agents/docs/testing.md) for complete guide.** @@ -293,7 +293,7 @@ __salt__["file.file_exists"](path) **Essential guides in `agents/docs/`:** -- **[agents/docs/development-setup.md](agents/docs/development-setup.md)** - venv310/venv312 setup, dependencies +- **[agents/docs/development-setup.md](agents/docs/development-setup.md)** - venv310/venv311 setup, dependencies - **[agents/docs/architecture.md](agents/docs/architecture.md)** - Complete architecture, module types, loader - **[agents/docs/module-templates.md](agents/docs/module-templates.md)** - Complete templates, all patterns - **[agents/docs/testing.md](agents/docs/testing.md)** - Test templates, mocking, running tests diff --git a/agents/CURSOR.md b/agents/CURSOR.md index 1af29eb00d87..3e62e2495e66 100644 --- a/agents/CURSOR.md +++ b/agents/CURSOR.md @@ -139,7 +139,7 @@ from salt.utils.decorators import depends, memoize **Two environments required:** - **venv310**: Testing 3006.x/3007.x branches -- **venv312**: Testing master branch + pre-commit +- **venv311**: Testing master branch + pre-commit ```bash # venv310 @@ -149,15 +149,15 @@ pip install -r requirements/static/pkg/py3.10/linux.lock # or darwin.lock/windo pip install -r requirements/pytest.txt -r requirements/static/ci/py3.10/tools.lock pip install pre-commit python-tools-scripts && pip install -e . && deactivate -# venv312 -python3.12 -m venv venv312 && source venv312/bin/activate +# venv311 +python3.11 -m venv venv311 && source venv311/bin/activate pip install --upgrade pip setuptools wheel pip install -r requirements/static/pkg/py3.11/linux.lock # or darwin.lock/windows.lock pip install -r requirements/pytest.txt -r requirements/static/ci/py3.11/tools.lock pip install pre-commit python-tools-scripts && pip install -e . && pre-commit install && deactivate ``` -**Always use full paths:** `./venv312/bin/python`, `./venv312/bin/pytest` (master); `./venv310/...` for 3006.x/3007.x +**Always use full paths:** `./venv310/bin/python`, `./venv310/bin/pytest` **Complete setup: [agents/docs/development-setup.md](agents/docs/development-setup.md)** @@ -192,7 +192,7 @@ nox -e test-3 -- tests/pytests/unit/test_loader.py nox -e test-3 -- --lf # Last failed # Direct (faster) -./venv312/bin/pytest tests/pytests/unit/test_foo.py -v +./venv310/bin/pytest tests/pytests/unit/test_foo.py -v ``` **Complete guide: [agents/docs/testing.md](agents/docs/testing.md)** diff --git a/agents/GEMINI.md b/agents/GEMINI.md index e51cb1b5d0ea..0e4e6e99dcc0 100644 --- a/agents/GEMINI.md +++ b/agents/GEMINI.md @@ -190,7 +190,7 @@ Co-Authored-By: Claude **Essential guides in `agents/docs/`:** -- **[agents/docs/development-setup.md](agents/docs/development-setup.md)** - venv310/venv312 setup, dependencies, verification +- **[agents/docs/development-setup.md](agents/docs/development-setup.md)** - venv310/venv311 setup, dependencies, verification - **[agents/docs/architecture.md](agents/docs/architecture.md)** - Complete architecture, module types, loader, event bus - **[agents/docs/module-templates.md](agents/docs/module-templates.md)** - Complete templates, `__virtual__()` patterns, decorators - **[agents/docs/testing.md](agents/docs/testing.md)** - Test structure, templates, mocking, running tests diff --git a/agents/README.md b/agents/README.md index bea94c39baa1..d4698ed6145d 100644 --- a/agents/README.md +++ b/agents/README.md @@ -24,7 +24,7 @@ Each instruction file provides a quick reference and links to detailed documenta The `docs/` directory contains comprehensive guides that are referenced by all agent instruction files. This provides a single source of truth for detailed information: - **[development-setup.md](docs/development-setup.md)** - Complete virtual environment setup - - venv310 and venv312 setup instructions + - venv310 and venv311 setup instructions - Platform-specific dependencies - Installation verification steps - Common troubleshooting diff --git a/agents/docs/architecture.md b/agents/docs/architecture.md index d610c4651253..0cec2be75fa6 100644 --- a/agents/docs/architecture.md +++ b/agents/docs/architecture.md @@ -158,8 +158,6 @@ Salt includes a file server that serves files to minions: - Multiple backends: local, git, S3, HTTP, etc. - Files are cached on minions -For details on the Git backends and performance, see [GitFS Providers](gitfs-providers.md). - ## Targeting Minions can be targeted in multiple ways: diff --git a/agents/docs/development-setup.md b/agents/docs/development-setup.md index f879cd05ad5e..92b4237e2d5c 100644 --- a/agents/docs/development-setup.md +++ b/agents/docs/development-setup.md @@ -114,13 +114,13 @@ deactivate ## Verify Installation ```bash -# Test Salt import (master / default dev) -./venv314/bin/python -c "import salt.version; print(salt.version.__version__)" +# Test Salt import +./venv310/bin/python -c "import salt.version; print(salt.version.__version__)" # Run a simple test -./venv314/bin/pytest tests/pytests/unit/test_loader.py -v +./venv310/bin/pytest tests/pytests/unit/test_loader.py -v -# Test tools (3006.x/3007.x workflow) +# Test tools ./venv310/bin/python -m tools --help ``` @@ -137,7 +137,7 @@ When running tests or tools, always use the full path to the venv executable or **Alternatively, activate first:** ```bash -source venv314/bin/activate +source venv310/bin/activate pytest tests/pytests/unit/test_foo.py -v deactivate ``` diff --git a/agents/docs/testing.md b/agents/docs/testing.md index bf5fdadd3134..65246fe44fe7 100644 --- a/agents/docs/testing.md +++ b/agents/docs/testing.md @@ -197,11 +197,11 @@ nox -e coverage-report If you have a local venv setup: ```bash -# Run tests directly with pytest (master branch: use venv312) -./venv312/bin/pytest tests/pytests/unit/test_foo.py::test_bar -v +# Run tests directly with pytest +./venv310/bin/pytest tests/pytests/unit/test_foo.py::test_bar -v # Run pre-commit checks on specific files -./venv312/bin/pre-commit run --files salt/loader/lazy.py +./venv310/bin/pre-commit run --files salt/loader/lazy.py ``` ## Container Testing (Reproduce CI Failures) diff --git a/agents/mcp/salt_test/README.md b/agents/mcp/salt_test/README.md index 6983bd1e9601..7f19e2275309 100644 --- a/agents/mcp/salt_test/README.md +++ b/agents/mcp/salt_test/README.md @@ -281,8 +281,8 @@ deactivate **Verify setup:** ```bash -./venv314/bin/python -c "import salt.version; print(salt.version.__version__)" -./venv314/bin/pytest tests/pytests/unit/test_loader.py -v +./venv310/bin/python -c "import salt.version; print(salt.version.__version__)" +./venv310/bin/pytest tests/pytests/unit/test_loader.py -v ./venv310/bin/python -m tools --help ``` diff --git a/changelog/62852.added.md b/changelog/62852.added.md deleted file mode 100644 index cf8dc7a4e155..000000000000 --- a/changelog/62852.added.md +++ /dev/null @@ -1 +0,0 @@ -added conditional X functionality to linux_acl diff --git a/changelog/66603.fixed.md b/changelog/66603.fixed.md new file mode 100644 index 000000000000..a3bd1d948e9f --- /dev/null +++ b/changelog/66603.fixed.md @@ -0,0 +1,9 @@ +Fixed a regression where setting ``ipv6: true`` in the minion configuration +caused the minion to fail to start on Windows. Three IPC socket paths in the +TCP transport hardcoded ``AF_INET`` or ``127.0.0.1`` regardless of the IPv6 +setting: the IPC publish server/client addresses in ``salt.transport.base``, +the ``TCPPuller`` server socket, and the ``_TCPPubServerPublisher`` client +socket. On Windows, mixing an ``AF_INET6`` socket with the IPv4 loopback +address (or vice-versa) is rejected by the OS. All three paths now use +``::1`` with ``AF_INET6`` when ``ipv6: true`` is set, and ``127.0.0.1`` +with ``AF_INET`` otherwise. diff --git a/changelog/69018.fixed.md b/changelog/69018.fixed.md deleted file mode 100644 index c486558f1b1f..000000000000 --- a/changelog/69018.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -Restore the ``reclass`` ext_pillar adapter (``salt.pillar.reclass_adapter``) -that was dropped when community extensions were purged from the 3008 tree. -Existing ``ext_pillar: - reclass:`` master configurations work again on -3008.x without downgrading. diff --git a/changelog/69228.fixed.md b/changelog/69228.fixed.md deleted file mode 100644 index e9db451edc7b..000000000000 --- a/changelog/69228.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Added a regression test covering the `TypeError: string indices must be integers` crash in `AsyncReqChannel.crypted_transfer_decode_dictentry` when the master returns a bare-string error payload for a pillar request. The crash itself was already fixed on master by the layered `isinstance(ret, dict)` guards in `salt/channel/client.py`; the test pins that behavior. diff --git a/changelog/69303.fixed.md b/changelog/69303.fixed.md deleted file mode 100644 index a978f5d8bc89..000000000000 --- a/changelog/69303.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fix PAM authentication always returning 401 on relenv/onedir installs by preferring `sys.executable` over `/usr/bin/python3` when launching the PAM helper subprocess. diff --git a/changelog/69307.fixed.md b/changelog/69307.fixed.md deleted file mode 100644 index 932c865a0841..000000000000 --- a/changelog/69307.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed auth tokens being deleted from the `localfs` cache driver within one master `loop_interval` (default 60s) of being minted: `Cache.clean_expired`'s fallback path now consults the cache-level `_expires` envelope instead of file mtime, and `LoadAuth.mk_token` passes a relative duration to `Cache.store(expires=...)` rather than an absolute epoch. diff --git a/changelog/69418.fixed.md b/changelog/69418.fixed.md deleted file mode 100644 index 6e84b197ae37..000000000000 --- a/changelog/69418.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -Fixed `salt -b` (sync batch mode) failing with `SaltClientError: Some exception handling minion payload` when the salt-master runs as a non-root user (e.g. `salt`). The sync CLI batch driver had been writing batch-state persistence files (`.batch.p`, `batch_active.p`) under the master's `cachedir` from the CLI process — pre-creating the JID directory with root ownership and tripping a `PermissionError` in `local_cache.prep_jid` on the master. - -The sync CLI driver no longer writes anything under the master's `cachedir` itself. Instead it ships every state transition to the master-side `BatchManager` as `salt/batch//{new,progress,complete,halted}` events; the manager — already running as the master daemon's user — persists `.batch.p` and maintains the active-batch index on the CLI's behalf. `salt-run batch.status `, `salt-run batch.list_active`, and `salt-run batch.stop ` now work for sync batches in the same deployment shape (non-root master, root CLI) where the original feature was broken. Event-bus failures degrade gracefully: the batch still completes, just without visibility from the runner commands. diff --git a/changelog/69448.fixed.md b/changelog/69448.fixed.md deleted file mode 100644 index 08a40b56d4de..000000000000 --- a/changelog/69448.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed Photon OS Arm64 FIPS CI by re-enabling the OpenSSL default provider after installing openssl-fips-provider, working around the disabled-default-provider bug in `openssl-fips-provider <= 3.1.2-3.ph5` on the lagging Photon aarch64 mirror. diff --git a/changelog/69451.fixed.md b/changelog/69451.fixed.md deleted file mode 100644 index 3e60e5bca5da..000000000000 --- a/changelog/69451.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed `AESFuncs._register_resources` to fire a `minion_data_cache_events` notification on the master event bus when resource grains are written to the cache, mirroring the existing notification fired by `_pillar` for ordinary minion grains. diff --git a/changelog/69453.added.md b/changelog/69453.added.md deleted file mode 100644 index 5d9dce8ff849..000000000000 --- a/changelog/69453.added.md +++ /dev/null @@ -1 +0,0 @@ -Added ``unmask`` parameter to ``pillar.ls``, ``pillar.raw``, ``pillar.ext``, ``pillar.keys``, and ``pillar.obfuscate`` for API consistency with ``pillar.get`` / ``pillar.items`` / ``pillar.item`` / ``pillar.data``. Default masking behavior is unchanged. diff --git a/changelog/69454.fixed.md b/changelog/69454.fixed.md deleted file mode 100644 index a874f33e4ef3..000000000000 --- a/changelog/69454.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed the towncrier changelog template splitting every multi-line fragment into separate top-level bullets with a duplicate `[#NNNN]` link on each. Multi-line fragments now render as a single bullet with continuation lines indented under it, and the issue link is appended exactly once. diff --git a/changelog/69472.fixed.md b/changelog/69472.fixed.md deleted file mode 100644 index ba6b4e953f79..000000000000 --- a/changelog/69472.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed ``salt.utils.url.parse`` so ``salt:///path`` (three-slash URLs with an empty authority) resolves the same as ``salt://path``. Restores ``cp.get_file salt:///path/to/file`` and similar fileclient calls that previously failed because the surplus leading slash was rejected by the master fileserver's absolute-path guard. diff --git a/changelog/69488.removed.md b/changelog/69488.removed.md deleted file mode 100644 index dc30ec78fb84..000000000000 --- a/changelog/69488.removed.md +++ /dev/null @@ -1 +0,0 @@ -Removed 11 stale `.txt` files under `requirements/static/{pkg,ci}/py*/` that were missed by the `.txt` -> `.lock` migration. Three are true orphans from dropped Python 3.8 support; eight shadowed current `.lock` siblings which are the authoritative artifacts. diff --git a/changelog/69494.added.md b/changelog/69494.added.md deleted file mode 100644 index 6d5381422f40..000000000000 --- a/changelog/69494.added.md +++ /dev/null @@ -1 +0,0 @@ -Documented the ``gitcli`` GitFS provider (added in 3008.0) which shells out to the system ``git`` binary, auto-detected after ``pygit2`` and ``gitpython`` and used as a silent fallback when neither Python library is installed. Documented the ``cluster_isolated_filesystem`` master option (added in 3008.0) which lets master clusters run without a shared filesystem; keys, denied keys, ``file_roots`` and ``pillar_roots`` are sync'd in-band over the cluster transport, with ``keys.cache_driver: mmap_key`` as the recommended companion. diff --git a/cicd/shared-gh-workflows-context.yml b/cicd/shared-gh-workflows-context.yml index 4eb2723abf25..31d4c331c4c2 100644 --- a/cicd/shared-gh-workflows-context.yml +++ b/cicd/shared-gh-workflows-context.yml @@ -1,16 +1,10 @@ -# Shared context variables for GitHub Actions workflows -# This file defines versions and configuration used across CI/CD workflows - -# Tool versions nox_version: "2022.8.7" -python_version: "3.14.6" +python_version: "3.10.20" relenv_version: "0.22.14" release_branches: - "3006.x" - "3007.x" - "3008.x" - -# Test run slugs for PR testing (subset of platforms) pr-testrun-slugs: - ubuntu-24.04-pkg - ubuntu-24.04 @@ -21,8 +15,6 @@ pr-testrun-slugs: - windows-2025-msi-pkg - macos-15 - macos-15-pkg - -# Test run slugs for full testing (all platforms) full-testrun-slugs: - all test-salt-listing: diff --git a/conf/master b/conf/master index ebe5503cdc16..b72d89ef00d1 100644 --- a/conf/master +++ b/conf/master @@ -839,11 +839,6 @@ # - git://github.com/saltstack/salt-states.git # - file:///var/git/saltmaster # -# The gitfs_proxy option specifies the URL of the proxy server that will be -# used for contacting the gitfs backend. It defaults to the empty string, which -# means that no proxy server will be used. -#gitfs_proxy: '' -# # The gitfs_ssl_verify option specifies whether to ignore ssl certificate # errors when contacting the gitfs backend. You might want to set this to # false if you're using a git backend that uses a self-signed certificate but @@ -980,10 +975,6 @@ # and SLS files are located. #git_pillar_root: '' -# Specifies the URL of the proxy server that will be used for contacting the -# remote repository. -#git_pillar_proxy: '' - # Specifies whether or not to ignore SSL certificate errors when contacting # the remote repository. #git_pillar_ssl_verify: False @@ -1286,9 +1277,6 @@ # List of git repositories to include with the local repo: #winrepo_remotes_ng: # - 'https://github.com/saltstack/salt-winrepo-ng.git' -# -# Proxy server used for contacting the remote repository: -#winrepo_proxy: '' ##### Windows Software Repo settings - Pre 2015.8 ##### @@ -1399,52 +1387,3 @@ # The port required to be open for a master cluster to properly function #cluster_pool_port: 4520 - -# Pre-shared string that authenticates a master joining the cluster. All peers -# must be configured with the same value. Leaving it unset matches empty against -# empty and provides no authentication -- always set a high-entropy value in -# production. -#cluster_secret: "" - -# Optional SHA-256 hex digest of the shared cluster public key. When set, a -# joining master rejects any discover-reply whose cluster public key does not -# hash to this value. Use when the joining master cannot read the cluster -# public key from a shared cluster_pki_dir. -#cluster_pub_fingerprint: "" - -# When True, cluster masters do not share cluster_pki_dir or cachedir between -# members. Each peer keeps a local copy and a joining master pulls keys, -# denied keys, file_roots and pillar_roots from a peer in-band over the -# cluster transport. Recommended companion: keys.cache_driver: mmap_key. -#cluster_isolated_filesystem: False - -# Backend driver for accepted, pending, denied, and rejected minion keys. -# Default is localfs_key. Set to mmap_key when running an isolated-filesystem -# cluster; run `salt-run pki.migrate_to_mmap` to convert an existing master. -#keys.cache_driver: localfs_key - -# Maximum in-memory Raft log entries before the log compacts to a snapshot. -# None disables compaction (default). Set a positive integer at scale. -#cluster_max_log_size: None - -# Upper bound on the number of voting Raft peers. None means uncapped (default). -# Late joiners above the cap stay as non-voting learners. -#cluster_max_voters: None - -# Floor on the number of voting peers used by the voter-health watchdog. -#cluster_min_voters: 3 - -# Seconds a voter may be silent before becoming a candidate for demotion. Only -# applies when cluster_auto_replace_voters is True. -#cluster_voter_timeout: 10.0 - -# Seconds between voter-health watchdog ticks on the leader. -#cluster_voter_health_check_interval: 1.0 - -# Seconds after demotion before the same node can be re-promoted. Prevents -# flapping voters. -#cluster_demote_cooldown: 60.0 - -# When True, the leader demotes voters that have been silent longer than -# cluster_voter_timeout and promotes a caught-up learner to replace them. -#cluster_auto_replace_voters: False diff --git a/conf/suse/master b/conf/suse/master index 0ac611aacf77..863d87902402 100644 --- a/conf/suse/master +++ b/conf/suse/master @@ -764,11 +764,6 @@ syndic_user: salt # - git://github.com/saltstack/salt-states.git # - file:///var/git/saltmaster # -# The gitfs_proxy option specifies the URL of the proxy server that will be -# used for contacting the gitfs backend. It defaults to the empty string, which -# means that no proxy server will be used. -#gitfs_proxy: '' -# # The gitfs_ssl_verify option specifies whether to ignore ssl certificate # errors when contacting the gitfs backend. You might want to set this to # false if you're using a git backend that uses a self-signed certificate but @@ -901,10 +896,6 @@ syndic_user: salt # and SLS files are located. #git_pillar_root: '' -# Specifies the URL of the proxy server that will be used for contacting the -# remote repository. -#git_pillar_proxy: '' - # Specifies whether or not to ignore SSL certificate errors when contacting # the remote repository. #git_pillar_ssl_verify: False @@ -1174,9 +1165,6 @@ syndic_user: salt # List of git repositories to include with the local repo: #winrepo_remotes_ng: # - 'https://github.com/saltstack/salt-winrepo-ng.git' -# -# Proxy server used for contacting the remote repository: -#winrepo_proxy: '' ##### Windows Software Repo settings - Pre 2015.8 ##### diff --git a/doc/Makefile b/doc/Makefile index 5aa4dd9e21d2..e767cebb4756 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -16,7 +16,7 @@ ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -.PHONY: help clean check_sphinx-build html dirhtml singlehtml pickle json htmlhelp qthelp devhelp latex latexpdf text man changes linkcheck linkcheck-audit sitemap doctest +.PHONY: help clean check_sphinx-build html dirhtml singlehtml pickle json htmlhelp qthelp devhelp latex latexpdf text man changes linkcheck doctest help: @echo "Please use \`make ' where is one of" @@ -41,8 +41,6 @@ help: @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" - @echo " linkcheck-audit to run the wrapped audit (strips the catch-all ignore) and emit a CSV" - @echo " sitemap to build the HTML output with sphinx-sitemap and emit a sitemap.xml" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: @@ -175,20 +173,6 @@ linkcheck: check_sphinx-build @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." -linkcheck-audit: - cd .. && python tools/audit_doc_links.py --doc-dir doc \ - --build-dir doc/$(BUILDDIR)/linkcheck-audit \ - --csv doc/$(BUILDDIR)/linkcheck-audit/report.csv - @echo - @echo "Link audit complete; CSV report at $(BUILDDIR)/linkcheck-audit/report.csv." - -sitemap: check_sphinx-build - $(SPHINXBUILD) -b html -D extensions=sphinx_sitemap \ - -D html_baseurl=https://docs.saltproject.io/en/latest/ \ - $(ALLSPHINXOPTS) $(BUILDDIR)/sitemap - @echo - @echo "Sitemap generated under $(BUILDDIR)/sitemap/sitemap.xml." - doctest: check_sphinx-build $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ diff --git a/doc/conf.py b/doc/conf.py index c944f58dcb5d..05f35a4bd39c 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -165,14 +165,7 @@ def _safe_urlsplit(url, scheme="", allow_fragments=True): master_doc = "contents" templates_path = ["_templates"] -exclude_patterns = [ - "_build", - "_incl/*", - "ref/cli/_includes/*.rst", - # Stand-alone design notes; not linked from the doc toctree (see e.g. - # ``topics/proposals/async-batch.md``). - "topics/proposals/*.md", -] +exclude_patterns = ["_build", "_incl/*", "ref/cli/_includes/*.rst"] extensions = [ "saltdomain", # Must come early diff --git a/doc/contents.rst b/doc/contents.rst index 09d971e772f3..f27ea49c7ede 100644 --- a/doc/contents.rst +++ b/doc/contents.rst @@ -20,15 +20,12 @@ Salt Table of Contents topics/return_codes/index topics/utils/index topics/event/index - topics/tracing/index - topics/metrics/index topics/orchestrate/index topics/solaris/index topics/ssh/index topics/thorium/index topics/cloud/index topics/proxyminion/index - topics/resources/index topics/network_automation/index topics/virt/index topics/packaging/index @@ -39,7 +36,6 @@ Salt Table of Contents topics/api topics/topology/index topics/cache/index - topics/performance/index topics/slots/index topics/windows/index topics/development/index diff --git a/doc/ref/auth/all/index.rst b/doc/ref/auth/all/index.rst index 5cd70e54a43c..cad05ba139b2 100644 --- a/doc/ref/auth/all/index.rst +++ b/doc/ref/auth/all/index.rst @@ -11,8 +11,13 @@ auth modules :template: autosummary.rst.tmpl auto + django file + keystone ldap + mysql pam + pki rest sharedsecret + yubico diff --git a/doc/ref/auth/all/salt.auth.django.rst b/doc/ref/auth/all/salt.auth.django.rst new file mode 100644 index 000000000000..1e33d80c221d --- /dev/null +++ b/doc/ref/auth/all/salt.auth.django.rst @@ -0,0 +1,5 @@ +salt.auth.django +================ + +.. automodule:: salt.auth.django + :members: diff --git a/doc/ref/auth/all/salt.auth.keystone.rst b/doc/ref/auth/all/salt.auth.keystone.rst new file mode 100644 index 000000000000..04e3dd56e7ba --- /dev/null +++ b/doc/ref/auth/all/salt.auth.keystone.rst @@ -0,0 +1,5 @@ +salt.auth.keystone +================== + +.. automodule:: salt.auth.keystone + :members: diff --git a/doc/ref/auth/all/salt.auth.mysql.rst b/doc/ref/auth/all/salt.auth.mysql.rst new file mode 100644 index 000000000000..387d6ffc02fe --- /dev/null +++ b/doc/ref/auth/all/salt.auth.mysql.rst @@ -0,0 +1,5 @@ +salt.auth.mysql +=============== + +.. automodule:: salt.auth.mysql + :members: diff --git a/doc/ref/auth/all/salt.auth.pki.rst b/doc/ref/auth/all/salt.auth.pki.rst new file mode 100644 index 000000000000..55901068cbb2 --- /dev/null +++ b/doc/ref/auth/all/salt.auth.pki.rst @@ -0,0 +1,5 @@ +salt.auth.pki +============= + +.. automodule:: salt.auth.pki + :members: diff --git a/doc/ref/auth/all/salt.auth.yubico.rst b/doc/ref/auth/all/salt.auth.yubico.rst new file mode 100644 index 000000000000..8db41b5d1bf7 --- /dev/null +++ b/doc/ref/auth/all/salt.auth.yubico.rst @@ -0,0 +1,5 @@ +salt.auth.yubico +================ + +.. automodule:: salt.auth.yubico + :members: diff --git a/doc/ref/beacons/all/index.rst b/doc/ref/beacons/all/index.rst index 83b8d769a338..b2b401c90de6 100644 --- a/doc/ref/beacons/all/index.rst +++ b/doc/ref/beacons/all/index.rst @@ -10,13 +10,22 @@ beacon modules :toctree: :template: autosummary.rst.tmpl + adb + aix_account + avahi_announce + bonjour_announce + btmp cert_info diskusage + glxinfo + haproxy inotify journald + junos_rre_keys load log_beacon memusage + napalm_beacon network_info network_settings pkg @@ -24,8 +33,14 @@ beacon modules ps salt_monitor salt_proxy + sensehat service sh + smartos_imgadm + smartos_vmadm status swapusage + telegram_bot_msg + twilio_txt_msg watchdog + wtmp diff --git a/doc/ref/beacons/all/salt.beacons.adb.rst b/doc/ref/beacons/all/salt.beacons.adb.rst new file mode 100644 index 000000000000..a9b74c404a20 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.adb.rst @@ -0,0 +1,5 @@ +salt.beacons.adb +================ + +.. automodule:: salt.beacons.adb + :members: diff --git a/doc/ref/beacons/all/salt.beacons.aix_account.rst b/doc/ref/beacons/all/salt.beacons.aix_account.rst new file mode 100644 index 000000000000..b9b273217e50 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.aix_account.rst @@ -0,0 +1,5 @@ +salt.beacons.aix_account +======================== + +.. automodule:: salt.beacons.aix_account + :members: diff --git a/doc/ref/beacons/all/salt.beacons.avahi_announce.rst b/doc/ref/beacons/all/salt.beacons.avahi_announce.rst new file mode 100644 index 000000000000..2dd3a3ea1eb5 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.avahi_announce.rst @@ -0,0 +1,6 @@ +salt.beacons.avahi_announce +=========================== + +.. automodule:: salt.beacons.avahi_announce + :members: + :undoc-members: diff --git a/doc/ref/beacons/all/salt.beacons.bonjour_announce.rst b/doc/ref/beacons/all/salt.beacons.bonjour_announce.rst new file mode 100644 index 000000000000..00bfb0e2c916 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.bonjour_announce.rst @@ -0,0 +1,6 @@ +salt.beacons.bonjour_announce +============================= + +.. automodule:: salt.beacons.bonjour_announce + :members: + :undoc-members: diff --git a/doc/ref/beacons/all/salt.beacons.btmp.rst b/doc/ref/beacons/all/salt.beacons.btmp.rst new file mode 100644 index 000000000000..27ead9795c69 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.btmp.rst @@ -0,0 +1,5 @@ +salt.beacons.btmp +================= + +.. automodule:: salt.beacons.btmp + :members: diff --git a/doc/ref/beacons/all/salt.beacons.glxinfo.rst b/doc/ref/beacons/all/salt.beacons.glxinfo.rst new file mode 100644 index 000000000000..2433be757a5d --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.glxinfo.rst @@ -0,0 +1,5 @@ +salt.beacons.glxinfo +==================== + +.. automodule:: salt.beacons.glxinfo + :members: diff --git a/doc/ref/beacons/all/salt.beacons.haproxy.rst b/doc/ref/beacons/all/salt.beacons.haproxy.rst new file mode 100644 index 000000000000..07b8e6d4ba09 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.haproxy.rst @@ -0,0 +1,6 @@ +salt.beacons.haproxy +==================== + +.. automodule:: salt.beacons.haproxy + :members: + :undoc-members: diff --git a/doc/ref/beacons/all/salt.beacons.junos_rre_keys.rst b/doc/ref/beacons/all/salt.beacons.junos_rre_keys.rst new file mode 100644 index 000000000000..2c15557fa62d --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.junos_rre_keys.rst @@ -0,0 +1,5 @@ +salt.beacons.junos_rre_keys +=========================== + +.. automodule:: salt.beacons.junos_rre_keys + :members: diff --git a/doc/ref/beacons/all/salt.beacons.napalm_beacon.rst b/doc/ref/beacons/all/salt.beacons.napalm_beacon.rst new file mode 100644 index 000000000000..58303c3d0359 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.napalm_beacon.rst @@ -0,0 +1,5 @@ +salt.beacons.napalm_beacon +========================== + +.. automodule:: salt.beacons.napalm_beacon + :members: diff --git a/doc/ref/beacons/all/salt.beacons.sensehat.rst b/doc/ref/beacons/all/salt.beacons.sensehat.rst new file mode 100644 index 000000000000..fc08e62337f2 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.sensehat.rst @@ -0,0 +1,5 @@ +salt.beacons.sensehat module +============================ + +.. automodule:: salt.beacons.sensehat + :members: diff --git a/doc/ref/beacons/all/salt.beacons.smartos_imgadm.rst b/doc/ref/beacons/all/salt.beacons.smartos_imgadm.rst new file mode 100644 index 000000000000..8a2b0bd285b8 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.smartos_imgadm.rst @@ -0,0 +1,5 @@ +salt.beacons.smartos_imgadm +=========================== + +.. automodule:: salt.beacons.smartos_imgadm + :members: diff --git a/doc/ref/beacons/all/salt.beacons.smartos_vmadm.rst b/doc/ref/beacons/all/salt.beacons.smartos_vmadm.rst new file mode 100644 index 000000000000..d5d2e6fde818 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.smartos_vmadm.rst @@ -0,0 +1,5 @@ +salt.beacons.smartos_vmadm +========================== + +.. automodule:: salt.beacons.smartos_vmadm + :members: diff --git a/doc/ref/beacons/all/salt.beacons.telegram_bot_msg.rst b/doc/ref/beacons/all/salt.beacons.telegram_bot_msg.rst new file mode 100644 index 000000000000..10d7dfadb092 --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.telegram_bot_msg.rst @@ -0,0 +1,5 @@ +salt.beacons.telegram_bot_msg +============================= + +.. automodule:: salt.beacons.telegram_bot_msg + :members: diff --git a/doc/ref/beacons/all/salt.beacons.twilio_txt_msg.rst b/doc/ref/beacons/all/salt.beacons.twilio_txt_msg.rst new file mode 100644 index 000000000000..3fb40c67bdcb --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.twilio_txt_msg.rst @@ -0,0 +1,5 @@ +salt.beacons.twilio_txt_msg +=========================== + +.. automodule:: salt.beacons.twilio_txt_msg + :members: diff --git a/doc/ref/beacons/all/salt.beacons.wtmp.rst b/doc/ref/beacons/all/salt.beacons.wtmp.rst new file mode 100644 index 000000000000..31095dd2204d --- /dev/null +++ b/doc/ref/beacons/all/salt.beacons.wtmp.rst @@ -0,0 +1,5 @@ +salt.beacons.wtmp +================= + +.. automodule:: salt.beacons.wtmp + :members: diff --git a/doc/ref/cache/all/index.rst b/doc/ref/cache/all/index.rst index 8047be56a334..7976f3e853bd 100644 --- a/doc/ref/cache/all/index.rst +++ b/doc/ref/cache/all/index.rst @@ -15,8 +15,5 @@ For understanding and usage of the cache modules see the :ref:`cache` topic. consul etcd_cache localfs - localfs_key - mmap_cache - mmap_key mysql_cache redis_cache diff --git a/doc/ref/cache/all/salt.cache.localfs_key.rst b/doc/ref/cache/all/salt.cache.localfs_key.rst deleted file mode 100644 index d40919480e50..000000000000 --- a/doc/ref/cache/all/salt.cache.localfs_key.rst +++ /dev/null @@ -1,5 +0,0 @@ -salt.cache.localfs_key -================================= - -.. automodule:: salt.cache.localfs_key - :members: diff --git a/doc/ref/cache/all/salt.cache.mmap_cache.rst b/doc/ref/cache/all/salt.cache.mmap_cache.rst deleted file mode 100644 index 519c58e4a95f..000000000000 --- a/doc/ref/cache/all/salt.cache.mmap_cache.rst +++ /dev/null @@ -1,10 +0,0 @@ -salt.cache.mmap_cache -===================== - -A memory-mapped cache backend, drop-in for ``localfs``. On large fleets -``mmap_cache`` runs orders of magnitude faster than the default -file-per-entry layout — see :ref:`mmap-cache` for benchmark numbers, -sizing guidance, and the migration path. - -.. automodule:: salt.cache.mmap_cache - :members: diff --git a/doc/ref/cache/all/salt.cache.mmap_key.rst b/doc/ref/cache/all/salt.cache.mmap_key.rst deleted file mode 100644 index 1e38ef1c82d2..000000000000 --- a/doc/ref/cache/all/salt.cache.mmap_key.rst +++ /dev/null @@ -1,12 +0,0 @@ -salt.cache.mmap_key -=================== - -A memory-mapped backend specialised for the master's minion-key store -(``keys`` and ``denied_keys`` banks). Replaces the ``localfs_key`` -directory layout with an O(1) hash table; ``salt-key -L`` and -authentication probes drop from seconds to milliseconds at fleet scale. -See :ref:`mmap-cache` for the full performance picture and migration -runner. - -.. automodule:: salt.cache.mmap_key - :members: diff --git a/doc/ref/cli/salt-call.rst b/doc/ref/cli/salt-call.rst index cc1b0c6143f9..7b316f0f93da 100644 --- a/doc/ref/cli/salt-call.rst +++ b/doc/ref/cli/salt-call.rst @@ -69,18 +69,11 @@ Options .. option:: --file-root=FILE_ROOT - Set this directory as the base file root. Can be specified more than once - to include multiple base file roots. + Set this directory as the base file root. .. option:: --pillar-root=PILLAR_ROOT - Set this directory as the base pillar root. Can be specified more than - once to include multiple base pillar roots. - -.. option:: --states-dir=STATES_DIR - - Set this directory to search for additional states. Can be specified more - than once to include multiple states directories. + Set this directory as the base pillar root. .. option:: --retcode-passthrough @@ -108,38 +101,6 @@ Options Force a refresh of the grains cache -.. option:: -r, --resources - - .. versionadded:: 3008.0 - - Dispatch the call to managed resources in addition to the managing - minion. Without ``-r``, ``salt-call`` runs the function on the - managing minion only and returns a single bare value, preserving - legacy script behaviour. With ``-r``, the function runs against - every resource matched by ``--tgt`` and the result is a dict keyed - by resource id (or by managing-minion id, for the host itself). - - See :ref:`resources-operations` for usage and :ref:`resources` for - background. - -.. option:: --tgt=TGT - - .. versionadded:: 3008.0 - - Targeting expression. Only honoured when ``-r``/``--resources`` is - also passed. Default ``*`` — matches the managing minion and all - managed resources. Supports all targeting forms documented in - :ref:`resources-targeting`, controlled by ``--tgt-type``. - -.. option:: --tgt-type=TGT_TYPE - - .. versionadded:: 3008.0 - - Targeting expression type. Only honoured when ``-r``/``--resources`` - is also passed. Mirrors the master CLI's ``-t``/``--target-type`` - options. Common values: ``glob`` (default), ``compound``, ``grain``, - ``grain_pcre``, ``list``, ``pillar``, ``range``, ``pcre``. - .. include:: _includes/logging-options.rst .. |logfile| replace:: /var/log/salt/minion .. |loglevel| replace:: ``warning`` diff --git a/doc/ref/clouds/all/index.rst b/doc/ref/clouds/all/index.rst index 51a2a29d54a8..14829dec4546 100644 --- a/doc/ref/clouds/all/index.rst +++ b/doc/ref/clouds/all/index.rst @@ -10,4 +10,35 @@ cloud modules :toctree: :template: autosummary.rst.tmpl + aliyun + clc + cloudstack + digitalocean + dimensiondata + ec2 + gce + gogrid + hetzner + joyent + libvirt + linode + lxc + oneandone + opennebula + openstack + packet + parallels + profitbricks + proxmox + pyrax + qingcloud saltify + scaleway + softlayer + softlayer_hw + tencentcloud + vagrant + virtualbox + vmware + vultrpy + xen diff --git a/doc/ref/clouds/all/salt.cloud.clouds.aliyun.rst b/doc/ref/clouds/all/salt.cloud.clouds.aliyun.rst new file mode 100644 index 000000000000..0d78564202a8 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.aliyun.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.aliyun +======================== + +.. automodule:: salt.cloud.clouds.aliyun + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.clc.rst b/doc/ref/clouds/all/salt.cloud.clouds.clc.rst new file mode 100644 index 000000000000..fccc76345cd1 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.clc.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.clc +===================== + +.. automodule:: salt.cloud.clouds.clc + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.cloudstack.rst b/doc/ref/clouds/all/salt.cloud.clouds.cloudstack.rst new file mode 100644 index 000000000000..96b4c0775a31 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.cloudstack.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.cloudstack +============================ + +.. automodule:: salt.cloud.clouds.cloudstack + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.digitalocean.rst b/doc/ref/clouds/all/salt.cloud.clouds.digitalocean.rst new file mode 100644 index 000000000000..a05c8d772f23 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.digitalocean.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.digitalocean +============================== + +.. automodule:: salt.cloud.clouds.digitalocean + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.dimensiondata.rst b/doc/ref/clouds/all/salt.cloud.clouds.dimensiondata.rst new file mode 100644 index 000000000000..8e6b64e4ac53 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.dimensiondata.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.dimensiondata +=============================== + +.. automodule:: salt.cloud.clouds.dimensiondata + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.ec2.rst b/doc/ref/clouds/all/salt.cloud.clouds.ec2.rst new file mode 100644 index 000000000000..ecb9aa949162 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.ec2.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.ec2 +===================== + +.. automodule:: salt.cloud.clouds.ec2 + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.gce.rst b/doc/ref/clouds/all/salt.cloud.clouds.gce.rst new file mode 100644 index 000000000000..6801f48931fe --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.gce.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.gce +===================== + +.. automodule:: salt.cloud.clouds.gce + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.gogrid.rst b/doc/ref/clouds/all/salt.cloud.clouds.gogrid.rst new file mode 100644 index 000000000000..ec9a059d9a45 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.gogrid.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.gogrid +======================== + +.. automodule:: salt.cloud.clouds.gogrid + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.hetzner.rst b/doc/ref/clouds/all/salt.cloud.clouds.hetzner.rst new file mode 100644 index 000000000000..943ac9988441 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.hetzner.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.hetzner +========================= + +.. automodule:: salt.cloud.clouds.hetzner + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.joyent.rst b/doc/ref/clouds/all/salt.cloud.clouds.joyent.rst new file mode 100644 index 000000000000..c55f2d541105 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.joyent.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.joyent +======================== + +.. automodule:: salt.cloud.clouds.joyent + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.libvirt.rst b/doc/ref/clouds/all/salt.cloud.clouds.libvirt.rst new file mode 100644 index 000000000000..4a8dbf040c15 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.libvirt.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.libvirt +========================= + +.. automodule:: salt.cloud.clouds.libvirt + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.linode.rst b/doc/ref/clouds/all/salt.cloud.clouds.linode.rst new file mode 100644 index 000000000000..465ca9bf3ea5 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.linode.rst @@ -0,0 +1,6 @@ +salt.cloud.clouds.linode +======================== + +.. automodule:: salt.cloud.clouds.linode + :members: + :exclude-members: LinodeAPI, LinodeAPIv4 diff --git a/doc/ref/clouds/all/salt.cloud.clouds.lxc.rst b/doc/ref/clouds/all/salt.cloud.clouds.lxc.rst new file mode 100644 index 000000000000..5ec58cb76379 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.lxc.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.lxc +===================== + +.. automodule:: salt.cloud.clouds.lxc + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.oneandone.rst b/doc/ref/clouds/all/salt.cloud.clouds.oneandone.rst new file mode 100644 index 000000000000..73bcb562f05c --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.oneandone.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.oneandone +=========================== + +.. automodule:: salt.cloud.clouds.oneandone + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.opennebula.rst b/doc/ref/clouds/all/salt.cloud.clouds.opennebula.rst new file mode 100644 index 000000000000..68555c36847f --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.opennebula.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.opennebula +============================ + +.. automodule:: salt.cloud.clouds.opennebula + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.openstack.rst b/doc/ref/clouds/all/salt.cloud.clouds.openstack.rst new file mode 100644 index 000000000000..35dc4cd2eb67 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.openstack.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.openstack +=========================== + +.. automodule:: salt.cloud.clouds.openstack + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.packet.rst b/doc/ref/clouds/all/salt.cloud.clouds.packet.rst new file mode 100644 index 000000000000..3729036df0c8 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.packet.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.packet +======================== + +.. automodule:: salt.cloud.clouds.packet + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.parallels.rst b/doc/ref/clouds/all/salt.cloud.clouds.parallels.rst new file mode 100644 index 000000000000..4b4281ed107b --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.parallels.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.parallels +=========================== + +.. automodule:: salt.cloud.clouds.parallels + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.profitbricks.rst b/doc/ref/clouds/all/salt.cloud.clouds.profitbricks.rst new file mode 100644 index 000000000000..9d3fd69af364 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.profitbricks.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.profitbricks +============================== + +.. automodule:: salt.cloud.clouds.profitbricks + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.proxmox.rst b/doc/ref/clouds/all/salt.cloud.clouds.proxmox.rst new file mode 100644 index 000000000000..891e70ac7764 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.proxmox.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.proxmox +========================= + +.. automodule:: salt.cloud.clouds.proxmox + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.pyrax.rst b/doc/ref/clouds/all/salt.cloud.clouds.pyrax.rst new file mode 100644 index 000000000000..555d4a1fbb79 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.pyrax.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.pyrax +======================= + +.. automodule:: salt.cloud.clouds.pyrax + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.qingcloud.rst b/doc/ref/clouds/all/salt.cloud.clouds.qingcloud.rst new file mode 100644 index 000000000000..e1492b3f22fa --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.qingcloud.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.qingcloud +=========================== + +.. automodule:: salt.cloud.clouds.qingcloud + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.scaleway.rst b/doc/ref/clouds/all/salt.cloud.clouds.scaleway.rst new file mode 100644 index 000000000000..10c8dd794f94 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.scaleway.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.scaleway +========================== + +.. automodule:: salt.cloud.clouds.scaleway + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.softlayer.rst b/doc/ref/clouds/all/salt.cloud.clouds.softlayer.rst new file mode 100644 index 000000000000..5c2d1e252f81 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.softlayer.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.softlayer +=========================== + +.. automodule:: salt.cloud.clouds.softlayer + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.softlayer_hw.rst b/doc/ref/clouds/all/salt.cloud.clouds.softlayer_hw.rst new file mode 100644 index 000000000000..d1b2d098eb1d --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.softlayer_hw.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.softlayer_hw +============================== + +.. automodule:: salt.cloud.clouds.softlayer_hw + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.tencentcloud.rst b/doc/ref/clouds/all/salt.cloud.clouds.tencentcloud.rst new file mode 100644 index 000000000000..d3ed6545063d --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.tencentcloud.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.tencentcloud +============================== + +.. automodule:: salt.cloud.clouds.tencentcloud + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.vagrant.rst b/doc/ref/clouds/all/salt.cloud.clouds.vagrant.rst new file mode 100644 index 000000000000..85f6098d30ea --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.vagrant.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.vagrant +========================= + +.. automodule:: salt.cloud.clouds.vagrant + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.virtualbox.rst b/doc/ref/clouds/all/salt.cloud.clouds.virtualbox.rst new file mode 100644 index 000000000000..4dbc43cca9d0 --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.virtualbox.rst @@ -0,0 +1,6 @@ +salt.cloud.clouds.virtualbox +============================ + +.. automodule:: salt.cloud.clouds.virtualbox + :members: + :exclude-members: get_configured_provider diff --git a/doc/ref/clouds/all/salt.cloud.clouds.vmware.rst b/doc/ref/clouds/all/salt.cloud.clouds.vmware.rst new file mode 100644 index 000000000000..9025f16ea92f --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.vmware.rst @@ -0,0 +1,6 @@ +salt.cloud.clouds.vmware +======================== + +.. automodule:: salt.cloud.clouds.vmware + :members: + :exclude-members: get_configured_provider, get_dependencies, script diff --git a/doc/ref/clouds/all/salt.cloud.clouds.vultrpy.rst b/doc/ref/clouds/all/salt.cloud.clouds.vultrpy.rst new file mode 100644 index 000000000000..d767d3f1f32c --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.vultrpy.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.vultrpy +========================= + +.. automodule:: salt.cloud.clouds.vultrpy + :members: diff --git a/doc/ref/clouds/all/salt.cloud.clouds.xen.rst b/doc/ref/clouds/all/salt.cloud.clouds.xen.rst new file mode 100644 index 000000000000..cf038448eb3b --- /dev/null +++ b/doc/ref/clouds/all/salt.cloud.clouds.xen.rst @@ -0,0 +1,5 @@ +salt.cloud.clouds.xen +===================== + +.. automodule:: salt.cloud.clouds.xen + :members: diff --git a/doc/ref/configuration/logging/handlers/index.rst b/doc/ref/configuration/logging/handlers/index.rst index 0b334bd347dc..4565b2ce31a9 100644 --- a/doc/ref/configuration/logging/handlers/index.rst +++ b/doc/ref/configuration/logging/handlers/index.rst @@ -8,3 +8,8 @@ External Logging Handlers .. autosummary:: :toctree: :template: autosummary.rst.tmpl + + fluent_mod + log4mongo_mod + logstash_mod + sentry_mod diff --git a/doc/ref/configuration/logging/handlers/salt.log_handlers.fluent_mod.rst b/doc/ref/configuration/logging/handlers/salt.log_handlers.fluent_mod.rst new file mode 100644 index 000000000000..4952dd96e1b4 --- /dev/null +++ b/doc/ref/configuration/logging/handlers/salt.log_handlers.fluent_mod.rst @@ -0,0 +1,5 @@ +============================ +salt.log_handlers.fluent_mod +============================ + +.. automodule:: salt.log_handlers.fluent_mod diff --git a/doc/ref/configuration/logging/handlers/salt.log_handlers.log4mongo_mod.rst b/doc/ref/configuration/logging/handlers/salt.log_handlers.log4mongo_mod.rst new file mode 100644 index 000000000000..94b9084c47e6 --- /dev/null +++ b/doc/ref/configuration/logging/handlers/salt.log_handlers.log4mongo_mod.rst @@ -0,0 +1,5 @@ +=============================== +salt.log_handlers.log4mongo_mod +=============================== + +.. automodule:: salt.log_handlers.log4mongo_mod diff --git a/doc/ref/configuration/logging/handlers/salt.log_handlers.logstash_mod.rst b/doc/ref/configuration/logging/handlers/salt.log_handlers.logstash_mod.rst new file mode 100644 index 000000000000..f7f4b424c81d --- /dev/null +++ b/doc/ref/configuration/logging/handlers/salt.log_handlers.logstash_mod.rst @@ -0,0 +1,5 @@ +============================== +salt.log_handlers.logstash_mod +============================== + +.. automodule:: salt.log_handlers.logstash_mod diff --git a/doc/ref/configuration/logging/handlers/salt.log_handlers.sentry_mod.rst b/doc/ref/configuration/logging/handlers/salt.log_handlers.sentry_mod.rst new file mode 100644 index 000000000000..0ef323333716 --- /dev/null +++ b/doc/ref/configuration/logging/handlers/salt.log_handlers.sentry_mod.rst @@ -0,0 +1,5 @@ +============================ +salt.log_handlers.sentry_mod +============================ + +.. automodule:: salt.log_handlers.sentry_mod diff --git a/doc/ref/configuration/logging/index.rst b/doc/ref/configuration/logging/index.rst index 08671ad3a1d4..d5d8f9cac6b7 100644 --- a/doc/ref/configuration/logging/index.rst +++ b/doc/ref/configuration/logging/index.rst @@ -184,8 +184,6 @@ formatting matches those used in :py:func:`time.strftime`. Default: ``[%(levelname)-8s] %(message)s`` -Recommended: ``[%(levelname)-8s]%(jid)s%(minion_id)s %(message)s`` - The format of the console logging messages. All standard python logging :py:class:`~logging.LogRecord` attributes can be used. Salt also provides these custom LogRecord attributes to colorize console log output: @@ -207,11 +205,6 @@ custom LogRecord attributes to colorize console log output: log_fmt_console: '[%(levelname)-8s] %(message)s' -.. note:: - - It is recommended to include ``%(jid)s`` and ``%(minion_id)s`` in the log - format to identify messages that relate to specific jobs and minions. - .. conf_log:: log_fmt_logfile ``log_fmt_logfile`` @@ -219,8 +212,6 @@ custom LogRecord attributes to colorize console log output: Default: ``%(asctime)s,%(msecs)03d [%(name)-17s][%(levelname)-8s] %(message)s`` -Recommended: ``%(asctime)s,%(msecs)03d [%(name)-17s:%(lineno)-4d][%(levelname)-8s][%(process)d]%(jid)s%(minion_id)s %(message)s`` - The format of the log file logging messages. All standard python logging :py:class:`~logging.LogRecord` attributes can be used. Salt also provides these custom LogRecord attributes that include padding and enclosing brackets @@ -236,11 +227,6 @@ these custom LogRecord attributes that include padding and enclosing brackets log_fmt_logfile: '%(asctime)s,%(msecs)03d [%(name)-17s][%(levelname)-8s] %(message)s' -.. note:: - - It is recommended to include ``%(jid)s`` and ``%(minion_id)s`` in the log - format to identify messages that relate to specific jobs and minions. - .. conf_log:: log_granular_levels ``log_granular_levels`` @@ -259,11 +245,12 @@ at the ``debug`` level, and sets a custom module to the ``all`` level: 'salt.modules': 'debug' 'salt.loader.saltmaster.ext.module.custom_module': 'all' +.. conf_log:: log_fmt_jid + You can determine what log call name to use here by adding ``%(module)s`` to the log format. Typically, it is the path of the file which generates the log without the trailing ``.py`` and with path separators replaced with ``.`` -.. conf_log:: log_fmt_jid ``log_fmt_jid`` ------------------- @@ -276,20 +263,6 @@ The format of the JID when added to logging messages. log_fmt_jid: '[JID: %(jid)s]' -.. conf_log:: log_fmt_minion_id - -``log_fmt_minion_id`` ----------------------- - -Default: ``[%(minion_id)s]`` - -The format of the minion ID when added to logging messages. - -.. code-block:: yaml - - log_fmt_minion_id: '[%(minion_id)s]' - - External Logging Handlers ------------------------- diff --git a/doc/ref/configuration/master.rst b/doc/ref/configuration/master.rst index c62d6ce308b6..18ae13868b79 100644 --- a/doc/ref/configuration/master.rst +++ b/doc/ref/configuration/master.rst @@ -274,226 +274,6 @@ listens on for incoming TCP connections. The default is ``4520`` cluster_pool_port: 4520 -.. conf_master:: cluster_secret - -``cluster_secret`` ------------------- - -.. versionadded:: 3008.0 - -Pre-shared string that authenticates a master joining the cluster. All peers -must be configured with the same value. Leaving it unset matches empty against -empty and provides no authentication -- always set a high-entropy value in -production. See :ref:`tutorial-master-cluster`. - -.. code-block:: yaml - - cluster_secret: "d8b4c2e1f07a4c3e8a1b5d0a9c7f3e42b6d9a1c4f8e2b7d0a3c6e9f1b4d7a0c3" - -.. conf_master:: cluster_pub_fingerprint - -``cluster_pub_fingerprint`` ---------------------------- - -.. versionadded:: 3008.0 - -Optional SHA-256 hex digest of the shared cluster public key. When set, a -joining master rejects any discover-reply whose cluster public key does not -hash to this value. Useful when the joining master cannot read the cluster -public key from a shared ``cluster_pki_dir``; otherwise leave unset and rely -on ``cluster_secret`` to authenticate the join. - -.. code-block:: shell - - openssl dgst -sha256 /path/to/cluster_pki_dir/cluster.pub - -.. code-block:: yaml - - cluster_pub_fingerprint: "3b1f9d...<64 hex chars>...c7a2" - -.. conf_master:: cluster_isolated_filesystem - -``cluster_isolated_filesystem`` -------------------------------- - -.. versionadded:: 3008.0 - -Default: ``False`` - -When ``True``, cluster masters do not share ``cluster_pki_dir`` or -``cachedir`` between members. Each peer keeps a local copy; a joining -master pulls accepted minion keys, denied keys, :conf_master:`file_roots` -and :conf_master:`pillar_roots` from an existing peer in-band over the -cluster transport before being promoted to a Raft voter. In this mode -:conf_master:`keys.cache_driver` should be set to ``mmap_key`` (see -:ref:`mmap-cache`) so that cache files are deterministic per-bank and -can be sync'd as opaque blobs. - -When ``False`` (the default), the cluster requires a shared filesystem -between peers as described in :ref:`tutorial-master-cluster`. - -.. code-block:: yaml - - cluster_isolated_filesystem: True - keys.cache_driver: mmap_key - -.. conf_master:: cluster_max_voters - -``cluster_max_voters`` ----------------------- - -.. versionadded:: 3008.0 - -Default: ``None`` - -Upper bound on the number of voting peers in the cluster Raft group. -``None`` (the default) preserves the original behaviour: every master that -joins is promoted to a voter once its log catches up. Setting a positive -integer caps the voter set; late joiners that arrive after the cap stay -as non-voting learners indefinitely. Learners still receive log -replication and cluster events, so they remain useful for handling minion -traffic -- they just don't count toward election or commit quorum. - -.. code-block:: yaml - - cluster_max_voters: 5 - -.. conf_master:: cluster_min_voters - -``cluster_min_voters`` ----------------------- - -.. versionadded:: 3008.0 - -Default: ``3`` - -Floor on the number of voting peers. When -:conf_master:`cluster_auto_replace_voters` is enabled, the leader refuses -to demote a silent voter if doing so would shrink the voter set below this -floor. Raising this above the cluster's actual voter count effectively -disables voter auto-replacement. - -.. code-block:: yaml - - cluster_min_voters: 3 - -.. conf_master:: cluster_voter_timeout - -``cluster_voter_timeout`` -------------------------- - -.. versionadded:: 3008.0 - -Default: ``10.0`` - -Seconds a voter may be silent (no successful ``AppendEntries`` or other -contact recorded by the leader) before it becomes a candidate for -demotion by the voter-health watchdog. Only takes effect when -:conf_master:`cluster_auto_replace_voters` is ``True``. - -.. code-block:: yaml - - cluster_voter_timeout: 10.0 - -.. conf_master:: cluster_voter_health_check_interval - -``cluster_voter_health_check_interval`` ---------------------------------------- - -.. versionadded:: 3008.0 - -Default: ``1.0`` - -Seconds between voter-health watchdog ticks on the leader. Each tick the -leader walks the voter set and checks every voter's ``last_contact`` -timestamp against :conf_master:`cluster_voter_timeout`. - -.. code-block:: yaml - - cluster_voter_health_check_interval: 1.0 - -.. conf_master:: cluster_demote_cooldown - -``cluster_demote_cooldown`` ---------------------------- - -.. versionadded:: 3008.0 - -Default: ``60.0`` - -Seconds the voter-health watchdog must wait after demoting a voter before -the same node can be re-promoted. Prevents a flapping node from rapidly -oscillating between voter and learner. - -.. code-block:: yaml - - cluster_demote_cooldown: 60.0 - -.. conf_master:: cluster_auto_replace_voters - -``cluster_auto_replace_voters`` -------------------------------- - -.. versionadded:: 3008.0 - -Default: ``False`` - -When ``True``, the leader runs the voter-health watchdog and demotes -voters that have been silent for :conf_master:`cluster_voter_timeout` -seconds. A caught-up learner is then promoted to fill the slot, subject -to :conf_master:`cluster_max_voters` and :conf_master:`cluster_min_voters`. -Default is opt-in until field-tested. - -.. code-block:: yaml - - cluster_auto_replace_voters: True - -.. conf_master:: cluster_max_log_size - -``cluster_max_log_size`` ------------------------- - -.. versionadded:: 3008.0 - -Default: ``None`` - -Maximum number of in-memory Raft log entries before the log compacts -into a snapshot. ``None`` (the default) disables compaction, which is -fine for small clusters but allows unbounded growth at scale. Set to a -positive integer to trigger ``Log.snapshot()`` whenever the log reaches -the threshold. The snapshot envelope carries every registered state -machine, so membership and ring state survive compaction. - -.. code-block:: yaml - - cluster_max_log_size: 100000 - -.. conf_master:: keys.cache_driver - -``keys.cache_driver`` ---------------------- - -.. versionadded:: 3008.0 - -Default: ``localfs_key`` - -Backend driver for accepted, pending, denied, and rejected minion keys. - -* ``localfs_key`` (default) writes each key to its own file under - ``pki_dir`` / ``cluster_pki_dir`` -- the historical layout that every - prior Salt release used. -* ``mmap_key`` stores keys in a single mmap'd file per bank. Recommended - for isolated-filesystem master clusters - (:conf_master:`cluster_isolated_filesystem`), where deterministic - per-bank layout makes the file safe to sync as an opaque blob between - peers. See :ref:`mmap-cache` for the full driver description and use - :py:func:`pki.migrate_to_mmap ` to - convert an existing master. - -.. code-block:: yaml - - keys.cache_driver: mmap_key - .. conf_master:: extension_modules ``extension_modules`` @@ -609,6 +389,23 @@ Verify and set permissions on configuration directories at startup. verify_env: True +.. conf_master:: keep_jobs + +``keep_jobs`` +------------- + +Default: ``24`` + +Set the number of hours to keep old job information. Note that setting this option +to ``0`` disables the cache cleaner. + +.. deprecated:: 3006 + Replaced by :conf_master:`keep_jobs_seconds` + +.. code-block:: yaml + + keep_jobs: 24 + .. conf_master:: keep_jobs_seconds ``keep_jobs_seconds`` @@ -904,26 +701,11 @@ are expected to reply from executions. Default: ``localfs`` -Cache subsystem module to use for minion data cache. Common values: - -* ``localfs`` — file-per-entry under :conf_master:`cachedir`. The default; - fine for small deployments. -* ``mmap_cache`` — fast memory-mapped hash-table backend. Drop-in for - ``localfs`` with an O(1) get/contains/updated and O(occupied) bulk - listing. On large fleets ``salt-key -L`` and grain/pillar target - matching can run **orders of magnitude faster** than ``localfs``. - Migrate existing data with ``salt-run cache.migrate``. See - :ref:`mmap-cache` for benchmarks, sizing, and durability notes. -* ``consul``, ``redis``, ``etcd``, ``mysql`` — networked backends, useful - for sharing cache across multiple masters. - -The minion-key store is selected separately via ``keys.cache_driver`` -(``localfs_key`` by default; set to ``mmap_key`` for the memory-mapped -variant). +Cache subsystem module to use for minion data cache. .. code-block:: yaml - cache: mmap_cache + cache: consul .. conf_master:: memcache_expire_seconds @@ -1451,22 +1233,6 @@ a minion performs an authentication check with the master. auth_events: True -.. conf_master:: auth_events_autosign_grains - -``auth_events_autosign_grains`` -------------------------------- - -.. versionadded:: 3008 - -Default: ``[]`` - -Determines which actions the master will include autosign_grains for when -firing authentication events. - -.. code-block:: yaml - - auth_events_autosign_grains: ["accept", "pend", "reject", "full", "denied", "error"] - .. conf_master:: minion_data_cache_events ``minion_data_cache_events`` @@ -1950,8 +1716,6 @@ Pass a list of importable Python modules that are typically located in the `site-packages` Python directory so they will be also always included into the Salt Thin, once generated. -.. conf_master:: min_extra_mods - ``min_extra_mods`` ------------------ @@ -1959,47 +1723,6 @@ Default: None Identical as `thin_extra_mods`, only applied to the Salt Minimal. -.. conf_master:: thin_exclude_saltexts - -``thin_exclude_saltexts`` -------------------------- - -Default: False - -By default, Salt-SSH autodiscovers Salt extensions in the current Python environment -and adds them to the Salt Thin. This disables that behavior. - -.. note:: - - When the list of modules/extensions to include in the Salt Thin changes - for any reason (e.g. Saltext was added/removed, :conf_master:`thin_exclude_saltexts`, - :conf_master:`thin_saltext_allowlist` or :conf_master:`thin_saltext_blocklist` - was changed), you typically need to regenerate the Salt Thin by passing - ``--regen-thin`` to the next Salt-SSH invocation. - -.. conf_master:: thin_saltext_allowlist - -``thin_saltext_allowlist`` --------------------------- - -Default: None - -A list of Salt extension **distribution** names which are allowed to be -included in the Salt Thin (when :conf_master:`thin_exclude_saltexts` -is inactive) and they are discovered. Any extension not in this list -will be excluded. If unset, all discovered extensions are added, -unless present in :conf_master:`thin_saltext_blocklist`. - -.. conf_master:: thin_saltext_blocklist - -``thin_saltext_blocklist`` --------------------------- - -Default: None - -A list of Salt extension **distribution** names which should never be -included in the Salt Thin (when :conf_master:`thin_exclude_saltexts` -is inactive). .. _master-security-settings: @@ -2504,116 +2227,6 @@ constant names without ssl module prefix: ``CERT_REQUIRED`` or ``PROTOCOL_SSLv23 certfile: ssl_version: PROTOCOL_TLSv1_2 -.. conf_master:: disable_aes_with_tls - -``disable_aes_with_tls`` ------------------------- - -.. versionadded:: 3008.0 - -Default: ``False`` - -When set to ``True``, Salt will skip application-layer AES encryption when TLS -is active with validated certificates. This optimization can improve performance -by eliminating redundant encryption, as TLS already provides encryption at the -transport layer. - -**Requirements for optimization to activate:** - -1. ``disable_aes_with_tls: true`` on both master and minion -2. Valid SSL configuration (``ssl`` option configured) -3. Mutual TLS authentication (``cert_reqs: CERT_REQUIRED``) -4. TCP or WebSocket transport (not ZeroMQ) -5. Valid peer certificates -6. Minion certificates must contain minion ID in CN or SAN - -If any requirement is not met, Salt automatically falls back to standard AES -encryption. This ensures the feature is safe to enable and maintains backward -compatibility. - -.. code-block:: yaml - - transport: tcp - ssl: - certfile: /etc/pki/tls/certs/salt-master.crt - keyfile: /etc/pki/tls/private/salt-master.key - ca_certs: /etc/pki/tls/certs/ca-bundle.crt - cert_reqs: CERT_REQUIRED - disable_aes_with_tls: true - -.. warning:: - Minion certificates **must** contain the minion ID in either the Common Name - (CN) or Subject Alternative Name (SAN) field to prevent impersonation attacks. - -See :ref:`tls-encryption-optimization` for detailed configuration and security -information. - -.. conf_master:: use_os_truststore - -``use_os_truststore`` ----------------------- - -.. versionadded:: 3008.0 - -Default: ``False`` - -If ``True``, Salt will use the native operating system certificate store for -SSL/TLS verification instead of the bundled ``certifi`` CA bundle. This is -the recommended setting for environments with transparent proxies or internal -root CAs deployed via Group Policy or a device-management system. - -Platform mapping: - -- **Windows** — Local Machine Certificate Store (CryptoAPI) -- **macOS** — Keychain -- **Linux** — ``/etc/ssl/certs`` or ``/etc/pki/tls`` - -.. code-block:: yaml - - use_os_truststore: True - -.. rubric:: Requirements - -The ``truststore`` package must be installed (Python 3.10 or newer). -If the package is not present, Salt logs a warning and falls back to -``certifi``. The ``ca_truststore`` grain reports which store is active. - -.. warning:: - - Do **not** install ``pip-system-certs`` into the Salt Python environment. - That package ships a ``.pth`` file that unconditionally activates the OS - trust store on every Python startup, before Salt reads its configuration, - completely bypassing this setting. - -.. rubric:: Interaction with ``ca_bundle`` - -An explicit ``ca_bundle: /path/to/bundle.pem`` setting always takes -precedence over ``use_os_truststore``. Use ``ca_bundle`` when you need to -pin a specific certificate file regardless of the OS store. - -.. rubric:: PKI architecture - -This setting has **no effect** on Salt's master/minion key authentication -system (``pki_dir``, AES session keys, minion key acceptance). It only -affects outbound HTTPS/TLS connections made by Salt — HTTP runner, gitfs, -fileserver backends, cloud drivers, and similar components. - -.. note:: - - On Windows, the ``LocalSystem`` service account (the default account - for the salt-master and salt-minion Windows services) only has access to - the **Local Machine** certificate store, not the Current User store. - Certificates must be deployed to the Local Machine store, for example - via Group Policy, to be visible to Salt. - -.. note:: - - On Windows, certificate verification is performed via a CryptoAPI service - call rather than a simple file read. This may add a small amount of - latency on the first TLS connection made by a new process compared with - the simple file read used with ``certifi``. On Linux and macOS the - performance difference is negligible. - .. conf_master:: preserve_minion_cache ``preserve_minion_cache`` @@ -2710,9 +2323,9 @@ limit is to search the internet for something like this: Default: ``5`` -The number of MWorker processes to start for receiving commands and replies -from minions. If minions are stalling on replies because you have many -minions, raise the ``worker_threads`` value. +The number of threads to start for receiving commands and replies from minions. +If minions are stalling on replies because you have many minions, raise the +worker_threads value. Worker threads should not be put below 3 when using the peer system, but can drop down to 1 worker otherwise. @@ -2720,107 +2333,20 @@ drop down to 1 worker otherwise. Standards for busy environments: * Use one worker thread per 200 minions. -* The value of ``worker_threads`` should not exceed 1½ times the available CPU - cores. +* The value of worker_threads should not exceed 1½ times the available CPU cores. .. note:: When the master daemon starts, it is expected behaviour to see - multiple salt-master processes, even if ``worker_threads`` is set to - ``1``. At a minimum, a controlling process will start along with a - Publisher, an EventPublisher, and a number of MWorker processes will be - started. The number of MWorker processes is tuneable by the - ``worker_threads`` configuration value while the others are not. + multiple salt-master processes, even if 'worker_threads' is set to '1'. At + a minimum, a controlling process will start along with a Publisher, an + EventPublisher, and a number of MWorker processes will be started. The + number of MWorker processes is tuneable by the 'worker_threads' + configuration value while the others are not. .. code-block:: yaml worker_threads: 5 -.. note:: - ``worker_threads`` only controls the size of the single default worker - pool used by the legacy code path. For finer-grained routing — for - example to give ``_auth`` its own dedicated MWorkers — see - :conf_master:`worker_pools`, :conf_master:`worker_pools_enabled`, and the - :ref:`tunable worker pools ` topic guide. When - ``worker_pools`` is unset the master automatically builds a single - catchall pool sized by ``worker_threads``, so existing configurations - behave exactly as before. - -.. conf_master:: worker_pools_enabled - -``worker_pools_enabled`` ------------------------- - -.. versionadded:: 3008.0 - -Default: ``True`` - -Master-level switch for the :ref:`tunable worker pools ` -feature. When ``True`` (the default) the master uses -:conf_master:`worker_pools` (or, if that is unset, a single catchall pool -sized by :conf_master:`worker_threads`) to route requests to per-pool -MWorkers. When ``False`` the master falls back to the legacy single-queue -MWorker model. - -The default value preserves the historical behavior when no other pool -settings are provided, so upgrading does not require any configuration -changes. Set this to ``False`` only if you need to disable pooled routing -entirely — for example to debug a transport issue. - -.. code-block:: yaml - - worker_pools_enabled: True - -.. conf_master:: worker_pools - -``worker_pools`` ----------------- - -.. versionadded:: 3008.0 - -Default: ``{}`` (an implicit single catchall pool sized by -:conf_master:`worker_threads`) - -Defines the MWorker pools the master should start and the commands each pool -should service. When unset, the master builds a single pool named -``default`` with ``worker_count`` equal to :conf_master:`worker_threads` and -a catchall that receives every command — equivalent to the pre-3008.0 -behavior. - -Each key under ``worker_pools`` names a pool. The value is a dictionary -with two required fields: - -``worker_count`` - Integer ``>= 1``. The number of MWorker processes to start for the - pool. - -``commands`` - List of command strings. Each string must be either an exact command - name (for example ``_auth`` or ``_return``) or the single catchall - entry ``"*"``. - -A command may be mapped to at most one pool. Exactly one pool must use -the ``"*"`` catchall so that every command has a routing destination; -payloads whose ``cmd`` is not matched by an explicit mapping are sent to -that pool. - -The master refuses to start if the configuration is invalid — for example -if two pools claim the same command, if no pool (or more than one pool) -uses the ``"*"`` catchall, or if a pool has no ``commands``. See -:ref:`tunable worker pools ` for a full walkthrough -of the validation rules and recommended layouts. - -.. code-block:: yaml - - worker_pools: - auth: - worker_count: 2 - commands: - - _auth - default: - worker_count: 8 - commands: - - "*" - .. conf_master:: pub_hwm ``pub_hwm`` @@ -2928,11 +2454,7 @@ This option has no default value. Set it to an environment name to ensure that :ref:`highstate `. .. note:: - Minions which have an explicit :conf_minion:`saltenv` set will use that - environment's top file, ignoring this master config option. - -.. note:: - Using this option does not change the merging strategy. For instance, if + Using this value does not change the merging strategy. For instance, if :conf_master:`top_file_merging_strategy` is set to ``merge``, and :conf_master:`state_top_saltenv` is set to ``foo``, then any sections for environments other than ``foo`` in the top file for the ``foo`` environment @@ -3729,12 +3251,9 @@ Walkthrough `. Optional parameter used to specify the provider to be used for gitfs. More information can be found in the :ref:`GitFS Walkthrough `. -Must be ``pygit2``, ``gitpython``, or ``gitcli``. If unset, each will be -tried in the order ``pygit2`` → ``gitpython`` → ``gitcli`` and the first -one with a compatible version installed will be the provider that is used. - -.. versionchanged:: 3008.0 - Added the ``gitcli`` provider and the auto-detect fallback to it. +Must be either ``pygit2`` or ``gitpython``. If unset, then each will be tried +in that same order, and the first one with a compatible version installed will +be the provider that is used. .. code-block:: yaml @@ -3769,43 +3288,6 @@ be a better option. .. versionchanged:: 2016.11.0 The default config value changed from ``False`` to ``True``. -.. conf_master:: gitfs_proxy - -``gitfs_proxy`` -*************** - -.. versionadded:: 3008.0 - -Default: ``''`` - -Specifies the URL of the proxy server that will be used to connect to the -repositories configured in :conf_master:`gitfs_remotes`. By default, no proxy -server will be used. - -.. code-block:: yaml - - gitfs_proxy: http://foo.com:8080/ - -.. conf_master:: gitfs_depth - -``gitfs_depth`` -*************** - -.. versionadded:: 3008.0 - -Default: ``1`` - -Shallow-clone depth used by the ``gitcli`` -:conf_master:`gitfs_provider`. Has no effect on the ``pygit2`` or -``gitpython`` providers. A depth of ``1`` keeps only the latest commit on -each tracked ref, which is the lowest-footprint and lowest-latency mode and -is typically what production gitfs deployments want. Increase it when -documentation tooling or per-file blame need walkable history on the master. - -.. code-block:: yaml - - gitfs_depth: 1 - .. conf_master:: gitfs_mountpoint ``gitfs_mountpoint`` @@ -5118,13 +4600,10 @@ Git External Pillar (git_pillar) Configuration Options .. versionadded:: 2015.8.0 -Specify the provider to be used for git_pillar. Must be ``pygit2``, -``gitpython``, or ``gitcli``. If unset, each will be tried in the order -``pygit2`` → ``gitpython`` → ``gitcli`` and the first one with a compatible -version installed will be the provider that is used. - -.. versionchanged:: 3008.0 - Added the ``gitcli`` provider and the auto-detect fallback to it. +Specify the provider to be used for git_pillar. Must be either ``pygit2`` or +``gitpython``. If unset, then both will be tried in that same order, and the +first one with a compatible version installed will be the provider that is +used. .. code-block:: yaml @@ -5277,40 +4756,6 @@ In the 2016.11.0 release, the default config value changed from ``False`` to pygit2 only supports disabling SSL verification in versions 0.23.2 and newer. -.. conf_master:: git_pillar_proxy - -``git_pillar_proxy`` -******************** - -.. versionadded:: 3008.0 - -Default: ``''`` - -Specifies the URL of the proxy server that will be used to connect to the -remote repository. By default, no proxy server will be used. - -.. code-block:: yaml - - git_pillar_proxy: http://foo.com:8080/ - -.. conf_master:: git_pillar_depth - -``git_pillar_depth`` -******************** - -.. versionadded:: 3008.0 - -Default: ``1`` - -Shallow-clone depth used by the ``gitcli`` -:conf_master:`git_pillar_provider`. Has no effect on the ``pygit2`` or -``gitpython`` providers. Defaults to ``1`` to keep the on-disk footprint -and update latency small at scale. - -.. code-block:: yaml - - git_pillar_depth: 1 - .. conf_master:: git_pillar_global_lock ``git_pillar_global_lock`` @@ -6531,13 +5976,10 @@ Windows Software Repo Settings .. versionadded:: 2015.8.0 -Specify the provider to be used for winrepo. Must be ``pygit2``, -``gitpython``, or ``gitcli``. If unset, each will be tried in the order -``pygit2`` → ``gitpython`` → ``gitcli`` and the first one with a compatible -version installed will be the provider that is used. - -.. versionchanged:: 3008.0 - Added the ``gitcli`` provider and the auto-detect fallback to it. +Specify the provider to be used for winrepo. Must be either ``pygit2`` or +``gitpython``. If unset, then both will be tried in that same order, and the +first one with a compatible version installed will be the provider that is +used. .. code-block:: yaml @@ -6713,40 +6155,6 @@ In the 2016.11.0 release, the default config value changed from ``False`` to winrepo_ssl_verify: True -.. conf_master:: winrepo_proxy - -``winrepo_proxy`` ------------------ - -.. versionadded:: 3008.0 - -Default: ``''`` - -Specifies the URL of the proxy server that will be used to connect to the -remote repository. By default, no proxy server will be used. - -.. code-block:: yaml - - winrepo_proxy: http://foo.com:8080/ - -.. conf_master:: winrepo_depth - -``winrepo_depth`` ------------------ - -.. versionadded:: 3008.0 - -Default: ``1`` - -Shallow-clone depth used by the ``gitcli`` -:conf_master:`winrepo_provider`. Has no effect on the ``pygit2`` or -``gitpython`` providers. Defaults to ``1`` to keep the on-disk footprint -and update latency small at scale. - -.. code-block:: yaml - - winrepo_depth: 1 - Winrepo Authentication Options ------------------------------ diff --git a/doc/ref/configuration/minion.rst b/doc/ref/configuration/minion.rst index cecb3ca89583..4bc528a6dc04 100644 --- a/doc/ref/configuration/minion.rst +++ b/doc/ref/configuration/minion.rst @@ -2367,16 +2367,6 @@ performance is hampered. state_queue: 2 -.. conf_minion:: state_max_parallel - -``state_max_parallel`` ----------------------- - -Default: ``0`` - -Limit the number of ``parallel: true`` states that can be running at the same time. -By default, there is no limit. - .. conf_minion:: state_verbose ``state_verbose`` @@ -3216,118 +3206,6 @@ constant names without ssl module prefix: ``CERT_REQUIRED`` or ``PROTOCOL_SSLv23 certfile: ssl_version: PROTOCOL_TLSv1_2 -.. conf_minion:: disable_aes_with_tls - -``disable_aes_with_tls`` ------------------------- - -.. versionadded:: 3008.0 - -Default: ``False`` - -When set to ``True``, Salt will skip application-layer AES encryption when TLS -is active with validated certificates. This optimization can improve performance -by eliminating redundant encryption, as TLS already provides encryption at the -transport layer. - -**Requirements for optimization to activate:** - -1. ``disable_aes_with_tls: true`` on both master and minion -2. Valid SSL configuration (``ssl`` option configured) -3. Mutual TLS authentication (``cert_reqs: CERT_REQUIRED``) -4. TCP or WebSocket transport (not ZeroMQ) -5. Valid peer certificates -6. Minion certificate must contain minion ID in CN or SAN - -If any requirement is not met, Salt automatically falls back to standard AES -encryption. This ensures the feature is safe to enable and maintains backward -compatibility. - -.. code-block:: yaml - - transport: tcp - ssl: - certfile: /etc/pki/tls/certs/minion.crt - keyfile: /etc/pki/tls/private/minion.key - ca_certs: /etc/pki/tls/certs/ca-bundle.crt - cert_reqs: CERT_REQUIRED - disable_aes_with_tls: true - -.. important:: - The minion certificate **must** contain the minion ID in either the Common - Name (CN) or Subject Alternative Name (SAN) field to prevent impersonation - attacks. See :ref:`tls-encryption-optimization` for certificate generation - instructions. - -See :ref:`tls-encryption-optimization` for detailed configuration and security -information. - -.. conf_minion:: use_os_truststore - -``use_os_truststore`` ----------------------- - -.. versionadded:: 3008.0 - -Default: ``False`` - -If ``True``, Salt will use the native operating system certificate store for -SSL/TLS verification instead of the bundled ``certifi`` CA bundle. This is -the recommended setting for environments with transparent proxies or internal -root CAs deployed via Group Policy or a device-management system. - -Platform mapping: - -- **Windows** — Local Machine Certificate Store (CryptoAPI) -- **macOS** — Keychain -- **Linux** — ``/etc/ssl/certs`` or ``/etc/pki/tls`` - -.. code-block:: yaml - - use_os_truststore: True - -.. rubric:: Requirements - -The ``truststore`` package must be installed (Python 3.10 or newer). -If the package is not present, Salt logs a warning and falls back to -``certifi``. The ``ca_truststore`` grain reports which store is active. - -.. warning:: - - Do **not** install ``pip-system-certs`` into the Salt Python environment. - That package ships a ``.pth`` file that unconditionally activates the OS - trust store on every Python startup, before Salt reads its configuration, - completely bypassing this setting. - -.. rubric:: Interaction with ``ca_bundle`` - -An explicit ``ca_bundle: /path/to/bundle.pem`` setting always takes -precedence over ``use_os_truststore``. Use ``ca_bundle`` when you need to -pin a specific certificate file regardless of the OS store. - -.. rubric:: PKI architecture - -This setting has **no effect** on Salt's master/minion key authentication -system (``pki_dir``, AES session keys, minion key acceptance). It only -affects outbound HTTPS/TLS connections made by Salt — HTTP runner, gitfs, -fileserver backends, cloud drivers, and similar components. - -.. note:: - - On Windows, the ``LocalSystem`` service account (the default account - for the salt-minion Windows service) only has access to the **Local - Machine** certificate store, not the Current User store. Certificates - must be deployed to the Local Machine store, for example via Group - Policy, to be visible to Salt. - -.. note:: - - On Windows, certificate verification is performed via a CryptoAPI service - call rather than a simple file read. This may add a small amount of - latency on the first TLS connection made by a new process compared with - the simple file read used with ``certifi``. On Linux and macOS the - performance difference is negligible. - ``encryption_algorithm`` ------------------------ diff --git a/doc/ref/engines/all/index.rst b/doc/ref/engines/all/index.rst index adecd30f44b7..b0da4230b5c8 100644 --- a/doc/ref/engines/all/index.rst +++ b/doc/ref/engines/all/index.rst @@ -10,8 +10,22 @@ engine modules :toctree: :template: autosummary.rst.tmpl + docker_events + fluent + http_logstash + ircbot + junos_syslog + libvirt_events + logentries + logstash_engine + napalm_syslog reactor + redis_sentinel script + slack + slack_bolt_engine + sqs_events + stalekey test thorium webhook diff --git a/doc/ref/engines/all/salt.engines.docker_events.rst b/doc/ref/engines/all/salt.engines.docker_events.rst new file mode 100644 index 000000000000..1e9e4c9c912c --- /dev/null +++ b/doc/ref/engines/all/salt.engines.docker_events.rst @@ -0,0 +1,5 @@ +salt.engines.docker_events +========================== + +.. automodule:: salt.engines.docker_events + :members: diff --git a/doc/ref/engines/all/salt.engines.fluent.rst b/doc/ref/engines/all/salt.engines.fluent.rst new file mode 100644 index 000000000000..82eae94e391e --- /dev/null +++ b/doc/ref/engines/all/salt.engines.fluent.rst @@ -0,0 +1,5 @@ +salt.engines.fluent +=================== + +.. automodule:: salt.engines.fluent + :members: diff --git a/doc/ref/engines/all/salt.engines.http_logstash.rst b/doc/ref/engines/all/salt.engines.http_logstash.rst new file mode 100644 index 000000000000..d0c2bb78590b --- /dev/null +++ b/doc/ref/engines/all/salt.engines.http_logstash.rst @@ -0,0 +1,5 @@ +salt.engines.http_logstash +========================== + +.. automodule:: salt.engines.http_logstash + :members: diff --git a/doc/ref/engines/all/salt.engines.ircbot.rst b/doc/ref/engines/all/salt.engines.ircbot.rst new file mode 100644 index 000000000000..cf1f47098d38 --- /dev/null +++ b/doc/ref/engines/all/salt.engines.ircbot.rst @@ -0,0 +1,5 @@ +salt.engines.ircbot +=================== + +.. automodule:: salt.engines.ircbot + :members: diff --git a/doc/ref/engines/all/salt.engines.junos_syslog.rst b/doc/ref/engines/all/salt.engines.junos_syslog.rst new file mode 100644 index 000000000000..914988ae9cf3 --- /dev/null +++ b/doc/ref/engines/all/salt.engines.junos_syslog.rst @@ -0,0 +1,6 @@ +salt.engines.junos_syslog +========================= + +.. automodule:: salt.engines.junos_syslog + :members: + :undoc-members: diff --git a/doc/ref/engines/all/salt.engines.libvirt_events.rst b/doc/ref/engines/all/salt.engines.libvirt_events.rst new file mode 100644 index 000000000000..29810b811246 --- /dev/null +++ b/doc/ref/engines/all/salt.engines.libvirt_events.rst @@ -0,0 +1,6 @@ +salt.engines.libvirt_events +=========================== + +.. automodule:: salt.engines.libvirt_events + :members: + :undoc-members: diff --git a/doc/ref/engines/all/salt.engines.logentries.rst b/doc/ref/engines/all/salt.engines.logentries.rst new file mode 100644 index 000000000000..3811065b0e40 --- /dev/null +++ b/doc/ref/engines/all/salt.engines.logentries.rst @@ -0,0 +1,5 @@ +salt.engines.logentries +======================= + +.. automodule:: salt.engines.logentries + :members: diff --git a/doc/ref/engines/all/salt.engines.logstash_engine.rst b/doc/ref/engines/all/salt.engines.logstash_engine.rst new file mode 100644 index 000000000000..3ea0f1829804 --- /dev/null +++ b/doc/ref/engines/all/salt.engines.logstash_engine.rst @@ -0,0 +1,5 @@ +salt.engines.logstash_engine +============================ + +.. automodule:: salt.engines.logstash_engine + :members: diff --git a/doc/ref/engines/all/salt.engines.napalm_syslog.rst b/doc/ref/engines/all/salt.engines.napalm_syslog.rst new file mode 100644 index 000000000000..7ea28a691561 --- /dev/null +++ b/doc/ref/engines/all/salt.engines.napalm_syslog.rst @@ -0,0 +1,5 @@ +salt.engines.napalm_syslog +========================== + +.. automodule:: salt.engines.napalm_syslog + :members: diff --git a/doc/ref/engines/all/salt.engines.redis_sentinel.rst b/doc/ref/engines/all/salt.engines.redis_sentinel.rst new file mode 100644 index 000000000000..0805b31352c8 --- /dev/null +++ b/doc/ref/engines/all/salt.engines.redis_sentinel.rst @@ -0,0 +1,5 @@ +salt.engines.redis_sentinel +=========================== + +.. automodule:: salt.engines.redis_sentinel + :members: diff --git a/doc/ref/engines/all/salt.engines.slack.rst b/doc/ref/engines/all/salt.engines.slack.rst new file mode 100644 index 000000000000..5b63a1a3e26a --- /dev/null +++ b/doc/ref/engines/all/salt.engines.slack.rst @@ -0,0 +1,5 @@ +salt.engines.slack +================== + +.. automodule:: salt.engines.slack + :members: diff --git a/doc/ref/engines/all/salt.engines.slack_bolt_engine.rst b/doc/ref/engines/all/salt.engines.slack_bolt_engine.rst new file mode 100644 index 000000000000..6eee8b4f9b6c --- /dev/null +++ b/doc/ref/engines/all/salt.engines.slack_bolt_engine.rst @@ -0,0 +1,5 @@ +salt.engines.slack_bolt_engine +============================== + +.. automodule:: salt.engines.slack_bolt_engine + :members: diff --git a/doc/ref/engines/all/salt.engines.sqs_events.rst b/doc/ref/engines/all/salt.engines.sqs_events.rst new file mode 100644 index 000000000000..101edf16746d --- /dev/null +++ b/doc/ref/engines/all/salt.engines.sqs_events.rst @@ -0,0 +1,5 @@ +salt.engines.sqs_events +======================= + +.. automodule:: salt.engines.sqs_events + :members: diff --git a/doc/ref/engines/all/salt.engines.stalekey.rst b/doc/ref/engines/all/salt.engines.stalekey.rst new file mode 100644 index 000000000000..9bb3078e9087 --- /dev/null +++ b/doc/ref/engines/all/salt.engines.stalekey.rst @@ -0,0 +1,6 @@ +salt.engines.stalekey +===================== + +.. automodule:: salt.engines.stalekey + :members: + :undoc-members: diff --git a/doc/ref/executors/all/index.rst b/doc/ref/executors/all/index.rst index fd846f371468..4cd430d8e3a8 100644 --- a/doc/ref/executors/all/index.rst +++ b/doc/ref/executors/all/index.rst @@ -11,5 +11,7 @@ executors modules :template: autosummary.rst.tmpl direct_call + docker splay sudo + transactional_update diff --git a/doc/ref/executors/all/salt.executors.docker.rst b/doc/ref/executors/all/salt.executors.docker.rst new file mode 100644 index 000000000000..5971e4777f81 --- /dev/null +++ b/doc/ref/executors/all/salt.executors.docker.rst @@ -0,0 +1,5 @@ +salt.executors.docker +===================== + +.. automodule:: salt.executors.docker + :members: diff --git a/doc/ref/executors/all/salt.executors.transactional_update.rst b/doc/ref/executors/all/salt.executors.transactional_update.rst new file mode 100644 index 000000000000..10a4cb569b1e --- /dev/null +++ b/doc/ref/executors/all/salt.executors.transactional_update.rst @@ -0,0 +1,5 @@ +salt.executors.transactional_update module +========================================== + +.. automodule:: salt.executors.transactional_update + :members: diff --git a/doc/ref/file_server/all/index.rst b/doc/ref/file_server/all/index.rst index 3683e24835ad..ce06ea14a06f 100644 --- a/doc/ref/file_server/all/index.rst +++ b/doc/ref/file_server/all/index.rst @@ -11,5 +11,8 @@ fileserver modules :template: autosummary.rst.tmpl gitfs + hgfs minionfs roots + s3fs + svnfs diff --git a/doc/ref/file_server/all/salt.fileserver.hgfs.rst b/doc/ref/file_server/all/salt.fileserver.hgfs.rst new file mode 100644 index 000000000000..b3df4a8e5874 --- /dev/null +++ b/doc/ref/file_server/all/salt.fileserver.hgfs.rst @@ -0,0 +1,4 @@ +salt.fileserver.hgfs +==================== + +.. automodule:: salt.fileserver.hgfs diff --git a/doc/ref/file_server/all/salt.fileserver.s3fs.rst b/doc/ref/file_server/all/salt.fileserver.s3fs.rst new file mode 100644 index 000000000000..0b20606eade9 --- /dev/null +++ b/doc/ref/file_server/all/salt.fileserver.s3fs.rst @@ -0,0 +1,4 @@ +salt.fileserver.s3fs +==================== + +.. automodule:: salt.fileserver.s3fs diff --git a/doc/ref/file_server/all/salt.fileserver.svnfs.rst b/doc/ref/file_server/all/salt.fileserver.svnfs.rst new file mode 100644 index 000000000000..2f550afcc0bb --- /dev/null +++ b/doc/ref/file_server/all/salt.fileserver.svnfs.rst @@ -0,0 +1,4 @@ +salt.fileserver.svnfs +===================== + +.. automodule:: salt.fileserver.svnfs diff --git a/doc/ref/grains/all/index.rst b/doc/ref/grains/all/index.rst index eab619330f09..15cad0595322 100644 --- a/doc/ref/grains/all/index.rst +++ b/doc/ref/grains/all/index.rst @@ -10,15 +10,32 @@ grains modules :toctree: :template: autosummary.rst.tmpl + chronos + cimc core disks + esxi extra + fibre_channel + fx2 + iscsi + junos lvm + marathon mdadm + mdata + metadata + metadata_gce minion_process + napalm + nvme + nxos opts package + panos pending_reboot - resources + philips_hue rest_sample - truststore + smartos + ssh_sample + zfs diff --git a/doc/ref/grains/all/salt.grains.chronos.rst b/doc/ref/grains/all/salt.grains.chronos.rst new file mode 100644 index 000000000000..af6f0626a9fd --- /dev/null +++ b/doc/ref/grains/all/salt.grains.chronos.rst @@ -0,0 +1,5 @@ +salt.grains.chronos +=================== + +.. automodule:: salt.grains.chronos + :members: diff --git a/doc/ref/grains/all/salt.grains.cimc.rst b/doc/ref/grains/all/salt.grains.cimc.rst new file mode 100644 index 000000000000..81fea0c76694 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.cimc.rst @@ -0,0 +1,5 @@ +salt.grains.cimc +================ + +.. automodule:: salt.grains.cimc + :members: diff --git a/doc/ref/grains/all/salt.grains.esxi.rst b/doc/ref/grains/all/salt.grains.esxi.rst new file mode 100644 index 000000000000..78d9f612a759 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.esxi.rst @@ -0,0 +1,5 @@ +salt.grains.esxi +================ + +.. automodule:: salt.grains.esxi + :members: diff --git a/doc/ref/grains/all/salt.grains.fibre_channel.rst b/doc/ref/grains/all/salt.grains.fibre_channel.rst new file mode 100644 index 000000000000..328ddba28012 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.fibre_channel.rst @@ -0,0 +1,5 @@ +salt.grains.fibre_channel +========================= + +.. automodule:: salt.grains.fibre_channel + :members: diff --git a/doc/ref/grains/all/salt.grains.fx2.rst b/doc/ref/grains/all/salt.grains.fx2.rst new file mode 100644 index 000000000000..092bfd4bb798 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.fx2.rst @@ -0,0 +1,5 @@ +salt.grains.fx2 +=============== + +.. automodule:: salt.grains.fx2 + :members: diff --git a/doc/ref/grains/all/salt.grains.iscsi.rst b/doc/ref/grains/all/salt.grains.iscsi.rst new file mode 100644 index 000000000000..72468feec1cb --- /dev/null +++ b/doc/ref/grains/all/salt.grains.iscsi.rst @@ -0,0 +1,5 @@ +salt.grains.iscsi +================= + +.. automodule:: salt.grains.iscsi + :members: diff --git a/doc/ref/grains/all/salt.grains.junos.rst b/doc/ref/grains/all/salt.grains.junos.rst new file mode 100644 index 000000000000..ad70118b4e7f --- /dev/null +++ b/doc/ref/grains/all/salt.grains.junos.rst @@ -0,0 +1,5 @@ +salt.grains.junos +================= + +.. automodule:: salt.grains.junos + :members: diff --git a/doc/ref/grains/all/salt.grains.marathon.rst b/doc/ref/grains/all/salt.grains.marathon.rst new file mode 100644 index 000000000000..3e2ad0c79e99 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.marathon.rst @@ -0,0 +1,5 @@ +salt.grains.marathon +==================== + +.. automodule:: salt.grains.marathon + :members: diff --git a/doc/ref/grains/all/salt.grains.mdata.rst b/doc/ref/grains/all/salt.grains.mdata.rst new file mode 100644 index 000000000000..32387d5a1f6e --- /dev/null +++ b/doc/ref/grains/all/salt.grains.mdata.rst @@ -0,0 +1,5 @@ +salt.grains.mdata +================= + +.. automodule:: salt.grains.mdata + :members: diff --git a/doc/ref/grains/all/salt.grains.metadata.rst b/doc/ref/grains/all/salt.grains.metadata.rst new file mode 100644 index 000000000000..24aa7f6a1bf1 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.metadata.rst @@ -0,0 +1,5 @@ +salt.grains.metadata +==================== + +.. automodule:: salt.grains.metadata + :members: diff --git a/doc/ref/grains/all/salt.grains.metadata_gce.rst b/doc/ref/grains/all/salt.grains.metadata_gce.rst new file mode 100644 index 000000000000..1df32a662862 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.metadata_gce.rst @@ -0,0 +1,5 @@ +salt.grains.metadata_gce +======================== + +.. automodule:: salt.grains.metadata_gce + :members: diff --git a/doc/ref/grains/all/salt.grains.napalm.rst b/doc/ref/grains/all/salt.grains.napalm.rst new file mode 100644 index 000000000000..79d385d119ed --- /dev/null +++ b/doc/ref/grains/all/salt.grains.napalm.rst @@ -0,0 +1,5 @@ +salt.grains.napalm +================== + +.. automodule:: salt.grains.napalm + :members: diff --git a/doc/ref/grains/all/salt.grains.nvme.rst b/doc/ref/grains/all/salt.grains.nvme.rst new file mode 100644 index 000000000000..a7891a85a196 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.nvme.rst @@ -0,0 +1,5 @@ +salt.grains.nvme +================ + +.. automodule:: salt.grains.nvme + :members: diff --git a/doc/ref/grains/all/salt.grains.nxos.rst b/doc/ref/grains/all/salt.grains.nxos.rst new file mode 100644 index 000000000000..289ee3b232ef --- /dev/null +++ b/doc/ref/grains/all/salt.grains.nxos.rst @@ -0,0 +1,5 @@ +salt.grains.nxos +================ + +.. automodule:: salt.grains.nxos + :members: diff --git a/doc/ref/grains/all/salt.grains.panos.rst b/doc/ref/grains/all/salt.grains.panos.rst new file mode 100644 index 000000000000..b5688a6757f2 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.panos.rst @@ -0,0 +1,5 @@ +salt.grains.panos +================= + +.. automodule:: salt.grains.panos + :members: diff --git a/doc/ref/grains/all/salt.grains.philips_hue.rst b/doc/ref/grains/all/salt.grains.philips_hue.rst new file mode 100644 index 000000000000..cdb02697820e --- /dev/null +++ b/doc/ref/grains/all/salt.grains.philips_hue.rst @@ -0,0 +1,5 @@ +salt.grains.philips_hue +======================= + +.. automodule:: salt.grains.philips_hue + :members: diff --git a/doc/ref/grains/all/salt.grains.resources.rst b/doc/ref/grains/all/salt.grains.resources.rst deleted file mode 100644 index 5bc9bfb76fbf..000000000000 --- a/doc/ref/grains/all/salt.grains.resources.rst +++ /dev/null @@ -1,5 +0,0 @@ -salt.grains.resources -===================== - -.. automodule:: salt.grains.resources - :members: diff --git a/doc/ref/grains/all/salt.grains.smartos.rst b/doc/ref/grains/all/salt.grains.smartos.rst new file mode 100644 index 000000000000..0b93c5567248 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.smartos.rst @@ -0,0 +1,5 @@ +salt.grains.smartos +=================== + +.. automodule:: salt.grains.smartos + :members: diff --git a/doc/ref/grains/all/salt.grains.ssh_sample.rst b/doc/ref/grains/all/salt.grains.ssh_sample.rst new file mode 100644 index 000000000000..75d274f77186 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.ssh_sample.rst @@ -0,0 +1,5 @@ +salt.grains.ssh_sample +====================== + +.. automodule:: salt.grains.ssh_sample + :members: diff --git a/doc/ref/grains/all/salt.grains.truststore.rst b/doc/ref/grains/all/salt.grains.truststore.rst deleted file mode 100644 index 8bc685c178d0..000000000000 --- a/doc/ref/grains/all/salt.grains.truststore.rst +++ /dev/null @@ -1,5 +0,0 @@ -salt.grains.truststore -====================== - -.. automodule:: salt.grains.truststore - :members: diff --git a/doc/ref/grains/all/salt.grains.zfs.rst b/doc/ref/grains/all/salt.grains.zfs.rst new file mode 100644 index 000000000000..53691bc3e157 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.zfs.rst @@ -0,0 +1,5 @@ +salt.grains.zfs +=============== + +.. automodule:: salt.grains.zfs + :members: diff --git a/doc/ref/modules/all/index.rst b/doc/ref/modules/all/index.rst index 30ac5c43d067..83e16261ed12 100644 --- a/doc/ref/modules/all/index.rst +++ b/doc/ref/modules/all/index.rst @@ -22,6 +22,7 @@ execution modules :toctree: :template: autosummary.rst.tmpl + acme aix_group aix_shadow aixpkg @@ -29,31 +30,93 @@ execution modules alternatives ansiblegate apache + apcups apf + apkpkg + aptly aptpkg archive arista_pyeapi artifactory - asymmetric at at_solaris + augeas_cfg + aws_sqs + bamboohr baredoc bcache beacons + bigip + bluez_bluetooth + boto3_elasticache + boto3_elasticsearch + boto3_route53 + boto3_sns + boto_apigateway + boto_asg + boto_cfn + boto_cloudfront + boto_cloudtrail + boto_cloudwatch + boto_cloudwatch_event + boto_cognitoidentity + boto_datapipeline + boto_dynamodb + boto_ec2 + boto_efs + boto_elasticache + boto_elasticsearch_domain + boto_elb + boto_elbv2 + boto_iam + boto_iot + boto_kinesis + boto_kms + boto_lambda + boto_rds + boto_route53 + boto_s3 + boto_s3_bucket + boto_secgroup + boto_sns + boto_sqs + boto_ssm + boto_vpc + bower bridge + bsd_shadow + btrfs + cabal + capirca_acl cassandra_cql celery + ceph + chassis chef chocolatey chronos chroot + cimc + ciscoconfparse_mod + cisconso cloud cmdmod + composer config + consul + container_resource cp + cpan cron cryptdev + csf + cyg + daemontools data + datadog_api + ddns + deb_apache + deb_postgres debconfmod debian_ip debian_service @@ -63,39 +126,118 @@ execution modules devmap dig disk + djangomod + dnsmasq dnsutil + dockercompose + dockermod dpkg_lowpkg + drac + dracr + drbd dummyproxy_pkg dummyproxy_service + ebuildpkg + eix + elasticsearch environ + eselect + esxcluster + esxdatacenter + esxi + esxvm etcd_mod ethtool event extfs file firewalld + freebsd_sysctl + freebsd_update + freebsdjail + freebsdkmod + freebsdpkg + freebsdports + freebsdservice + freezer + gcp_addon + gem + genesis + gentoo_service + gentoolkitmod git + github + glanceng + glassfish + glusterfs + gnomedesktop + google_chat gpg + grafana4 grains groupadd + grub_legacy + guestfs + hadoop + haproxyconn hashutil + heat + helm + hg highstate_doc hosts http + icinga2 idem + ifttt + ilo incron + influxdb08mod + influxdbmod + infoblox ini_manage + inspectlib + inspectlib.collector + inspectlib.dbhandle + inspectlib.entities + inspectlib.exceptions + inspectlib.fsdb + inspectlib.kiwiproc + inspectlib.query + inspector + introspect iosconfig + ipmi ipset iptables iwtools + jboss7 + jboss7_cli + jenkinsmod jinja + jira_mod junos + k8s + kapacitor + kerberos kernelpkg_linux_apt kernelpkg_linux_yum key keyboard + keystone + keystoneng + keystore kmod + kubeadm + kubernetesmod + launchctl_service + layman + ldap3 + ldapmod + libcloud_compute + libcloud_dns + libcloud_loadbalancer + libcloud_storage linux_acl linux_ip linux_lvm @@ -104,8 +246,12 @@ execution modules linux_sysctl localemod locate + logadm logmod logrotate + lvs + lxc + lxd mac_assistive mac_brew_pkg mac_desktop @@ -124,14 +270,34 @@ execution modules mac_xattr macdefaults macpackage + makeconf + mandrill + marathon match + mattermost mdadm_raid + mdata + memcached mine minion mod_random + modjk + mongodb + monit + moosefs mount + mssql + msteams + munin mysql nacl + nagios + nagios_rpc + namecheap_domains + namecheap_domains_dns + namecheap_domains_ns + namecheap_ssl + namecheap_users napalm_bgp napalm_formula napalm_mod @@ -144,50 +310,104 @@ execution modules napalm_users napalm_yang_mod netaddress + netbox + netbsd_sysctl + netbsdservice + netmiko_mod netplan_ip + netscaler network + neutron + neutronng + nexus nfs3 nftables - nixpkg + nginx + nilrt_ip + nix + nova npm + nspawn nxos nxos_api nxos_upgrade + omapi + openbsd_sysctl + openbsdpkg + openbsdrcctl_service + openbsdservice + openscap + openstack_config + openstack_mng + openvswitch + opkg + opsgenie oracle osquery out pacmanpkg + pagerduty + pagerduty_util pam + panos + parallels parted_partition + pcs + pdbedit + pecl + peeringdb + pf + philips_hue pillar pip pkg_resource pkgin pkgng pkgutil + portage_config + postfix postgres + poudriere + powerpath proxy ps publish puppet + purefa + purefb + pushbullet + pushover_notify pw_group pw_user pyenv + qemu_img + qemu_nbd quota rabbitmq + rallydev + random_org rbac_solaris + rbenv rdp + rebootmgr + redismod reg rest_pkg rest_sample_utils rest_service restartcheck + restconf ret rh_ip rh_service + riak rpm_lowpkg rpmbuild_pkgbuild rsync + runit + rvm + s3 + s6 salt_proxy salt_version saltcheck @@ -199,10 +419,20 @@ execution modules sdb seed selinux + sensehat + sensors + serverdensity_device + servicenow slack_notify + slackware_service slsutil + smartos_imgadm + smartos_nictagadm + smartos_virt + smartos_vmadm smbios smf_service + smtp snapper solaris_fmadm solaris_group @@ -211,30 +441,61 @@ execution modules solaris_user solarisipspkg solarispkg + solr + solrcloud + splunk + splunk_search sqlite3 ssh ssh_pkg - ssh_pki ssh_service state status + statuspage supervisord + suse_apache + suse_ip + svn + swarm + swift + sysbench sysfs syslog_ng sysmod + sysrc system + system_profiler systemd_service + telegram + telemetry temp test test_virtual + testinframod textfsm_mod timezone tls + tomcat + trafficserver + transactional_update + travisci + tuned + twilio_notify udev upstart_service + uptime useradd + uwsgi vagrant + varnish + vault + vbox_guest + vboxmanage + vcenter + victorops + virt virtualenv_mod + vmctl vsphere webutil win_appx @@ -246,7 +507,6 @@ execution modules win_dism win_dns_client win_dsc - win_dsc_resource win_event win_file win_firewall @@ -277,13 +537,24 @@ execution modules win_wua win_wusa winrepo + wordpress x509 x509_v2 + xapi_virt + xbpspkg xfs xml + xmpp yaml yumpkg + zabbix + zcbuildout + zenoss + zfs zk_concurrency + znc zoneadm zonecfg + zookeeper + zpool zypperpkg diff --git a/doc/ref/modules/all/salt.modules.acme.rst b/doc/ref/modules/all/salt.modules.acme.rst new file mode 100644 index 000000000000..f256a0577afc --- /dev/null +++ b/doc/ref/modules/all/salt.modules.acme.rst @@ -0,0 +1,5 @@ +salt.modules.acme +================= + +.. automodule:: salt.modules.acme + :members: diff --git a/doc/ref/modules/all/salt.modules.apcups.rst b/doc/ref/modules/all/salt.modules.apcups.rst new file mode 100644 index 000000000000..ecc55630d209 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.apcups.rst @@ -0,0 +1,5 @@ +salt.modules.apcups +=================== + +.. automodule:: salt.modules.apcups + :members: diff --git a/doc/ref/modules/all/salt.modules.apkpkg.rst b/doc/ref/modules/all/salt.modules.apkpkg.rst new file mode 100644 index 000000000000..8823a3f4a67a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.apkpkg.rst @@ -0,0 +1,6 @@ +salt.modules.apkpkg +=================== + +.. automodule:: salt.modules.apkpkg + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.aptly.rst b/doc/ref/modules/all/salt.modules.aptly.rst new file mode 100644 index 000000000000..f0321fff25ee --- /dev/null +++ b/doc/ref/modules/all/salt.modules.aptly.rst @@ -0,0 +1,6 @@ +salt.modules.aptly +================== + +.. automodule:: salt.modules.aptly + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.asymmetric.rst b/doc/ref/modules/all/salt.modules.asymmetric.rst deleted file mode 100644 index babca913d878..000000000000 --- a/doc/ref/modules/all/salt.modules.asymmetric.rst +++ /dev/null @@ -1,6 +0,0 @@ -salt.modules.asymmetric -======================= - -.. automodule:: salt.modules.asymmetric - :members: - :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.augeas_cfg.rst b/doc/ref/modules/all/salt.modules.augeas_cfg.rst new file mode 100644 index 000000000000..a9b1219d1d66 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.augeas_cfg.rst @@ -0,0 +1,5 @@ +salt.modules.augeas_cfg +======================= + +.. automodule:: salt.modules.augeas_cfg + :members: diff --git a/doc/ref/modules/all/salt.modules.aws_sqs.rst b/doc/ref/modules/all/salt.modules.aws_sqs.rst new file mode 100644 index 000000000000..b6ed5b97d0a4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.aws_sqs.rst @@ -0,0 +1,5 @@ +salt.modules.aws_sqs +==================== + +.. automodule:: salt.modules.aws_sqs + :members: diff --git a/doc/ref/modules/all/salt.modules.bamboohr.rst b/doc/ref/modules/all/salt.modules.bamboohr.rst new file mode 100644 index 000000000000..b67e00fa8e3d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.bamboohr.rst @@ -0,0 +1,5 @@ +salt.modules.bamboohr +===================== + +.. automodule:: salt.modules.bamboohr + :members: diff --git a/doc/ref/modules/all/salt.modules.bigip.rst b/doc/ref/modules/all/salt.modules.bigip.rst new file mode 100644 index 000000000000..a2f227278790 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.bigip.rst @@ -0,0 +1,5 @@ +salt.modules.bigip +================== + +.. automodule:: salt.modules.bigip + :members: diff --git a/doc/ref/modules/all/salt.modules.bluez_bluetooth.rst b/doc/ref/modules/all/salt.modules.bluez_bluetooth.rst new file mode 100644 index 000000000000..db703418f25f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.bluez_bluetooth.rst @@ -0,0 +1,5 @@ +salt.modules.bluez_bluetooth +============================ + +.. automodule:: salt.modules.bluez_bluetooth + :members: diff --git a/doc/ref/modules/all/salt.modules.boto3_elasticache.rst b/doc/ref/modules/all/salt.modules.boto3_elasticache.rst new file mode 100644 index 000000000000..8bd8b3f1677d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto3_elasticache.rst @@ -0,0 +1,6 @@ +salt.modules.boto3_elasticache +============================== + +.. automodule:: salt.modules.boto3_elasticache + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.boto3_elasticsearch.rst b/doc/ref/modules/all/salt.modules.boto3_elasticsearch.rst new file mode 100644 index 000000000000..64c79db0c197 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto3_elasticsearch.rst @@ -0,0 +1,5 @@ +salt.modules.boto3_elasticsearch +================================ + +.. automodule:: salt.modules.boto3_elasticsearch + :members: diff --git a/doc/ref/modules/all/salt.modules.boto3_route53.rst b/doc/ref/modules/all/salt.modules.boto3_route53.rst new file mode 100644 index 000000000000..b7db8ca72b57 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto3_route53.rst @@ -0,0 +1,6 @@ +salt.modules.boto3_route53 +========================== + +.. automodule:: salt.modules.boto3_route53 + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.boto3_sns.rst b/doc/ref/modules/all/salt.modules.boto3_sns.rst new file mode 100644 index 000000000000..8f64e8d252af --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto3_sns.rst @@ -0,0 +1,6 @@ +salt.modules.boto3_sns +====================== + +.. automodule:: salt.modules.boto3_sns + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.boto_apigateway.rst b/doc/ref/modules/all/salt.modules.boto_apigateway.rst new file mode 100644 index 000000000000..66cd0984d403 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_apigateway.rst @@ -0,0 +1,5 @@ +salt.modules.boto_apigateway +============================ + +.. automodule:: salt.modules.boto_apigateway + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_asg.rst b/doc/ref/modules/all/salt.modules.boto_asg.rst new file mode 100644 index 000000000000..23ad5125d9bd --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_asg.rst @@ -0,0 +1,5 @@ +salt.modules.boto_asg +===================== + +.. automodule:: salt.modules.boto_asg + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_cfn.rst b/doc/ref/modules/all/salt.modules.boto_cfn.rst new file mode 100644 index 000000000000..fab76669dd89 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_cfn.rst @@ -0,0 +1,5 @@ +salt.modules.boto_cfn +===================== + +.. automodule:: salt.modules.boto_cfn + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_cloudfront.rst b/doc/ref/modules/all/salt.modules.boto_cloudfront.rst new file mode 100644 index 000000000000..5a5a1c3ad94f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_cloudfront.rst @@ -0,0 +1,5 @@ +salt.modules.boto_cloudfront +============================ + +.. automodule:: salt.modules.boto_cloudfront + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_cloudtrail.rst b/doc/ref/modules/all/salt.modules.boto_cloudtrail.rst new file mode 100644 index 000000000000..fe65ed2d6f3e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_cloudtrail.rst @@ -0,0 +1,5 @@ +salt.modules.boto_cloudtrail +============================ + +.. automodule:: salt.modules.boto_cloudtrail + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_cloudwatch.rst b/doc/ref/modules/all/salt.modules.boto_cloudwatch.rst new file mode 100644 index 000000000000..3d5e9aef7ef4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_cloudwatch.rst @@ -0,0 +1,5 @@ +salt.modules.boto_cloudwatch +============================ + +.. automodule:: salt.modules.boto_cloudwatch + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_cloudwatch_event.rst b/doc/ref/modules/all/salt.modules.boto_cloudwatch_event.rst new file mode 100644 index 000000000000..59ae2db53a0e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_cloudwatch_event.rst @@ -0,0 +1,6 @@ +salt.modules.boto_cloudwatch_event +================================== + +.. automodule:: salt.modules.boto_cloudwatch_event + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.boto_cognitoidentity.rst b/doc/ref/modules/all/salt.modules.boto_cognitoidentity.rst new file mode 100644 index 000000000000..218f94af4824 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_cognitoidentity.rst @@ -0,0 +1,5 @@ +salt.modules.boto_cognitoidentity +================================= + +.. automodule:: salt.modules.boto_cognitoidentity + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_datapipeline.rst b/doc/ref/modules/all/salt.modules.boto_datapipeline.rst new file mode 100644 index 000000000000..af01f007b326 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_datapipeline.rst @@ -0,0 +1,5 @@ +salt.modules.boto_datapipeline +============================== + +.. automodule:: salt.modules.boto_datapipeline + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_dynamodb.rst b/doc/ref/modules/all/salt.modules.boto_dynamodb.rst new file mode 100644 index 000000000000..f938f687e76c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_dynamodb.rst @@ -0,0 +1,5 @@ +salt.modules.boto_dynamodb +========================== + +.. automodule:: salt.modules.boto_dynamodb + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_ec2.rst b/doc/ref/modules/all/salt.modules.boto_ec2.rst new file mode 100644 index 000000000000..75191b71376b --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_ec2.rst @@ -0,0 +1,5 @@ +salt.modules.boto_ec2 +===================== + +.. automodule:: salt.modules.boto_ec2 + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_efs.rst b/doc/ref/modules/all/salt.modules.boto_efs.rst new file mode 100644 index 000000000000..4487461d6033 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_efs.rst @@ -0,0 +1,6 @@ +salt.modules.boto_efs +===================== + +.. automodule:: salt.modules.boto_efs + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.boto_elasticache.rst b/doc/ref/modules/all/salt.modules.boto_elasticache.rst new file mode 100644 index 000000000000..d62618d3b61e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_elasticache.rst @@ -0,0 +1,5 @@ +salt.modules.boto_elasticache +============================= + +.. automodule:: salt.modules.boto_elasticache + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_elasticsearch_domain.rst b/doc/ref/modules/all/salt.modules.boto_elasticsearch_domain.rst new file mode 100644 index 000000000000..e0c83587dcbf --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_elasticsearch_domain.rst @@ -0,0 +1,5 @@ +salt.modules.boto_elasticsearch_domain +====================================== + +.. automodule:: salt.modules.boto_elasticsearch_domain + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_elb.rst b/doc/ref/modules/all/salt.modules.boto_elb.rst new file mode 100644 index 000000000000..23542f60943d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_elb.rst @@ -0,0 +1,5 @@ +salt.modules.boto_elb +===================== + +.. automodule:: salt.modules.boto_elb + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_elbv2.rst b/doc/ref/modules/all/salt.modules.boto_elbv2.rst new file mode 100644 index 000000000000..d30680958bb1 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_elbv2.rst @@ -0,0 +1,6 @@ +salt.modules.boto_elbv2 +======================= + +.. automodule:: salt.modules.boto_elbv2 + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.boto_iam.rst b/doc/ref/modules/all/salt.modules.boto_iam.rst new file mode 100644 index 000000000000..91865fa1cc1a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_iam.rst @@ -0,0 +1,5 @@ +salt.modules.boto_iam +===================== + +.. automodule:: salt.modules.boto_iam + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_iot.rst b/doc/ref/modules/all/salt.modules.boto_iot.rst new file mode 100644 index 000000000000..12779c792cc1 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_iot.rst @@ -0,0 +1,5 @@ +salt.modules.boto_iot +===================== + +.. automodule:: salt.modules.boto_iot + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_kinesis.rst b/doc/ref/modules/all/salt.modules.boto_kinesis.rst new file mode 100644 index 000000000000..f08c701ea924 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_kinesis.rst @@ -0,0 +1,6 @@ +salt.modules.boto_kinesis +========================= + +.. automodule:: salt.modules.boto_kinesis + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.boto_kms.rst b/doc/ref/modules/all/salt.modules.boto_kms.rst new file mode 100644 index 000000000000..0a0a54caa486 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_kms.rst @@ -0,0 +1,5 @@ +salt.modules.boto_kms +===================== + +.. automodule:: salt.modules.boto_kms + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_lambda.rst b/doc/ref/modules/all/salt.modules.boto_lambda.rst new file mode 100644 index 000000000000..30cfb800e486 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_lambda.rst @@ -0,0 +1,5 @@ +salt.modules.boto_lambda +======================== + +.. automodule:: salt.modules.boto_lambda + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_rds.rst b/doc/ref/modules/all/salt.modules.boto_rds.rst new file mode 100644 index 000000000000..7dc12243e8cb --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_rds.rst @@ -0,0 +1,5 @@ +salt.modules.boto_rds +===================== + +.. automodule:: salt.modules.boto_rds + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_route53.rst b/doc/ref/modules/all/salt.modules.boto_route53.rst new file mode 100644 index 000000000000..30fbfff77b5b --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_route53.rst @@ -0,0 +1,5 @@ +salt.modules.boto_route53 +========================= + +.. automodule:: salt.modules.boto_route53 + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_s3.rst b/doc/ref/modules/all/salt.modules.boto_s3.rst new file mode 100644 index 000000000000..2465eb985d5c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_s3.rst @@ -0,0 +1,6 @@ +salt.modules.boto_s3 +==================== + +.. automodule:: salt.modules.boto_s3 + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.boto_s3_bucket.rst b/doc/ref/modules/all/salt.modules.boto_s3_bucket.rst new file mode 100644 index 000000000000..7156a2ec0c59 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_s3_bucket.rst @@ -0,0 +1,5 @@ +salt.modules.boto_s3_bucket +=========================== + +.. automodule:: salt.modules.boto_s3_bucket + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_secgroup.rst b/doc/ref/modules/all/salt.modules.boto_secgroup.rst new file mode 100644 index 000000000000..1a12bf52de85 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_secgroup.rst @@ -0,0 +1,5 @@ +salt.modules.boto_secgroup +========================== + +.. automodule:: salt.modules.boto_secgroup + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_sns.rst b/doc/ref/modules/all/salt.modules.boto_sns.rst new file mode 100644 index 000000000000..ef88f2ad1229 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_sns.rst @@ -0,0 +1,5 @@ +salt.modules.boto_sns +===================== + +.. automodule:: salt.modules.boto_sns + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_sqs.rst b/doc/ref/modules/all/salt.modules.boto_sqs.rst new file mode 100644 index 000000000000..62119c9d522f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_sqs.rst @@ -0,0 +1,5 @@ +salt.modules.boto_sqs +===================== + +.. automodule:: salt.modules.boto_sqs + :members: diff --git a/doc/ref/modules/all/salt.modules.boto_ssm.rst b/doc/ref/modules/all/salt.modules.boto_ssm.rst new file mode 100644 index 000000000000..c4db051bfc47 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_ssm.rst @@ -0,0 +1,6 @@ +salt.modules.boto_ssm +===================== + +.. automodule:: salt.modules.boto_ssm + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.boto_vpc.rst b/doc/ref/modules/all/salt.modules.boto_vpc.rst new file mode 100644 index 000000000000..eca9f9cbee05 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.boto_vpc.rst @@ -0,0 +1,5 @@ +salt.modules.boto_vpc +===================== + +.. automodule:: salt.modules.boto_vpc + :members: diff --git a/doc/ref/modules/all/salt.modules.bower.rst b/doc/ref/modules/all/salt.modules.bower.rst new file mode 100644 index 000000000000..4c9a32916605 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.bower.rst @@ -0,0 +1,5 @@ +salt.modules.bower +================== + +.. automodule:: salt.modules.bower + :members: diff --git a/doc/ref/modules/all/salt.modules.bsd_shadow.rst b/doc/ref/modules/all/salt.modules.bsd_shadow.rst new file mode 100644 index 000000000000..0dfb2bdfb8d8 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.bsd_shadow.rst @@ -0,0 +1,5 @@ +salt.modules.bsd_shadow +======================= + +.. automodule:: salt.modules.bsd_shadow + :members: diff --git a/doc/ref/modules/all/salt.modules.btrfs.rst b/doc/ref/modules/all/salt.modules.btrfs.rst new file mode 100644 index 000000000000..a576acc9fdba --- /dev/null +++ b/doc/ref/modules/all/salt.modules.btrfs.rst @@ -0,0 +1,5 @@ +salt.modules.btrfs +================== + +.. automodule:: salt.modules.btrfs + :members: diff --git a/doc/ref/modules/all/salt.modules.cabal.rst b/doc/ref/modules/all/salt.modules.cabal.rst new file mode 100644 index 000000000000..f0527c9ef15e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.cabal.rst @@ -0,0 +1,5 @@ +salt.modules.cabal +================== + +.. automodule:: salt.modules.cabal + :members: diff --git a/doc/ref/modules/all/salt.modules.capirca_acl.rst b/doc/ref/modules/all/salt.modules.capirca_acl.rst new file mode 100644 index 000000000000..56bf1f56e4bb --- /dev/null +++ b/doc/ref/modules/all/salt.modules.capirca_acl.rst @@ -0,0 +1,5 @@ +salt.modules.capirca_acl +======================== + +.. automodule:: salt.modules.capirca_acl + :members: diff --git a/doc/ref/modules/all/salt.modules.ceph.rst b/doc/ref/modules/all/salt.modules.ceph.rst new file mode 100644 index 000000000000..e35d7e777f38 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.ceph.rst @@ -0,0 +1,6 @@ +salt.modules.ceph +================= + +.. automodule:: salt.modules.ceph + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.chassis.rst b/doc/ref/modules/all/salt.modules.chassis.rst new file mode 100644 index 000000000000..560bec572286 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.chassis.rst @@ -0,0 +1,5 @@ +salt.modules.chassis +==================== + +.. automodule:: salt.modules.chassis + :members: diff --git a/doc/ref/modules/all/salt.modules.cimc.rst b/doc/ref/modules/all/salt.modules.cimc.rst new file mode 100644 index 000000000000..665e19febac5 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.cimc.rst @@ -0,0 +1,5 @@ +salt.modules.cimc +================= + +.. automodule:: salt.modules.cimc + :members: diff --git a/doc/ref/modules/all/salt.modules.ciscoconfparse_mod.rst b/doc/ref/modules/all/salt.modules.ciscoconfparse_mod.rst new file mode 100644 index 000000000000..7b8a63027c71 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.ciscoconfparse_mod.rst @@ -0,0 +1,5 @@ +salt.modules.ciscoconfparse_mod +=============================== + +.. automodule:: salt.modules.ciscoconfparse_mod + :members: diff --git a/doc/ref/modules/all/salt.modules.cisconso.rst b/doc/ref/modules/all/salt.modules.cisconso.rst new file mode 100644 index 000000000000..eda392c5501d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.cisconso.rst @@ -0,0 +1,6 @@ +===================== +salt.modules.cisconso +===================== + +.. automodule:: salt.modules.cisconso + :members: diff --git a/doc/ref/modules/all/salt.modules.composer.rst b/doc/ref/modules/all/salt.modules.composer.rst new file mode 100644 index 000000000000..45205b231097 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.composer.rst @@ -0,0 +1,5 @@ +salt.modules.composer +===================== + +.. automodule:: salt.modules.composer + :members: diff --git a/doc/ref/modules/all/salt.modules.consul.rst b/doc/ref/modules/all/salt.modules.consul.rst new file mode 100644 index 000000000000..132ec974fcd1 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.consul.rst @@ -0,0 +1,5 @@ +salt.modules.consul +=================== + +.. automodule:: salt.modules.consul + :members: diff --git a/doc/ref/modules/all/salt.modules.container_resource.rst b/doc/ref/modules/all/salt.modules.container_resource.rst new file mode 100644 index 000000000000..8ddb9b3c0e0d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.container_resource.rst @@ -0,0 +1,5 @@ +salt.modules.container_resource +=============================== + +.. automodule:: salt.modules.container_resource + :members: diff --git a/doc/ref/modules/all/salt.modules.cpan.rst b/doc/ref/modules/all/salt.modules.cpan.rst new file mode 100644 index 000000000000..41ec5c85861f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.cpan.rst @@ -0,0 +1,5 @@ +salt.modules.cpan +================= + +.. automodule:: salt.modules.cpan + :members: diff --git a/doc/ref/modules/all/salt.modules.csf.rst b/doc/ref/modules/all/salt.modules.csf.rst new file mode 100644 index 000000000000..2118e7dcf5ab --- /dev/null +++ b/doc/ref/modules/all/salt.modules.csf.rst @@ -0,0 +1,5 @@ +salt.modules.csf +================ + +.. automodule:: salt.modules.csf + :members: diff --git a/doc/ref/modules/all/salt.modules.cyg.rst b/doc/ref/modules/all/salt.modules.cyg.rst new file mode 100644 index 000000000000..717e4444b5ff --- /dev/null +++ b/doc/ref/modules/all/salt.modules.cyg.rst @@ -0,0 +1,5 @@ +salt.modules.cyg +================ + +.. automodule:: salt.modules.cyg + :members: diff --git a/doc/ref/modules/all/salt.modules.daemontools.rst b/doc/ref/modules/all/salt.modules.daemontools.rst new file mode 100644 index 000000000000..e84b8287f49a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.daemontools.rst @@ -0,0 +1,5 @@ +salt.modules.daemontools +======================== + +.. automodule:: salt.modules.daemontools + :members: diff --git a/doc/ref/modules/all/salt.modules.datadog_api.rst b/doc/ref/modules/all/salt.modules.datadog_api.rst new file mode 100644 index 000000000000..7184c4701bad --- /dev/null +++ b/doc/ref/modules/all/salt.modules.datadog_api.rst @@ -0,0 +1,5 @@ +salt.modules.datadog_api +======================== + +.. automodule:: salt.modules.datadog_api + :members: diff --git a/doc/ref/modules/all/salt.modules.ddns.rst b/doc/ref/modules/all/salt.modules.ddns.rst new file mode 100644 index 000000000000..674bc0298a35 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.ddns.rst @@ -0,0 +1,5 @@ +salt.modules.ddns +================= + +.. automodule:: salt.modules.ddns + :members: diff --git a/doc/ref/modules/all/salt.modules.deb_apache.rst b/doc/ref/modules/all/salt.modules.deb_apache.rst new file mode 100644 index 000000000000..8fe0cafceb8e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.deb_apache.rst @@ -0,0 +1,5 @@ +salt.modules.deb_apache +======================= + +.. automodule:: salt.modules.deb_apache + :members: diff --git a/doc/ref/modules/all/salt.modules.deb_postgres.rst b/doc/ref/modules/all/salt.modules.deb_postgres.rst new file mode 100644 index 000000000000..81cb8b517e22 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.deb_postgres.rst @@ -0,0 +1,5 @@ +salt.modules.deb_postgres +========================= + +.. automodule:: salt.modules.deb_postgres + :members: diff --git a/doc/ref/modules/all/salt.modules.djangomod.rst b/doc/ref/modules/all/salt.modules.djangomod.rst new file mode 100644 index 000000000000..b453a4fe475b --- /dev/null +++ b/doc/ref/modules/all/salt.modules.djangomod.rst @@ -0,0 +1,5 @@ +salt.modules.djangomod +====================== + +.. automodule:: salt.modules.djangomod + :members: diff --git a/doc/ref/modules/all/salt.modules.dnsmasq.rst b/doc/ref/modules/all/salt.modules.dnsmasq.rst new file mode 100644 index 000000000000..fe71cb918e47 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.dnsmasq.rst @@ -0,0 +1,5 @@ +salt.modules.dnsmasq +==================== + +.. automodule:: salt.modules.dnsmasq + :members: diff --git a/doc/ref/modules/all/salt.modules.dockercompose.rst b/doc/ref/modules/all/salt.modules.dockercompose.rst new file mode 100644 index 000000000000..dd3862adc639 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.dockercompose.rst @@ -0,0 +1,5 @@ +salt.modules.dockercompose +========================== + +.. automodule:: salt.modules.dockercompose + :members: diff --git a/doc/ref/modules/all/salt.modules.dockermod.rst b/doc/ref/modules/all/salt.modules.dockermod.rst new file mode 100644 index 000000000000..10e3ba7fe28c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.dockermod.rst @@ -0,0 +1,6 @@ +salt.modules.dockermod +====================== + +.. automodule:: salt.modules.dockermod + :members: + :exclude-members: cp, freeze, unfreeze diff --git a/doc/ref/modules/all/salt.modules.drac.rst b/doc/ref/modules/all/salt.modules.drac.rst new file mode 100644 index 000000000000..2061a26a334e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.drac.rst @@ -0,0 +1,5 @@ +salt.modules.drac +================= + +.. automodule:: salt.modules.drac + :members: diff --git a/doc/ref/modules/all/salt.modules.dracr.rst b/doc/ref/modules/all/salt.modules.dracr.rst new file mode 100644 index 000000000000..aaae59a96957 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.dracr.rst @@ -0,0 +1,5 @@ +salt.modules.dracr +================== + +.. automodule:: salt.modules.dracr + :members: diff --git a/doc/ref/modules/all/salt.modules.drbd.rst b/doc/ref/modules/all/salt.modules.drbd.rst new file mode 100644 index 000000000000..c6b91c5d58ee --- /dev/null +++ b/doc/ref/modules/all/salt.modules.drbd.rst @@ -0,0 +1,5 @@ +salt.modules.drbd +================= + +.. automodule:: salt.modules.drbd + :members: diff --git a/doc/ref/modules/all/salt.modules.ebuildpkg.rst b/doc/ref/modules/all/salt.modules.ebuildpkg.rst new file mode 100644 index 000000000000..7d6124ddbb06 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.ebuildpkg.rst @@ -0,0 +1,6 @@ +salt.modules.ebuildpkg +====================== + +.. automodule:: salt.modules.ebuildpkg + :members: + :exclude-members: available_version diff --git a/doc/ref/modules/all/salt.modules.eix.rst b/doc/ref/modules/all/salt.modules.eix.rst new file mode 100644 index 000000000000..f3ce28211dca --- /dev/null +++ b/doc/ref/modules/all/salt.modules.eix.rst @@ -0,0 +1,5 @@ +salt.modules.eix +================ + +.. automodule:: salt.modules.eix + :members: diff --git a/doc/ref/modules/all/salt.modules.elasticsearch.rst b/doc/ref/modules/all/salt.modules.elasticsearch.rst new file mode 100644 index 000000000000..9a41fbad04a8 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.elasticsearch.rst @@ -0,0 +1,5 @@ +salt.modules.elasticsearch +========================== + +.. automodule:: salt.modules.elasticsearch + :members: diff --git a/doc/ref/modules/all/salt.modules.eselect.rst b/doc/ref/modules/all/salt.modules.eselect.rst new file mode 100644 index 000000000000..8e850e2f1b69 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.eselect.rst @@ -0,0 +1,5 @@ +salt.modules.eselect +==================== + +.. automodule:: salt.modules.eselect + :members: diff --git a/doc/ref/modules/all/salt.modules.esxcluster.rst b/doc/ref/modules/all/salt.modules.esxcluster.rst new file mode 100644 index 000000000000..4b7ddd29c4dd --- /dev/null +++ b/doc/ref/modules/all/salt.modules.esxcluster.rst @@ -0,0 +1,6 @@ +salt.modules.esxcluster +======================= + +.. automodule:: salt.modules.esxcluster + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.esxdatacenter.rst b/doc/ref/modules/all/salt.modules.esxdatacenter.rst new file mode 100644 index 000000000000..7f61edbffb29 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.esxdatacenter.rst @@ -0,0 +1,6 @@ +salt.modules.esxdatacenter +========================== + +.. automodule:: salt.modules.esxdatacenter + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.esxi.rst b/doc/ref/modules/all/salt.modules.esxi.rst new file mode 100644 index 000000000000..5c2cab0fabf7 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.esxi.rst @@ -0,0 +1,5 @@ +salt.modules.esxi +================= + +.. automodule:: salt.modules.esxi + :members: diff --git a/doc/ref/modules/all/salt.modules.esxvm.rst b/doc/ref/modules/all/salt.modules.esxvm.rst new file mode 100644 index 000000000000..276bedeb5ee3 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.esxvm.rst @@ -0,0 +1,6 @@ +salt.modules.esxvm +================== + +.. automodule:: salt.modules.esxvm + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.freebsd_sysctl.rst b/doc/ref/modules/all/salt.modules.freebsd_sysctl.rst new file mode 100644 index 000000000000..5e837a975154 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.freebsd_sysctl.rst @@ -0,0 +1,5 @@ +salt.modules.freebsd_sysctl +=========================== + +.. automodule:: salt.modules.freebsd_sysctl + :members: diff --git a/doc/ref/modules/all/salt.modules.freebsd_update.rst b/doc/ref/modules/all/salt.modules.freebsd_update.rst new file mode 100644 index 000000000000..3a4077ecc4c2 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.freebsd_update.rst @@ -0,0 +1,6 @@ +salt.modules.freebsd_update +=========================== + +.. automodule:: salt.modules.freebsd_update + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.freebsdjail.rst b/doc/ref/modules/all/salt.modules.freebsdjail.rst new file mode 100644 index 000000000000..1b496709e282 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.freebsdjail.rst @@ -0,0 +1,5 @@ +salt.modules.freebsdjail +======================== + +.. automodule:: salt.modules.freebsdjail + :members: diff --git a/doc/ref/modules/all/salt.modules.freebsdkmod.rst b/doc/ref/modules/all/salt.modules.freebsdkmod.rst new file mode 100644 index 000000000000..17df5da0e29f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.freebsdkmod.rst @@ -0,0 +1,5 @@ +salt.modules.freebsdkmod +======================== + +.. automodule:: salt.modules.freebsdkmod + :members: diff --git a/doc/ref/modules/all/salt.modules.freebsdpkg.rst b/doc/ref/modules/all/salt.modules.freebsdpkg.rst new file mode 100644 index 000000000000..df76d454671d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.freebsdpkg.rst @@ -0,0 +1,6 @@ +salt.modules.freebsdpkg +======================= + +.. automodule:: salt.modules.freebsdpkg + :members: + :exclude-members: available_version, delete, purge diff --git a/doc/ref/modules/all/salt.modules.freebsdports.rst b/doc/ref/modules/all/salt.modules.freebsdports.rst new file mode 100644 index 000000000000..571a9626bbf1 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.freebsdports.rst @@ -0,0 +1,5 @@ +salt.modules.freebsdports +========================= + +.. automodule:: salt.modules.freebsdports + :members: diff --git a/doc/ref/modules/all/salt.modules.freebsdservice.rst b/doc/ref/modules/all/salt.modules.freebsdservice.rst new file mode 100644 index 000000000000..f608b2def227 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.freebsdservice.rst @@ -0,0 +1,5 @@ +salt.modules.freebsdservice +=========================== + +.. automodule:: salt.modules.freebsdservice + :members: diff --git a/doc/ref/modules/all/salt.modules.freezer.rst b/doc/ref/modules/all/salt.modules.freezer.rst new file mode 100644 index 000000000000..b89b990c6a4f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.freezer.rst @@ -0,0 +1,6 @@ +salt.modules.freezer +==================== + +.. automodule:: salt.modules.freezer + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.gcp_addon.rst b/doc/ref/modules/all/salt.modules.gcp_addon.rst new file mode 100644 index 000000000000..ed209ead3b2a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.gcp_addon.rst @@ -0,0 +1,6 @@ +salt.modules.gcp_addon +====================== + +.. automodule:: salt.modules.gcp_addon + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.gem.rst b/doc/ref/modules/all/salt.modules.gem.rst new file mode 100644 index 000000000000..faaed3330c2b --- /dev/null +++ b/doc/ref/modules/all/salt.modules.gem.rst @@ -0,0 +1,5 @@ +salt.modules.gem +================ + +.. automodule:: salt.modules.gem + :members: diff --git a/doc/ref/modules/all/salt.modules.genesis.rst b/doc/ref/modules/all/salt.modules.genesis.rst new file mode 100644 index 000000000000..6a849a6a6efc --- /dev/null +++ b/doc/ref/modules/all/salt.modules.genesis.rst @@ -0,0 +1,5 @@ +salt.modules.genesis +==================== + +.. automodule:: salt.modules.genesis + :members: diff --git a/doc/ref/modules/all/salt.modules.gentoo_service.rst b/doc/ref/modules/all/salt.modules.gentoo_service.rst new file mode 100644 index 000000000000..c59a79e53c1b --- /dev/null +++ b/doc/ref/modules/all/salt.modules.gentoo_service.rst @@ -0,0 +1,5 @@ +salt.modules.gentoo_service +=========================== + +.. automodule:: salt.modules.gentoo_service + :members: diff --git a/doc/ref/modules/all/salt.modules.gentoolkitmod.rst b/doc/ref/modules/all/salt.modules.gentoolkitmod.rst new file mode 100644 index 000000000000..50c4a1733417 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.gentoolkitmod.rst @@ -0,0 +1,5 @@ +salt.modules.gentoolkitmod +========================== + +.. automodule:: salt.modules.gentoolkitmod + :members: diff --git a/doc/ref/modules/all/salt.modules.github.rst b/doc/ref/modules/all/salt.modules.github.rst new file mode 100644 index 000000000000..04d440688383 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.github.rst @@ -0,0 +1,5 @@ +salt.modules.github +=================== + +.. automodule:: salt.modules.github + :members: diff --git a/doc/ref/modules/all/salt.modules.glanceng.rst b/doc/ref/modules/all/salt.modules.glanceng.rst new file mode 100644 index 000000000000..52b6b27a182e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.glanceng.rst @@ -0,0 +1,5 @@ +salt.modules.glanceng +===================== + +.. automodule:: salt.modules.glanceng + :members: diff --git a/doc/ref/modules/all/salt.modules.glassfish.rst b/doc/ref/modules/all/salt.modules.glassfish.rst new file mode 100644 index 000000000000..aeb3ca28496a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.glassfish.rst @@ -0,0 +1,6 @@ +salt.modules.glassfish +====================== + +.. automodule:: salt.modules.glassfish + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.glusterfs.rst b/doc/ref/modules/all/salt.modules.glusterfs.rst new file mode 100644 index 000000000000..29ca56661bf8 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.glusterfs.rst @@ -0,0 +1,5 @@ +salt.modules.glusterfs +====================== + +.. automodule:: salt.modules.glusterfs + :members: diff --git a/doc/ref/modules/all/salt.modules.gnomedesktop.rst b/doc/ref/modules/all/salt.modules.gnomedesktop.rst new file mode 100644 index 000000000000..cf2388aa34d7 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.gnomedesktop.rst @@ -0,0 +1,5 @@ +salt.modules.gnomedesktop +========================= + +.. automodule:: salt.modules.gnomedesktop + :members: diff --git a/doc/ref/modules/all/salt.modules.google_chat.rst b/doc/ref/modules/all/salt.modules.google_chat.rst new file mode 100644 index 000000000000..8e1c13ae0f8f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.google_chat.rst @@ -0,0 +1,5 @@ +salt.modules.google_chat +======================== + +.. automodule:: salt.modules.google_chat + :members: diff --git a/doc/ref/modules/all/salt.modules.grafana4.rst b/doc/ref/modules/all/salt.modules.grafana4.rst new file mode 100644 index 000000000000..13b426181afb --- /dev/null +++ b/doc/ref/modules/all/salt.modules.grafana4.rst @@ -0,0 +1,6 @@ +salt.modules.grafana4 +===================== + +.. automodule:: salt.modules.grafana4 + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.grub_legacy.rst b/doc/ref/modules/all/salt.modules.grub_legacy.rst new file mode 100644 index 000000000000..71a0f77d39e2 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.grub_legacy.rst @@ -0,0 +1,5 @@ +salt.modules.grub_legacy +======================== + +.. automodule:: salt.modules.grub_legacy + :members: diff --git a/doc/ref/modules/all/salt.modules.guestfs.rst b/doc/ref/modules/all/salt.modules.guestfs.rst new file mode 100644 index 000000000000..16995452ba0a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.guestfs.rst @@ -0,0 +1,5 @@ +salt.modules.guestfs +==================== + +.. automodule:: salt.modules.guestfs + :members: diff --git a/doc/ref/modules/all/salt.modules.hadoop.rst b/doc/ref/modules/all/salt.modules.hadoop.rst new file mode 100644 index 000000000000..9589a658b8a2 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.hadoop.rst @@ -0,0 +1,5 @@ +salt.modules.hadoop +=================== + +.. automodule:: salt.modules.hadoop + :members: diff --git a/doc/ref/modules/all/salt.modules.haproxyconn.rst b/doc/ref/modules/all/salt.modules.haproxyconn.rst new file mode 100644 index 000000000000..8d2ebafd8d10 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.haproxyconn.rst @@ -0,0 +1,5 @@ +salt.modules.haproxyconn +======================== + +.. automodule:: salt.modules.haproxyconn + :members: diff --git a/doc/ref/modules/all/salt.modules.heat.rst b/doc/ref/modules/all/salt.modules.heat.rst new file mode 100644 index 000000000000..8c642fa9adf5 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.heat.rst @@ -0,0 +1,6 @@ +salt.modules.heat +================= + +.. automodule:: salt.modules.heat + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.helm.rst b/doc/ref/modules/all/salt.modules.helm.rst new file mode 100644 index 000000000000..8106e9dfc2c8 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.helm.rst @@ -0,0 +1,6 @@ +salt.modules.helm +================= + +.. automodule:: salt.modules.helm + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.hg.rst b/doc/ref/modules/all/salt.modules.hg.rst new file mode 100644 index 000000000000..d9468446f0f0 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.hg.rst @@ -0,0 +1,5 @@ +salt.modules.hg +=============== + +.. automodule:: salt.modules.hg + :members: diff --git a/doc/ref/modules/all/salt.modules.icinga2.rst b/doc/ref/modules/all/salt.modules.icinga2.rst new file mode 100644 index 000000000000..3469b5ce8c32 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.icinga2.rst @@ -0,0 +1,6 @@ +salt.modules.icinga2 +==================== + +.. automodule:: salt.modules.icinga2 + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.ifttt.rst b/doc/ref/modules/all/salt.modules.ifttt.rst new file mode 100644 index 000000000000..8b723395664d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.ifttt.rst @@ -0,0 +1,5 @@ +salt.modules.ifttt +================== + +.. automodule:: salt.modules.ifttt + :members: diff --git a/doc/ref/modules/all/salt.modules.ilo.rst b/doc/ref/modules/all/salt.modules.ilo.rst new file mode 100644 index 000000000000..bad4d1339d94 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.ilo.rst @@ -0,0 +1,5 @@ +salt.modules.ilo +================ + +.. automodule:: salt.modules.ilo + :members: diff --git a/doc/ref/modules/all/salt.modules.influxdb08mod.rst b/doc/ref/modules/all/salt.modules.influxdb08mod.rst new file mode 100644 index 000000000000..771632e13dfe --- /dev/null +++ b/doc/ref/modules/all/salt.modules.influxdb08mod.rst @@ -0,0 +1,6 @@ +salt.modules.influxdb08mod +========================== + +.. automodule:: salt.modules.influxdb08mod + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.influxdbmod.rst b/doc/ref/modules/all/salt.modules.influxdbmod.rst new file mode 100644 index 000000000000..1e153672b0b4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.influxdbmod.rst @@ -0,0 +1,5 @@ +salt.modules.influxdbmod +======================== + +.. automodule:: salt.modules.influxdbmod + :members: diff --git a/doc/ref/modules/all/salt.modules.infoblox.rst b/doc/ref/modules/all/salt.modules.infoblox.rst new file mode 100644 index 000000000000..dc1dbf926d62 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.infoblox.rst @@ -0,0 +1,5 @@ +salt.modules.infoblox +===================== + +.. automodule:: salt.modules.infoblox + :members: diff --git a/doc/ref/modules/all/salt.modules.inspectlib.collector.rst b/doc/ref/modules/all/salt.modules.inspectlib.collector.rst new file mode 100644 index 000000000000..91eaafd35811 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.inspectlib.collector.rst @@ -0,0 +1,5 @@ +salt.modules.inspectlib.collector +================================= + +.. automodule:: salt.modules.inspectlib.collector + :members: diff --git a/doc/ref/modules/all/salt.modules.inspectlib.dbhandle.rst b/doc/ref/modules/all/salt.modules.inspectlib.dbhandle.rst new file mode 100644 index 000000000000..501fe0edd2f9 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.inspectlib.dbhandle.rst @@ -0,0 +1,5 @@ +salt.modules.inspectlib.dbhandle +================================ + +.. automodule:: salt.modules.inspectlib.dbhandle + :members: diff --git a/doc/ref/modules/all/salt.modules.inspectlib.entities.rst b/doc/ref/modules/all/salt.modules.inspectlib.entities.rst new file mode 100644 index 000000000000..3dfa40760f4e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.inspectlib.entities.rst @@ -0,0 +1,6 @@ +salt.modules.inspectlib.entities +================================ + +.. automodule:: salt.modules.inspectlib.entities + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.inspectlib.exceptions.rst b/doc/ref/modules/all/salt.modules.inspectlib.exceptions.rst new file mode 100644 index 000000000000..cee66b3ba384 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.inspectlib.exceptions.rst @@ -0,0 +1,5 @@ +salt.modules.inspectlib.exceptions +================================== + +.. automodule:: salt.modules.inspectlib.exceptions + :members: diff --git a/doc/ref/modules/all/salt.modules.inspectlib.fsdb.rst b/doc/ref/modules/all/salt.modules.inspectlib.fsdb.rst new file mode 100644 index 000000000000..4983e861bbf5 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.inspectlib.fsdb.rst @@ -0,0 +1,6 @@ +salt.modules.inspectlib.fsdb +============================ + +.. automodule:: salt.modules.inspectlib.fsdb + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.inspectlib.kiwiproc.rst b/doc/ref/modules/all/salt.modules.inspectlib.kiwiproc.rst new file mode 100644 index 000000000000..2602d16623e0 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.inspectlib.kiwiproc.rst @@ -0,0 +1,6 @@ +salt.modules.inspectlib.kiwiproc +================================ + +.. automodule:: salt.modules.inspectlib.kiwiproc + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.inspectlib.query.rst b/doc/ref/modules/all/salt.modules.inspectlib.query.rst new file mode 100644 index 000000000000..d7fc1e1f9e81 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.inspectlib.query.rst @@ -0,0 +1,5 @@ +salt.modules.inspectlib.query +============================= + +.. automodule:: salt.modules.inspectlib.query + :members: diff --git a/doc/ref/modules/all/salt.modules.inspectlib.rst b/doc/ref/modules/all/salt.modules.inspectlib.rst new file mode 100644 index 000000000000..69b0db9fc0ab --- /dev/null +++ b/doc/ref/modules/all/salt.modules.inspectlib.rst @@ -0,0 +1,18 @@ +salt.modules.inspectlib package +=============================== + +Submodules +---------- + +.. toctree:: + + salt.modules.inspectlib.collector + salt.modules.inspectlib.dbhandle + salt.modules.inspectlib.exceptions + salt.modules.inspectlib.query + +Module contents +--------------- + +.. automodule:: salt.modules.inspectlib + :members: diff --git a/doc/ref/modules/all/salt.modules.inspector.rst b/doc/ref/modules/all/salt.modules.inspector.rst new file mode 100644 index 000000000000..33ea742cc1f9 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.inspector.rst @@ -0,0 +1,6 @@ +salt.modules.inspector +====================== + +.. automodule:: salt.modules.inspector + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.introspect.rst b/doc/ref/modules/all/salt.modules.introspect.rst new file mode 100644 index 000000000000..fa740ac00d3c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.introspect.rst @@ -0,0 +1,5 @@ +salt.modules.introspect +======================= + +.. automodule:: salt.modules.introspect + :members: diff --git a/doc/ref/modules/all/salt.modules.ipmi.rst b/doc/ref/modules/all/salt.modules.ipmi.rst new file mode 100644 index 000000000000..f81858dc3a80 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.ipmi.rst @@ -0,0 +1,5 @@ +salt.modules.ipmi +================= + +.. automodule:: salt.modules.ipmi + :members: diff --git a/doc/ref/modules/all/salt.modules.jboss7.rst b/doc/ref/modules/all/salt.modules.jboss7.rst new file mode 100644 index 000000000000..10a2a6b16045 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.jboss7.rst @@ -0,0 +1,5 @@ +salt.modules.jboss7 +=================== + +.. automodule:: salt.modules.jboss7 + :members: diff --git a/doc/ref/modules/all/salt.modules.jboss7_cli.rst b/doc/ref/modules/all/salt.modules.jboss7_cli.rst new file mode 100644 index 000000000000..e0bc98bc7205 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.jboss7_cli.rst @@ -0,0 +1,5 @@ +salt.modules.jboss7_cli +======================= + +.. automodule:: salt.modules.jboss7_cli + :members: diff --git a/doc/ref/modules/all/salt.modules.jenkinsmod.rst b/doc/ref/modules/all/salt.modules.jenkinsmod.rst new file mode 100644 index 000000000000..e8ecf6fd7eb4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.jenkinsmod.rst @@ -0,0 +1,5 @@ +salt.modules.jenkinsmod +======================= + +.. automodule:: salt.modules.jenkinsmod + :members: diff --git a/doc/ref/modules/all/salt.modules.jira_mod.rst b/doc/ref/modules/all/salt.modules.jira_mod.rst new file mode 100644 index 000000000000..f7ab4c358c8f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.jira_mod.rst @@ -0,0 +1,5 @@ +salt.modules.jira_mod +===================== + +.. automodule:: salt.modules.jira_mod + :members: diff --git a/doc/ref/modules/all/salt.modules.k8s.rst b/doc/ref/modules/all/salt.modules.k8s.rst new file mode 100644 index 000000000000..9fd426562fa9 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.k8s.rst @@ -0,0 +1,5 @@ +salt.modules.k8s +================ + +.. automodule:: salt.modules.k8s + :members: diff --git a/doc/ref/modules/all/salt.modules.kapacitor.rst b/doc/ref/modules/all/salt.modules.kapacitor.rst new file mode 100644 index 000000000000..ab3176c723f5 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.kapacitor.rst @@ -0,0 +1,5 @@ +salt.modules.kapacitor +====================== + +.. automodule:: salt.modules.kapacitor + :members: diff --git a/doc/ref/modules/all/salt.modules.kerberos.rst b/doc/ref/modules/all/salt.modules.kerberos.rst new file mode 100644 index 000000000000..6ecc59cbc7b8 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.kerberos.rst @@ -0,0 +1,5 @@ +salt.modules.kerberos +===================== + +.. automodule:: salt.modules.kerberos + :members: diff --git a/doc/ref/modules/all/salt.modules.keystone.rst b/doc/ref/modules/all/salt.modules.keystone.rst new file mode 100644 index 000000000000..b2374ecb2bb2 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.keystone.rst @@ -0,0 +1,5 @@ +salt.modules.keystone +===================== + +.. automodule:: salt.modules.keystone + :members: diff --git a/doc/ref/modules/all/salt.modules.keystoneng.rst b/doc/ref/modules/all/salt.modules.keystoneng.rst new file mode 100644 index 000000000000..eb9d1b02c4ad --- /dev/null +++ b/doc/ref/modules/all/salt.modules.keystoneng.rst @@ -0,0 +1,5 @@ +salt.modules.keystoneng +======================= + +.. automodule:: salt.modules.keystoneng + :members: diff --git a/doc/ref/modules/all/salt.modules.keystore.rst b/doc/ref/modules/all/salt.modules.keystore.rst new file mode 100644 index 000000000000..d66ce25ffa35 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.keystore.rst @@ -0,0 +1,5 @@ +salt.modules.keystore +===================== + +.. automodule:: salt.modules.keystore + :members: diff --git a/doc/ref/modules/all/salt.modules.kubeadm.rst b/doc/ref/modules/all/salt.modules.kubeadm.rst new file mode 100644 index 000000000000..c17cee43ee63 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.kubeadm.rst @@ -0,0 +1,5 @@ +salt.modules.kubeadm +==================== + +.. automodule:: salt.modules.kubeadm + :members: diff --git a/doc/ref/modules/all/salt.modules.kubernetesmod.rst b/doc/ref/modules/all/salt.modules.kubernetesmod.rst new file mode 100644 index 000000000000..917afd327ad4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.kubernetesmod.rst @@ -0,0 +1,5 @@ +salt.modules.kubernetesmod +========================== + +.. automodule:: salt.modules.kubernetesmod + :members: diff --git a/doc/ref/modules/all/salt.modules.launchctl_service.rst b/doc/ref/modules/all/salt.modules.launchctl_service.rst new file mode 100644 index 000000000000..c6391c4b4e20 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.launchctl_service.rst @@ -0,0 +1,5 @@ +salt.modules.launchctl_service +============================== + +.. automodule:: salt.modules.launchctl_service + :members: diff --git a/doc/ref/modules/all/salt.modules.layman.rst b/doc/ref/modules/all/salt.modules.layman.rst new file mode 100644 index 000000000000..97771f135b96 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.layman.rst @@ -0,0 +1,5 @@ +salt.modules.layman +=================== + +.. automodule:: salt.modules.layman + :members: diff --git a/doc/ref/modules/all/salt.modules.ldap3.rst b/doc/ref/modules/all/salt.modules.ldap3.rst new file mode 100644 index 000000000000..e1755f332135 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.ldap3.rst @@ -0,0 +1,5 @@ +salt.modules.ldap3 +================== + +.. automodule:: salt.modules.ldap3 + :members: diff --git a/doc/ref/modules/all/salt.modules.ldapmod.rst b/doc/ref/modules/all/salt.modules.ldapmod.rst new file mode 100644 index 000000000000..5767353fbf71 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.ldapmod.rst @@ -0,0 +1,5 @@ +salt.modules.ldapmod +==================== + +.. automodule:: salt.modules.ldapmod + :members: diff --git a/doc/ref/modules/all/salt.modules.libcloud_compute.rst b/doc/ref/modules/all/salt.modules.libcloud_compute.rst new file mode 100644 index 000000000000..ef36854199bc --- /dev/null +++ b/doc/ref/modules/all/salt.modules.libcloud_compute.rst @@ -0,0 +1,6 @@ +salt.modules.libcloud_compute +============================= + +.. automodule:: salt.modules.libcloud_compute + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.libcloud_dns.rst b/doc/ref/modules/all/salt.modules.libcloud_dns.rst new file mode 100644 index 000000000000..d2aa6e9241c9 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.libcloud_dns.rst @@ -0,0 +1,6 @@ +salt.modules.libcloud_dns +========================= + +.. automodule:: salt.modules.libcloud_dns + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.libcloud_loadbalancer.rst b/doc/ref/modules/all/salt.modules.libcloud_loadbalancer.rst new file mode 100644 index 000000000000..7955a691d93f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.libcloud_loadbalancer.rst @@ -0,0 +1,5 @@ +salt.modules.libcloud_loadbalancer +================================== + +.. automodule:: salt.modules.libcloud_loadbalancer + :members: diff --git a/doc/ref/modules/all/salt.modules.libcloud_storage.rst b/doc/ref/modules/all/salt.modules.libcloud_storage.rst new file mode 100644 index 000000000000..19569af41efb --- /dev/null +++ b/doc/ref/modules/all/salt.modules.libcloud_storage.rst @@ -0,0 +1,6 @@ +salt.modules.libcloud_storage +============================= + +.. automodule:: salt.modules.libcloud_storage + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.logadm.rst b/doc/ref/modules/all/salt.modules.logadm.rst new file mode 100644 index 000000000000..da60e638cef4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.logadm.rst @@ -0,0 +1,5 @@ +salt.modules.logadm +=================== + +.. automodule:: salt.modules.logadm + :members: diff --git a/doc/ref/modules/all/salt.modules.lvs.rst b/doc/ref/modules/all/salt.modules.lvs.rst new file mode 100644 index 000000000000..338f53579627 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.lvs.rst @@ -0,0 +1,5 @@ +salt.modules.lvs +================ + +.. automodule:: salt.modules.lvs + :members: diff --git a/doc/ref/modules/all/salt.modules.lxc.rst b/doc/ref/modules/all/salt.modules.lxc.rst new file mode 100644 index 000000000000..a7c4f8f5f043 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.lxc.rst @@ -0,0 +1,6 @@ +salt.modules.lxc +================ + +.. automodule:: salt.modules.lxc + :members: + :exclude-members: cp, set_pass, remove diff --git a/doc/ref/modules/all/salt.modules.lxd.rst b/doc/ref/modules/all/salt.modules.lxd.rst new file mode 100644 index 000000000000..275369fd4cbd --- /dev/null +++ b/doc/ref/modules/all/salt.modules.lxd.rst @@ -0,0 +1,5 @@ +salt.modules.lxd +================ + +.. automodule:: salt.modules.lxd + :members: diff --git a/doc/ref/modules/all/salt.modules.makeconf.rst b/doc/ref/modules/all/salt.modules.makeconf.rst new file mode 100644 index 000000000000..c9733af347e4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.makeconf.rst @@ -0,0 +1,5 @@ +salt.modules.makeconf +===================== + +.. automodule:: salt.modules.makeconf + :members: diff --git a/doc/ref/modules/all/salt.modules.mandrill.rst b/doc/ref/modules/all/salt.modules.mandrill.rst new file mode 100644 index 000000000000..c0c87746a258 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.mandrill.rst @@ -0,0 +1,5 @@ +salt.modules.mandrill +===================== + +.. automodule:: salt.modules.mandrill + :members: diff --git a/doc/ref/modules/all/salt.modules.marathon.rst b/doc/ref/modules/all/salt.modules.marathon.rst new file mode 100644 index 000000000000..29391a74643c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.marathon.rst @@ -0,0 +1,5 @@ +salt.modules.marathon +===================== + +.. automodule:: salt.modules.marathon + :members: diff --git a/doc/ref/modules/all/salt.modules.mattermost.rst b/doc/ref/modules/all/salt.modules.mattermost.rst new file mode 100644 index 000000000000..cf82a81ddb74 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.mattermost.rst @@ -0,0 +1,6 @@ +salt.modules.mattermost +======================= + +.. automodule:: salt.modules.mattermost + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.mdata.rst b/doc/ref/modules/all/salt.modules.mdata.rst new file mode 100644 index 000000000000..e5293c9477c7 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.mdata.rst @@ -0,0 +1,5 @@ +salt.modules.mdata +================== + +.. automodule:: salt.modules.mdata + :members: diff --git a/doc/ref/modules/all/salt.modules.memcached.rst b/doc/ref/modules/all/salt.modules.memcached.rst new file mode 100644 index 000000000000..3a5dda1a2d0c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.memcached.rst @@ -0,0 +1,6 @@ +salt.modules.memcached +====================== + +.. automodule:: salt.modules.memcached + :members: + :exclude-members: incr, decr diff --git a/doc/ref/modules/all/salt.modules.modjk.rst b/doc/ref/modules/all/salt.modules.modjk.rst new file mode 100644 index 000000000000..a13f4b979438 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.modjk.rst @@ -0,0 +1,5 @@ +salt.modules.modjk +================== + +.. automodule:: salt.modules.modjk + :members: diff --git a/doc/ref/modules/all/salt.modules.mongodb.rst b/doc/ref/modules/all/salt.modules.mongodb.rst new file mode 100644 index 000000000000..3b71403ea03f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.mongodb.rst @@ -0,0 +1,5 @@ +salt.modules.mongodb +==================== + +.. automodule:: salt.modules.mongodb + :members: diff --git a/doc/ref/modules/all/salt.modules.monit.rst b/doc/ref/modules/all/salt.modules.monit.rst new file mode 100644 index 000000000000..f3c75f6639bd --- /dev/null +++ b/doc/ref/modules/all/salt.modules.monit.rst @@ -0,0 +1,5 @@ +salt.modules.monit +================== + +.. automodule:: salt.modules.monit + :members: diff --git a/doc/ref/modules/all/salt.modules.moosefs.rst b/doc/ref/modules/all/salt.modules.moosefs.rst new file mode 100644 index 000000000000..523b9fba1b86 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.moosefs.rst @@ -0,0 +1,5 @@ +salt.modules.moosefs +==================== + +.. automodule:: salt.modules.moosefs + :members: diff --git a/doc/ref/modules/all/salt.modules.mssql.rst b/doc/ref/modules/all/salt.modules.mssql.rst new file mode 100644 index 000000000000..68a0f3c73cf4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.mssql.rst @@ -0,0 +1,5 @@ +salt.modules.mssql +================== + +.. automodule:: salt.modules.mssql + :members: diff --git a/doc/ref/modules/all/salt.modules.msteams.rst b/doc/ref/modules/all/salt.modules.msteams.rst new file mode 100644 index 000000000000..b9dd7a4418ff --- /dev/null +++ b/doc/ref/modules/all/salt.modules.msteams.rst @@ -0,0 +1,6 @@ +salt.modules.msteams +==================== + +.. automodule:: salt.modules.msteams + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.munin.rst b/doc/ref/modules/all/salt.modules.munin.rst new file mode 100644 index 000000000000..084d2ce81805 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.munin.rst @@ -0,0 +1,5 @@ +salt.modules.munin +================== + +.. automodule:: salt.modules.munin + :members: diff --git a/doc/ref/modules/all/salt.modules.nagios.rst b/doc/ref/modules/all/salt.modules.nagios.rst new file mode 100644 index 000000000000..982100cf24bb --- /dev/null +++ b/doc/ref/modules/all/salt.modules.nagios.rst @@ -0,0 +1,5 @@ +salt.modules.nagios +=================== + +.. automodule:: salt.modules.nagios + :members: diff --git a/doc/ref/modules/all/salt.modules.nagios_rpc.rst b/doc/ref/modules/all/salt.modules.nagios_rpc.rst new file mode 100644 index 000000000000..738a6d0a778f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.nagios_rpc.rst @@ -0,0 +1,5 @@ +salt.modules.nagios_rpc +======================= + +.. automodule:: salt.modules.nagios_rpc + :members: diff --git a/doc/ref/modules/all/salt.modules.namecheap_domains.rst b/doc/ref/modules/all/salt.modules.namecheap_domains.rst new file mode 100644 index 000000000000..120fba3d3765 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.namecheap_domains.rst @@ -0,0 +1,6 @@ +salt.modules.namecheap_domains +============================== + +.. automodule:: salt.modules.namecheap_domains + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.namecheap_domains_dns.rst b/doc/ref/modules/all/salt.modules.namecheap_domains_dns.rst new file mode 100644 index 000000000000..4f66844a140a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.namecheap_domains_dns.rst @@ -0,0 +1,6 @@ +salt.modules.namecheap_domains_dns +================================== + +.. automodule:: salt.modules.namecheap_domains_dns + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.namecheap_domains_ns.rst b/doc/ref/modules/all/salt.modules.namecheap_domains_ns.rst new file mode 100644 index 000000000000..43c90a5e55df --- /dev/null +++ b/doc/ref/modules/all/salt.modules.namecheap_domains_ns.rst @@ -0,0 +1,6 @@ +salt.modules.namecheap_domains_ns +================================= + +.. automodule:: salt.modules.namecheap_domains_ns + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.namecheap_ssl.rst b/doc/ref/modules/all/salt.modules.namecheap_ssl.rst new file mode 100644 index 000000000000..d64265de21ab --- /dev/null +++ b/doc/ref/modules/all/salt.modules.namecheap_ssl.rst @@ -0,0 +1,6 @@ +salt.modules.namecheap_ssl +========================== + +.. automodule:: salt.modules.namecheap_ssl + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.namecheap_users.rst b/doc/ref/modules/all/salt.modules.namecheap_users.rst new file mode 100644 index 000000000000..755cc1e92c2a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.namecheap_users.rst @@ -0,0 +1,6 @@ +salt.modules.namecheap_users +============================ + +.. automodule:: salt.modules.namecheap_users + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.netbox.rst b/doc/ref/modules/all/salt.modules.netbox.rst new file mode 100644 index 000000000000..b00de0be6d0e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.netbox.rst @@ -0,0 +1,5 @@ +salt.modules.netbox +=================== + +.. automodule:: salt.modules.netbox + :members: diff --git a/doc/ref/modules/all/salt.modules.netbsd_sysctl.rst b/doc/ref/modules/all/salt.modules.netbsd_sysctl.rst new file mode 100644 index 000000000000..85a84d1e2e6b --- /dev/null +++ b/doc/ref/modules/all/salt.modules.netbsd_sysctl.rst @@ -0,0 +1,5 @@ +salt.modules.netbsd_sysctl +========================== + +.. automodule:: salt.modules.netbsd_sysctl + :members: diff --git a/doc/ref/modules/all/salt.modules.netbsdservice.rst b/doc/ref/modules/all/salt.modules.netbsdservice.rst new file mode 100644 index 000000000000..fa962b690ae2 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.netbsdservice.rst @@ -0,0 +1,5 @@ +salt.modules.netbsdservice +========================== + +.. automodule:: salt.modules.netbsdservice + :members: diff --git a/doc/ref/modules/all/salt.modules.netmiko_mod.rst b/doc/ref/modules/all/salt.modules.netmiko_mod.rst new file mode 100644 index 000000000000..1146d4a72e2c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.netmiko_mod.rst @@ -0,0 +1,5 @@ +salt.modules.netmiko_mod +======================== + +.. automodule:: salt.modules.netmiko_mod + :members: diff --git a/doc/ref/modules/all/salt.modules.netscaler.rst b/doc/ref/modules/all/salt.modules.netscaler.rst new file mode 100644 index 000000000000..c698447a20b9 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.netscaler.rst @@ -0,0 +1,5 @@ +salt.modules.netscaler +====================== + +.. automodule:: salt.modules.netscaler + :members: diff --git a/doc/ref/modules/all/salt.modules.neutron.rst b/doc/ref/modules/all/salt.modules.neutron.rst new file mode 100644 index 000000000000..86d8b685a75a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.neutron.rst @@ -0,0 +1,5 @@ +salt.modules.neutron +==================== + +.. automodule:: salt.modules.neutron + :members: diff --git a/doc/ref/modules/all/salt.modules.neutronng.rst b/doc/ref/modules/all/salt.modules.neutronng.rst new file mode 100644 index 000000000000..2bc3420cc1db --- /dev/null +++ b/doc/ref/modules/all/salt.modules.neutronng.rst @@ -0,0 +1,5 @@ +salt.modules.neutronng +====================== + +.. automodule:: salt.modules.neutronng + :members: diff --git a/doc/ref/modules/all/salt.modules.nexus.rst b/doc/ref/modules/all/salt.modules.nexus.rst new file mode 100644 index 000000000000..c292993358a9 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.nexus.rst @@ -0,0 +1,6 @@ +salt.modules.nexus +================== + +.. automodule:: salt.modules.nexus + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.nginx.rst b/doc/ref/modules/all/salt.modules.nginx.rst new file mode 100644 index 000000000000..5171cd56f1af --- /dev/null +++ b/doc/ref/modules/all/salt.modules.nginx.rst @@ -0,0 +1,5 @@ +salt.modules.nginx +================== + +.. automodule:: salt.modules.nginx + :members: diff --git a/doc/ref/modules/all/salt.modules.nilrt_ip.rst b/doc/ref/modules/all/salt.modules.nilrt_ip.rst new file mode 100644 index 000000000000..2a84c2eb464d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.nilrt_ip.rst @@ -0,0 +1,6 @@ +salt.modules.nilrt_ip +===================== + +.. automodule:: salt.modules.nilrt_ip + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.nix.rst b/doc/ref/modules/all/salt.modules.nix.rst new file mode 100644 index 000000000000..defce9617547 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.nix.rst @@ -0,0 +1,5 @@ +salt.modules.nix +================ + +.. automodule:: salt.modules.nix + :members: diff --git a/doc/ref/modules/all/salt.modules.nixpkg.rst b/doc/ref/modules/all/salt.modules.nixpkg.rst deleted file mode 100644 index 903501d1e468..000000000000 --- a/doc/ref/modules/all/salt.modules.nixpkg.rst +++ /dev/null @@ -1,6 +0,0 @@ -salt.modules.nixpkg -=================== - -.. automodule:: salt.modules.nixpkg - :members: - :exclude-members: available_version diff --git a/doc/ref/modules/all/salt.modules.nova.rst b/doc/ref/modules/all/salt.modules.nova.rst new file mode 100644 index 000000000000..4c35b8cf4900 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.nova.rst @@ -0,0 +1,5 @@ +salt.modules.nova +================= + +.. automodule:: salt.modules.nova + :members: diff --git a/doc/ref/modules/all/salt.modules.nspawn.rst b/doc/ref/modules/all/salt.modules.nspawn.rst new file mode 100644 index 000000000000..e3020479bccc --- /dev/null +++ b/doc/ref/modules/all/salt.modules.nspawn.rst @@ -0,0 +1,6 @@ +salt.modules.nspawn +=================== + +.. automodule:: salt.modules.nspawn + :members: + :exclude-members: cp, destroy, list_, pull_docker, restart, stop diff --git a/doc/ref/modules/all/salt.modules.omapi.rst b/doc/ref/modules/all/salt.modules.omapi.rst new file mode 100644 index 000000000000..1962ff7052d1 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.omapi.rst @@ -0,0 +1,5 @@ +salt.modules.omapi +================== + +.. automodule:: salt.modules.omapi + :members: diff --git a/doc/ref/modules/all/salt.modules.openbsd_sysctl.rst b/doc/ref/modules/all/salt.modules.openbsd_sysctl.rst new file mode 100644 index 000000000000..83bd38c7c25e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.openbsd_sysctl.rst @@ -0,0 +1,5 @@ +salt.modules.openbsd_sysctl +=========================== + +.. automodule:: salt.modules.openbsd_sysctl + :members: diff --git a/doc/ref/modules/all/salt.modules.openbsdpkg.rst b/doc/ref/modules/all/salt.modules.openbsdpkg.rst new file mode 100644 index 000000000000..6bf0b8a94df9 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.openbsdpkg.rst @@ -0,0 +1,6 @@ +salt.modules.openbsdpkg +======================= + +.. automodule:: salt.modules.openbsdpkg + :members: + :exclude-members: available_version diff --git a/doc/ref/modules/all/salt.modules.openbsdrcctl_service.rst b/doc/ref/modules/all/salt.modules.openbsdrcctl_service.rst new file mode 100644 index 000000000000..7189f604bf51 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.openbsdrcctl_service.rst @@ -0,0 +1,5 @@ +salt.modules.openbsdrcctl_service.py +==================================== + +.. automodule:: salt.modules.openbsdrcctl_service + :members: diff --git a/doc/ref/modules/all/salt.modules.openbsdservice.rst b/doc/ref/modules/all/salt.modules.openbsdservice.rst new file mode 100644 index 000000000000..1f6bcfe3bcf5 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.openbsdservice.rst @@ -0,0 +1,5 @@ +salt.modules.openbsdservice +=========================== + +.. automodule:: salt.modules.openbsdservice + :members: diff --git a/doc/ref/modules/all/salt.modules.openscap.rst b/doc/ref/modules/all/salt.modules.openscap.rst new file mode 100644 index 000000000000..56ac2e8441fe --- /dev/null +++ b/doc/ref/modules/all/salt.modules.openscap.rst @@ -0,0 +1,6 @@ +salt.modules.openscap +===================== + +.. automodule:: salt.modules.openscap + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.openstack_config.rst b/doc/ref/modules/all/salt.modules.openstack_config.rst new file mode 100644 index 000000000000..157ab70229d1 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.openstack_config.rst @@ -0,0 +1,5 @@ +salt.modules.openstack_config +============================= + +.. automodule:: salt.modules.openstack_config + :members: diff --git a/doc/ref/modules/all/salt.modules.openstack_mng.rst b/doc/ref/modules/all/salt.modules.openstack_mng.rst new file mode 100644 index 000000000000..8d57ff48ff3e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.openstack_mng.rst @@ -0,0 +1,6 @@ +salt.modules.openstack_mng +========================== + +.. automodule:: salt.modules.openstack_mng + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.openvswitch.rst b/doc/ref/modules/all/salt.modules.openvswitch.rst new file mode 100644 index 000000000000..ecbce2a3138d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.openvswitch.rst @@ -0,0 +1,5 @@ +salt.modules.openvswitch +======================== + +.. automodule:: salt.modules.openvswitch + :members: diff --git a/doc/ref/modules/all/salt.modules.opkg.rst b/doc/ref/modules/all/salt.modules.opkg.rst new file mode 100644 index 000000000000..36ec9702324d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.opkg.rst @@ -0,0 +1,5 @@ +salt.modules.opkg +================= + +.. automodule:: salt.modules.opkg + :members: diff --git a/doc/ref/modules/all/salt.modules.opsgenie.rst b/doc/ref/modules/all/salt.modules.opsgenie.rst new file mode 100644 index 000000000000..0968b5e10824 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.opsgenie.rst @@ -0,0 +1,5 @@ +salt.modules.opsgenie +===================== + +.. automodule:: salt.modules.opsgenie + :members: diff --git a/doc/ref/modules/all/salt.modules.pagerduty.rst b/doc/ref/modules/all/salt.modules.pagerduty.rst new file mode 100644 index 000000000000..651cebd017f0 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.pagerduty.rst @@ -0,0 +1,5 @@ +salt.modules.pagerduty +====================== + +.. automodule:: salt.modules.pagerduty + :members: diff --git a/doc/ref/modules/all/salt.modules.pagerduty_util.rst b/doc/ref/modules/all/salt.modules.pagerduty_util.rst new file mode 100644 index 000000000000..295eabf2a9dd --- /dev/null +++ b/doc/ref/modules/all/salt.modules.pagerduty_util.rst @@ -0,0 +1,5 @@ +salt.modules.pagerduty_util +=========================== + +.. automodule:: salt.modules.pagerduty_util + :members: diff --git a/doc/ref/modules/all/salt.modules.panos.rst b/doc/ref/modules/all/salt.modules.panos.rst new file mode 100644 index 000000000000..e0717672f2bc --- /dev/null +++ b/doc/ref/modules/all/salt.modules.panos.rst @@ -0,0 +1,5 @@ +salt.modules.panos +================== + +.. automodule:: salt.modules.panos + :members: diff --git a/doc/ref/modules/all/salt.modules.parallels.rst b/doc/ref/modules/all/salt.modules.parallels.rst new file mode 100644 index 000000000000..cb24363150df --- /dev/null +++ b/doc/ref/modules/all/salt.modules.parallels.rst @@ -0,0 +1,5 @@ +salt.modules.parallels +====================== + +.. automodule:: salt.modules.parallels + :members: diff --git a/doc/ref/modules/all/salt.modules.pcs.rst b/doc/ref/modules/all/salt.modules.pcs.rst new file mode 100644 index 000000000000..c4a879d1a408 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.pcs.rst @@ -0,0 +1,5 @@ +salt.modules.pcs +================ + +.. automodule:: salt.modules.pcs + :members: diff --git a/doc/ref/modules/all/salt.modules.pdbedit.rst b/doc/ref/modules/all/salt.modules.pdbedit.rst new file mode 100644 index 000000000000..0565f98c6e26 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.pdbedit.rst @@ -0,0 +1,5 @@ +salt.modules.pdbedit +==================== + +.. automodule:: salt.modules.pdbedit + :members: diff --git a/doc/ref/modules/all/salt.modules.pecl.rst b/doc/ref/modules/all/salt.modules.pecl.rst new file mode 100644 index 000000000000..8c65a4dc9d02 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.pecl.rst @@ -0,0 +1,5 @@ +salt.modules.pecl +================= + +.. automodule:: salt.modules.pecl + :members: diff --git a/doc/ref/modules/all/salt.modules.peeringdb.rst b/doc/ref/modules/all/salt.modules.peeringdb.rst new file mode 100644 index 000000000000..f39714d9236f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.peeringdb.rst @@ -0,0 +1,5 @@ +salt.modules.peeringdb +====================== + +.. automodule:: salt.modules.peeringdb + :members: diff --git a/doc/ref/modules/all/salt.modules.pf.rst b/doc/ref/modules/all/salt.modules.pf.rst new file mode 100644 index 000000000000..f21717fb828c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.pf.rst @@ -0,0 +1,5 @@ +salt.modules.pf +=============== + +.. automodule:: salt.modules.pf + :members: diff --git a/doc/ref/modules/all/salt.modules.philips_hue.rst b/doc/ref/modules/all/salt.modules.philips_hue.rst new file mode 100644 index 000000000000..b27a82d4da4d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.philips_hue.rst @@ -0,0 +1,5 @@ +salt.modules.philips_hue +======================== + +.. automodule:: salt.modules.philips_hue + :members: diff --git a/doc/ref/modules/all/salt.modules.pkg.rst b/doc/ref/modules/all/salt.modules.pkg.rst index ef2600788f26..c5972c18411f 100644 --- a/doc/ref/modules/all/salt.modules.pkg.rst +++ b/doc/ref/modules/all/salt.modules.pkg.rst @@ -22,8 +22,6 @@ Execution Module Used for ``emerge(1)``) :py:mod:`~salt.modules.freebsdpkg` FreeBSD-based OSes using ``pkg_add(1)`` :py:mod:`~salt.modules.openbsdpkg` OpenBSD-based OSes using ``pkg_add(1)`` -:py:mod:`~salt.modules.nixpkg` Systems using the `Nix`_ package - manager :py:mod:`~salt.modules.pacmanpkg` Arch Linux-based distros using ``pacman(8)`` :py:mod:`~salt.modules.pkgin` NetBSD-based OSes using ``pkgin(1)`` @@ -40,5 +38,4 @@ Execution Module Used for ====================================== ======================================== .. _Homebrew: https://brew.sh/ -.. _Nix: https://nixos.org/ .. _OpenCSW: https://www.opencsw.org/ diff --git a/doc/ref/modules/all/salt.modules.portage_config.rst b/doc/ref/modules/all/salt.modules.portage_config.rst new file mode 100644 index 000000000000..9175e2d4fcfa --- /dev/null +++ b/doc/ref/modules/all/salt.modules.portage_config.rst @@ -0,0 +1,5 @@ +salt.modules.portage_config +=========================== + +.. automodule:: salt.modules.portage_config + :members: diff --git a/doc/ref/modules/all/salt.modules.postfix.rst b/doc/ref/modules/all/salt.modules.postfix.rst new file mode 100644 index 000000000000..83c8c7f5fda3 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.postfix.rst @@ -0,0 +1,5 @@ +salt.modules.postfix +==================== + +.. automodule:: salt.modules.postfix + :members: diff --git a/doc/ref/modules/all/salt.modules.poudriere.rst b/doc/ref/modules/all/salt.modules.poudriere.rst new file mode 100644 index 000000000000..87f56bef7627 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.poudriere.rst @@ -0,0 +1,5 @@ +salt.modules.poudriere +====================== + +.. automodule:: salt.modules.poudriere + :members: diff --git a/doc/ref/modules/all/salt.modules.powerpath.rst b/doc/ref/modules/all/salt.modules.powerpath.rst new file mode 100644 index 000000000000..9d7bb48219b4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.powerpath.rst @@ -0,0 +1,5 @@ +salt.modules.powerpath +====================== + +.. automodule:: salt.modules.powerpath + :members: diff --git a/doc/ref/modules/all/salt.modules.purefa.rst b/doc/ref/modules/all/salt.modules.purefa.rst new file mode 100644 index 000000000000..bdeadcc5b7a0 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.purefa.rst @@ -0,0 +1,5 @@ +salt.modules.purefa +=================== + +.. automodule:: salt.modules.purefa + :members: diff --git a/doc/ref/modules/all/salt.modules.purefb.rst b/doc/ref/modules/all/salt.modules.purefb.rst new file mode 100644 index 000000000000..93029d96434e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.purefb.rst @@ -0,0 +1,5 @@ +salt.modules.purefb +=================== + +.. automodule:: salt.modules.purefb + :members: diff --git a/doc/ref/modules/all/salt.modules.pushbullet.rst b/doc/ref/modules/all/salt.modules.pushbullet.rst new file mode 100644 index 000000000000..35a20f044144 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.pushbullet.rst @@ -0,0 +1,5 @@ +salt.modules.pushbullet +======================= + +.. automodule:: salt.modules.pushbullet + :members: diff --git a/doc/ref/modules/all/salt.modules.pushover_notify.rst b/doc/ref/modules/all/salt.modules.pushover_notify.rst new file mode 100644 index 000000000000..bd6403958880 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.pushover_notify.rst @@ -0,0 +1,5 @@ +salt.modules.pushover_notify +============================ + +.. automodule:: salt.modules.pushover_notify + :members: diff --git a/doc/ref/modules/all/salt.modules.qemu_img.rst b/doc/ref/modules/all/salt.modules.qemu_img.rst new file mode 100644 index 000000000000..883e2be26c45 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.qemu_img.rst @@ -0,0 +1,5 @@ +salt.modules.qemu_img +===================== + +.. automodule:: salt.modules.qemu_img + :members: diff --git a/doc/ref/modules/all/salt.modules.qemu_nbd.rst b/doc/ref/modules/all/salt.modules.qemu_nbd.rst new file mode 100644 index 000000000000..f172bb40a30d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.qemu_nbd.rst @@ -0,0 +1,5 @@ +salt.modules.qemu_nbd +===================== + +.. automodule:: salt.modules.qemu_nbd + :members: diff --git a/doc/ref/modules/all/salt.modules.rallydev.rst b/doc/ref/modules/all/salt.modules.rallydev.rst new file mode 100644 index 000000000000..a2f8acc8b2c5 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.rallydev.rst @@ -0,0 +1,5 @@ +salt.modules.rallydev +===================== + +.. automodule:: salt.modules.rallydev + :members: diff --git a/doc/ref/modules/all/salt.modules.random_org.rst b/doc/ref/modules/all/salt.modules.random_org.rst new file mode 100644 index 000000000000..158199e9ddad --- /dev/null +++ b/doc/ref/modules/all/salt.modules.random_org.rst @@ -0,0 +1,5 @@ +salt.modules.random_org +======================= + +.. automodule:: salt.modules.random_org + :members: diff --git a/doc/ref/modules/all/salt.modules.rbenv.rst b/doc/ref/modules/all/salt.modules.rbenv.rst new file mode 100644 index 000000000000..52f2a33affbf --- /dev/null +++ b/doc/ref/modules/all/salt.modules.rbenv.rst @@ -0,0 +1,5 @@ +salt.modules.rbenv +================== + +.. automodule:: salt.modules.rbenv + :members: diff --git a/doc/ref/modules/all/salt.modules.rebootmgr.rst b/doc/ref/modules/all/salt.modules.rebootmgr.rst new file mode 100644 index 000000000000..22240080b0e6 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.rebootmgr.rst @@ -0,0 +1,5 @@ +salt.modules.rebootmgr module +============================= + +.. automodule:: salt.modules.rebootmgr + :members: diff --git a/doc/ref/modules/all/salt.modules.redismod.rst b/doc/ref/modules/all/salt.modules.redismod.rst new file mode 100644 index 000000000000..15b1199c9feb --- /dev/null +++ b/doc/ref/modules/all/salt.modules.redismod.rst @@ -0,0 +1,5 @@ +salt.modules.redis +================== + +.. automodule:: salt.modules.redismod + :members: diff --git a/doc/ref/modules/all/salt.modules.restconf.rst b/doc/ref/modules/all/salt.modules.restconf.rst new file mode 100644 index 000000000000..65c22271fcc6 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.restconf.rst @@ -0,0 +1,6 @@ +===================== +salt.modules.restconf +===================== + +.. automodule:: salt.modules.restconf + :members: diff --git a/doc/ref/modules/all/salt.modules.riak.rst b/doc/ref/modules/all/salt.modules.riak.rst new file mode 100644 index 000000000000..5f9906f95d7f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.riak.rst @@ -0,0 +1,5 @@ +salt.modules.riak +================= + +.. automodule:: salt.modules.riak + :members: diff --git a/doc/ref/modules/all/salt.modules.runit.rst b/doc/ref/modules/all/salt.modules.runit.rst new file mode 100644 index 000000000000..1c21c2ad617e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.runit.rst @@ -0,0 +1,5 @@ +salt.modules.runit +================== + +.. automodule:: salt.modules.runit + :members: diff --git a/doc/ref/modules/all/salt.modules.rvm.rst b/doc/ref/modules/all/salt.modules.rvm.rst new file mode 100644 index 000000000000..c782694986d1 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.rvm.rst @@ -0,0 +1,5 @@ +salt.modules.rvm +================ + +.. automodule:: salt.modules.rvm + :members: diff --git a/doc/ref/modules/all/salt.modules.s3.rst b/doc/ref/modules/all/salt.modules.s3.rst new file mode 100644 index 000000000000..46e43bde6019 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.s3.rst @@ -0,0 +1,5 @@ +salt.modules.s3 +=============== + +.. automodule:: salt.modules.s3 + :members: diff --git a/doc/ref/modules/all/salt.modules.s6.rst b/doc/ref/modules/all/salt.modules.s6.rst new file mode 100644 index 000000000000..7392c925358a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.s6.rst @@ -0,0 +1,5 @@ +salt.modules.s6 +=============== + +.. automodule:: salt.modules.s6 + :members: diff --git a/doc/ref/modules/all/salt.modules.sensehat.rst b/doc/ref/modules/all/salt.modules.sensehat.rst new file mode 100644 index 000000000000..065ab63827c9 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.sensehat.rst @@ -0,0 +1,5 @@ +salt.modules.sensehat +===================== + +.. automodule:: salt.modules.sensehat + :members: diff --git a/doc/ref/modules/all/salt.modules.sensors.rst b/doc/ref/modules/all/salt.modules.sensors.rst new file mode 100644 index 000000000000..8d1046b7e107 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.sensors.rst @@ -0,0 +1,5 @@ +salt.modules.sensors +==================== + +.. automodule:: salt.modules.sensors + :members: diff --git a/doc/ref/modules/all/salt.modules.serverdensity_device.rst b/doc/ref/modules/all/salt.modules.serverdensity_device.rst new file mode 100644 index 000000000000..5de96cb5b2b2 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.serverdensity_device.rst @@ -0,0 +1,5 @@ +salt.modules.serverdensity_device +================================= + +.. automodule:: salt.modules.serverdensity_device + :members: diff --git a/doc/ref/modules/all/salt.modules.servicenow.rst b/doc/ref/modules/all/salt.modules.servicenow.rst new file mode 100644 index 000000000000..1b020596da31 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.servicenow.rst @@ -0,0 +1,6 @@ +salt.modules.servicenow +======================= + +.. automodule:: salt.modules.servicenow + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.slackware_service.rst b/doc/ref/modules/all/salt.modules.slackware_service.rst new file mode 100644 index 000000000000..43e385c4df21 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.slackware_service.rst @@ -0,0 +1,5 @@ +salt.modules.slackware_service +============================== + +.. automodule:: salt.modules.slackware_service + :members: diff --git a/doc/ref/modules/all/salt.modules.smartos_imgadm.rst b/doc/ref/modules/all/salt.modules.smartos_imgadm.rst new file mode 100644 index 000000000000..974c45e51f33 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.smartos_imgadm.rst @@ -0,0 +1,5 @@ +salt.modules.smartos_imgadm +=========================== + +.. automodule:: salt.modules.smartos_imgadm + :members: diff --git a/doc/ref/modules/all/salt.modules.smartos_nictagadm.rst b/doc/ref/modules/all/salt.modules.smartos_nictagadm.rst new file mode 100644 index 000000000000..f00ecfed446f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.smartos_nictagadm.rst @@ -0,0 +1,5 @@ +salt.modules.smartos_nictagadm +============================== + +.. automodule:: salt.modules.smartos_nictagadm + :members: diff --git a/doc/ref/modules/all/salt.modules.smartos_virt.rst b/doc/ref/modules/all/salt.modules.smartos_virt.rst new file mode 100644 index 000000000000..780ec0d8be37 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.smartos_virt.rst @@ -0,0 +1,5 @@ +salt.modules.smartos_virt +========================= + +.. automodule:: salt.modules.smartos_virt + :members: diff --git a/doc/ref/modules/all/salt.modules.smartos_vmadm.rst b/doc/ref/modules/all/salt.modules.smartos_vmadm.rst new file mode 100644 index 000000000000..f01d9479d2e4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.smartos_vmadm.rst @@ -0,0 +1,5 @@ +salt.modules.smartos_vmadm +========================== + +.. automodule:: salt.modules.smartos_vmadm + :members: diff --git a/doc/ref/modules/all/salt.modules.smtp.rst b/doc/ref/modules/all/salt.modules.smtp.rst new file mode 100644 index 000000000000..45c3ea74f17b --- /dev/null +++ b/doc/ref/modules/all/salt.modules.smtp.rst @@ -0,0 +1,5 @@ +salt.modules.smtp +================= + +.. automodule:: salt.modules.smtp + :members: diff --git a/doc/ref/modules/all/salt.modules.solr.rst b/doc/ref/modules/all/salt.modules.solr.rst new file mode 100644 index 000000000000..9a8bfc6b491c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.solr.rst @@ -0,0 +1,5 @@ +salt.modules.solr +================= + +.. automodule:: salt.modules.solr + :members: diff --git a/doc/ref/modules/all/salt.modules.solrcloud.rst b/doc/ref/modules/all/salt.modules.solrcloud.rst new file mode 100644 index 000000000000..5ec8bee4ab81 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.solrcloud.rst @@ -0,0 +1,6 @@ +salt.modules.solrcloud +====================== + +.. automodule:: salt.modules.solrcloud + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.splunk.rst b/doc/ref/modules/all/salt.modules.splunk.rst new file mode 100644 index 000000000000..8873fe277028 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.splunk.rst @@ -0,0 +1,5 @@ +salt.modules.splunk +=================== + +.. automodule:: salt.modules.splunk + :members: diff --git a/doc/ref/modules/all/salt.modules.splunk_search.rst b/doc/ref/modules/all/salt.modules.splunk_search.rst new file mode 100644 index 000000000000..a809eed6bf21 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.splunk_search.rst @@ -0,0 +1,5 @@ +salt.modules.splunk_search +========================== + +.. automodule:: salt.modules.splunk_search + :members: diff --git a/doc/ref/modules/all/salt.modules.ssh_pki.rst b/doc/ref/modules/all/salt.modules.ssh_pki.rst deleted file mode 100644 index bb28843d625a..000000000000 --- a/doc/ref/modules/all/salt.modules.ssh_pki.rst +++ /dev/null @@ -1,5 +0,0 @@ -salt.modules.ssh_pki -==================== - -.. automodule:: salt.modules.ssh_pki - :members: diff --git a/doc/ref/modules/all/salt.modules.statuspage.rst b/doc/ref/modules/all/salt.modules.statuspage.rst new file mode 100644 index 000000000000..cac009f35006 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.statuspage.rst @@ -0,0 +1,5 @@ +salt.modules.statuspage +======================= + +.. automodule:: salt.modules.statuspage + :members: diff --git a/doc/ref/modules/all/salt.modules.suse_apache.rst b/doc/ref/modules/all/salt.modules.suse_apache.rst new file mode 100644 index 000000000000..2ece82552a2c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.suse_apache.rst @@ -0,0 +1,5 @@ +salt.modules.suse_apache +======================== + +.. automodule:: salt.modules.suse_apache + :members: diff --git a/doc/ref/modules/all/salt.modules.suse_ip.rst b/doc/ref/modules/all/salt.modules.suse_ip.rst new file mode 100644 index 000000000000..ba46716723e8 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.suse_ip.rst @@ -0,0 +1,6 @@ +==================== +salt.modules.suse_ip +==================== + +.. automodule:: salt.modules.suse_ip + :members: diff --git a/doc/ref/modules/all/salt.modules.svn.rst b/doc/ref/modules/all/salt.modules.svn.rst new file mode 100644 index 000000000000..8c4f97b30ab3 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.svn.rst @@ -0,0 +1,5 @@ +salt.modules.svn +================ + +.. automodule:: salt.modules.svn + :members: diff --git a/doc/ref/modules/all/salt.modules.swarm.rst b/doc/ref/modules/all/salt.modules.swarm.rst new file mode 100644 index 000000000000..ecd1d86cd083 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.swarm.rst @@ -0,0 +1,5 @@ +salt.modules.swarm +================== + +.. automodule:: salt.modules.swarm + :members: diff --git a/doc/ref/modules/all/salt.modules.swift.rst b/doc/ref/modules/all/salt.modules.swift.rst new file mode 100644 index 000000000000..df4a86c50b1c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.swift.rst @@ -0,0 +1,5 @@ +salt.modules.swift +================== + +.. automodule:: salt.modules.swift + :members: diff --git a/doc/ref/modules/all/salt.modules.sysbench.rst b/doc/ref/modules/all/salt.modules.sysbench.rst new file mode 100644 index 000000000000..b6ac322d7d56 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.sysbench.rst @@ -0,0 +1,5 @@ +salt.modules.sysbench +===================== + +.. automodule:: salt.modules.sysbench + :members: diff --git a/doc/ref/modules/all/salt.modules.sysrc.rst b/doc/ref/modules/all/salt.modules.sysrc.rst new file mode 100644 index 000000000000..4a79f22e1a1a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.sysrc.rst @@ -0,0 +1,5 @@ +salt.modules.sysrc +================== + +.. automodule:: salt.modules.sysrc + :members: diff --git a/doc/ref/modules/all/salt.modules.system_profiler.rst b/doc/ref/modules/all/salt.modules.system_profiler.rst new file mode 100644 index 000000000000..0452a39dad38 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.system_profiler.rst @@ -0,0 +1,5 @@ +salt.modules.system_profiler +============================ + +.. automodule:: salt.modules.system_profiler + :members: diff --git a/doc/ref/modules/all/salt.modules.telegram.rst b/doc/ref/modules/all/salt.modules.telegram.rst new file mode 100644 index 000000000000..8fb96ccf37de --- /dev/null +++ b/doc/ref/modules/all/salt.modules.telegram.rst @@ -0,0 +1,5 @@ +salt.modules.telegram +===================== + +.. automodule:: salt.modules.telegram + :members: diff --git a/doc/ref/modules/all/salt.modules.telemetry.rst b/doc/ref/modules/all/salt.modules.telemetry.rst new file mode 100644 index 000000000000..fb269dfb7c27 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.telemetry.rst @@ -0,0 +1,5 @@ +salt.modules.telemetry +====================== + +.. automodule:: salt.modules.telemetry + :members: diff --git a/doc/ref/modules/all/salt.modules.testinframod.rst b/doc/ref/modules/all/salt.modules.testinframod.rst new file mode 100644 index 000000000000..f0d59314559f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.testinframod.rst @@ -0,0 +1,6 @@ +salt.modules.testinframod +========================= + +.. automodule:: salt.modules.testinframod + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.tomcat.rst b/doc/ref/modules/all/salt.modules.tomcat.rst new file mode 100644 index 000000000000..108df7361421 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.tomcat.rst @@ -0,0 +1,5 @@ +salt.modules.tomcat +=================== + +.. automodule:: salt.modules.tomcat + :members: diff --git a/doc/ref/modules/all/salt.modules.trafficserver.rst b/doc/ref/modules/all/salt.modules.trafficserver.rst new file mode 100644 index 000000000000..457b06b59c9e --- /dev/null +++ b/doc/ref/modules/all/salt.modules.trafficserver.rst @@ -0,0 +1,5 @@ +salt.modules.trafficserver +========================== + +.. automodule:: salt.modules.trafficserver + :members: diff --git a/doc/ref/modules/all/salt.modules.transactional_update.rst b/doc/ref/modules/all/salt.modules.transactional_update.rst new file mode 100644 index 000000000000..2f15b95ad41d --- /dev/null +++ b/doc/ref/modules/all/salt.modules.transactional_update.rst @@ -0,0 +1,5 @@ +salt.modules.transactional_update module +======================================== + +.. automodule:: salt.modules.transactional_update + :members: diff --git a/doc/ref/modules/all/salt.modules.travisci.rst b/doc/ref/modules/all/salt.modules.travisci.rst new file mode 100644 index 000000000000..3f7278e33d92 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.travisci.rst @@ -0,0 +1,5 @@ +salt.modules.travisci +===================== + +.. automodule:: salt.modules.travisci + :members: diff --git a/doc/ref/modules/all/salt.modules.tuned.rst b/doc/ref/modules/all/salt.modules.tuned.rst new file mode 100644 index 000000000000..a27ef86815a6 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.tuned.rst @@ -0,0 +1,5 @@ +salt.modules.tuned +================== + +.. automodule:: salt.modules.tuned + :members: diff --git a/doc/ref/modules/all/salt.modules.twilio_notify.rst b/doc/ref/modules/all/salt.modules.twilio_notify.rst new file mode 100644 index 000000000000..7b7006cc0468 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.twilio_notify.rst @@ -0,0 +1,5 @@ +salt.modules.twilio_notify +========================== + +.. automodule:: salt.modules.twilio_notify + :members: diff --git a/doc/ref/modules/all/salt.modules.uptime.rst b/doc/ref/modules/all/salt.modules.uptime.rst new file mode 100644 index 000000000000..b1e2fb85553c --- /dev/null +++ b/doc/ref/modules/all/salt.modules.uptime.rst @@ -0,0 +1,5 @@ +salt.modules.uptime +=================== + +.. automodule:: salt.modules.uptime + :members: diff --git a/doc/ref/modules/all/salt.modules.uwsgi.rst b/doc/ref/modules/all/salt.modules.uwsgi.rst new file mode 100644 index 000000000000..f2162427cbfc --- /dev/null +++ b/doc/ref/modules/all/salt.modules.uwsgi.rst @@ -0,0 +1,5 @@ +salt.modules.uwsgi +================== + +.. automodule:: salt.modules.uwsgi + :members: diff --git a/doc/ref/modules/all/salt.modules.varnish.rst b/doc/ref/modules/all/salt.modules.varnish.rst new file mode 100644 index 000000000000..0b48bb8e3cf5 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.varnish.rst @@ -0,0 +1,5 @@ +salt.modules.varnish +==================== + +.. automodule:: salt.modules.varnish + :members: diff --git a/doc/ref/modules/all/salt.modules.vault.rst b/doc/ref/modules/all/salt.modules.vault.rst new file mode 100644 index 000000000000..3a279eb26589 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.vault.rst @@ -0,0 +1,6 @@ +salt.modules.vault +================== + +.. automodule:: salt.modules.vault + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.vbox_guest.rst b/doc/ref/modules/all/salt.modules.vbox_guest.rst new file mode 100644 index 000000000000..c1e55e016791 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.vbox_guest.rst @@ -0,0 +1,5 @@ +salt.modules.vbox_guest +======================= + +.. automodule:: salt.modules.vbox_guest + :members: diff --git a/doc/ref/modules/all/salt.modules.vboxmanage.rst b/doc/ref/modules/all/salt.modules.vboxmanage.rst new file mode 100644 index 000000000000..80b1276dbd83 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.vboxmanage.rst @@ -0,0 +1,5 @@ +salt.modules.vboxmanage +======================= + +.. automodule:: salt.modules.vboxmanage + :members: diff --git a/doc/ref/modules/all/salt.modules.vcenter.rst b/doc/ref/modules/all/salt.modules.vcenter.rst new file mode 100644 index 000000000000..722f11f3afc2 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.vcenter.rst @@ -0,0 +1,6 @@ +salt.modules.vcenter +==================== + +.. automodule:: salt.modules.vcenter + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.victorops.rst b/doc/ref/modules/all/salt.modules.victorops.rst new file mode 100644 index 000000000000..19578ed9e2d8 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.victorops.rst @@ -0,0 +1,5 @@ +salt.modules.victorops +====================== + +.. automodule:: salt.modules.victorops + :members: diff --git a/doc/ref/modules/all/salt.modules.virt.rst b/doc/ref/modules/all/salt.modules.virt.rst new file mode 100644 index 000000000000..43c79f2d0525 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.virt.rst @@ -0,0 +1,5 @@ +salt.modules.virt +================= + +.. automodule:: salt.modules.virt + :members: diff --git a/doc/ref/modules/all/salt.modules.vmctl.rst b/doc/ref/modules/all/salt.modules.vmctl.rst new file mode 100644 index 000000000000..890897655fe1 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.vmctl.rst @@ -0,0 +1,5 @@ +salt.modules.vmctl +================== + +.. automodule:: salt.modules.vmctl + :members: diff --git a/doc/ref/modules/all/salt.modules.win_dsc_resource.rst b/doc/ref/modules/all/salt.modules.win_dsc_resource.rst deleted file mode 100644 index 38475d980772..000000000000 --- a/doc/ref/modules/all/salt.modules.win_dsc_resource.rst +++ /dev/null @@ -1,5 +0,0 @@ -salt.modules.win_dsc_resource -============================= - -.. automodule:: salt.modules.win_dsc_resource - :members: diff --git a/doc/ref/modules/all/salt.modules.win_lgpo.rst b/doc/ref/modules/all/salt.modules.win_lgpo.rst index 1b54a32e9ce6..dad7e19305b0 100644 --- a/doc/ref/modules/all/salt.modules.win_lgpo.rst +++ b/doc/ref/modules/all/salt.modules.win_lgpo.rst @@ -1,4 +1,3 @@ -===================== salt.modules.win_lgpo ===================== diff --git a/doc/ref/modules/all/salt.modules.wordpress.rst b/doc/ref/modules/all/salt.modules.wordpress.rst new file mode 100644 index 000000000000..a39e422afe06 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.wordpress.rst @@ -0,0 +1,6 @@ +salt.modules.wordpress +====================== + +.. automodule:: salt.modules.wordpress + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.xapi_virt.rst b/doc/ref/modules/all/salt.modules.xapi_virt.rst new file mode 100644 index 000000000000..e9c535c5e557 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.xapi_virt.rst @@ -0,0 +1,5 @@ +salt.modules.xapi_virt +====================== + +.. automodule:: salt.modules.xapi_virt + :members: diff --git a/doc/ref/modules/all/salt.modules.xbpspkg.rst b/doc/ref/modules/all/salt.modules.xbpspkg.rst new file mode 100644 index 000000000000..e3b40d30b916 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.xbpspkg.rst @@ -0,0 +1,6 @@ +salt.modules.xbpspkg +==================== + +.. automodule:: salt.modules.xbpspkg + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.xmpp.rst b/doc/ref/modules/all/salt.modules.xmpp.rst new file mode 100644 index 000000000000..6bf919731bd5 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.xmpp.rst @@ -0,0 +1,5 @@ +salt.modules.xmpp +================= + +.. automodule:: salt.modules.xmpp + :members: diff --git a/doc/ref/modules/all/salt.modules.zabbix.rst b/doc/ref/modules/all/salt.modules.zabbix.rst new file mode 100644 index 000000000000..470000d23fdb --- /dev/null +++ b/doc/ref/modules/all/salt.modules.zabbix.rst @@ -0,0 +1,5 @@ +salt.modules.zabbix +=================== + +.. automodule:: salt.modules.zabbix + :members: diff --git a/doc/ref/modules/all/salt.modules.zcbuildout.rst b/doc/ref/modules/all/salt.modules.zcbuildout.rst new file mode 100644 index 000000000000..4554bdfb2fed --- /dev/null +++ b/doc/ref/modules/all/salt.modules.zcbuildout.rst @@ -0,0 +1,5 @@ +salt.modules.zcbuildout +======================= + +.. automodule:: salt.modules.zcbuildout + :members: diff --git a/doc/ref/modules/all/salt.modules.zenoss.rst b/doc/ref/modules/all/salt.modules.zenoss.rst new file mode 100644 index 000000000000..9c4ed460e1cb --- /dev/null +++ b/doc/ref/modules/all/salt.modules.zenoss.rst @@ -0,0 +1,5 @@ +salt.modules.zenoss +=================== + +.. automodule:: salt.modules.zenoss + :members: diff --git a/doc/ref/modules/all/salt.modules.zfs.rst b/doc/ref/modules/all/salt.modules.zfs.rst new file mode 100644 index 000000000000..5191df650080 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.zfs.rst @@ -0,0 +1,5 @@ +salt.modules.zfs +================ + +.. automodule:: salt.modules.zfs + :members: diff --git a/doc/ref/modules/all/salt.modules.znc.rst b/doc/ref/modules/all/salt.modules.znc.rst new file mode 100644 index 000000000000..b90be977bb55 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.znc.rst @@ -0,0 +1,5 @@ +salt.modules.znc +================ + +.. automodule:: salt.modules.znc + :members: diff --git a/doc/ref/modules/all/salt.modules.zookeeper.rst b/doc/ref/modules/all/salt.modules.zookeeper.rst new file mode 100644 index 000000000000..158d5542080a --- /dev/null +++ b/doc/ref/modules/all/salt.modules.zookeeper.rst @@ -0,0 +1,5 @@ +salt.modules.zookeeper +====================== + +.. automodule:: salt.modules.zookeeper + :members: diff --git a/doc/ref/modules/all/salt.modules.zpool.rst b/doc/ref/modules/all/salt.modules.zpool.rst new file mode 100644 index 000000000000..d11b90d3cc85 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.zpool.rst @@ -0,0 +1,5 @@ +salt.modules.zpool +================== + +.. automodule:: salt.modules.zpool + :members: diff --git a/doc/ref/output/all/index.rst b/doc/ref/output/all/index.rst index d51a9dee2bb2..5f0a8bc74936 100644 --- a/doc/ref/output/all/index.rst +++ b/doc/ref/output/all/index.rst @@ -12,14 +12,21 @@ Follow one of the below links for further information and examples :toctree: :template: autosummary.rst.tmpl + dson highstate json_out key nested + newline_values_only + no_out_quiet no_return + overstatestage + pony pprint_out + profile progress raw table_out txt + virt_query yaml_out diff --git a/doc/ref/output/all/salt.output.dson.rst b/doc/ref/output/all/salt.output.dson.rst new file mode 100644 index 000000000000..35610b926f88 --- /dev/null +++ b/doc/ref/output/all/salt.output.dson.rst @@ -0,0 +1,5 @@ +salt.output.dson +================ + +.. automodule:: salt.output.dson + :members: diff --git a/doc/ref/output/all/salt.output.newline_values_only.rst b/doc/ref/output/all/salt.output.newline_values_only.rst new file mode 100644 index 000000000000..80048846803e --- /dev/null +++ b/doc/ref/output/all/salt.output.newline_values_only.rst @@ -0,0 +1,5 @@ +salt.output.newline_values_only +=============================== + +.. automodule:: salt.output.newline_values_only + :members: diff --git a/doc/ref/output/all/salt.output.no_out_quiet.rst b/doc/ref/output/all/salt.output.no_out_quiet.rst new file mode 100644 index 000000000000..3c514d99bc7f --- /dev/null +++ b/doc/ref/output/all/salt.output.no_out_quiet.rst @@ -0,0 +1,5 @@ +salt.output.no_out_quiet +======================== + +.. automodule:: salt.output.no_out_quiet + :members: diff --git a/doc/ref/output/all/salt.output.overstatestage.rst b/doc/ref/output/all/salt.output.overstatestage.rst new file mode 100644 index 000000000000..bc2b60c2234b --- /dev/null +++ b/doc/ref/output/all/salt.output.overstatestage.rst @@ -0,0 +1,5 @@ +salt.output.overstatestage +========================== + +.. automodule:: salt.output.overstatestage + :members: diff --git a/doc/ref/output/all/salt.output.pony.rst b/doc/ref/output/all/salt.output.pony.rst new file mode 100644 index 000000000000..a375a01c66d8 --- /dev/null +++ b/doc/ref/output/all/salt.output.pony.rst @@ -0,0 +1,6 @@ +salt.output.pony +================ + +.. automodule:: salt.output.pony + :members: + :undoc-members: diff --git a/doc/ref/output/all/salt.output.profile.rst b/doc/ref/output/all/salt.output.profile.rst new file mode 100644 index 000000000000..9f387ff30f7a --- /dev/null +++ b/doc/ref/output/all/salt.output.profile.rst @@ -0,0 +1,5 @@ +salt.output.profile +=================== + +.. automodule:: salt.output.profile + :members: diff --git a/doc/ref/output/all/salt.output.virt_query.rst b/doc/ref/output/all/salt.output.virt_query.rst new file mode 100644 index 000000000000..4c32ded98b4c --- /dev/null +++ b/doc/ref/output/all/salt.output.virt_query.rst @@ -0,0 +1,5 @@ +salt.output.virt_query +====================== + +.. automodule:: salt.output.virt_query + :members: diff --git a/doc/ref/pillar/all/index.rst b/doc/ref/pillar/all/index.rst index af2a4482a0a1..b5e60f9a1d86 100644 --- a/doc/ref/pillar/all/index.rst +++ b/doc/ref/pillar/all/index.rst @@ -12,12 +12,48 @@ pillar modules cmd_json cmd_yaml + cmd_yamlex + cobbler + confidant + consul_pillar + csvpillar + digicert + django_orm + ec2_pillar + etcd_pillar extra_minion_data_in_pillar file_tree + foreman git_pillar gpg + hg_pillar + hiera + http_json + http_yaml + libvirt + makostack + mongo + mysql + nacl + netbox + neutron nodegroups + pepa + pillar_ldap postgres + puppet reclass_adapter + redismod + rethinkdb_pillar + s3 + saltclass sql_base + sqlcipher + sqlite3 stack + svn_pillar + varstack_pillar + vault + venafi + virtkey + vmware_pillar diff --git a/doc/ref/pillar/all/salt.pillar.cmd_yamlex.rst b/doc/ref/pillar/all/salt.pillar.cmd_yamlex.rst new file mode 100644 index 000000000000..d07d1c458a30 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.cmd_yamlex.rst @@ -0,0 +1,5 @@ +salt.pillar.cmd_yamlex +====================== + +.. automodule:: salt.pillar.cmd_yamlex + :members: diff --git a/doc/ref/pillar/all/salt.pillar.cobbler.rst b/doc/ref/pillar/all/salt.pillar.cobbler.rst new file mode 100644 index 000000000000..3d0d5e00b998 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.cobbler.rst @@ -0,0 +1,5 @@ +salt.pillar.cobbler +=================== + +.. automodule:: salt.pillar.cobbler + :members: diff --git a/doc/ref/pillar/all/salt.pillar.confidant.rst b/doc/ref/pillar/all/salt.pillar.confidant.rst new file mode 100644 index 000000000000..5fc47a804827 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.confidant.rst @@ -0,0 +1,5 @@ +salt.pillar.confidant +===================== + +.. automodule:: salt.pillar.confidant + :members: diff --git a/doc/ref/pillar/all/salt.pillar.consul_pillar.rst b/doc/ref/pillar/all/salt.pillar.consul_pillar.rst new file mode 100644 index 000000000000..29e95b585d17 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.consul_pillar.rst @@ -0,0 +1,5 @@ +salt.pillar.consul_pillar +========================= + +.. automodule:: salt.pillar.consul_pillar + :members: diff --git a/doc/ref/pillar/all/salt.pillar.csvpillar.rst b/doc/ref/pillar/all/salt.pillar.csvpillar.rst new file mode 100644 index 000000000000..eb583c7ad815 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.csvpillar.rst @@ -0,0 +1,6 @@ +salt.pillar.csvpillar +===================== + +.. automodule:: salt.pillar.csvpillar + :members: + :undoc-members: diff --git a/doc/ref/pillar/all/salt.pillar.digicert.rst b/doc/ref/pillar/all/salt.pillar.digicert.rst new file mode 100644 index 000000000000..04737d8371c3 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.digicert.rst @@ -0,0 +1,6 @@ +salt.pillar.digicert +==================== + +.. automodule:: salt.pillar.digicert + :members: + :undoc-members: diff --git a/doc/ref/pillar/all/salt.pillar.django_orm.rst b/doc/ref/pillar/all/salt.pillar.django_orm.rst new file mode 100644 index 000000000000..e8078618d3d0 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.django_orm.rst @@ -0,0 +1,5 @@ +salt.pillar.django_orm +====================== + +.. automodule:: salt.pillar.django_orm + :members: diff --git a/doc/ref/pillar/all/salt.pillar.ec2_pillar.rst b/doc/ref/pillar/all/salt.pillar.ec2_pillar.rst new file mode 100644 index 000000000000..ca365a6bee7a --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.ec2_pillar.rst @@ -0,0 +1,5 @@ +salt.pillar.ec2_pillar +====================== + +.. automodule:: salt.pillar.ec2_pillar + :members: diff --git a/doc/ref/pillar/all/salt.pillar.etcd_pillar.rst b/doc/ref/pillar/all/salt.pillar.etcd_pillar.rst new file mode 100644 index 000000000000..d7c176e7b070 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.etcd_pillar.rst @@ -0,0 +1,5 @@ +salt.pillar.etcd_pillar +======================= + +.. automodule:: salt.pillar.etcd_pillar + :members: diff --git a/doc/ref/pillar/all/salt.pillar.foreman.rst b/doc/ref/pillar/all/salt.pillar.foreman.rst new file mode 100644 index 000000000000..6f3004dbc6a1 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.foreman.rst @@ -0,0 +1,5 @@ +salt.pillar.foreman +=================== + +.. automodule:: salt.pillar.foreman + :members: diff --git a/doc/ref/pillar/all/salt.pillar.hg_pillar.rst b/doc/ref/pillar/all/salt.pillar.hg_pillar.rst new file mode 100644 index 000000000000..cb44a01f9631 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.hg_pillar.rst @@ -0,0 +1,5 @@ +salt.pillar.hg_pillar +===================== + +.. automodule:: salt.pillar.hg_pillar + :members: diff --git a/doc/ref/pillar/all/salt.pillar.hiera.rst b/doc/ref/pillar/all/salt.pillar.hiera.rst new file mode 100644 index 000000000000..12a9fd8a86e3 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.hiera.rst @@ -0,0 +1,5 @@ +salt.pillar.hiera +================= + +.. automodule:: salt.pillar.hiera + :members: diff --git a/doc/ref/pillar/all/salt.pillar.http_json.rst b/doc/ref/pillar/all/salt.pillar.http_json.rst new file mode 100644 index 000000000000..f8eb6326df01 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.http_json.rst @@ -0,0 +1,6 @@ +salt.pillar.http_json +===================== + +.. automodule:: salt.pillar.http_json + :members: + :undoc-members: diff --git a/doc/ref/pillar/all/salt.pillar.http_yaml.rst b/doc/ref/pillar/all/salt.pillar.http_yaml.rst new file mode 100644 index 000000000000..d74ad2d9f39f --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.http_yaml.rst @@ -0,0 +1,5 @@ +salt.pillar.http_yaml +===================== + +.. automodule:: salt.pillar.http_yaml + :members: diff --git a/doc/ref/pillar/all/salt.pillar.libvirt.rst b/doc/ref/pillar/all/salt.pillar.libvirt.rst new file mode 100644 index 000000000000..0119fddcfd7f --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.libvirt.rst @@ -0,0 +1,5 @@ +salt.pillar.libvirt +=================== + +.. automodule:: salt.pillar.libvirt + :members: diff --git a/doc/ref/pillar/all/salt.pillar.makostack.rst b/doc/ref/pillar/all/salt.pillar.makostack.rst new file mode 100644 index 000000000000..f6c8f88275e4 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.makostack.rst @@ -0,0 +1,6 @@ +salt.pillar.makostack +===================== + +.. automodule:: salt.pillar.makostack + :members: + :undoc-members: diff --git a/doc/ref/pillar/all/salt.pillar.mongo.rst b/doc/ref/pillar/all/salt.pillar.mongo.rst new file mode 100644 index 000000000000..96ee523b5036 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.mongo.rst @@ -0,0 +1,5 @@ +salt.pillar.mongo +================= + +.. automodule:: salt.pillar.mongo + :members: diff --git a/doc/ref/pillar/all/salt.pillar.mysql.rst b/doc/ref/pillar/all/salt.pillar.mysql.rst new file mode 100644 index 000000000000..0f35514b00f6 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.mysql.rst @@ -0,0 +1,5 @@ +salt.pillar.mysql +================= + +.. automodule:: salt.pillar.mysql + :members: diff --git a/doc/ref/pillar/all/salt.pillar.nacl.rst b/doc/ref/pillar/all/salt.pillar.nacl.rst new file mode 100644 index 000000000000..769297cd518f --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.nacl.rst @@ -0,0 +1,5 @@ +salt.pillar.nacl +================ + +.. automodule:: salt.pillar.nacl + :members: diff --git a/doc/ref/pillar/all/salt.pillar.netbox.rst b/doc/ref/pillar/all/salt.pillar.netbox.rst new file mode 100644 index 000000000000..e322f686d231 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.netbox.rst @@ -0,0 +1,5 @@ +salt.pillar.netbox +================== + +.. automodule:: salt.pillar.netbox + :members: diff --git a/doc/ref/pillar/all/salt.pillar.neutron.rst b/doc/ref/pillar/all/salt.pillar.neutron.rst new file mode 100644 index 000000000000..3328c3f04afd --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.neutron.rst @@ -0,0 +1,5 @@ +salt.pillar.neutron +=================== + +.. automodule:: salt.pillar.neutron + :members: diff --git a/doc/ref/pillar/all/salt.pillar.pepa.rst b/doc/ref/pillar/all/salt.pillar.pepa.rst new file mode 100644 index 000000000000..d6dbb5c597e0 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.pepa.rst @@ -0,0 +1,5 @@ +salt.pillar.pepa +================ + +.. automodule:: salt.pillar.pepa + :members: diff --git a/doc/ref/pillar/all/salt.pillar.pillar_ldap.rst b/doc/ref/pillar/all/salt.pillar.pillar_ldap.rst new file mode 100644 index 000000000000..2b034cc339f2 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.pillar_ldap.rst @@ -0,0 +1,5 @@ +salt.pillar.pillar_ldap +======================= + +.. automodule:: salt.pillar.pillar_ldap + :members: diff --git a/doc/ref/pillar/all/salt.pillar.postgres.rst b/doc/ref/pillar/all/salt.pillar.postgres.rst index 8eaeab71ca12..58ba41a87cfa 100644 --- a/doc/ref/pillar/all/salt.pillar.postgres.rst +++ b/doc/ref/pillar/all/salt.pillar.postgres.rst @@ -1,6 +1,6 @@ -==================== salt.pillar.postgres ==================== .. automodule:: salt.pillar.postgres :members: + :undoc-members: diff --git a/doc/ref/pillar/all/salt.pillar.puppet.rst b/doc/ref/pillar/all/salt.pillar.puppet.rst new file mode 100644 index 000000000000..074db0004ec1 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.puppet.rst @@ -0,0 +1,5 @@ +salt.pillar.puppet +================== + +.. automodule:: salt.pillar.puppet + :members: diff --git a/doc/ref/pillar/all/salt.pillar.redismod.rst b/doc/ref/pillar/all/salt.pillar.redismod.rst new file mode 100644 index 000000000000..4c4f43a17e56 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.redismod.rst @@ -0,0 +1,5 @@ +salt.pillar.redismod +==================== + +.. automodule:: salt.pillar.redismod + :members: diff --git a/doc/ref/pillar/all/salt.pillar.rethinkdb_pillar.rst b/doc/ref/pillar/all/salt.pillar.rethinkdb_pillar.rst new file mode 100644 index 000000000000..016f9a825232 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.rethinkdb_pillar.rst @@ -0,0 +1,5 @@ +salt.pillar.rethinkdb_pillar +============================ + +.. automodule:: salt.pillar.rethinkdb_pillar + :members: diff --git a/doc/ref/pillar/all/salt.pillar.s3.rst b/doc/ref/pillar/all/salt.pillar.s3.rst new file mode 100644 index 000000000000..a829ab68b6df --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.s3.rst @@ -0,0 +1,5 @@ +salt.pillar.s3 +============== + +.. automodule:: salt.pillar.s3 + :members: diff --git a/doc/ref/pillar/all/salt.pillar.saltclass.rst b/doc/ref/pillar/all/salt.pillar.saltclass.rst new file mode 100644 index 000000000000..274b38a3776a --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.saltclass.rst @@ -0,0 +1,5 @@ +salt.pillar.saltclass +===================== + +.. automodule:: salt.pillar.saltclass + :members: diff --git a/doc/ref/pillar/all/salt.pillar.sql_base.rst b/doc/ref/pillar/all/salt.pillar.sql_base.rst index 792126c300f3..8f6844e6d02f 100644 --- a/doc/ref/pillar/all/salt.pillar.sql_base.rst +++ b/doc/ref/pillar/all/salt.pillar.sql_base.rst @@ -1,4 +1,3 @@ -==================== salt.pillar.sql_base ==================== diff --git a/doc/ref/pillar/all/salt.pillar.sqlcipher.rst b/doc/ref/pillar/all/salt.pillar.sqlcipher.rst new file mode 100644 index 000000000000..00ef7c6c8778 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.sqlcipher.rst @@ -0,0 +1,5 @@ +salt.pillar.sqlcipher +===================== + +.. automodule:: salt.pillar.sqlcipher + :members: diff --git a/doc/ref/pillar/all/salt.pillar.sqlite3.rst b/doc/ref/pillar/all/salt.pillar.sqlite3.rst new file mode 100644 index 000000000000..2036d0a9eff6 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.sqlite3.rst @@ -0,0 +1,5 @@ +salt.pillar.sqlite3 +=================== + +.. automodule:: salt.pillar.sqlite3 + :members: diff --git a/doc/ref/pillar/all/salt.pillar.svn_pillar.rst b/doc/ref/pillar/all/salt.pillar.svn_pillar.rst new file mode 100644 index 000000000000..4e820faf2dfe --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.svn_pillar.rst @@ -0,0 +1,5 @@ +salt.pillar.svn_pillar +====================== + +.. automodule:: salt.pillar.svn_pillar + :members: diff --git a/doc/ref/pillar/all/salt.pillar.varstack_pillar.rst b/doc/ref/pillar/all/salt.pillar.varstack_pillar.rst new file mode 100644 index 000000000000..3ccdf10908ba --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.varstack_pillar.rst @@ -0,0 +1,5 @@ +salt.pillar.varstack_pillar +=========================== + +.. automodule:: salt.pillar.varstack_pillar + :members: diff --git a/doc/ref/pillar/all/salt.pillar.vault.rst b/doc/ref/pillar/all/salt.pillar.vault.rst new file mode 100644 index 000000000000..03b2816bd797 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.vault.rst @@ -0,0 +1,5 @@ +salt.pillar.vault +================= + +.. automodule:: salt.pillar.vault + :members: diff --git a/doc/ref/pillar/all/salt.pillar.venafi.rst b/doc/ref/pillar/all/salt.pillar.venafi.rst new file mode 100644 index 000000000000..2dfb92e85fbb --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.venafi.rst @@ -0,0 +1,6 @@ +salt.pillar.venafi +================== + +.. automodule:: salt.pillar.venafi + :members: + :undoc-members: diff --git a/doc/ref/pillar/all/salt.pillar.virtkey.rst b/doc/ref/pillar/all/salt.pillar.virtkey.rst new file mode 100644 index 000000000000..75187ee00f52 --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.virtkey.rst @@ -0,0 +1,5 @@ +salt.pillar.virtkey +=================== + +.. automodule:: salt.pillar.virtkey + :members: diff --git a/doc/ref/pillar/all/salt.pillar.vmware_pillar.rst b/doc/ref/pillar/all/salt.pillar.vmware_pillar.rst new file mode 100644 index 000000000000..df00ea9d0dca --- /dev/null +++ b/doc/ref/pillar/all/salt.pillar.vmware_pillar.rst @@ -0,0 +1,6 @@ +salt.pillar.vmware_pillar +========================= + +.. automodule:: salt.pillar.vmware_pillar + :members: + :undoc-members: diff --git a/doc/ref/proxy/all/index.rst b/doc/ref/proxy/all/index.rst index f1f06b3bb6a0..a98d8dc73bad 100644 --- a/doc/ref/proxy/all/index.rst +++ b/doc/ref/proxy/all/index.rst @@ -10,5 +10,27 @@ proxy modules :toctree: :template: autosummary.rst.tmpl + arista_pyeapi + chronos + cimc + cisconso deltaproxy + docker dummy + esxcluster + esxdatacenter + esxi + esxvm + fx2 + junos + marathon + napalm + netmiko_px + nxos + nxos_api + panos + philips_hue + rest_sample + restconf + ssh_sample + vcenter diff --git a/doc/ref/proxy/all/salt.proxy.arista_pyeapi.rst b/doc/ref/proxy/all/salt.proxy.arista_pyeapi.rst new file mode 100644 index 000000000000..f911edf69a37 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.arista_pyeapi.rst @@ -0,0 +1,5 @@ +salt.proxy.arista_pyeapi +======================== + +.. automodule:: salt.proxy.arista_pyeapi + :members: diff --git a/doc/ref/proxy/all/salt.proxy.chronos.rst b/doc/ref/proxy/all/salt.proxy.chronos.rst new file mode 100644 index 000000000000..f83e3bc5e7e3 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.chronos.rst @@ -0,0 +1,5 @@ +salt.proxy.chronos +================== + +.. automodule:: salt.proxy.chronos + :members: diff --git a/doc/ref/proxy/all/salt.proxy.cimc.rst b/doc/ref/proxy/all/salt.proxy.cimc.rst new file mode 100644 index 000000000000..f4ba42686a4d --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.cimc.rst @@ -0,0 +1,5 @@ +salt.proxy.cimc +=============== + +.. automodule:: salt.proxy.cimc + :members: diff --git a/doc/ref/proxy/all/salt.proxy.cisconso.rst b/doc/ref/proxy/all/salt.proxy.cisconso.rst new file mode 100644 index 000000000000..5b268fe65970 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.cisconso.rst @@ -0,0 +1,6 @@ +=================== +salt.proxy.cisconso +=================== + +.. automodule:: salt.proxy.cisconso + :members: diff --git a/doc/ref/proxy/all/salt.proxy.docker.rst b/doc/ref/proxy/all/salt.proxy.docker.rst new file mode 100644 index 000000000000..1de95fd05682 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.docker.rst @@ -0,0 +1,6 @@ +salt.proxy.docker +================= + +.. automodule:: salt.proxy.docker + :members: + :undoc-members: diff --git a/doc/ref/proxy/all/salt.proxy.esxcluster.rst b/doc/ref/proxy/all/salt.proxy.esxcluster.rst new file mode 100644 index 000000000000..6734437e1653 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.esxcluster.rst @@ -0,0 +1,6 @@ +salt.proxy.esxcluster +===================== + +.. automodule:: salt.proxy.esxcluster + :members: + :undoc-members: diff --git a/doc/ref/proxy/all/salt.proxy.esxdatacenter.rst b/doc/ref/proxy/all/salt.proxy.esxdatacenter.rst new file mode 100644 index 000000000000..6c69229d8ecb --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.esxdatacenter.rst @@ -0,0 +1,6 @@ +salt.proxy.esxdatacenter +======================== + +.. automodule:: salt.proxy.esxdatacenter + :members: + :undoc-members: diff --git a/doc/ref/proxy/all/salt.proxy.esxi.rst b/doc/ref/proxy/all/salt.proxy.esxi.rst new file mode 100644 index 000000000000..5ccce6e8ffb0 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.esxi.rst @@ -0,0 +1,5 @@ +salt.proxy.esxi +=============== + +.. automodule:: salt.proxy.esxi + :members: diff --git a/doc/ref/proxy/all/salt.proxy.esxvm.rst b/doc/ref/proxy/all/salt.proxy.esxvm.rst new file mode 100644 index 000000000000..714a26cf0d54 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.esxvm.rst @@ -0,0 +1,6 @@ +salt.proxy.esxvm +================ + +.. automodule:: salt.proxy.esxvm + :members: + :undoc-members: diff --git a/doc/ref/proxy/all/salt.proxy.fx2.rst b/doc/ref/proxy/all/salt.proxy.fx2.rst new file mode 100644 index 000000000000..6fc56cea5203 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.fx2.rst @@ -0,0 +1,5 @@ +salt.proxy.fx2 +============== + +.. automodule:: salt.proxy.fx2 + :members: diff --git a/doc/ref/proxy/all/salt.proxy.junos.rst b/doc/ref/proxy/all/salt.proxy.junos.rst new file mode 100644 index 000000000000..401fb1de95bc --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.junos.rst @@ -0,0 +1,5 @@ +salt.proxy.junos +================ + +.. automodule:: salt.proxy.junos + :members: diff --git a/doc/ref/proxy/all/salt.proxy.marathon.rst b/doc/ref/proxy/all/salt.proxy.marathon.rst new file mode 100644 index 000000000000..1a70417ffbe4 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.marathon.rst @@ -0,0 +1,5 @@ +salt.proxy.marathon +=================== + +.. automodule:: salt.proxy.marathon + :members: diff --git a/doc/ref/proxy/all/salt.proxy.napalm.rst b/doc/ref/proxy/all/salt.proxy.napalm.rst new file mode 100644 index 000000000000..077a44f38c90 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.napalm.rst @@ -0,0 +1,5 @@ +salt.proxy.napalm +================= + +.. automodule:: salt.proxy.napalm + :members: diff --git a/doc/ref/proxy/all/salt.proxy.netmiko_px.rst b/doc/ref/proxy/all/salt.proxy.netmiko_px.rst new file mode 100644 index 000000000000..316a8cc01c23 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.netmiko_px.rst @@ -0,0 +1,5 @@ +salt.proxy.netmiko_px +===================== + +.. automodule:: salt.proxy.netmiko_px + :members: diff --git a/doc/ref/proxy/all/salt.proxy.nxos.rst b/doc/ref/proxy/all/salt.proxy.nxos.rst new file mode 100644 index 000000000000..a579b2c8a47c --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.nxos.rst @@ -0,0 +1,5 @@ +salt.proxy.nxos +=============== + +.. automodule:: salt.proxy.nxos + :members: diff --git a/doc/ref/proxy/all/salt.proxy.nxos_api.rst b/doc/ref/proxy/all/salt.proxy.nxos_api.rst new file mode 100644 index 000000000000..f0b838152f11 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.nxos_api.rst @@ -0,0 +1,5 @@ +salt.proxy.nxos_api +=================== + +.. automodule:: salt.proxy.nxos_api + :members: diff --git a/doc/ref/proxy/all/salt.proxy.panos.rst b/doc/ref/proxy/all/salt.proxy.panos.rst new file mode 100644 index 000000000000..d960050a3e4b --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.panos.rst @@ -0,0 +1,5 @@ +salt.proxy.panos +================ + +.. automodule:: salt.proxy.panos + :members: diff --git a/doc/ref/proxy/all/salt.proxy.philips_hue.rst b/doc/ref/proxy/all/salt.proxy.philips_hue.rst new file mode 100644 index 000000000000..48e9cf15dec6 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.philips_hue.rst @@ -0,0 +1,5 @@ +salt.proxy.philips_hue +====================== + +.. automodule:: salt.proxy.philips_hue + :members: diff --git a/doc/ref/proxy/all/salt.proxy.rest_sample.rst b/doc/ref/proxy/all/salt.proxy.rest_sample.rst new file mode 100644 index 000000000000..525d2918ebfe --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.rest_sample.rst @@ -0,0 +1,5 @@ +salt.proxy.rest_sample +====================== + +.. automodule:: salt.proxy.rest_sample + :members: diff --git a/doc/ref/proxy/all/salt.proxy.restconf.rst b/doc/ref/proxy/all/salt.proxy.restconf.rst new file mode 100644 index 000000000000..2be9d4fceec6 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.restconf.rst @@ -0,0 +1,6 @@ +=================== +salt.proxy.restconf +=================== + +.. automodule:: salt.proxy.restconf + :members: diff --git a/doc/ref/proxy/all/salt.proxy.ssh_sample.rst b/doc/ref/proxy/all/salt.proxy.ssh_sample.rst new file mode 100644 index 000000000000..c849fd7fd128 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.ssh_sample.rst @@ -0,0 +1,5 @@ +salt.proxy.ssh_sample +===================== + +.. automodule:: salt.proxy.ssh_sample + :members: diff --git a/doc/ref/proxy/all/salt.proxy.vcenter.rst b/doc/ref/proxy/all/salt.proxy.vcenter.rst new file mode 100644 index 000000000000..0ea86cc3d3e2 --- /dev/null +++ b/doc/ref/proxy/all/salt.proxy.vcenter.rst @@ -0,0 +1,6 @@ +salt.proxy.vcenter +================== + +.. automodule:: salt.proxy.vcenter + :members: + :undoc-members: diff --git a/doc/ref/queues/all/index.rst b/doc/ref/queues/all/index.rst index 53a4970f4c2e..2d579aa01558 100644 --- a/doc/ref/queues/all/index.rst +++ b/doc/ref/queues/all/index.rst @@ -9,3 +9,6 @@ queue modules .. autosummary:: :toctree: :template: autosummary.rst.tmpl + + pgjsonb_queue + sqlite_queue diff --git a/doc/ref/queues/all/salt.queues.pgjsonb_queue.rst b/doc/ref/queues/all/salt.queues.pgjsonb_queue.rst new file mode 100644 index 000000000000..76291cdafc95 --- /dev/null +++ b/doc/ref/queues/all/salt.queues.pgjsonb_queue.rst @@ -0,0 +1,5 @@ +salt.queues.pgjsonb_queue +========================= + +.. automodule:: salt.queues.pgjsonb_queue + :members: diff --git a/doc/ref/queues/all/salt.queues.sqlite_queue.rst b/doc/ref/queues/all/salt.queues.sqlite_queue.rst new file mode 100644 index 000000000000..e4b2390fdde7 --- /dev/null +++ b/doc/ref/queues/all/salt.queues.sqlite_queue.rst @@ -0,0 +1,5 @@ +salt.queues.sqlite_queue +======================== + +.. automodule:: salt.queues.sqlite_queue + :members: diff --git a/doc/ref/renderers/all/index.rst b/doc/ref/renderers/all/index.rst index 3caabf5d0352..a0399f99f5b7 100644 --- a/doc/ref/renderers/all/index.rst +++ b/doc/ref/renderers/all/index.rst @@ -12,15 +12,24 @@ renderer modules :toctree: :template: autosummary.rst.tmpl + aws_kms + cheetah + dson + genshi gpg + hjson jinja json + json5 mako msgpack nacl + pass py + pydsl pyobjects stateconf tomlmod + wempy yaml yamlex diff --git a/doc/ref/renderers/all/salt.renderers.aws_kms.rst b/doc/ref/renderers/all/salt.renderers.aws_kms.rst new file mode 100644 index 000000000000..6595b253d81d --- /dev/null +++ b/doc/ref/renderers/all/salt.renderers.aws_kms.rst @@ -0,0 +1,5 @@ +salt.renderers.aws_kms +====================== + +.. automodule:: salt.renderers.aws_kms + :members: diff --git a/doc/ref/renderers/all/salt.renderers.cheetah.rst b/doc/ref/renderers/all/salt.renderers.cheetah.rst new file mode 100644 index 000000000000..133972e76435 --- /dev/null +++ b/doc/ref/renderers/all/salt.renderers.cheetah.rst @@ -0,0 +1,5 @@ +salt.renderers.cheetah +====================== + +.. automodule:: salt.renderers.cheetah + :members: diff --git a/doc/ref/renderers/all/salt.renderers.dson.rst b/doc/ref/renderers/all/salt.renderers.dson.rst new file mode 100644 index 000000000000..b9e4e8d9ff99 --- /dev/null +++ b/doc/ref/renderers/all/salt.renderers.dson.rst @@ -0,0 +1,5 @@ +salt.renderers.dson +=================== + +.. automodule:: salt.renderers.dson + :members: diff --git a/doc/ref/renderers/all/salt.renderers.genshi.rst b/doc/ref/renderers/all/salt.renderers.genshi.rst new file mode 100644 index 000000000000..9f2f2d38da99 --- /dev/null +++ b/doc/ref/renderers/all/salt.renderers.genshi.rst @@ -0,0 +1,5 @@ +salt.renderers.genshi +===================== + +.. automodule:: salt.renderers.genshi + :members: diff --git a/doc/ref/renderers/all/salt.renderers.hjson.rst b/doc/ref/renderers/all/salt.renderers.hjson.rst new file mode 100644 index 000000000000..b647547083e4 --- /dev/null +++ b/doc/ref/renderers/all/salt.renderers.hjson.rst @@ -0,0 +1,5 @@ +salt.renderers.hjson +==================== + +.. automodule:: salt.renderers.hjson + :members: diff --git a/doc/ref/renderers/all/salt.renderers.json5.rst b/doc/ref/renderers/all/salt.renderers.json5.rst new file mode 100644 index 000000000000..3c77b845de45 --- /dev/null +++ b/doc/ref/renderers/all/salt.renderers.json5.rst @@ -0,0 +1,5 @@ +salt.renderers.json5 +==================== + +.. automodule:: salt.renderers.json5 + :members: diff --git a/doc/ref/renderers/all/salt.renderers.pass.rst b/doc/ref/renderers/all/salt.renderers.pass.rst new file mode 100644 index 000000000000..cb1d6add2d90 --- /dev/null +++ b/doc/ref/renderers/all/salt.renderers.pass.rst @@ -0,0 +1,6 @@ +salt.renderers.pass +=================== + +.. automodule:: salt.renderers.pass + :members: + :undoc-members: diff --git a/doc/ref/renderers/all/salt.renderers.pydsl.rst b/doc/ref/renderers/all/salt.renderers.pydsl.rst new file mode 100644 index 000000000000..f881c47bc719 --- /dev/null +++ b/doc/ref/renderers/all/salt.renderers.pydsl.rst @@ -0,0 +1,5 @@ +salt.renderers.pydsl +==================== + +.. automodule:: salt.renderers.pydsl + :members: diff --git a/doc/ref/renderers/all/salt.renderers.wempy.rst b/doc/ref/renderers/all/salt.renderers.wempy.rst new file mode 100644 index 000000000000..ffc15b5bfacf --- /dev/null +++ b/doc/ref/renderers/all/salt.renderers.wempy.rst @@ -0,0 +1,5 @@ +salt.renderers.wempy +==================== + +.. automodule:: salt.renderers.wempy + :members: diff --git a/doc/ref/resources/all/index.rst b/doc/ref/resources/all/index.rst deleted file mode 100644 index ea61a6977338..000000000000 --- a/doc/ref/resources/all/index.rst +++ /dev/null @@ -1,28 +0,0 @@ -.. _all-salt.resources.types: - -========================== -Resource types -========================== - -In-tree resource types shipped with Salt. Resource types in extensions -follow the same shape under ``saltext..resources.``. - -.. currentmodule:: salt.resources - -.. autosummary:: - :toctree: - :template: autosummary.rst.tmpl - - dummy - ssh - -Per-type submodules: - -.. toctree:: - :maxdepth: 1 - - salt.resources.dummy.modules.test - salt.resources.ssh.modules.cmd - salt.resources.ssh.modules.pkg - salt.resources.ssh.modules.state - salt.resources.ssh.modules.test diff --git a/doc/ref/resources/all/salt.resources.dummy.modules.test.rst b/doc/ref/resources/all/salt.resources.dummy.modules.test.rst deleted file mode 100644 index fd4be7556e93..000000000000 --- a/doc/ref/resources/all/salt.resources.dummy.modules.test.rst +++ /dev/null @@ -1,6 +0,0 @@ -================================= -salt.resources.dummy.modules.test -================================= - -.. automodule:: salt.resources.dummy.modules.test - :members: diff --git a/doc/ref/resources/all/salt.resources.dummy.rst b/doc/ref/resources/all/salt.resources.dummy.rst deleted file mode 100644 index 88a9c839bfb2..000000000000 --- a/doc/ref/resources/all/salt.resources.dummy.rst +++ /dev/null @@ -1,6 +0,0 @@ -==================== -salt.resources.dummy -==================== - -.. automodule:: salt.resources.dummy - :members: diff --git a/doc/ref/resources/all/salt.resources.ssh.modules.cmd.rst b/doc/ref/resources/all/salt.resources.ssh.modules.cmd.rst deleted file mode 100644 index 0cb12d51889d..000000000000 --- a/doc/ref/resources/all/salt.resources.ssh.modules.cmd.rst +++ /dev/null @@ -1,6 +0,0 @@ -============================== -salt.resources.ssh.modules.cmd -============================== - -.. automodule:: salt.resources.ssh.modules.cmd - :members: diff --git a/doc/ref/resources/all/salt.resources.ssh.modules.pkg.rst b/doc/ref/resources/all/salt.resources.ssh.modules.pkg.rst deleted file mode 100644 index 56df823eb0c2..000000000000 --- a/doc/ref/resources/all/salt.resources.ssh.modules.pkg.rst +++ /dev/null @@ -1,6 +0,0 @@ -============================== -salt.resources.ssh.modules.pkg -============================== - -.. automodule:: salt.resources.ssh.modules.pkg - :members: diff --git a/doc/ref/resources/all/salt.resources.ssh.modules.state.rst b/doc/ref/resources/all/salt.resources.ssh.modules.state.rst deleted file mode 100644 index 1d6b227d65a3..000000000000 --- a/doc/ref/resources/all/salt.resources.ssh.modules.state.rst +++ /dev/null @@ -1,6 +0,0 @@ -================================ -salt.resources.ssh.modules.state -================================ - -.. automodule:: salt.resources.ssh.modules.state - :members: diff --git a/doc/ref/resources/all/salt.resources.ssh.modules.test.rst b/doc/ref/resources/all/salt.resources.ssh.modules.test.rst deleted file mode 100644 index 594722d38a9b..000000000000 --- a/doc/ref/resources/all/salt.resources.ssh.modules.test.rst +++ /dev/null @@ -1,6 +0,0 @@ -=============================== -salt.resources.ssh.modules.test -=============================== - -.. automodule:: salt.resources.ssh.modules.test - :members: diff --git a/doc/ref/resources/all/salt.resources.ssh.rst b/doc/ref/resources/all/salt.resources.ssh.rst deleted file mode 100644 index 43cee0e7c05e..000000000000 --- a/doc/ref/resources/all/salt.resources.ssh.rst +++ /dev/null @@ -1,6 +0,0 @@ -================== -salt.resources.ssh -================== - -.. automodule:: salt.resources.ssh - :members: diff --git a/doc/ref/resources/index.rst b/doc/ref/resources/index.rst deleted file mode 100644 index fce32e92b72c..000000000000 --- a/doc/ref/resources/index.rst +++ /dev/null @@ -1,44 +0,0 @@ -.. _all-salt.resources: - -========================== -Salt Resources reference -========================== - -.. versionadded:: 3008.0 - -Autodoc reference for the resource-framework modules and the resource -types shipped in core Salt. For the user-facing guide see -:ref:`resources`. - - -Resource types -============== - -.. toctree:: - :maxdepth: 1 - - all/index - - -Framework -========= - -The resource registry — the master-side index of which minion manages -which resource: - -.. automodule:: salt.utils.resource_registry - :no-members: - -Operator runner: - -.. toctree:: - :maxdepth: 1 - - /ref/runners/all/salt.runners.resource - -Per-resource grains module: - -.. toctree:: - :maxdepth: 1 - - /ref/grains/all/salt.grains.resources diff --git a/doc/ref/returners/all/index.rst b/doc/ref/returners/all/index.rst index ab22ab52f533..edce578d4b8a 100644 --- a/doc/ref/returners/all/index.rst +++ b/doc/ref/returners/all/index.rst @@ -10,13 +10,41 @@ returner modules :toctree: :template: autosummary.rst.tmpl + appoptics_return + carbon_return + cassandra_cql_return + couchbase_return + couchdb_return + elasticsearch_return + etcd_return highstate_return + influxdb_return + kafka_return + librato_return local local_cache + mattermost_returner + memcache_return + mongo_future_return + mongo_return multi_returner + mysql + nagios_nrdp_return + odbc pgjsonb postgres postgres_local_cache + pushover_returner rawfile_json - salt_cache + redis_return + sentry_return + slack_returner + slack_webhook_return + sms_return + smtp_return + splunk + sqlite3_return syslog_return + telegram_return + xmpp_return + zabbix_return diff --git a/doc/ref/returners/all/salt.returners.appoptics_return.rst b/doc/ref/returners/all/salt.returners.appoptics_return.rst new file mode 100644 index 000000000000..68931b7482bc --- /dev/null +++ b/doc/ref/returners/all/salt.returners.appoptics_return.rst @@ -0,0 +1,6 @@ +salt.returners.appoptics_return +=============================== + +.. automodule:: salt.returners.appoptics_return + :members: + :undoc-members: diff --git a/doc/ref/returners/all/salt.returners.carbon_return.rst b/doc/ref/returners/all/salt.returners.carbon_return.rst new file mode 100644 index 000000000000..ebb67ffe4b11 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.carbon_return.rst @@ -0,0 +1,5 @@ +salt.returners.carbon_return +============================ + +.. automodule:: salt.returners.carbon_return + :members: diff --git a/doc/ref/returners/all/salt.returners.cassandra_cql_return.rst b/doc/ref/returners/all/salt.returners.cassandra_cql_return.rst new file mode 100644 index 000000000000..cf00db44041d --- /dev/null +++ b/doc/ref/returners/all/salt.returners.cassandra_cql_return.rst @@ -0,0 +1,6 @@ +salt.returners.cassandra_cql_return +=================================== + +.. automodule:: salt.returners.cassandra_cql_return + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.couchbase_return.rst b/doc/ref/returners/all/salt.returners.couchbase_return.rst new file mode 100644 index 000000000000..6fcb0ca6fa66 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.couchbase_return.rst @@ -0,0 +1,5 @@ +salt.returners.couchbase_return +=============================== + +.. automodule:: salt.returners.couchbase_return + :members: diff --git a/doc/ref/returners/all/salt.returners.couchdb_return.rst b/doc/ref/returners/all/salt.returners.couchdb_return.rst new file mode 100644 index 000000000000..fd6997580a68 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.couchdb_return.rst @@ -0,0 +1,6 @@ +salt.returners.couchdb_return +============================= + +.. automodule:: salt.returners.couchdb_return + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.elasticsearch_return.rst b/doc/ref/returners/all/salt.returners.elasticsearch_return.rst new file mode 100644 index 000000000000..7275613f3491 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.elasticsearch_return.rst @@ -0,0 +1,5 @@ +salt.returners.elasticsearch_return +=================================== + +.. automodule:: salt.returners.elasticsearch_return + :members: diff --git a/doc/ref/returners/all/salt.returners.etcd_return.rst b/doc/ref/returners/all/salt.returners.etcd_return.rst new file mode 100644 index 000000000000..7361b05ec884 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.etcd_return.rst @@ -0,0 +1,6 @@ +salt.returners.etcd_return +========================== + +.. automodule:: salt.returners.etcd_return + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.influxdb_return.rst b/doc/ref/returners/all/salt.returners.influxdb_return.rst new file mode 100644 index 000000000000..7f75ecffb10c --- /dev/null +++ b/doc/ref/returners/all/salt.returners.influxdb_return.rst @@ -0,0 +1,6 @@ +salt.returners.influxdb_return +============================== + +.. automodule:: salt.returners.influxdb_return + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.kafka_return.rst b/doc/ref/returners/all/salt.returners.kafka_return.rst new file mode 100644 index 000000000000..b058bd9569a5 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.kafka_return.rst @@ -0,0 +1,5 @@ +salt.returners.kafka_return +=========================== + +.. automodule:: salt.returners.kafka_return + :members: diff --git a/doc/ref/returners/all/salt.returners.librato_return.rst b/doc/ref/returners/all/salt.returners.librato_return.rst new file mode 100644 index 000000000000..2bfe3791bbb1 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.librato_return.rst @@ -0,0 +1,5 @@ +salt.returners.librato_return +============================= + +.. automodule:: salt.returners.librato_return + :members: diff --git a/doc/ref/returners/all/salt.returners.mattermost_returner.rst b/doc/ref/returners/all/salt.returners.mattermost_returner.rst new file mode 100644 index 000000000000..d2c74d7ce274 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.mattermost_returner.rst @@ -0,0 +1,6 @@ +salt.returners.mattermost_returner +================================== + +.. automodule:: salt.returners.mattermost_returner + :members: + :undoc-members: diff --git a/doc/ref/returners/all/salt.returners.memcache_return.rst b/doc/ref/returners/all/salt.returners.memcache_return.rst new file mode 100644 index 000000000000..40dbbfe1440e --- /dev/null +++ b/doc/ref/returners/all/salt.returners.memcache_return.rst @@ -0,0 +1,6 @@ +salt.returners.memcache_return +============================== + +.. automodule:: salt.returners.memcache_return + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.mongo_future_return.rst b/doc/ref/returners/all/salt.returners.mongo_future_return.rst new file mode 100644 index 000000000000..aeb999b6e07d --- /dev/null +++ b/doc/ref/returners/all/salt.returners.mongo_future_return.rst @@ -0,0 +1,6 @@ +salt.returners.mongo_future_return +================================== + +.. automodule:: salt.returners.mongo_future_return + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.mongo_return.rst b/doc/ref/returners/all/salt.returners.mongo_return.rst new file mode 100644 index 000000000000..504c1bee68db --- /dev/null +++ b/doc/ref/returners/all/salt.returners.mongo_return.rst @@ -0,0 +1,6 @@ +salt.returners.mongo_return +=========================== + +.. automodule:: salt.returners.mongo_return + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.mysql.rst b/doc/ref/returners/all/salt.returners.mysql.rst new file mode 100644 index 000000000000..fc38d56dfe60 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.mysql.rst @@ -0,0 +1,6 @@ +salt.returners.mysql +==================== + +.. automodule:: salt.returners.mysql + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.nagios_nrdp_return.rst b/doc/ref/returners/all/salt.returners.nagios_nrdp_return.rst new file mode 100644 index 000000000000..8f84811e2f9a --- /dev/null +++ b/doc/ref/returners/all/salt.returners.nagios_nrdp_return.rst @@ -0,0 +1,5 @@ +salt.returners.nagios_nrdp_return +================================= + +.. automodule:: salt.returners.nagios_nrdp_return + :members: diff --git a/doc/ref/returners/all/salt.returners.odbc.rst b/doc/ref/returners/all/salt.returners.odbc.rst new file mode 100644 index 000000000000..fa9074c82d13 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.odbc.rst @@ -0,0 +1,6 @@ +salt.returners.odbc +=================== + +.. automodule:: salt.returners.odbc + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.pushover_returner.rst b/doc/ref/returners/all/salt.returners.pushover_returner.rst new file mode 100644 index 000000000000..0bd95a525961 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.pushover_returner.rst @@ -0,0 +1,5 @@ +salt.returners.pushover_returner +================================ + +.. automodule:: salt.returners.pushover_returner + :members: diff --git a/doc/ref/returners/all/salt.returners.redis_return.rst b/doc/ref/returners/all/salt.returners.redis_return.rst new file mode 100644 index 000000000000..444bde0c4c2b --- /dev/null +++ b/doc/ref/returners/all/salt.returners.redis_return.rst @@ -0,0 +1,6 @@ +salt.returners.redis_return +=========================== + +.. automodule:: salt.returners.redis_return + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.salt_cache.rst b/doc/ref/returners/all/salt.returners.salt_cache.rst deleted file mode 100644 index de5585d3c98b..000000000000 --- a/doc/ref/returners/all/salt.returners.salt_cache.rst +++ /dev/null @@ -1,5 +0,0 @@ -salt.returners.salt_cache -========================= - -.. automodule:: salt.returners.salt_cache - :members: diff --git a/doc/ref/returners/all/salt.returners.sentry_return.rst b/doc/ref/returners/all/salt.returners.sentry_return.rst new file mode 100644 index 000000000000..2a7fed9796b6 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.sentry_return.rst @@ -0,0 +1,5 @@ +salt.returners.sentry_return +============================ + +.. automodule:: salt.returners.sentry_return + :members: diff --git a/doc/ref/returners/all/salt.returners.slack_returner.rst b/doc/ref/returners/all/salt.returners.slack_returner.rst new file mode 100644 index 000000000000..bacfce2433f1 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.slack_returner.rst @@ -0,0 +1,5 @@ +salt.returners.slack_returner +============================= + +.. automodule:: salt.returners.slack_returner + :members: diff --git a/doc/ref/returners/all/salt.returners.slack_webhook_return.rst b/doc/ref/returners/all/salt.returners.slack_webhook_return.rst new file mode 100644 index 000000000000..127062c5d6c2 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.slack_webhook_return.rst @@ -0,0 +1,5 @@ +salt.returners.slack_webhook_return +=================================== + +.. automodule:: salt.returners.slack_webhook_return + :members: diff --git a/doc/ref/returners/all/salt.returners.sms_return.rst b/doc/ref/returners/all/salt.returners.sms_return.rst new file mode 100644 index 000000000000..633e784e92c4 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.sms_return.rst @@ -0,0 +1,5 @@ +salt.returners.sms_return +========================= + +.. automodule:: salt.returners.sms_return + :members: diff --git a/doc/ref/returners/all/salt.returners.smtp_return.rst b/doc/ref/returners/all/salt.returners.smtp_return.rst new file mode 100644 index 000000000000..af158c8ca02f --- /dev/null +++ b/doc/ref/returners/all/salt.returners.smtp_return.rst @@ -0,0 +1,5 @@ +salt.returners.smtp_return +========================== + +.. automodule:: salt.returners.smtp_return + :members: diff --git a/doc/ref/returners/all/salt.returners.splunk.rst b/doc/ref/returners/all/salt.returners.splunk.rst new file mode 100644 index 000000000000..29ff6bda0b89 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.splunk.rst @@ -0,0 +1,5 @@ +salt.returners.splunk +===================== + +.. automodule:: salt.returners.splunk + :members: diff --git a/doc/ref/returners/all/salt.returners.sqlite3_return.rst b/doc/ref/returners/all/salt.returners.sqlite3_return.rst new file mode 100644 index 000000000000..d9f084045a00 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.sqlite3_return.rst @@ -0,0 +1,6 @@ +salt.returners.sqlite3 +====================== + +.. automodule:: salt.returners.sqlite3_return + :members: + :exclude-members: save_minions diff --git a/doc/ref/returners/all/salt.returners.telegram_return.rst b/doc/ref/returners/all/salt.returners.telegram_return.rst new file mode 100644 index 000000000000..14e9ef6e0481 --- /dev/null +++ b/doc/ref/returners/all/salt.returners.telegram_return.rst @@ -0,0 +1,5 @@ +salt.returners.telegram_return +============================== + +.. automodule:: salt.returners.telegram_return + :members: diff --git a/doc/ref/returners/all/salt.returners.xmpp_return.rst b/doc/ref/returners/all/salt.returners.xmpp_return.rst new file mode 100644 index 000000000000..af8900aa1fff --- /dev/null +++ b/doc/ref/returners/all/salt.returners.xmpp_return.rst @@ -0,0 +1,5 @@ +salt.returners.xmpp_return +========================== + +.. automodule:: salt.returners.xmpp_return + :members: diff --git a/doc/ref/returners/all/salt.returners.zabbix_return.rst b/doc/ref/returners/all/salt.returners.zabbix_return.rst new file mode 100644 index 000000000000..c4026fec01ab --- /dev/null +++ b/doc/ref/returners/all/salt.returners.zabbix_return.rst @@ -0,0 +1,6 @@ +salt.returners.zabbix_return +============================ + +.. automodule:: salt.returners.zabbix_return + :members: + :undoc-members: diff --git a/doc/ref/roster/all/index.rst b/doc/ref/roster/all/index.rst index 34b0f67e55c1..a0f11b02f89f 100644 --- a/doc/ref/roster/all/index.rst +++ b/doc/ref/roster/all/index.rst @@ -12,9 +12,12 @@ roster modules ansible cache + cloud + clustershell dir flat range scan sshconfig sshknownhosts + terraform diff --git a/doc/ref/roster/all/salt.roster.cloud.rst b/doc/ref/roster/all/salt.roster.cloud.rst new file mode 100644 index 000000000000..96db467227b5 --- /dev/null +++ b/doc/ref/roster/all/salt.roster.cloud.rst @@ -0,0 +1,5 @@ +salt.roster.cloud +================= + +.. automodule:: salt.roster.cloud + :members: diff --git a/doc/ref/roster/all/salt.roster.clustershell.rst b/doc/ref/roster/all/salt.roster.clustershell.rst new file mode 100644 index 000000000000..afb510a536d4 --- /dev/null +++ b/doc/ref/roster/all/salt.roster.clustershell.rst @@ -0,0 +1,5 @@ +salt.roster.clustershell +======================== + +.. automodule:: salt.roster.clustershell + :members: diff --git a/doc/ref/roster/all/salt.roster.terraform.rst b/doc/ref/roster/all/salt.roster.terraform.rst new file mode 100644 index 000000000000..2145b38ce9cf --- /dev/null +++ b/doc/ref/roster/all/salt.roster.terraform.rst @@ -0,0 +1,5 @@ +salt.roster.terraform +===================== + +.. automodule:: salt.roster.terraform + :members: diff --git a/doc/ref/runners/all/index.rst b/doc/ref/runners/all/index.rst index a144901ec39b..c4817d287036 100644 --- a/doc/ref/runners/all/index.rst +++ b/doc/ref/runners/all/index.rst @@ -10,33 +10,49 @@ runner modules :toctree: :template: autosummary.rst.tmpl + asam auth - batch + bgp cache - cluster + cloud config + ddns + digicertapi doc + drac error event + f5 fileserver git_pillar http jobs + launchd + lxc manage match + mattermost mine + nacl net network + pagerduty pillar - pki + pkg queue reactor - resource salt saltutil sdb + smartos_vmadm + spacewalk ssh state survey test + thin + vault + venafiapi + virt + vistara winrepo diff --git a/doc/ref/runners/all/salt.runners.asam.rst b/doc/ref/runners/all/salt.runners.asam.rst new file mode 100644 index 000000000000..2d03898bcb3e --- /dev/null +++ b/doc/ref/runners/all/salt.runners.asam.rst @@ -0,0 +1,6 @@ +salt.runners.asam +================= + +.. automodule:: salt.runners.asam + :members: + :exclude-members: ASAMHTMLParser diff --git a/doc/ref/runners/all/salt.runners.batch.rst b/doc/ref/runners/all/salt.runners.batch.rst deleted file mode 100644 index 97a5260fc0e4..000000000000 --- a/doc/ref/runners/all/salt.runners.batch.rst +++ /dev/null @@ -1,5 +0,0 @@ -salt.runners.batch -================== - -.. automodule:: salt.runners.batch - :members: diff --git a/doc/ref/runners/all/salt.runners.bgp.rst b/doc/ref/runners/all/salt.runners.bgp.rst new file mode 100644 index 000000000000..993d2b62fd09 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.bgp.rst @@ -0,0 +1,5 @@ +salt.runners.bgp +================ + +.. automodule:: salt.runners.bgp + :members: diff --git a/doc/ref/runners/all/salt.runners.cloud.rst b/doc/ref/runners/all/salt.runners.cloud.rst new file mode 100644 index 000000000000..d7acf831f19d --- /dev/null +++ b/doc/ref/runners/all/salt.runners.cloud.rst @@ -0,0 +1,5 @@ +salt.runners.cloud +================== + +.. automodule:: salt.runners.cloud + :members: diff --git a/doc/ref/runners/all/salt.runners.cluster.rst b/doc/ref/runners/all/salt.runners.cluster.rst deleted file mode 100644 index 9623d8c5bd63..000000000000 --- a/doc/ref/runners/all/salt.runners.cluster.rst +++ /dev/null @@ -1,5 +0,0 @@ -salt.runners.cluster -==================== - -.. automodule:: salt.runners.cluster - :members: diff --git a/doc/ref/runners/all/salt.runners.ddns.rst b/doc/ref/runners/all/salt.runners.ddns.rst new file mode 100644 index 000000000000..c710d86db107 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.ddns.rst @@ -0,0 +1,5 @@ +salt.runners.ddns +================= + +.. automodule:: salt.runners.ddns + :members: diff --git a/doc/ref/runners/all/salt.runners.digicertapi.rst b/doc/ref/runners/all/salt.runners.digicertapi.rst new file mode 100644 index 000000000000..280fc059fafe --- /dev/null +++ b/doc/ref/runners/all/salt.runners.digicertapi.rst @@ -0,0 +1,6 @@ +salt.runners.digicertapi +======================== + +.. automodule:: salt.runners.digicertapi + :members: + :undoc-members: diff --git a/doc/ref/runners/all/salt.runners.drac.rst b/doc/ref/runners/all/salt.runners.drac.rst new file mode 100644 index 000000000000..a73c2974d990 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.drac.rst @@ -0,0 +1,5 @@ +salt.runners.drac +================= + +.. automodule:: salt.runners.drac + :members: diff --git a/doc/ref/runners/all/salt.runners.f5.rst b/doc/ref/runners/all/salt.runners.f5.rst new file mode 100644 index 000000000000..c83603cf47fb --- /dev/null +++ b/doc/ref/runners/all/salt.runners.f5.rst @@ -0,0 +1,5 @@ +salt.runners.f5 +=============== + +.. automodule:: salt.runners.f5 + :members: diff --git a/doc/ref/runners/all/salt.runners.launchd.rst b/doc/ref/runners/all/salt.runners.launchd.rst new file mode 100644 index 000000000000..7642a6517194 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.launchd.rst @@ -0,0 +1,5 @@ +salt.runners.launchd +==================== + +.. automodule:: salt.runners.launchd + :members: diff --git a/doc/ref/runners/all/salt.runners.lxc.rst b/doc/ref/runners/all/salt.runners.lxc.rst new file mode 100644 index 000000000000..9a2c57c6d911 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.lxc.rst @@ -0,0 +1,5 @@ +salt.runners.lxc +================ + +.. automodule:: salt.runners.lxc + :members: diff --git a/doc/ref/runners/all/salt.runners.mattermost.rst b/doc/ref/runners/all/salt.runners.mattermost.rst new file mode 100644 index 000000000000..869745ffae2e --- /dev/null +++ b/doc/ref/runners/all/salt.runners.mattermost.rst @@ -0,0 +1,12 @@ +salt.runners.mattermost +======================= + +**Note for 2017.7 releases!** + +Due to the `salt.runners.config `_ module not being available in this release series, importing the `salt.runners.config `_ module from the |repo_primary_branch| branch is required to make this module work. + +Ref: `Mattermost runner failing to retrieve config values due to unavailable config runner #43479 `_ + +.. automodule:: salt.runners.mattermost + :members: + :undoc-members: diff --git a/doc/ref/runners/all/salt.runners.nacl.rst b/doc/ref/runners/all/salt.runners.nacl.rst new file mode 100644 index 000000000000..96d02439a367 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.nacl.rst @@ -0,0 +1,5 @@ +salt.runners.nacl +================= + +.. automodule:: salt.runners.nacl + :members: diff --git a/doc/ref/runners/all/salt.runners.pagerduty.rst b/doc/ref/runners/all/salt.runners.pagerduty.rst new file mode 100644 index 000000000000..4e326b6a39d0 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.pagerduty.rst @@ -0,0 +1,5 @@ +salt.runners.pagerduty +====================== + +.. automodule:: salt.runners.pagerduty + :members: diff --git a/doc/ref/runners/all/salt.runners.pkg.rst b/doc/ref/runners/all/salt.runners.pkg.rst new file mode 100644 index 000000000000..1ffcccf979f2 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.pkg.rst @@ -0,0 +1,5 @@ +salt.runners.pkg +================ + +.. automodule:: salt.runners.pkg + :members: diff --git a/doc/ref/runners/all/salt.runners.pki.rst b/doc/ref/runners/all/salt.runners.pki.rst deleted file mode 100644 index d8299b47367b..000000000000 --- a/doc/ref/runners/all/salt.runners.pki.rst +++ /dev/null @@ -1,9 +0,0 @@ -.. _all-salt.runners.pki: - -================ -salt.runners.pki -================ - -.. automodule:: salt.runners.pki - :members: - :undoc-members: diff --git a/doc/ref/runners/all/salt.runners.resource.rst b/doc/ref/runners/all/salt.runners.resource.rst deleted file mode 100644 index 31f04d366708..000000000000 --- a/doc/ref/runners/all/salt.runners.resource.rst +++ /dev/null @@ -1,9 +0,0 @@ -.. _all-salt.runners.resource: - -===================== -salt.runners.resource -===================== - -.. automodule:: salt.runners.resource - :members: - :undoc-members: diff --git a/doc/ref/runners/all/salt.runners.smartos_vmadm.rst b/doc/ref/runners/all/salt.runners.smartos_vmadm.rst new file mode 100644 index 000000000000..7b5a7c4834eb --- /dev/null +++ b/doc/ref/runners/all/salt.runners.smartos_vmadm.rst @@ -0,0 +1,6 @@ +salt.runners.smartos_vmadm +========================== + +.. automodule:: salt.runners.smartos_vmadm + :members: + :undoc-members: diff --git a/doc/ref/runners/all/salt.runners.spacewalk.rst b/doc/ref/runners/all/salt.runners.spacewalk.rst new file mode 100644 index 000000000000..a567e04b2128 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.spacewalk.rst @@ -0,0 +1,5 @@ +salt.runners.spacewalk +====================== + +.. automodule:: salt.runners.spacewalk + :members: diff --git a/doc/ref/runners/all/salt.runners.thin.rst b/doc/ref/runners/all/salt.runners.thin.rst new file mode 100644 index 000000000000..090e8f568c31 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.thin.rst @@ -0,0 +1,5 @@ +salt.runners.thin +================= + +.. automodule:: salt.runners.thin + :members: diff --git a/doc/ref/runners/all/salt.runners.vault.rst b/doc/ref/runners/all/salt.runners.vault.rst new file mode 100644 index 000000000000..434774b0dd22 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.vault.rst @@ -0,0 +1,6 @@ +salt.runners.vault +================== + +.. automodule:: salt.runners.vault + :members: + :undoc-members: diff --git a/doc/ref/runners/all/salt.runners.venafiapi.rst b/doc/ref/runners/all/salt.runners.venafiapi.rst new file mode 100644 index 000000000000..d7e4d545eb67 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.venafiapi.rst @@ -0,0 +1,6 @@ +salt.runners.venafiapi +====================== + +.. automodule:: salt.runners.venafiapi + :members: + :undoc-members: diff --git a/doc/ref/runners/all/salt.runners.virt.rst b/doc/ref/runners/all/salt.runners.virt.rst new file mode 100644 index 000000000000..04d5275d5521 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.virt.rst @@ -0,0 +1,5 @@ +salt.runners.virt +================= + +.. automodule:: salt.runners.virt + :members: diff --git a/doc/ref/runners/all/salt.runners.vistara.rst b/doc/ref/runners/all/salt.runners.vistara.rst new file mode 100644 index 000000000000..0f1400f4c7b0 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.vistara.rst @@ -0,0 +1,6 @@ +salt.runners.vistara +==================== + +.. automodule:: salt.runners.vistara + :members: + :undoc-members: diff --git a/doc/ref/sdb/all/index.rst b/doc/ref/sdb/all/index.rst index 7772b51d6842..958970eac38f 100644 --- a/doc/ref/sdb/all/index.rst +++ b/doc/ref/sdb/all/index.rst @@ -10,5 +10,17 @@ sdb modules :toctree: :template: autosummary.rst.tmpl + cache + confidant + consul + couchdb env + etcd_db + keyring_db + memcached + redis_sdb + rest + sqlite3 + tism + vault yaml diff --git a/doc/ref/sdb/all/salt.sdb.cache.rst b/doc/ref/sdb/all/salt.sdb.cache.rst new file mode 100644 index 000000000000..0dc71b25e71c --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.cache.rst @@ -0,0 +1,6 @@ +salt.sdb.cache +============== + +.. automodule:: salt.sdb.cache + :members: + :undoc-members: diff --git a/doc/ref/sdb/all/salt.sdb.confidant.rst b/doc/ref/sdb/all/salt.sdb.confidant.rst new file mode 100644 index 000000000000..b47aee309944 --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.confidant.rst @@ -0,0 +1,5 @@ +salt.sdb.confidant +================== + +.. automodule:: salt.sdb.confidant + :members: diff --git a/doc/ref/sdb/all/salt.sdb.consul.rst b/doc/ref/sdb/all/salt.sdb.consul.rst new file mode 100644 index 000000000000..043a783986ad --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.consul.rst @@ -0,0 +1,5 @@ +salt.sdb.consul +=============== + +.. automodule:: salt.sdb.consul + :members: diff --git a/doc/ref/sdb/all/salt.sdb.couchdb.rst b/doc/ref/sdb/all/salt.sdb.couchdb.rst new file mode 100644 index 000000000000..e57f5615f92d --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.couchdb.rst @@ -0,0 +1,5 @@ +salt.sdb.couchdb +================ + +.. automodule:: salt.sdb.couchdb + :members: diff --git a/doc/ref/sdb/all/salt.sdb.etcd_db.rst b/doc/ref/sdb/all/salt.sdb.etcd_db.rst new file mode 100644 index 000000000000..a2870b973771 --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.etcd_db.rst @@ -0,0 +1,5 @@ +salt.sdb.etcd_db +================ + +.. automodule:: salt.sdb.etcd_db + :members: diff --git a/doc/ref/sdb/all/salt.sdb.keyring_db.rst b/doc/ref/sdb/all/salt.sdb.keyring_db.rst new file mode 100644 index 000000000000..1b52dd44b51b --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.keyring_db.rst @@ -0,0 +1,5 @@ +salt.sdb.keyring_db +=================== + +.. automodule:: salt.sdb.keyring_db + :members: diff --git a/doc/ref/sdb/all/salt.sdb.memcached.rst b/doc/ref/sdb/all/salt.sdb.memcached.rst new file mode 100644 index 000000000000..39b55ff29a28 --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.memcached.rst @@ -0,0 +1,5 @@ +salt.sdb.memcached +================== + +.. automodule:: salt.sdb.memcached + :members: diff --git a/doc/ref/sdb/all/salt.sdb.redis_sdb.rst b/doc/ref/sdb/all/salt.sdb.redis_sdb.rst new file mode 100644 index 000000000000..192c9ef15d9e --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.redis_sdb.rst @@ -0,0 +1,5 @@ +salt.sdb.redis_sdb +================== + +.. automodule:: salt.sdb.redis_sdb + :members: diff --git a/doc/ref/sdb/all/salt.sdb.rest.rst b/doc/ref/sdb/all/salt.sdb.rest.rst new file mode 100644 index 000000000000..62f178a70635 --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.rest.rst @@ -0,0 +1,5 @@ +salt.sdb.rest +============= + +.. automodule:: salt.sdb.rest + :members: diff --git a/doc/ref/sdb/all/salt.sdb.sqlite3.rst b/doc/ref/sdb/all/salt.sdb.sqlite3.rst new file mode 100644 index 000000000000..59ba45a2509f --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.sqlite3.rst @@ -0,0 +1,5 @@ +salt.sdb.sqlite3 +================ + +.. automodule:: salt.sdb.sqlite3 + :members: diff --git a/doc/ref/sdb/all/salt.sdb.tism.rst b/doc/ref/sdb/all/salt.sdb.tism.rst new file mode 100644 index 000000000000..5f74b948278e --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.tism.rst @@ -0,0 +1,6 @@ +salt.sdb.tism +============= + +.. automodule:: salt.sdb.tism + :members: + :undoc-members: diff --git a/doc/ref/sdb/all/salt.sdb.vault.rst b/doc/ref/sdb/all/salt.sdb.vault.rst new file mode 100644 index 000000000000..41e3124c8a25 --- /dev/null +++ b/doc/ref/sdb/all/salt.sdb.vault.rst @@ -0,0 +1,5 @@ +salt.sdb.vault +============== + +.. automodule:: salt.sdb.vault + :members: diff --git a/doc/ref/serializers/all/index.rst b/doc/ref/serializers/all/index.rst index 89b280fe9cf8..afa2d3345569 100644 --- a/doc/ref/serializers/all/index.rst +++ b/doc/ref/serializers/all/index.rst @@ -15,7 +15,10 @@ serializer modules configparser json + keyvalue msgpack + plist + python tomlmod yaml yamlex diff --git a/doc/ref/serializers/all/salt.serializers.configparser.rst b/doc/ref/serializers/all/salt.serializers.configparser.rst index 3a9339a52e70..2a493e89c039 100644 --- a/doc/ref/serializers/all/salt.serializers.configparser.rst +++ b/doc/ref/serializers/all/salt.serializers.configparser.rst @@ -3,4 +3,3 @@ salt.serializers.configparser .. automodule:: salt.serializers.configparser :members: - :noindex: salt.serializers.DeserializationError salt.serializers.SerializationError diff --git a/doc/ref/serializers/all/salt.serializers.json.rst b/doc/ref/serializers/all/salt.serializers.json.rst index 333bde7b64b2..47e5b58b46ef 100644 --- a/doc/ref/serializers/all/salt.serializers.json.rst +++ b/doc/ref/serializers/all/salt.serializers.json.rst @@ -3,4 +3,3 @@ salt.serializers.json .. automodule:: salt.serializers.json :members: - :noindex: salt.serializers.DeserializationError salt.serializers.SerializationError diff --git a/doc/ref/serializers/all/salt.serializers.keyvalue.rst b/doc/ref/serializers/all/salt.serializers.keyvalue.rst new file mode 100644 index 000000000000..1766a4664538 --- /dev/null +++ b/doc/ref/serializers/all/salt.serializers.keyvalue.rst @@ -0,0 +1,5 @@ +salt.serializers.keyvalue +========================= + +.. automodule:: salt.serializers.keyvalue + :members: diff --git a/doc/ref/serializers/all/salt.serializers.msgpack.rst b/doc/ref/serializers/all/salt.serializers.msgpack.rst index 2e177b4b1df7..8c97e23134ac 100644 --- a/doc/ref/serializers/all/salt.serializers.msgpack.rst +++ b/doc/ref/serializers/all/salt.serializers.msgpack.rst @@ -3,4 +3,3 @@ salt.serializers.msgpack .. automodule:: salt.serializers.msgpack :members: - :noindex: salt.serializers.DeserializationError salt.serializers.SerializationError diff --git a/doc/ref/serializers/all/salt.serializers.plist.rst b/doc/ref/serializers/all/salt.serializers.plist.rst new file mode 100644 index 000000000000..c5f5fded3991 --- /dev/null +++ b/doc/ref/serializers/all/salt.serializers.plist.rst @@ -0,0 +1,5 @@ +salt.serializers.plist +====================== + +.. automodule:: salt.serializers.plist + :members: diff --git a/doc/ref/serializers/all/salt.serializers.python.rst b/doc/ref/serializers/all/salt.serializers.python.rst new file mode 100644 index 000000000000..afe6b7fc3b1f --- /dev/null +++ b/doc/ref/serializers/all/salt.serializers.python.rst @@ -0,0 +1,5 @@ +salt.serializers.python +======================= + +.. automodule:: salt.serializers.python + :members: diff --git a/doc/ref/states/all/index.rst b/doc/ref/states/all/index.rst index 3996a0a58ccd..924979985fa2 100644 --- a/doc/ref/states/all/index.rst +++ b/doc/ref/states/all/index.rst @@ -10,68 +10,241 @@ state modules :toctree: :template: autosummary.rst.tmpl + acme alias + alternatives ansiblegate apache apache_conf apache_module apache_site + aptpkg archive + artifactory at + augeas + aws_sqs beacon + bigip blockdev + boto3_elasticache + boto3_elasticsearch + boto3_route53 + boto3_sns + boto_apigateway + boto_asg + boto_cfn + boto_cloudfront + boto_cloudtrail + boto_cloudwatch_alarm + boto_cloudwatch_event + boto_cognitoidentity + boto_datapipeline + boto_dynamodb + boto_ec2 + boto_elasticache + boto_elasticsearch_domain + boto_elb + boto_elbv2 + boto_iam + boto_iam_role + boto_iot + boto_kinesis + boto_kms + boto_lambda + boto_lc + boto_rds + boto_route53 + boto_s3 + boto_s3_bucket + boto_secgroup + boto_sns + boto_sqs + boto_vpc + bower + btrfs + cabal + ceph + chef chocolatey + chronos_job + cimc + cisconso cloud cmd + composer + consul cron + cryptdev + csf + cyg + ddns debconfmod + dellchassis disk + docker_container + docker_image + docker_network + docker_volume + drac + dvs + elasticsearch + elasticsearch_index + elasticsearch_index_template environ + eselect + esxcluster + esxdatacenter + esxi + esxvm etcd_mod + ethtool event file firewall firewalld + gem git + github + glance_image + glassfish + glusterfs + gnomedesktop gpg + grafana + grafana4_dashboard + grafana4_datasource + grafana4_org + grafana4_user + grafana_dashboard + grafana_datasource grains group + heat + helm + hg highstate_doc host http + icinga2 idem + ifttt + incron + influxdb08_database + influxdb08_user + influxdb_continuous_query + influxdb_database + influxdb_retention_policy + influxdb_user + infoblox_a + infoblox_cname + infoblox_host_record + infoblox_range ini_manage + ipmi ipset iptables + jboss7 + jenkins + junos + kapacitor + kernelpkg keyboard + keystone + keystone_domain + keystone_endpoint + keystone_group + keystone_project + keystone_role + keystone_role_grant + keystone_service + keystone_user + keystore kmod + kubernetes + layman + ldap + libcloud_dns + libcloud_loadbalancer + libcloud_storage linux_acl locale + logadm logrotate loop lvm + lvs_server + lvs_service + lxc + lxd + lxd_container + lxd_image + lxd_profile mac_assistive mac_keychain mac_xattr macdefaults macpackage makeconf + marathon_app mdadm_raid + memcached + modjk + modjk_worker module + mongodb_database + mongodb_user + monit mount + mssql_database + mssql_login + mssql_role + mssql_user + msteams + mysql_database + mysql_grants + mysql_query + mysql_user + net_napalm_yang netacl netconfig netntp netsnmp netusers network + neutron_network + neutron_secgroup + neutron_secgroup_rule + neutron_subnet + nexus + nfs_export nftables + npm ntp + nxos + nxos_upgrade + openstack_config + openvswitch_bridge + openvswitch_db + openvswitch_port + opsgenie + pagerduty + pagerduty_escalation_policy + pagerduty_schedule + pagerduty_service + pagerduty_user + panos + pbm + pcs + pdbedit + pecl pip_state pkg pkgbuild pkgng pkgrepo + portage_config + ports postgres_cluster postgres_database postgres_extension @@ -82,9 +255,13 @@ state modules postgres_schema postgres_tablespace postgres_user + powerpath + probes process proxy + pushover pyenv + pyrax_queues quota rabbitmq_cluster rabbitmq_plugin @@ -92,33 +269,62 @@ state modules rabbitmq_upstream rabbitmq_user rabbitmq_vhost + rbac_solaris + rbenv + rdp + redismod reg + restconf + rsync + rvm salt_proxy saltmod saltutil schedule selinux + serverdensity_device service + slack + smartos + smtp + snapper + solrcloud + splunk + splunk_search + sqlite3 ssh_auth ssh_known_hosts - ssh_pki stateconf status + statuspage + supervisord + svn sysctl sysfs syslog_ng + sysrc + telemetry_alert test + testinframod timezone tls + tomcat + trafficserver + tuned uptime user + vagrant + vault + vbox_guest + victorops + virt virtualenv_mod + webutil win_appx win_certutil win_dacl win_dism win_dns_client - win_dsc_resource win_firewall win_iis win_lgpo @@ -137,5 +343,24 @@ state modules win_wua win_wusa winrepo + wordpress x509 x509_v2 + xml + xmpp + zabbix_action + zabbix_host + zabbix_hostgroup + zabbix_mediatype + zabbix_template + zabbix_user + zabbix_usergroup + zabbix_usermacro + zabbix_valuemap + zcbuildout + zenoss + zfs + zk_concurrency + zone + zookeeper + zpool diff --git a/doc/ref/states/all/salt.states.acme.rst b/doc/ref/states/all/salt.states.acme.rst new file mode 100644 index 000000000000..4678063d2b03 --- /dev/null +++ b/doc/ref/states/all/salt.states.acme.rst @@ -0,0 +1,5 @@ +salt.states.acme +================ + +.. automodule:: salt.states.acme + :members: diff --git a/doc/ref/states/all/salt.states.alternatives.rst b/doc/ref/states/all/salt.states.alternatives.rst new file mode 100644 index 000000000000..30e0a9ee1a06 --- /dev/null +++ b/doc/ref/states/all/salt.states.alternatives.rst @@ -0,0 +1,5 @@ +salt.states.alternatives +======================== + +.. automodule:: salt.states.alternatives + :members: diff --git a/doc/ref/states/all/salt.states.aptpkg.rst b/doc/ref/states/all/salt.states.aptpkg.rst new file mode 100644 index 000000000000..66cf9ca80e16 --- /dev/null +++ b/doc/ref/states/all/salt.states.aptpkg.rst @@ -0,0 +1,5 @@ +salt.states.aptpkg +================== + +.. automodule:: salt.states.aptpkg + :members: diff --git a/doc/ref/states/all/salt.states.artifactory.rst b/doc/ref/states/all/salt.states.artifactory.rst new file mode 100644 index 000000000000..ad7cb3c4cbc0 --- /dev/null +++ b/doc/ref/states/all/salt.states.artifactory.rst @@ -0,0 +1,5 @@ +salt.states.artifactory +======================= + +.. automodule:: salt.states.artifactory + :members: diff --git a/doc/ref/states/all/salt.states.augeas.rst b/doc/ref/states/all/salt.states.augeas.rst new file mode 100644 index 000000000000..6a4564d8c2c3 --- /dev/null +++ b/doc/ref/states/all/salt.states.augeas.rst @@ -0,0 +1,5 @@ +salt.states.augeas +================== + +.. automodule:: salt.states.augeas + :members: diff --git a/doc/ref/states/all/salt.states.aws_sqs.rst b/doc/ref/states/all/salt.states.aws_sqs.rst new file mode 100644 index 000000000000..4f6570eafd3d --- /dev/null +++ b/doc/ref/states/all/salt.states.aws_sqs.rst @@ -0,0 +1,5 @@ +salt.states.aws_sqs +=================== + +.. automodule:: salt.states.aws_sqs + :members: diff --git a/doc/ref/states/all/salt.states.bigip.rst b/doc/ref/states/all/salt.states.bigip.rst new file mode 100644 index 000000000000..0a791294473d --- /dev/null +++ b/doc/ref/states/all/salt.states.bigip.rst @@ -0,0 +1,5 @@ +salt.states.bigip +================= + +.. automodule:: salt.states.bigip + :members: diff --git a/doc/ref/states/all/salt.states.boto3_elasticache.rst b/doc/ref/states/all/salt.states.boto3_elasticache.rst new file mode 100644 index 000000000000..e12b2f1e707d --- /dev/null +++ b/doc/ref/states/all/salt.states.boto3_elasticache.rst @@ -0,0 +1,6 @@ +salt.states.boto3_elasticache +============================= + +.. automodule:: salt.states.boto3_elasticache + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.boto3_elasticsearch.rst b/doc/ref/states/all/salt.states.boto3_elasticsearch.rst new file mode 100644 index 000000000000..0975533a6f7c --- /dev/null +++ b/doc/ref/states/all/salt.states.boto3_elasticsearch.rst @@ -0,0 +1,6 @@ +salt.states.boto3_elasticsearch +=============================== + +.. automodule:: salt.states.boto3_elasticsearch + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.boto3_route53.rst b/doc/ref/states/all/salt.states.boto3_route53.rst new file mode 100644 index 000000000000..19a46806080c --- /dev/null +++ b/doc/ref/states/all/salt.states.boto3_route53.rst @@ -0,0 +1,6 @@ +salt.states.boto3_route53 +========================= + +.. automodule:: salt.states.boto3_route53 + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.boto3_sns.rst b/doc/ref/states/all/salt.states.boto3_sns.rst new file mode 100644 index 000000000000..5c5f57855b5c --- /dev/null +++ b/doc/ref/states/all/salt.states.boto3_sns.rst @@ -0,0 +1,5 @@ +salt.states.boto3_sns +===================== + +.. automodule:: salt.states.boto3_sns + :members: diff --git a/doc/ref/states/all/salt.states.boto_apigateway.rst b/doc/ref/states/all/salt.states.boto_apigateway.rst new file mode 100644 index 000000000000..cb73df08c6cb --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_apigateway.rst @@ -0,0 +1,5 @@ +salt.states.boto_apigateway +=========================== + +.. automodule:: salt.states.boto_apigateway + :members: diff --git a/doc/ref/states/all/salt.states.boto_asg.rst b/doc/ref/states/all/salt.states.boto_asg.rst new file mode 100644 index 000000000000..0761259e88d0 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_asg.rst @@ -0,0 +1,5 @@ +salt.states.boto_asg +==================== + +.. automodule:: salt.states.boto_asg + :members: diff --git a/doc/ref/states/all/salt.states.boto_cfn.rst b/doc/ref/states/all/salt.states.boto_cfn.rst new file mode 100644 index 000000000000..094bbb7c72fb --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_cfn.rst @@ -0,0 +1,5 @@ +salt.states.boto_cfn +==================== + +.. automodule:: salt.states.boto_cfn + :members: diff --git a/doc/ref/states/all/salt.states.boto_cloudfront.rst b/doc/ref/states/all/salt.states.boto_cloudfront.rst new file mode 100644 index 000000000000..caad7c95df7c --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_cloudfront.rst @@ -0,0 +1,5 @@ +salt.states.boto_cloudfront +=========================== + +.. automodule:: salt.states.boto_cloudfront + :members: diff --git a/doc/ref/states/all/salt.states.boto_cloudtrail.rst b/doc/ref/states/all/salt.states.boto_cloudtrail.rst new file mode 100644 index 000000000000..4b1c25da5caa --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_cloudtrail.rst @@ -0,0 +1,5 @@ +salt.states.boto_cloudtrail +=========================== + +.. automodule:: salt.states.boto_cloudtrail + :members: diff --git a/doc/ref/states/all/salt.states.boto_cloudwatch_alarm.rst b/doc/ref/states/all/salt.states.boto_cloudwatch_alarm.rst new file mode 100644 index 000000000000..66f6fb294416 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_cloudwatch_alarm.rst @@ -0,0 +1,5 @@ +salt.states.boto_cloudwatch_alarm +================================= + +.. automodule:: salt.states.boto_cloudwatch_alarm + :members: diff --git a/doc/ref/states/all/salt.states.boto_cloudwatch_event.rst b/doc/ref/states/all/salt.states.boto_cloudwatch_event.rst new file mode 100644 index 000000000000..f3638e420159 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_cloudwatch_event.rst @@ -0,0 +1,6 @@ +salt.states.boto_cloudwatch_event +================================= + +.. automodule:: salt.states.boto_cloudwatch_event + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.boto_cognitoidentity.rst b/doc/ref/states/all/salt.states.boto_cognitoidentity.rst new file mode 100644 index 000000000000..be2fed50d1f8 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_cognitoidentity.rst @@ -0,0 +1,5 @@ +salt.states.boto_cognitoidentity +================================ + +.. automodule:: salt.states.boto_cognitoidentity + :members: diff --git a/doc/ref/states/all/salt.states.boto_datapipeline.rst b/doc/ref/states/all/salt.states.boto_datapipeline.rst new file mode 100644 index 000000000000..e3feb48a2b93 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_datapipeline.rst @@ -0,0 +1,5 @@ +salt.states.boto_datapipeline +============================= + +.. automodule:: salt.states.boto_datapipeline + :members: diff --git a/doc/ref/states/all/salt.states.boto_dynamodb.rst b/doc/ref/states/all/salt.states.boto_dynamodb.rst new file mode 100644 index 000000000000..7d866ca9ec6e --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_dynamodb.rst @@ -0,0 +1,5 @@ +salt.states.boto_dynamodb +========================= + +.. automodule:: salt.states.boto_dynamodb + :members: diff --git a/doc/ref/states/all/salt.states.boto_ec2.rst b/doc/ref/states/all/salt.states.boto_ec2.rst new file mode 100644 index 000000000000..57a3cbec72e4 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_ec2.rst @@ -0,0 +1,5 @@ +salt.states.boto_ec2 +==================== + +.. automodule:: salt.states.boto_ec2 + :members: diff --git a/doc/ref/states/all/salt.states.boto_elasticache.rst b/doc/ref/states/all/salt.states.boto_elasticache.rst new file mode 100644 index 000000000000..1f2267c3504c --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_elasticache.rst @@ -0,0 +1,5 @@ +salt.states.boto_elasticache +============================ + +.. automodule:: salt.states.boto_elasticache + :members: diff --git a/doc/ref/states/all/salt.states.boto_elasticsearch_domain.rst b/doc/ref/states/all/salt.states.boto_elasticsearch_domain.rst new file mode 100644 index 000000000000..b527347d71ad --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_elasticsearch_domain.rst @@ -0,0 +1,5 @@ +salt.states.boto_elasticsearch_domain +===================================== + +.. automodule:: salt.states.boto_elasticsearch_domain + :members: diff --git a/doc/ref/states/all/salt.states.boto_elb.rst b/doc/ref/states/all/salt.states.boto_elb.rst new file mode 100644 index 000000000000..edb35f075535 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_elb.rst @@ -0,0 +1,5 @@ +salt.states.boto_elb +==================== + +.. automodule:: salt.states.boto_elb + :members: diff --git a/doc/ref/states/all/salt.states.boto_elbv2.rst b/doc/ref/states/all/salt.states.boto_elbv2.rst new file mode 100644 index 000000000000..d5b77543b893 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_elbv2.rst @@ -0,0 +1,6 @@ +salt.states.boto_elbv2 +====================== + +.. automodule:: salt.states.boto_elbv2 + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.boto_iam.rst b/doc/ref/states/all/salt.states.boto_iam.rst new file mode 100644 index 000000000000..ad3f0663e75a --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_iam.rst @@ -0,0 +1,5 @@ +salt.states.boto_iam +==================== + +.. automodule:: salt.states.boto_iam + :members: diff --git a/doc/ref/states/all/salt.states.boto_iam_role.rst b/doc/ref/states/all/salt.states.boto_iam_role.rst new file mode 100644 index 000000000000..00644e767395 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_iam_role.rst @@ -0,0 +1,5 @@ +salt.states.boto_iam_role +========================= + +.. automodule:: salt.states.boto_iam_role + :members: diff --git a/doc/ref/states/all/salt.states.boto_iot.rst b/doc/ref/states/all/salt.states.boto_iot.rst new file mode 100644 index 000000000000..0c03b5d59092 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_iot.rst @@ -0,0 +1,5 @@ +salt.states.boto_iot +==================== + +.. automodule:: salt.states.boto_iot + :members: diff --git a/doc/ref/states/all/salt.states.boto_kinesis.rst b/doc/ref/states/all/salt.states.boto_kinesis.rst new file mode 100644 index 000000000000..fbd03c653238 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_kinesis.rst @@ -0,0 +1,6 @@ +salt.states.boto_kinesis +======================== + +.. automodule:: salt.states.boto_kinesis + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.boto_kms.rst b/doc/ref/states/all/salt.states.boto_kms.rst new file mode 100644 index 000000000000..1a9f3779805a --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_kms.rst @@ -0,0 +1,5 @@ +salt.states.boto_kms +==================== + +.. automodule:: salt.states.boto_kms + :members: diff --git a/doc/ref/states/all/salt.states.boto_lambda.rst b/doc/ref/states/all/salt.states.boto_lambda.rst new file mode 100644 index 000000000000..27681356528a --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_lambda.rst @@ -0,0 +1,5 @@ +salt.states.boto_lambda +======================= + +.. automodule:: salt.states.boto_lambda + :members: diff --git a/doc/ref/states/all/salt.states.boto_lc.rst b/doc/ref/states/all/salt.states.boto_lc.rst new file mode 100644 index 000000000000..c7e44f71c235 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_lc.rst @@ -0,0 +1,5 @@ +salt.states.boto_lc +=================== + +.. automodule:: salt.states.boto_lc + :members: diff --git a/doc/ref/states/all/salt.states.boto_rds.rst b/doc/ref/states/all/salt.states.boto_rds.rst new file mode 100644 index 000000000000..0d07836d1dfd --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_rds.rst @@ -0,0 +1,5 @@ +salt.states.boto_rds +==================== + +.. automodule:: salt.states.boto_rds + :members: diff --git a/doc/ref/states/all/salt.states.boto_route53.rst b/doc/ref/states/all/salt.states.boto_route53.rst new file mode 100644 index 000000000000..947a8983d9c4 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_route53.rst @@ -0,0 +1,5 @@ +salt.states.boto_route53 +======================== + +.. automodule:: salt.states.boto_route53 + :members: diff --git a/doc/ref/states/all/salt.states.boto_s3.rst b/doc/ref/states/all/salt.states.boto_s3.rst new file mode 100644 index 000000000000..70839e7107f7 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_s3.rst @@ -0,0 +1,5 @@ +salt.states.boto_s3 +=================== + +.. automodule:: salt.states.boto_s3 + :members: diff --git a/doc/ref/states/all/salt.states.boto_s3_bucket.rst b/doc/ref/states/all/salt.states.boto_s3_bucket.rst new file mode 100644 index 000000000000..06c1d989833a --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_s3_bucket.rst @@ -0,0 +1,5 @@ +salt.states.boto_s3_bucket +========================== + +.. automodule:: salt.states.boto_s3_bucket + :members: diff --git a/doc/ref/states/all/salt.states.boto_secgroup.rst b/doc/ref/states/all/salt.states.boto_secgroup.rst new file mode 100644 index 000000000000..605f0198435e --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_secgroup.rst @@ -0,0 +1,5 @@ +salt.states.boto_secgroup +========================= + +.. automodule:: salt.states.boto_secgroup + :members: diff --git a/doc/ref/states/all/salt.states.boto_sns.rst b/doc/ref/states/all/salt.states.boto_sns.rst new file mode 100644 index 000000000000..851c512a6bbc --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_sns.rst @@ -0,0 +1,5 @@ +salt.states.boto_sns +==================== + +.. automodule:: salt.states.boto_sns + :members: diff --git a/doc/ref/states/all/salt.states.boto_sqs.rst b/doc/ref/states/all/salt.states.boto_sqs.rst new file mode 100644 index 000000000000..74ddd3da9467 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_sqs.rst @@ -0,0 +1,5 @@ +salt.states.boto_sqs +==================== + +.. automodule:: salt.states.boto_sqs + :members: diff --git a/doc/ref/states/all/salt.states.boto_vpc.rst b/doc/ref/states/all/salt.states.boto_vpc.rst new file mode 100644 index 000000000000..063495e90032 --- /dev/null +++ b/doc/ref/states/all/salt.states.boto_vpc.rst @@ -0,0 +1,5 @@ +salt.states.boto_vpc +==================== + +.. automodule:: salt.states.boto_vpc + :members: diff --git a/doc/ref/states/all/salt.states.bower.rst b/doc/ref/states/all/salt.states.bower.rst new file mode 100644 index 000000000000..9cd5b519e712 --- /dev/null +++ b/doc/ref/states/all/salt.states.bower.rst @@ -0,0 +1,5 @@ +salt.states.bower +================= + +.. automodule:: salt.states.bower + :members: diff --git a/doc/ref/states/all/salt.states.btrfs.rst b/doc/ref/states/all/salt.states.btrfs.rst new file mode 100644 index 000000000000..72fa90e8b4ba --- /dev/null +++ b/doc/ref/states/all/salt.states.btrfs.rst @@ -0,0 +1,5 @@ +salt.states.btrfs +================= + +.. automodule:: salt.states.btrfs + :members: diff --git a/doc/ref/states/all/salt.states.cabal.rst b/doc/ref/states/all/salt.states.cabal.rst new file mode 100644 index 000000000000..114162c54953 --- /dev/null +++ b/doc/ref/states/all/salt.states.cabal.rst @@ -0,0 +1,5 @@ +salt.states.cabal +================= + +.. automodule:: salt.states.cabal + :members: diff --git a/doc/ref/states/all/salt.states.ceph.rst b/doc/ref/states/all/salt.states.ceph.rst new file mode 100644 index 000000000000..571c2846feec --- /dev/null +++ b/doc/ref/states/all/salt.states.ceph.rst @@ -0,0 +1,6 @@ +salt.states.ceph +================ + +.. automodule:: salt.states.ceph + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.chef.rst b/doc/ref/states/all/salt.states.chef.rst new file mode 100644 index 000000000000..52148cc25c46 --- /dev/null +++ b/doc/ref/states/all/salt.states.chef.rst @@ -0,0 +1,5 @@ +salt.states.chef +================ + +.. automodule:: salt.states.chef + :members: diff --git a/doc/ref/states/all/salt.states.chronos_job.rst b/doc/ref/states/all/salt.states.chronos_job.rst new file mode 100644 index 000000000000..f26e40ac6e6c --- /dev/null +++ b/doc/ref/states/all/salt.states.chronos_job.rst @@ -0,0 +1,5 @@ +salt.states.chronos_job +======================= + +.. automodule:: salt.states.chronos_job + :members: diff --git a/doc/ref/states/all/salt.states.cimc.rst b/doc/ref/states/all/salt.states.cimc.rst new file mode 100644 index 000000000000..3c109222be67 --- /dev/null +++ b/doc/ref/states/all/salt.states.cimc.rst @@ -0,0 +1,5 @@ +salt.states.cimc +================ + +.. automodule:: salt.states.cimc + :members: diff --git a/doc/ref/states/all/salt.states.cisconso.rst b/doc/ref/states/all/salt.states.cisconso.rst new file mode 100644 index 000000000000..0d58fa6c285f --- /dev/null +++ b/doc/ref/states/all/salt.states.cisconso.rst @@ -0,0 +1,6 @@ +==================== +salt.states.cisconso +==================== + +.. automodule:: salt.states.cisconso + :members: diff --git a/doc/ref/states/all/salt.states.composer.rst b/doc/ref/states/all/salt.states.composer.rst new file mode 100644 index 000000000000..032d6fdd32a1 --- /dev/null +++ b/doc/ref/states/all/salt.states.composer.rst @@ -0,0 +1,5 @@ +salt.states.composer +==================== + +.. automodule:: salt.states.composer + :members: diff --git a/doc/ref/states/all/salt.states.consul.rst b/doc/ref/states/all/salt.states.consul.rst new file mode 100644 index 000000000000..a28a69c91752 --- /dev/null +++ b/doc/ref/states/all/salt.states.consul.rst @@ -0,0 +1,6 @@ +================== +salt.states.consul +================== + +.. automodule:: salt.states.consul + :members: diff --git a/doc/ref/states/all/salt.states.cryptdev.rst b/doc/ref/states/all/salt.states.cryptdev.rst new file mode 100644 index 000000000000..e42cb791ef3f --- /dev/null +++ b/doc/ref/states/all/salt.states.cryptdev.rst @@ -0,0 +1,5 @@ +salt.states.cryptdev +==================== + +.. automodule:: salt.states.cryptdev + :members: diff --git a/doc/ref/states/all/salt.states.csf.rst b/doc/ref/states/all/salt.states.csf.rst new file mode 100644 index 000000000000..405689868b96 --- /dev/null +++ b/doc/ref/states/all/salt.states.csf.rst @@ -0,0 +1,6 @@ +salt.states.csf +=============== + +.. automodule:: salt.states.csf + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.cyg.rst b/doc/ref/states/all/salt.states.cyg.rst new file mode 100644 index 000000000000..ecbe99957959 --- /dev/null +++ b/doc/ref/states/all/salt.states.cyg.rst @@ -0,0 +1,5 @@ +salt.states.cyg +=============== + +.. automodule:: salt.states.cyg + :members: diff --git a/doc/ref/states/all/salt.states.ddns.rst b/doc/ref/states/all/salt.states.ddns.rst new file mode 100644 index 000000000000..3e12f65f917a --- /dev/null +++ b/doc/ref/states/all/salt.states.ddns.rst @@ -0,0 +1,5 @@ +salt.states.ddns +================ + +.. automodule:: salt.states.ddns + :members: diff --git a/doc/ref/states/all/salt.states.dellchassis.rst b/doc/ref/states/all/salt.states.dellchassis.rst new file mode 100644 index 000000000000..dfff9ef46015 --- /dev/null +++ b/doc/ref/states/all/salt.states.dellchassis.rst @@ -0,0 +1,5 @@ +salt.states.dellchassis +======================= + +.. automodule:: salt.states.dellchassis + :members: diff --git a/doc/ref/states/all/salt.states.docker_container.rst b/doc/ref/states/all/salt.states.docker_container.rst new file mode 100644 index 000000000000..92a0ebdc0040 --- /dev/null +++ b/doc/ref/states/all/salt.states.docker_container.rst @@ -0,0 +1,5 @@ +salt.states.docker_container +============================ + +.. automodule:: salt.states.docker_container + :members: diff --git a/doc/ref/states/all/salt.states.docker_image.rst b/doc/ref/states/all/salt.states.docker_image.rst new file mode 100644 index 000000000000..4a274c80fc8d --- /dev/null +++ b/doc/ref/states/all/salt.states.docker_image.rst @@ -0,0 +1,5 @@ +salt.states.docker_image +======================== + +.. automodule:: salt.states.docker_image + :members: diff --git a/doc/ref/states/all/salt.states.docker_network.rst b/doc/ref/states/all/salt.states.docker_network.rst new file mode 100644 index 000000000000..dced80312217 --- /dev/null +++ b/doc/ref/states/all/salt.states.docker_network.rst @@ -0,0 +1,5 @@ +salt.states.docker_network +========================== + +.. automodule:: salt.states.docker_network + :members: diff --git a/doc/ref/states/all/salt.states.docker_volume.rst b/doc/ref/states/all/salt.states.docker_volume.rst new file mode 100644 index 000000000000..940aa3104b41 --- /dev/null +++ b/doc/ref/states/all/salt.states.docker_volume.rst @@ -0,0 +1,5 @@ +salt.states.docker_volume +========================= + +.. automodule:: salt.states.docker_volume + :members: diff --git a/doc/ref/states/all/salt.states.drac.rst b/doc/ref/states/all/salt.states.drac.rst new file mode 100644 index 000000000000..e53dee6b5921 --- /dev/null +++ b/doc/ref/states/all/salt.states.drac.rst @@ -0,0 +1,5 @@ +salt.states.drac +================ + +.. automodule:: salt.states.drac + :members: diff --git a/doc/ref/states/all/salt.states.dvs.rst b/doc/ref/states/all/salt.states.dvs.rst new file mode 100644 index 000000000000..b28a08846d9d --- /dev/null +++ b/doc/ref/states/all/salt.states.dvs.rst @@ -0,0 +1,5 @@ +salt.states.dvs +=============== + +.. automodule:: salt.states.dvs + :members: diff --git a/doc/ref/states/all/salt.states.elasticsearch.rst b/doc/ref/states/all/salt.states.elasticsearch.rst new file mode 100644 index 000000000000..94ec58673ff4 --- /dev/null +++ b/doc/ref/states/all/salt.states.elasticsearch.rst @@ -0,0 +1,5 @@ +salt.states.elasticsearch +========================= + +.. automodule:: salt.states.elasticsearch + :members: diff --git a/doc/ref/states/all/salt.states.elasticsearch_index.rst b/doc/ref/states/all/salt.states.elasticsearch_index.rst new file mode 100644 index 000000000000..0a81b3a60a24 --- /dev/null +++ b/doc/ref/states/all/salt.states.elasticsearch_index.rst @@ -0,0 +1,5 @@ +salt.states.elasticsearch_index +=============================== + +.. automodule:: salt.states.elasticsearch_index + :members: diff --git a/doc/ref/states/all/salt.states.elasticsearch_index_template.rst b/doc/ref/states/all/salt.states.elasticsearch_index_template.rst new file mode 100644 index 000000000000..a3b7aa8b3745 --- /dev/null +++ b/doc/ref/states/all/salt.states.elasticsearch_index_template.rst @@ -0,0 +1,5 @@ +salt.states.elasticsearch_index_template +======================================== + +.. automodule:: salt.states.elasticsearch_index_template + :members: diff --git a/doc/ref/states/all/salt.states.eselect.rst b/doc/ref/states/all/salt.states.eselect.rst new file mode 100644 index 000000000000..af2fa40cb0cd --- /dev/null +++ b/doc/ref/states/all/salt.states.eselect.rst @@ -0,0 +1,5 @@ +salt.states.eselect +=================== + +.. automodule:: salt.states.eselect + :members: diff --git a/doc/ref/states/all/salt.states.esxcluster.rst b/doc/ref/states/all/salt.states.esxcluster.rst new file mode 100644 index 000000000000..ec49878cf892 --- /dev/null +++ b/doc/ref/states/all/salt.states.esxcluster.rst @@ -0,0 +1,5 @@ +salt.states.esxcluster +====================== + +.. automodule:: salt.states.esxcluster + :members: diff --git a/doc/ref/states/all/salt.states.esxdatacenter.rst b/doc/ref/states/all/salt.states.esxdatacenter.rst new file mode 100644 index 000000000000..f525393cca49 --- /dev/null +++ b/doc/ref/states/all/salt.states.esxdatacenter.rst @@ -0,0 +1,5 @@ +salt.states.esxdatacenter +========================= + +.. automodule:: salt.states.esxdatacenter + :members: diff --git a/doc/ref/states/all/salt.states.esxi.rst b/doc/ref/states/all/salt.states.esxi.rst new file mode 100644 index 000000000000..aa87cb6cf7fe --- /dev/null +++ b/doc/ref/states/all/salt.states.esxi.rst @@ -0,0 +1,5 @@ +salt.states.esxi +================ + +.. automodule:: salt.states.esxi + :members: diff --git a/doc/ref/states/all/salt.states.esxvm.rst b/doc/ref/states/all/salt.states.esxvm.rst new file mode 100644 index 000000000000..3e317fa68062 --- /dev/null +++ b/doc/ref/states/all/salt.states.esxvm.rst @@ -0,0 +1,5 @@ +salt.states.esxvm +================= + +.. automodule:: salt.states.esxvm + :members: diff --git a/doc/ref/states/all/salt.states.ethtool.rst b/doc/ref/states/all/salt.states.ethtool.rst new file mode 100644 index 000000000000..c56fdf9945de --- /dev/null +++ b/doc/ref/states/all/salt.states.ethtool.rst @@ -0,0 +1,6 @@ +salt.states.ethtool +=================== + +.. automodule:: salt.states.ethtool + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.gem.rst b/doc/ref/states/all/salt.states.gem.rst new file mode 100644 index 000000000000..f81acabab1b0 --- /dev/null +++ b/doc/ref/states/all/salt.states.gem.rst @@ -0,0 +1,5 @@ +salt.states.gem +=============== + +.. automodule:: salt.states.gem + :members: diff --git a/doc/ref/states/all/salt.states.github.rst b/doc/ref/states/all/salt.states.github.rst new file mode 100644 index 000000000000..76ce26f6bd2f --- /dev/null +++ b/doc/ref/states/all/salt.states.github.rst @@ -0,0 +1,5 @@ +salt.states.github +================== + +.. automodule:: salt.states.github + :members: diff --git a/doc/ref/states/all/salt.states.glance_image.rst b/doc/ref/states/all/salt.states.glance_image.rst new file mode 100644 index 000000000000..f9fc193b296c --- /dev/null +++ b/doc/ref/states/all/salt.states.glance_image.rst @@ -0,0 +1,5 @@ +salt.states.glance_image +======================== + +.. automodule:: salt.states.glance_image + :members: diff --git a/doc/ref/states/all/salt.states.glassfish.rst b/doc/ref/states/all/salt.states.glassfish.rst new file mode 100644 index 000000000000..cad973bbb530 --- /dev/null +++ b/doc/ref/states/all/salt.states.glassfish.rst @@ -0,0 +1,5 @@ +salt.states.glassfish +===================== + +.. automodule:: salt.states.glassfish + :members: diff --git a/doc/ref/states/all/salt.states.glusterfs.rst b/doc/ref/states/all/salt.states.glusterfs.rst new file mode 100644 index 000000000000..ec2e3d62d6b9 --- /dev/null +++ b/doc/ref/states/all/salt.states.glusterfs.rst @@ -0,0 +1,5 @@ +salt.states.glusterfs +===================== + +.. automodule:: salt.states.glusterfs + :members: diff --git a/doc/ref/states/all/salt.states.gnomedesktop.rst b/doc/ref/states/all/salt.states.gnomedesktop.rst new file mode 100644 index 000000000000..3da7581bc97c --- /dev/null +++ b/doc/ref/states/all/salt.states.gnomedesktop.rst @@ -0,0 +1,5 @@ +salt.states.gnomedesktop +======================== + +.. automodule:: salt.states.gnomedesktop + :members: diff --git a/doc/ref/states/all/salt.states.grafana.rst b/doc/ref/states/all/salt.states.grafana.rst new file mode 100644 index 000000000000..c2f747655bd9 --- /dev/null +++ b/doc/ref/states/all/salt.states.grafana.rst @@ -0,0 +1,5 @@ +salt.states.grafana +=================== + +.. automodule:: salt.states.grafana + :members: diff --git a/doc/ref/states/all/salt.states.grafana4_dashboard.rst b/doc/ref/states/all/salt.states.grafana4_dashboard.rst new file mode 100644 index 000000000000..e306564cf0a1 --- /dev/null +++ b/doc/ref/states/all/salt.states.grafana4_dashboard.rst @@ -0,0 +1,6 @@ +salt.states.grafana4_dashboard +============================== + +.. automodule:: salt.states.grafana4_dashboard + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.grafana4_datasource.rst b/doc/ref/states/all/salt.states.grafana4_datasource.rst new file mode 100644 index 000000000000..9fd77a3eead4 --- /dev/null +++ b/doc/ref/states/all/salt.states.grafana4_datasource.rst @@ -0,0 +1,6 @@ +salt.states.grafana4_datasource +=============================== + +.. automodule:: salt.states.grafana4_datasource + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.grafana4_org.rst b/doc/ref/states/all/salt.states.grafana4_org.rst new file mode 100644 index 000000000000..0b407af21219 --- /dev/null +++ b/doc/ref/states/all/salt.states.grafana4_org.rst @@ -0,0 +1,6 @@ +salt.states.grafana4_org +======================== + +.. automodule:: salt.states.grafana4_org + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.grafana4_user.rst b/doc/ref/states/all/salt.states.grafana4_user.rst new file mode 100644 index 000000000000..6586205c6cdd --- /dev/null +++ b/doc/ref/states/all/salt.states.grafana4_user.rst @@ -0,0 +1,6 @@ +salt.states.grafana4_user +========================= + +.. automodule:: salt.states.grafana4_user + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.grafana_dashboard.rst b/doc/ref/states/all/salt.states.grafana_dashboard.rst new file mode 100644 index 000000000000..c5a16ba8a9aa --- /dev/null +++ b/doc/ref/states/all/salt.states.grafana_dashboard.rst @@ -0,0 +1,5 @@ +salt.states.grafana_dashboard +============================= + +.. automodule:: salt.states.grafana_dashboard + :members: diff --git a/doc/ref/states/all/salt.states.grafana_datasource.rst b/doc/ref/states/all/salt.states.grafana_datasource.rst new file mode 100644 index 000000000000..726a2e3b0702 --- /dev/null +++ b/doc/ref/states/all/salt.states.grafana_datasource.rst @@ -0,0 +1,5 @@ +salt.states.grafana_datasource +============================== + +.. automodule:: salt.states.grafana_datasource + :members: diff --git a/doc/ref/states/all/salt.states.heat.rst b/doc/ref/states/all/salt.states.heat.rst new file mode 100644 index 000000000000..805d6d41547f --- /dev/null +++ b/doc/ref/states/all/salt.states.heat.rst @@ -0,0 +1,6 @@ +salt.states.heat +================ + +.. automodule:: salt.states.heat + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.helm.rst b/doc/ref/states/all/salt.states.helm.rst new file mode 100644 index 000000000000..75d360e6044d --- /dev/null +++ b/doc/ref/states/all/salt.states.helm.rst @@ -0,0 +1,6 @@ +salt.states.helm +================ + +.. automodule:: salt.states.helm + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.hg.rst b/doc/ref/states/all/salt.states.hg.rst new file mode 100644 index 000000000000..1742f6903b2b --- /dev/null +++ b/doc/ref/states/all/salt.states.hg.rst @@ -0,0 +1,5 @@ +salt.states.hg +============== + +.. automodule:: salt.states.hg + :members: diff --git a/doc/ref/states/all/salt.states.icinga2.rst b/doc/ref/states/all/salt.states.icinga2.rst new file mode 100644 index 000000000000..e2ef6fa942cb --- /dev/null +++ b/doc/ref/states/all/salt.states.icinga2.rst @@ -0,0 +1,6 @@ +salt.states.icinga2 +=================== + +.. automodule:: salt.states.icinga2 + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.ifttt.rst b/doc/ref/states/all/salt.states.ifttt.rst new file mode 100644 index 000000000000..f74bc9c40e8f --- /dev/null +++ b/doc/ref/states/all/salt.states.ifttt.rst @@ -0,0 +1,5 @@ +salt.states.ifttt +================= + +.. automodule:: salt.states.ifttt + :members: diff --git a/doc/ref/states/all/salt.states.incron.rst b/doc/ref/states/all/salt.states.incron.rst new file mode 100644 index 000000000000..e9b23602b203 --- /dev/null +++ b/doc/ref/states/all/salt.states.incron.rst @@ -0,0 +1,5 @@ +salt.states.incron +================== + +.. automodule:: salt.states.incron + :members: diff --git a/doc/ref/states/all/salt.states.influxdb08_database.rst b/doc/ref/states/all/salt.states.influxdb08_database.rst new file mode 100644 index 000000000000..325bfbf258a7 --- /dev/null +++ b/doc/ref/states/all/salt.states.influxdb08_database.rst @@ -0,0 +1,6 @@ +salt.states.influxdb08_database +=============================== + +.. automodule:: salt.states.influxdb08_database + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.influxdb08_user.rst b/doc/ref/states/all/salt.states.influxdb08_user.rst new file mode 100644 index 000000000000..6e70f3f2b09e --- /dev/null +++ b/doc/ref/states/all/salt.states.influxdb08_user.rst @@ -0,0 +1,6 @@ +salt.states.influxdb08_user +=========================== + +.. automodule:: salt.states.influxdb08_user + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.influxdb_continuous_query.rst b/doc/ref/states/all/salt.states.influxdb_continuous_query.rst new file mode 100644 index 000000000000..0f2e72183696 --- /dev/null +++ b/doc/ref/states/all/salt.states.influxdb_continuous_query.rst @@ -0,0 +1,6 @@ +salt.states.influxdb_continuous_query +===================================== + +.. automodule:: salt.states.influxdb_continuous_query + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.influxdb_database.rst b/doc/ref/states/all/salt.states.influxdb_database.rst new file mode 100644 index 000000000000..07252d3f494d --- /dev/null +++ b/doc/ref/states/all/salt.states.influxdb_database.rst @@ -0,0 +1,5 @@ +salt.states.influxdb_database +============================= + +.. automodule:: salt.states.influxdb_database + :members: diff --git a/doc/ref/states/all/salt.states.influxdb_retention_policy.rst b/doc/ref/states/all/salt.states.influxdb_retention_policy.rst new file mode 100644 index 000000000000..6707a35ca26b --- /dev/null +++ b/doc/ref/states/all/salt.states.influxdb_retention_policy.rst @@ -0,0 +1,6 @@ +salt.states.influxdb_retention_policy +===================================== + +.. automodule:: salt.states.influxdb_retention_policy + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.influxdb_user.rst b/doc/ref/states/all/salt.states.influxdb_user.rst new file mode 100644 index 000000000000..20c2421120ac --- /dev/null +++ b/doc/ref/states/all/salt.states.influxdb_user.rst @@ -0,0 +1,5 @@ +salt.states.influxdb_user +========================= + +.. automodule:: salt.states.influxdb_user + :members: diff --git a/doc/ref/states/all/salt.states.infoblox_a.rst b/doc/ref/states/all/salt.states.infoblox_a.rst new file mode 100644 index 000000000000..c16bc26bd29a --- /dev/null +++ b/doc/ref/states/all/salt.states.infoblox_a.rst @@ -0,0 +1,6 @@ +salt.states.infoblox_a +====================== + +.. automodule:: salt.states.infoblox_a + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.infoblox_cname.rst b/doc/ref/states/all/salt.states.infoblox_cname.rst new file mode 100644 index 000000000000..f6757f4cd156 --- /dev/null +++ b/doc/ref/states/all/salt.states.infoblox_cname.rst @@ -0,0 +1,6 @@ +salt.states.infoblox_cname +========================== + +.. automodule:: salt.states.infoblox_cname + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.infoblox_host_record.rst b/doc/ref/states/all/salt.states.infoblox_host_record.rst new file mode 100644 index 000000000000..92e00d19af8e --- /dev/null +++ b/doc/ref/states/all/salt.states.infoblox_host_record.rst @@ -0,0 +1,6 @@ +salt.states.infoblox_host_record +================================ + +.. automodule:: salt.states.infoblox_host_record + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.infoblox_range.rst b/doc/ref/states/all/salt.states.infoblox_range.rst new file mode 100644 index 000000000000..02392579d828 --- /dev/null +++ b/doc/ref/states/all/salt.states.infoblox_range.rst @@ -0,0 +1,6 @@ +salt.states.infoblox_range +========================== + +.. automodule:: salt.states.infoblox_range + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.ipmi.rst b/doc/ref/states/all/salt.states.ipmi.rst new file mode 100644 index 000000000000..a5470189eed6 --- /dev/null +++ b/doc/ref/states/all/salt.states.ipmi.rst @@ -0,0 +1,5 @@ +salt.states.ipmi +================ + +.. automodule:: salt.states.ipmi + :members: diff --git a/doc/ref/states/all/salt.states.jboss7.rst b/doc/ref/states/all/salt.states.jboss7.rst new file mode 100644 index 000000000000..bdaa19ac9297 --- /dev/null +++ b/doc/ref/states/all/salt.states.jboss7.rst @@ -0,0 +1,5 @@ +salt.states.jboss7 +================== + +.. automodule:: salt.states.jboss7 + :members: diff --git a/doc/ref/states/all/salt.states.jenkins.rst b/doc/ref/states/all/salt.states.jenkins.rst new file mode 100644 index 000000000000..af56d96e7fed --- /dev/null +++ b/doc/ref/states/all/salt.states.jenkins.rst @@ -0,0 +1,5 @@ +salt.states.jenkins +=================== + +.. automodule:: salt.states.jenkins + :members: diff --git a/doc/ref/states/all/salt.states.junos.rst b/doc/ref/states/all/salt.states.junos.rst new file mode 100644 index 000000000000..515f18a7f12d --- /dev/null +++ b/doc/ref/states/all/salt.states.junos.rst @@ -0,0 +1,5 @@ +salt.states.junos +================= + +.. automodule:: salt.states.junos + :members: diff --git a/doc/ref/states/all/salt.states.kapacitor.rst b/doc/ref/states/all/salt.states.kapacitor.rst new file mode 100644 index 000000000000..2e9f8e062327 --- /dev/null +++ b/doc/ref/states/all/salt.states.kapacitor.rst @@ -0,0 +1,5 @@ +salt.states.kapacitor +===================== + +.. automodule:: salt.states.kapacitor + :members: diff --git a/doc/ref/states/all/salt.states.kernelpkg.rst b/doc/ref/states/all/salt.states.kernelpkg.rst new file mode 100644 index 000000000000..198c70a18b69 --- /dev/null +++ b/doc/ref/states/all/salt.states.kernelpkg.rst @@ -0,0 +1,5 @@ +salt.states.kernelpkg +===================== + +.. automodule:: salt.states.kernelpkg + :members: diff --git a/doc/ref/states/all/salt.states.keystone.rst b/doc/ref/states/all/salt.states.keystone.rst new file mode 100644 index 000000000000..6c74cf727242 --- /dev/null +++ b/doc/ref/states/all/salt.states.keystone.rst @@ -0,0 +1,5 @@ +salt.states.keystone +==================== + +.. automodule:: salt.states.keystone + :members: diff --git a/doc/ref/states/all/salt.states.keystone_domain.rst b/doc/ref/states/all/salt.states.keystone_domain.rst new file mode 100644 index 000000000000..affae4fa7631 --- /dev/null +++ b/doc/ref/states/all/salt.states.keystone_domain.rst @@ -0,0 +1,5 @@ +salt.states.keystone_domain +=========================== + +.. automodule:: salt.states.keystone_domain + :members: diff --git a/doc/ref/states/all/salt.states.keystone_endpoint.rst b/doc/ref/states/all/salt.states.keystone_endpoint.rst new file mode 100644 index 000000000000..11a1a7cf97c7 --- /dev/null +++ b/doc/ref/states/all/salt.states.keystone_endpoint.rst @@ -0,0 +1,5 @@ +salt.states.keystone_endpoint +============================= + +.. automodule:: salt.states.keystone_endpoint + :members: diff --git a/doc/ref/states/all/salt.states.keystone_group.rst b/doc/ref/states/all/salt.states.keystone_group.rst new file mode 100644 index 000000000000..802d5cbb7191 --- /dev/null +++ b/doc/ref/states/all/salt.states.keystone_group.rst @@ -0,0 +1,5 @@ +salt.states.keystone_group +========================== + +.. automodule:: salt.states.keystone_group + :members: diff --git a/doc/ref/states/all/salt.states.keystone_project.rst b/doc/ref/states/all/salt.states.keystone_project.rst new file mode 100644 index 000000000000..467fa2efba16 --- /dev/null +++ b/doc/ref/states/all/salt.states.keystone_project.rst @@ -0,0 +1,5 @@ +salt.states.keystone_project +============================ + +.. automodule:: salt.states.keystone_project + :members: diff --git a/doc/ref/states/all/salt.states.keystone_role.rst b/doc/ref/states/all/salt.states.keystone_role.rst new file mode 100644 index 000000000000..12a82bfdbf62 --- /dev/null +++ b/doc/ref/states/all/salt.states.keystone_role.rst @@ -0,0 +1,5 @@ +salt.states.keystone_role +========================= + +.. automodule:: salt.states.keystone_role + :members: diff --git a/doc/ref/states/all/salt.states.keystone_role_grant.rst b/doc/ref/states/all/salt.states.keystone_role_grant.rst new file mode 100644 index 000000000000..0528cce9bb1e --- /dev/null +++ b/doc/ref/states/all/salt.states.keystone_role_grant.rst @@ -0,0 +1,5 @@ +salt.states.keystone_role_grant +=============================== + +.. automodule:: salt.states.keystone_role_grant + :members: diff --git a/doc/ref/states/all/salt.states.keystone_service.rst b/doc/ref/states/all/salt.states.keystone_service.rst new file mode 100644 index 000000000000..11a361fc19e7 --- /dev/null +++ b/doc/ref/states/all/salt.states.keystone_service.rst @@ -0,0 +1,5 @@ +salt.states.keystone_service +============================ + +.. automodule:: salt.states.keystone_service + :members: diff --git a/doc/ref/states/all/salt.states.keystone_user.rst b/doc/ref/states/all/salt.states.keystone_user.rst new file mode 100644 index 000000000000..67d93cb65c0e --- /dev/null +++ b/doc/ref/states/all/salt.states.keystone_user.rst @@ -0,0 +1,5 @@ +salt.states.keystone_user +========================= + +.. automodule:: salt.states.keystone_user + :members: diff --git a/doc/ref/states/all/salt.states.keystore.rst b/doc/ref/states/all/salt.states.keystore.rst new file mode 100644 index 000000000000..eed003c4593f --- /dev/null +++ b/doc/ref/states/all/salt.states.keystore.rst @@ -0,0 +1,5 @@ +salt.states.keystore +==================== + +.. automodule:: salt.states.keystore + :members: diff --git a/doc/ref/states/all/salt.states.kubernetes.rst b/doc/ref/states/all/salt.states.kubernetes.rst new file mode 100644 index 000000000000..4b7f639a7c2e --- /dev/null +++ b/doc/ref/states/all/salt.states.kubernetes.rst @@ -0,0 +1,5 @@ +salt.states.kubernetes +====================== + +.. automodule:: salt.states.kubernetes + :members: diff --git a/doc/ref/states/all/salt.states.layman.rst b/doc/ref/states/all/salt.states.layman.rst new file mode 100644 index 000000000000..b9296db2abe2 --- /dev/null +++ b/doc/ref/states/all/salt.states.layman.rst @@ -0,0 +1,5 @@ +salt.states.layman +================== + +.. automodule:: salt.states.layman + :members: diff --git a/doc/ref/states/all/salt.states.ldap.rst b/doc/ref/states/all/salt.states.ldap.rst new file mode 100644 index 000000000000..efd64d734202 --- /dev/null +++ b/doc/ref/states/all/salt.states.ldap.rst @@ -0,0 +1,5 @@ +salt.states.ldap +================ + +.. automodule:: salt.states.ldap + :members: diff --git a/doc/ref/states/all/salt.states.libcloud_dns.rst b/doc/ref/states/all/salt.states.libcloud_dns.rst new file mode 100644 index 000000000000..ee42091781fa --- /dev/null +++ b/doc/ref/states/all/salt.states.libcloud_dns.rst @@ -0,0 +1,6 @@ +salt.states.libcloud_dns +======================== + +.. automodule:: salt.states.libcloud_dns + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.libcloud_loadbalancer.rst b/doc/ref/states/all/salt.states.libcloud_loadbalancer.rst new file mode 100644 index 000000000000..b537442f442e --- /dev/null +++ b/doc/ref/states/all/salt.states.libcloud_loadbalancer.rst @@ -0,0 +1,6 @@ +salt.states.libcloud_loadbalancer +================================= + +.. automodule:: salt.states.libcloud_loadbalancer + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.libcloud_storage.rst b/doc/ref/states/all/salt.states.libcloud_storage.rst new file mode 100644 index 000000000000..2f5e8734fdad --- /dev/null +++ b/doc/ref/states/all/salt.states.libcloud_storage.rst @@ -0,0 +1,6 @@ +salt.states.libcloud_storage +============================ + +.. automodule:: salt.states.libcloud_storage + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.logadm.rst b/doc/ref/states/all/salt.states.logadm.rst new file mode 100644 index 000000000000..e9619a050dc7 --- /dev/null +++ b/doc/ref/states/all/salt.states.logadm.rst @@ -0,0 +1,5 @@ +salt.states.logadm +================== + +.. automodule:: salt.states.logadm + :members: diff --git a/doc/ref/states/all/salt.states.lvs_server.rst b/doc/ref/states/all/salt.states.lvs_server.rst new file mode 100644 index 000000000000..98dfe394ddb8 --- /dev/null +++ b/doc/ref/states/all/salt.states.lvs_server.rst @@ -0,0 +1,5 @@ +salt.states.lvs_server +====================== + +.. automodule:: salt.states.lvs_server + :members: diff --git a/doc/ref/states/all/salt.states.lvs_service.rst b/doc/ref/states/all/salt.states.lvs_service.rst new file mode 100644 index 000000000000..4d57461b5ef6 --- /dev/null +++ b/doc/ref/states/all/salt.states.lvs_service.rst @@ -0,0 +1,5 @@ +salt.states.lvs_service +======================= + +.. automodule:: salt.states.lvs_service + :members: diff --git a/doc/ref/states/all/salt.states.lxc.rst b/doc/ref/states/all/salt.states.lxc.rst new file mode 100644 index 000000000000..123f77140fb3 --- /dev/null +++ b/doc/ref/states/all/salt.states.lxc.rst @@ -0,0 +1,5 @@ +salt.states.lxc +=============== + +.. automodule:: salt.states.lxc + :members: diff --git a/doc/ref/states/all/salt.states.lxd.rst b/doc/ref/states/all/salt.states.lxd.rst new file mode 100644 index 000000000000..be3aaa462468 --- /dev/null +++ b/doc/ref/states/all/salt.states.lxd.rst @@ -0,0 +1,5 @@ +salt.states.lxd +=============== + +.. automodule:: salt.states.lxd + :members: diff --git a/doc/ref/states/all/salt.states.lxd_container.rst b/doc/ref/states/all/salt.states.lxd_container.rst new file mode 100644 index 000000000000..607e6c111bbe --- /dev/null +++ b/doc/ref/states/all/salt.states.lxd_container.rst @@ -0,0 +1,5 @@ +salt.states.lxd_container +========================= + +.. automodule:: salt.states.lxd_container + :members: diff --git a/doc/ref/states/all/salt.states.lxd_image.rst b/doc/ref/states/all/salt.states.lxd_image.rst new file mode 100644 index 000000000000..ead8d951ab8b --- /dev/null +++ b/doc/ref/states/all/salt.states.lxd_image.rst @@ -0,0 +1,5 @@ +salt.states.lxd_image +===================== + +.. automodule:: salt.states.lxd_image + :members: diff --git a/doc/ref/states/all/salt.states.lxd_profile.rst b/doc/ref/states/all/salt.states.lxd_profile.rst new file mode 100644 index 000000000000..9d7613ab34c6 --- /dev/null +++ b/doc/ref/states/all/salt.states.lxd_profile.rst @@ -0,0 +1,5 @@ +salt.states.lxd_profile +======================= + +.. automodule:: salt.states.lxd_profile + :members: diff --git a/doc/ref/states/all/salt.states.marathon_app.rst b/doc/ref/states/all/salt.states.marathon_app.rst new file mode 100644 index 000000000000..c333fe8ff5a0 --- /dev/null +++ b/doc/ref/states/all/salt.states.marathon_app.rst @@ -0,0 +1,5 @@ +salt.states.marathon_app +======================== + +.. automodule:: salt.states.marathon_app + :members: diff --git a/doc/ref/states/all/salt.states.memcached.rst b/doc/ref/states/all/salt.states.memcached.rst new file mode 100644 index 000000000000..ad9c7655d636 --- /dev/null +++ b/doc/ref/states/all/salt.states.memcached.rst @@ -0,0 +1,5 @@ +salt.states.memcached +===================== + +.. automodule:: salt.states.memcached + :members: diff --git a/doc/ref/states/all/salt.states.modjk.rst b/doc/ref/states/all/salt.states.modjk.rst new file mode 100644 index 000000000000..543efe5f53de --- /dev/null +++ b/doc/ref/states/all/salt.states.modjk.rst @@ -0,0 +1,5 @@ +salt.states.modjk +================= + +.. automodule:: salt.states.modjk + :members: diff --git a/doc/ref/states/all/salt.states.modjk_worker.rst b/doc/ref/states/all/salt.states.modjk_worker.rst new file mode 100644 index 000000000000..0f2c1e344c92 --- /dev/null +++ b/doc/ref/states/all/salt.states.modjk_worker.rst @@ -0,0 +1,5 @@ +salt.states.modjk_worker +======================== + +.. automodule:: salt.states.modjk_worker + :members: diff --git a/doc/ref/states/all/salt.states.mongodb_database.rst b/doc/ref/states/all/salt.states.mongodb_database.rst new file mode 100644 index 000000000000..9beaac4ae619 --- /dev/null +++ b/doc/ref/states/all/salt.states.mongodb_database.rst @@ -0,0 +1,5 @@ +salt.states.mongodb_database +============================ + +.. automodule:: salt.states.mongodb_database + :members: diff --git a/doc/ref/states/all/salt.states.mongodb_user.rst b/doc/ref/states/all/salt.states.mongodb_user.rst new file mode 100644 index 000000000000..4bea009d48ce --- /dev/null +++ b/doc/ref/states/all/salt.states.mongodb_user.rst @@ -0,0 +1,5 @@ +salt.states.mongodb_user +======================== + +.. automodule:: salt.states.mongodb_user + :members: diff --git a/doc/ref/states/all/salt.states.monit.rst b/doc/ref/states/all/salt.states.monit.rst new file mode 100644 index 000000000000..516d9cb2cc19 --- /dev/null +++ b/doc/ref/states/all/salt.states.monit.rst @@ -0,0 +1,5 @@ +salt.states.monit +================= + +.. automodule:: salt.states.monit + :members: diff --git a/doc/ref/states/all/salt.states.mssql_database.rst b/doc/ref/states/all/salt.states.mssql_database.rst new file mode 100644 index 000000000000..7ccf83f7cd73 --- /dev/null +++ b/doc/ref/states/all/salt.states.mssql_database.rst @@ -0,0 +1,5 @@ +salt.states.mssql_database +========================== + +.. automodule:: salt.states.mssql_database + :members: diff --git a/doc/ref/states/all/salt.states.mssql_login.rst b/doc/ref/states/all/salt.states.mssql_login.rst new file mode 100644 index 000000000000..6f9d9e80f98a --- /dev/null +++ b/doc/ref/states/all/salt.states.mssql_login.rst @@ -0,0 +1,5 @@ +salt.states.mssql_login +======================= + +.. automodule:: salt.states.mssql_login + :members: diff --git a/doc/ref/states/all/salt.states.mssql_role.rst b/doc/ref/states/all/salt.states.mssql_role.rst new file mode 100644 index 000000000000..dc07417574e0 --- /dev/null +++ b/doc/ref/states/all/salt.states.mssql_role.rst @@ -0,0 +1,5 @@ +salt.states.mssql_role +====================== + +.. automodule:: salt.states.mssql_role + :members: diff --git a/doc/ref/states/all/salt.states.mssql_user.rst b/doc/ref/states/all/salt.states.mssql_user.rst new file mode 100644 index 000000000000..a8bab12e7f96 --- /dev/null +++ b/doc/ref/states/all/salt.states.mssql_user.rst @@ -0,0 +1,5 @@ +salt.states.mssql_user +====================== + +.. automodule:: salt.states.mssql_user + :members: diff --git a/doc/ref/states/all/salt.states.msteams.rst b/doc/ref/states/all/salt.states.msteams.rst new file mode 100644 index 000000000000..ad3c7b4bf977 --- /dev/null +++ b/doc/ref/states/all/salt.states.msteams.rst @@ -0,0 +1,6 @@ +salt.states.msteams +=================== + +.. automodule:: salt.states.msteams + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.mysql_database.rst b/doc/ref/states/all/salt.states.mysql_database.rst new file mode 100644 index 000000000000..8a9e2d664267 --- /dev/null +++ b/doc/ref/states/all/salt.states.mysql_database.rst @@ -0,0 +1,5 @@ +salt.states.mysql_database +========================== + +.. automodule:: salt.states.mysql_database + :members: diff --git a/doc/ref/states/all/salt.states.mysql_grants.rst b/doc/ref/states/all/salt.states.mysql_grants.rst new file mode 100644 index 000000000000..a90c86c144ab --- /dev/null +++ b/doc/ref/states/all/salt.states.mysql_grants.rst @@ -0,0 +1,5 @@ +salt.states.mysql_grants +======================== + +.. automodule:: salt.states.mysql_grants + :members: diff --git a/doc/ref/states/all/salt.states.mysql_query.rst b/doc/ref/states/all/salt.states.mysql_query.rst new file mode 100644 index 000000000000..c47a913361a3 --- /dev/null +++ b/doc/ref/states/all/salt.states.mysql_query.rst @@ -0,0 +1,5 @@ +salt.states.mysql_query +======================= + +.. automodule:: salt.states.mysql_query + :members: diff --git a/doc/ref/states/all/salt.states.mysql_user.rst b/doc/ref/states/all/salt.states.mysql_user.rst new file mode 100644 index 000000000000..226884ce9fe8 --- /dev/null +++ b/doc/ref/states/all/salt.states.mysql_user.rst @@ -0,0 +1,5 @@ +salt.states.mysql_user +====================== + +.. automodule:: salt.states.mysql_user + :members: diff --git a/doc/ref/states/all/salt.states.net_napalm_yang.rst b/doc/ref/states/all/salt.states.net_napalm_yang.rst new file mode 100644 index 000000000000..e37ae633e359 --- /dev/null +++ b/doc/ref/states/all/salt.states.net_napalm_yang.rst @@ -0,0 +1,5 @@ +salt.states.net_napalm_yang +=========================== + +.. automodule:: salt.states.net_napalm_yang + :members: diff --git a/doc/ref/states/all/salt.states.neutron_network.rst b/doc/ref/states/all/salt.states.neutron_network.rst new file mode 100644 index 000000000000..b2ffb407f0bb --- /dev/null +++ b/doc/ref/states/all/salt.states.neutron_network.rst @@ -0,0 +1,5 @@ +salt.states.neutron_network +=========================== + +.. automodule:: salt.states.neutron_network + :members: diff --git a/doc/ref/states/all/salt.states.neutron_secgroup.rst b/doc/ref/states/all/salt.states.neutron_secgroup.rst new file mode 100644 index 000000000000..10cf1434b61a --- /dev/null +++ b/doc/ref/states/all/salt.states.neutron_secgroup.rst @@ -0,0 +1,5 @@ +salt.states.neutron_secgroup +============================ + +.. automodule:: salt.states.neutron_secgroup + :members: diff --git a/doc/ref/states/all/salt.states.neutron_secgroup_rule.rst b/doc/ref/states/all/salt.states.neutron_secgroup_rule.rst new file mode 100644 index 000000000000..cc1846f12a50 --- /dev/null +++ b/doc/ref/states/all/salt.states.neutron_secgroup_rule.rst @@ -0,0 +1,5 @@ +salt.states.neutron_secgroup_rule +================================= + +.. automodule:: salt.states.neutron_secgroup_rule + :members: diff --git a/doc/ref/states/all/salt.states.neutron_subnet.rst b/doc/ref/states/all/salt.states.neutron_subnet.rst new file mode 100644 index 000000000000..1a9ebce0b6f7 --- /dev/null +++ b/doc/ref/states/all/salt.states.neutron_subnet.rst @@ -0,0 +1,5 @@ +salt.states.neutron_subnet +========================== + +.. automodule:: salt.states.neutron_subnet + :members: diff --git a/doc/ref/states/all/salt.states.nexus.rst b/doc/ref/states/all/salt.states.nexus.rst new file mode 100644 index 000000000000..745aa0f46c27 --- /dev/null +++ b/doc/ref/states/all/salt.states.nexus.rst @@ -0,0 +1,5 @@ +salt.states.nexus +================= + +.. automodule:: salt.states.nexus + :members: diff --git a/doc/ref/states/all/salt.states.nfs_export.rst b/doc/ref/states/all/salt.states.nfs_export.rst new file mode 100644 index 000000000000..edc270b0351d --- /dev/null +++ b/doc/ref/states/all/salt.states.nfs_export.rst @@ -0,0 +1,5 @@ +salt.states.nfs_export +====================== + +.. automodule:: salt.states.nfs_export + :members: diff --git a/doc/ref/states/all/salt.states.npm.rst b/doc/ref/states/all/salt.states.npm.rst new file mode 100644 index 000000000000..3ba983ed24bb --- /dev/null +++ b/doc/ref/states/all/salt.states.npm.rst @@ -0,0 +1,5 @@ +salt.states.npm +=============== + +.. automodule:: salt.states.npm + :members: diff --git a/doc/ref/states/all/salt.states.nxos.rst b/doc/ref/states/all/salt.states.nxos.rst new file mode 100644 index 000000000000..ef1cf7c9ccea --- /dev/null +++ b/doc/ref/states/all/salt.states.nxos.rst @@ -0,0 +1,5 @@ +salt.states.nxos +================ + +.. automodule:: salt.states.nxos + :members: diff --git a/doc/ref/states/all/salt.states.nxos_upgrade.rst b/doc/ref/states/all/salt.states.nxos_upgrade.rst new file mode 100644 index 000000000000..d6d134a44d86 --- /dev/null +++ b/doc/ref/states/all/salt.states.nxos_upgrade.rst @@ -0,0 +1,5 @@ +salt.states.nxos_upgrade +======================== + +.. automodule:: salt.states.nxos_upgrade + :members: diff --git a/doc/ref/states/all/salt.states.openstack_config.rst b/doc/ref/states/all/salt.states.openstack_config.rst new file mode 100644 index 000000000000..f1621bb8dcdc --- /dev/null +++ b/doc/ref/states/all/salt.states.openstack_config.rst @@ -0,0 +1,5 @@ +salt.states.openstack_config +============================ + +.. automodule:: salt.states.openstack_config + :members: diff --git a/doc/ref/states/all/salt.states.openvswitch_bridge.rst b/doc/ref/states/all/salt.states.openvswitch_bridge.rst new file mode 100644 index 000000000000..83f80ad743a3 --- /dev/null +++ b/doc/ref/states/all/salt.states.openvswitch_bridge.rst @@ -0,0 +1,5 @@ +salt.states.openvswitch_bridge +============================== + +.. automodule:: salt.states.openvswitch_bridge + :members: diff --git a/doc/ref/states/all/salt.states.openvswitch_db.rst b/doc/ref/states/all/salt.states.openvswitch_db.rst new file mode 100644 index 000000000000..f6ca8b2bdce0 --- /dev/null +++ b/doc/ref/states/all/salt.states.openvswitch_db.rst @@ -0,0 +1,5 @@ +salt.states.openvswitch_db module +================================= + +.. automodule:: salt.states.openvswitch_db + :members: diff --git a/doc/ref/states/all/salt.states.openvswitch_port.rst b/doc/ref/states/all/salt.states.openvswitch_port.rst new file mode 100644 index 000000000000..2918c33e10b0 --- /dev/null +++ b/doc/ref/states/all/salt.states.openvswitch_port.rst @@ -0,0 +1,5 @@ +salt.states.openvswitch_port +============================ + +.. automodule:: salt.states.openvswitch_port + :members: diff --git a/doc/ref/states/all/salt.states.opsgenie.rst b/doc/ref/states/all/salt.states.opsgenie.rst new file mode 100644 index 000000000000..838f26393731 --- /dev/null +++ b/doc/ref/states/all/salt.states.opsgenie.rst @@ -0,0 +1,5 @@ +salt.states.opsgenie +==================== + +.. automodule:: salt.states.opsgenie + :members: diff --git a/doc/ref/states/all/salt.states.pagerduty.rst b/doc/ref/states/all/salt.states.pagerduty.rst new file mode 100644 index 000000000000..333273fbb790 --- /dev/null +++ b/doc/ref/states/all/salt.states.pagerduty.rst @@ -0,0 +1,5 @@ +salt.states.pagerduty +===================== + +.. automodule:: salt.states.pagerduty + :members: diff --git a/doc/ref/states/all/salt.states.pagerduty_escalation_policy.rst b/doc/ref/states/all/salt.states.pagerduty_escalation_policy.rst new file mode 100644 index 000000000000..3cdea9f3652a --- /dev/null +++ b/doc/ref/states/all/salt.states.pagerduty_escalation_policy.rst @@ -0,0 +1,5 @@ +salt.states.pagerduty_escalation_policy +======================================= + +.. automodule:: salt.states.pagerduty_escalation_policy + :members: diff --git a/doc/ref/states/all/salt.states.pagerduty_schedule.rst b/doc/ref/states/all/salt.states.pagerduty_schedule.rst new file mode 100644 index 000000000000..f6d46952a786 --- /dev/null +++ b/doc/ref/states/all/salt.states.pagerduty_schedule.rst @@ -0,0 +1,5 @@ +salt.states.pagerduty_schedule +============================== + +.. automodule:: salt.states.pagerduty_schedule + :members: diff --git a/doc/ref/states/all/salt.states.pagerduty_service.rst b/doc/ref/states/all/salt.states.pagerduty_service.rst new file mode 100644 index 000000000000..52991ec2df9a --- /dev/null +++ b/doc/ref/states/all/salt.states.pagerduty_service.rst @@ -0,0 +1,5 @@ +salt.states.pagerduty_service +============================= + +.. automodule:: salt.states.pagerduty_service + :members: diff --git a/doc/ref/states/all/salt.states.pagerduty_user.rst b/doc/ref/states/all/salt.states.pagerduty_user.rst new file mode 100644 index 000000000000..72fd60dd94dd --- /dev/null +++ b/doc/ref/states/all/salt.states.pagerduty_user.rst @@ -0,0 +1,5 @@ +salt.states.pagerduty_user +========================== + +.. automodule:: salt.states.pagerduty_user + :members: diff --git a/doc/ref/states/all/salt.states.panos.rst b/doc/ref/states/all/salt.states.panos.rst new file mode 100644 index 000000000000..fc7597f6ce7a --- /dev/null +++ b/doc/ref/states/all/salt.states.panos.rst @@ -0,0 +1,5 @@ +salt.states.panos +================= + +.. automodule:: salt.states.panos + :members: diff --git a/doc/ref/states/all/salt.states.pbm.rst b/doc/ref/states/all/salt.states.pbm.rst new file mode 100644 index 000000000000..1d51b0abe580 --- /dev/null +++ b/doc/ref/states/all/salt.states.pbm.rst @@ -0,0 +1,5 @@ +salt.states.pbm +=============== + +.. automodule:: salt.states.pbm + :members: diff --git a/doc/ref/states/all/salt.states.pcs.rst b/doc/ref/states/all/salt.states.pcs.rst new file mode 100644 index 000000000000..f29a58018296 --- /dev/null +++ b/doc/ref/states/all/salt.states.pcs.rst @@ -0,0 +1,5 @@ +salt.states.pcs +=============== + +.. automodule:: salt.states.pcs + :members: diff --git a/doc/ref/states/all/salt.states.pdbedit.rst b/doc/ref/states/all/salt.states.pdbedit.rst new file mode 100644 index 000000000000..ca86c86d5cb5 --- /dev/null +++ b/doc/ref/states/all/salt.states.pdbedit.rst @@ -0,0 +1,5 @@ +salt.states.pdbedit +=================== + +.. automodule:: salt.states.pdbedit + :members: diff --git a/doc/ref/states/all/salt.states.pecl.rst b/doc/ref/states/all/salt.states.pecl.rst new file mode 100644 index 000000000000..2590718a51af --- /dev/null +++ b/doc/ref/states/all/salt.states.pecl.rst @@ -0,0 +1,5 @@ +salt.states.pecl +================ + +.. automodule:: salt.states.pecl + :members: diff --git a/doc/ref/states/all/salt.states.portage_config.rst b/doc/ref/states/all/salt.states.portage_config.rst new file mode 100644 index 000000000000..ad1908d28bd4 --- /dev/null +++ b/doc/ref/states/all/salt.states.portage_config.rst @@ -0,0 +1,5 @@ +salt.states.portage_config +========================== + +.. automodule:: salt.states.portage_config + :members: diff --git a/doc/ref/states/all/salt.states.ports.rst b/doc/ref/states/all/salt.states.ports.rst new file mode 100644 index 000000000000..9977c05246e6 --- /dev/null +++ b/doc/ref/states/all/salt.states.ports.rst @@ -0,0 +1,5 @@ +salt.states.ports +================= + +.. automodule:: salt.states.ports + :members: diff --git a/doc/ref/states/all/salt.states.powerpath.rst b/doc/ref/states/all/salt.states.powerpath.rst new file mode 100644 index 000000000000..c5cb17b3a8d9 --- /dev/null +++ b/doc/ref/states/all/salt.states.powerpath.rst @@ -0,0 +1,5 @@ +salt.states.powerpath +===================== + +.. automodule:: salt.states.powerpath + :members: diff --git a/doc/ref/states/all/salt.states.probes.rst b/doc/ref/states/all/salt.states.probes.rst new file mode 100644 index 000000000000..9d77954cae43 --- /dev/null +++ b/doc/ref/states/all/salt.states.probes.rst @@ -0,0 +1,5 @@ +salt.states.probes +================== + +.. automodule:: salt.states.probes + :members: diff --git a/doc/ref/states/all/salt.states.pushover.rst b/doc/ref/states/all/salt.states.pushover.rst new file mode 100644 index 000000000000..a129ce712ce4 --- /dev/null +++ b/doc/ref/states/all/salt.states.pushover.rst @@ -0,0 +1,5 @@ +salt.states.pushover +==================== + +.. automodule:: salt.states.pushover + :members: diff --git a/doc/ref/states/all/salt.states.pyrax_queues.rst b/doc/ref/states/all/salt.states.pyrax_queues.rst new file mode 100644 index 000000000000..8fe9e529cedd --- /dev/null +++ b/doc/ref/states/all/salt.states.pyrax_queues.rst @@ -0,0 +1,5 @@ +salt.states.pyrax_queues +======================== + +.. automodule:: salt.states.pyrax_queues + :members: diff --git a/doc/ref/states/all/salt.states.rbac_solaris.rst b/doc/ref/states/all/salt.states.rbac_solaris.rst new file mode 100644 index 000000000000..d874e4a835ea --- /dev/null +++ b/doc/ref/states/all/salt.states.rbac_solaris.rst @@ -0,0 +1,5 @@ +salt.states.rbac_solaris +======================== + +.. automodule:: salt.states.rbac_solaris + :members: diff --git a/doc/ref/states/all/salt.states.rbenv.rst b/doc/ref/states/all/salt.states.rbenv.rst new file mode 100644 index 000000000000..c04f5ef098f6 --- /dev/null +++ b/doc/ref/states/all/salt.states.rbenv.rst @@ -0,0 +1,5 @@ +salt.states.rbenv +================= + +.. automodule:: salt.states.rbenv + :members: diff --git a/doc/ref/states/all/salt.states.rdp.rst b/doc/ref/states/all/salt.states.rdp.rst new file mode 100644 index 000000000000..40c1d3d211af --- /dev/null +++ b/doc/ref/states/all/salt.states.rdp.rst @@ -0,0 +1,5 @@ +salt.states.rdp +=============== + +.. automodule:: salt.states.rdp + :members: diff --git a/doc/ref/states/all/salt.states.redismod.rst b/doc/ref/states/all/salt.states.redismod.rst new file mode 100644 index 000000000000..f3e6835dcf87 --- /dev/null +++ b/doc/ref/states/all/salt.states.redismod.rst @@ -0,0 +1,5 @@ +salt.states.redismod +==================== + +.. automodule:: salt.states.redismod + :members: diff --git a/doc/ref/states/all/salt.states.restconf.rst b/doc/ref/states/all/salt.states.restconf.rst new file mode 100644 index 000000000000..3aa4b5ee20ca --- /dev/null +++ b/doc/ref/states/all/salt.states.restconf.rst @@ -0,0 +1,6 @@ +==================== +salt.states.restconf +==================== + +.. automodule:: salt.states.restconf + :members: diff --git a/doc/ref/states/all/salt.states.rsync.rst b/doc/ref/states/all/salt.states.rsync.rst new file mode 100644 index 000000000000..55b28afe3a0a --- /dev/null +++ b/doc/ref/states/all/salt.states.rsync.rst @@ -0,0 +1,5 @@ +salt.states.rsync +================= + +.. automodule:: salt.states.rsync + :members: diff --git a/doc/ref/states/all/salt.states.rvm.rst b/doc/ref/states/all/salt.states.rvm.rst new file mode 100644 index 000000000000..cd8ce5cc1aab --- /dev/null +++ b/doc/ref/states/all/salt.states.rvm.rst @@ -0,0 +1,5 @@ +salt.states.rvm +=============== + +.. automodule:: salt.states.rvm + :members: diff --git a/doc/ref/states/all/salt.states.serverdensity_device.rst b/doc/ref/states/all/salt.states.serverdensity_device.rst new file mode 100644 index 000000000000..b892d835cb91 --- /dev/null +++ b/doc/ref/states/all/salt.states.serverdensity_device.rst @@ -0,0 +1,5 @@ +salt.states.serverdensity_device +================================ + +.. automodule:: salt.states.serverdensity_device + :members: diff --git a/doc/ref/states/all/salt.states.slack.rst b/doc/ref/states/all/salt.states.slack.rst new file mode 100644 index 000000000000..01d2f203c224 --- /dev/null +++ b/doc/ref/states/all/salt.states.slack.rst @@ -0,0 +1,5 @@ +salt.states.slack +================= + +.. automodule:: salt.states.slack + :members: diff --git a/doc/ref/states/all/salt.states.smartos.rst b/doc/ref/states/all/salt.states.smartos.rst new file mode 100644 index 000000000000..3f976b3f3f1e --- /dev/null +++ b/doc/ref/states/all/salt.states.smartos.rst @@ -0,0 +1,5 @@ +salt.states.smartos +=================== + +.. automodule:: salt.states.smartos + :members: diff --git a/doc/ref/states/all/salt.states.smtp.rst b/doc/ref/states/all/salt.states.smtp.rst new file mode 100644 index 000000000000..ef00384b88fe --- /dev/null +++ b/doc/ref/states/all/salt.states.smtp.rst @@ -0,0 +1,5 @@ +salt.states.smtp +================ + +.. automodule:: salt.states.smtp + :members: diff --git a/doc/ref/states/all/salt.states.snapper.rst b/doc/ref/states/all/salt.states.snapper.rst new file mode 100644 index 000000000000..3b86e9a49fc3 --- /dev/null +++ b/doc/ref/states/all/salt.states.snapper.rst @@ -0,0 +1,6 @@ +salt.states.snapper +=================== + +.. automodule:: salt.states.snapper + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.solrcloud.rst b/doc/ref/states/all/salt.states.solrcloud.rst new file mode 100644 index 000000000000..1265a6babaa8 --- /dev/null +++ b/doc/ref/states/all/salt.states.solrcloud.rst @@ -0,0 +1,6 @@ +salt.states.solrcloud +===================== + +.. automodule:: salt.states.solrcloud + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.splunk.rst b/doc/ref/states/all/salt.states.splunk.rst new file mode 100644 index 000000000000..38bccf4561fa --- /dev/null +++ b/doc/ref/states/all/salt.states.splunk.rst @@ -0,0 +1,5 @@ +salt.states.splunk +================== + +.. automodule:: salt.states.splunk + :members: diff --git a/doc/ref/states/all/salt.states.splunk_search.rst b/doc/ref/states/all/salt.states.splunk_search.rst new file mode 100644 index 000000000000..e5e6fc39fe07 --- /dev/null +++ b/doc/ref/states/all/salt.states.splunk_search.rst @@ -0,0 +1,5 @@ +salt.states.splunk_search +========================= + +.. automodule:: salt.states.splunk_search + :members: diff --git a/doc/ref/states/all/salt.states.sqlite3.rst b/doc/ref/states/all/salt.states.sqlite3.rst new file mode 100644 index 000000000000..f98b9ca08494 --- /dev/null +++ b/doc/ref/states/all/salt.states.sqlite3.rst @@ -0,0 +1,5 @@ +salt.states.sqlite3 +=================== + +.. automodule:: salt.states.sqlite3 + :members: diff --git a/doc/ref/states/all/salt.states.ssh_pki.rst b/doc/ref/states/all/salt.states.ssh_pki.rst deleted file mode 100644 index 6b6e737b2bf1..000000000000 --- a/doc/ref/states/all/salt.states.ssh_pki.rst +++ /dev/null @@ -1,5 +0,0 @@ -salt.states.ssh_pki -=================== - -.. automodule:: salt.states.ssh_pki - :members: diff --git a/doc/ref/states/all/salt.states.statuspage.rst b/doc/ref/states/all/salt.states.statuspage.rst new file mode 100644 index 000000000000..62d99fb5f4c6 --- /dev/null +++ b/doc/ref/states/all/salt.states.statuspage.rst @@ -0,0 +1,5 @@ +salt.states.statuspage +====================== + +.. automodule:: salt.states.statuspage + :members: diff --git a/doc/ref/states/all/salt.states.supervisord.rst b/doc/ref/states/all/salt.states.supervisord.rst new file mode 100644 index 000000000000..d3b068abec58 --- /dev/null +++ b/doc/ref/states/all/salt.states.supervisord.rst @@ -0,0 +1,5 @@ +salt.states.supervisord +======================= + +.. automodule:: salt.states.supervisord + :members: diff --git a/doc/ref/states/all/salt.states.svn.rst b/doc/ref/states/all/salt.states.svn.rst new file mode 100644 index 000000000000..8bd274f358c0 --- /dev/null +++ b/doc/ref/states/all/salt.states.svn.rst @@ -0,0 +1,5 @@ +salt.states.svn +=============== + +.. automodule:: salt.states.svn + :members: diff --git a/doc/ref/states/all/salt.states.sysrc.rst b/doc/ref/states/all/salt.states.sysrc.rst new file mode 100644 index 000000000000..1d5da718689c --- /dev/null +++ b/doc/ref/states/all/salt.states.sysrc.rst @@ -0,0 +1,5 @@ +salt.states.sysrc +================= + +.. automodule:: salt.states.sysrc + :members: diff --git a/doc/ref/states/all/salt.states.telemetry_alert.rst b/doc/ref/states/all/salt.states.telemetry_alert.rst new file mode 100644 index 000000000000..45dc03c48905 --- /dev/null +++ b/doc/ref/states/all/salt.states.telemetry_alert.rst @@ -0,0 +1,5 @@ +salt.states.telemetry_alert +=========================== + +.. automodule:: salt.states.telemetry_alert + :members: diff --git a/doc/ref/states/all/salt.states.testinframod.rst b/doc/ref/states/all/salt.states.testinframod.rst new file mode 100644 index 000000000000..b8f3b6a83d52 --- /dev/null +++ b/doc/ref/states/all/salt.states.testinframod.rst @@ -0,0 +1,6 @@ +salt.states.testinframod +======================== + +.. automodule:: salt.states.testinframod + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.tomcat.rst b/doc/ref/states/all/salt.states.tomcat.rst new file mode 100644 index 000000000000..eb852af9be79 --- /dev/null +++ b/doc/ref/states/all/salt.states.tomcat.rst @@ -0,0 +1,5 @@ +salt.states.tomcat +================== + +.. automodule:: salt.states.tomcat + :members: diff --git a/doc/ref/states/all/salt.states.trafficserver.rst b/doc/ref/states/all/salt.states.trafficserver.rst new file mode 100644 index 000000000000..d97a85c6c40e --- /dev/null +++ b/doc/ref/states/all/salt.states.trafficserver.rst @@ -0,0 +1,5 @@ +salt.states.trafficserver +========================= + +.. automodule:: salt.states.trafficserver + :members: diff --git a/doc/ref/states/all/salt.states.tuned.rst b/doc/ref/states/all/salt.states.tuned.rst new file mode 100644 index 000000000000..097c84132405 --- /dev/null +++ b/doc/ref/states/all/salt.states.tuned.rst @@ -0,0 +1,5 @@ +salt.states.tuned +================= + +.. automodule:: salt.states.tuned + :members: diff --git a/doc/ref/states/all/salt.states.vagrant.rst b/doc/ref/states/all/salt.states.vagrant.rst new file mode 100644 index 000000000000..6462bc04ab59 --- /dev/null +++ b/doc/ref/states/all/salt.states.vagrant.rst @@ -0,0 +1,5 @@ +salt.states.vagrant +=================== + +.. automodule:: salt.states.vagrant + :members: diff --git a/doc/ref/states/all/salt.states.vault.rst b/doc/ref/states/all/salt.states.vault.rst new file mode 100644 index 000000000000..82f272d5b657 --- /dev/null +++ b/doc/ref/states/all/salt.states.vault.rst @@ -0,0 +1,6 @@ +salt.states.vault +================= + +.. automodule:: salt.states.vault + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.vbox_guest.rst b/doc/ref/states/all/salt.states.vbox_guest.rst new file mode 100644 index 000000000000..3ddb047e89c3 --- /dev/null +++ b/doc/ref/states/all/salt.states.vbox_guest.rst @@ -0,0 +1,5 @@ +salt.states.vbox_guest +====================== + +.. automodule:: salt.states.vbox_guest + :members: diff --git a/doc/ref/states/all/salt.states.victorops.rst b/doc/ref/states/all/salt.states.victorops.rst new file mode 100644 index 000000000000..0cbe4c82b057 --- /dev/null +++ b/doc/ref/states/all/salt.states.victorops.rst @@ -0,0 +1,5 @@ +salt.states.victorops +===================== + +.. automodule:: salt.states.victorops + :members: diff --git a/doc/ref/states/all/salt.states.virt.rst b/doc/ref/states/all/salt.states.virt.rst new file mode 100644 index 000000000000..c1693076c669 --- /dev/null +++ b/doc/ref/states/all/salt.states.virt.rst @@ -0,0 +1,5 @@ +salt.states.virt +================ + +.. automodule:: salt.states.virt + :members: diff --git a/doc/ref/states/all/salt.states.webutil.rst b/doc/ref/states/all/salt.states.webutil.rst new file mode 100644 index 000000000000..2ca7db2d5287 --- /dev/null +++ b/doc/ref/states/all/salt.states.webutil.rst @@ -0,0 +1,5 @@ +salt.states.webutil +=================== + +.. automodule:: salt.states.webutil + :members: diff --git a/doc/ref/states/all/salt.states.win_dsc_resource.rst b/doc/ref/states/all/salt.states.win_dsc_resource.rst deleted file mode 100644 index 8cfe16931d97..000000000000 --- a/doc/ref/states/all/salt.states.win_dsc_resource.rst +++ /dev/null @@ -1,5 +0,0 @@ -salt.states.win_dsc_resource -============================ - -.. automodule:: salt.states.win_dsc_resource - :members: diff --git a/doc/ref/states/all/salt.states.wordpress.rst b/doc/ref/states/all/salt.states.wordpress.rst new file mode 100644 index 000000000000..645cd328f8aa --- /dev/null +++ b/doc/ref/states/all/salt.states.wordpress.rst @@ -0,0 +1,5 @@ +salt.states.wordpress +===================== + +.. automodule:: salt.states.wordpress + :members: diff --git a/doc/ref/states/all/salt.states.xml.rst b/doc/ref/states/all/salt.states.xml.rst new file mode 100644 index 000000000000..81735bbece61 --- /dev/null +++ b/doc/ref/states/all/salt.states.xml.rst @@ -0,0 +1,5 @@ +salt.states.xml +=============== + +.. automodule:: salt.states.xml + :members: diff --git a/doc/ref/states/all/salt.states.xmpp.rst b/doc/ref/states/all/salt.states.xmpp.rst new file mode 100644 index 000000000000..dab10f16d982 --- /dev/null +++ b/doc/ref/states/all/salt.states.xmpp.rst @@ -0,0 +1,5 @@ +salt.states.xmpp +================ + +.. automodule:: salt.states.xmpp + :members: diff --git a/doc/ref/states/all/salt.states.zabbix_action.rst b/doc/ref/states/all/salt.states.zabbix_action.rst new file mode 100644 index 000000000000..9a838fc22e64 --- /dev/null +++ b/doc/ref/states/all/salt.states.zabbix_action.rst @@ -0,0 +1,5 @@ +salt.states.zabbix_action +========================= + +.. automodule:: salt.states.zabbix_action + :members: diff --git a/doc/ref/states/all/salt.states.zabbix_host.rst b/doc/ref/states/all/salt.states.zabbix_host.rst new file mode 100644 index 000000000000..a9685406bcdb --- /dev/null +++ b/doc/ref/states/all/salt.states.zabbix_host.rst @@ -0,0 +1,5 @@ +salt.states.zabbix_host +======================= + +.. automodule:: salt.states.zabbix_host + :members: diff --git a/doc/ref/states/all/salt.states.zabbix_hostgroup.rst b/doc/ref/states/all/salt.states.zabbix_hostgroup.rst new file mode 100644 index 000000000000..7370dcecccd2 --- /dev/null +++ b/doc/ref/states/all/salt.states.zabbix_hostgroup.rst @@ -0,0 +1,5 @@ +salt.states.zabbix_hostgroup +============================ + +.. automodule:: salt.states.zabbix_hostgroup + :members: diff --git a/doc/ref/states/all/salt.states.zabbix_mediatype.rst b/doc/ref/states/all/salt.states.zabbix_mediatype.rst new file mode 100644 index 000000000000..db04331c7d73 --- /dev/null +++ b/doc/ref/states/all/salt.states.zabbix_mediatype.rst @@ -0,0 +1,6 @@ +salt.states.zabbix_mediatype +============================ + +.. automodule:: salt.states.zabbix_mediatype + :members: + :undoc-members: diff --git a/doc/ref/states/all/salt.states.zabbix_template.rst b/doc/ref/states/all/salt.states.zabbix_template.rst new file mode 100644 index 000000000000..00aed961b8ab --- /dev/null +++ b/doc/ref/states/all/salt.states.zabbix_template.rst @@ -0,0 +1,5 @@ +salt.states.zabbix_template +=========================== + +.. automodule:: salt.states.zabbix_template + :members: diff --git a/doc/ref/states/all/salt.states.zabbix_user.rst b/doc/ref/states/all/salt.states.zabbix_user.rst new file mode 100644 index 000000000000..6b3b50161341 --- /dev/null +++ b/doc/ref/states/all/salt.states.zabbix_user.rst @@ -0,0 +1,5 @@ +salt.states.zabbix_user +======================= + +.. automodule:: salt.states.zabbix_user + :members: diff --git a/doc/ref/states/all/salt.states.zabbix_usergroup.rst b/doc/ref/states/all/salt.states.zabbix_usergroup.rst new file mode 100644 index 000000000000..78828d0a348e --- /dev/null +++ b/doc/ref/states/all/salt.states.zabbix_usergroup.rst @@ -0,0 +1,5 @@ +salt.states.zabbix_usergroup +============================ + +.. automodule:: salt.states.zabbix_usergroup + :members: diff --git a/doc/ref/states/all/salt.states.zabbix_usermacro.rst b/doc/ref/states/all/salt.states.zabbix_usermacro.rst new file mode 100644 index 000000000000..af472a49afc6 --- /dev/null +++ b/doc/ref/states/all/salt.states.zabbix_usermacro.rst @@ -0,0 +1,5 @@ +salt.states.zabbix_usermacro +============================ + +.. automodule:: salt.states.zabbix_usermacro + :members: diff --git a/doc/ref/states/all/salt.states.zabbix_valuemap.rst b/doc/ref/states/all/salt.states.zabbix_valuemap.rst new file mode 100644 index 000000000000..c13a230e82a7 --- /dev/null +++ b/doc/ref/states/all/salt.states.zabbix_valuemap.rst @@ -0,0 +1,5 @@ +salt.states.zabbix_valuemap +=========================== + +.. automodule:: salt.states.zabbix_valuemap + :members: diff --git a/doc/ref/states/all/salt.states.zcbuildout.rst b/doc/ref/states/all/salt.states.zcbuildout.rst new file mode 100644 index 000000000000..cbac02c08f19 --- /dev/null +++ b/doc/ref/states/all/salt.states.zcbuildout.rst @@ -0,0 +1,5 @@ +salt.states.zcbuildout +====================== + +.. automodule:: salt.states.zcbuildout + :members: diff --git a/doc/ref/states/all/salt.states.zenoss.rst b/doc/ref/states/all/salt.states.zenoss.rst new file mode 100644 index 000000000000..22ad08c51f1d --- /dev/null +++ b/doc/ref/states/all/salt.states.zenoss.rst @@ -0,0 +1,5 @@ +salt.states.zenoss +================== + +.. automodule:: salt.states.zenoss + :members: diff --git a/doc/ref/states/all/salt.states.zfs.rst b/doc/ref/states/all/salt.states.zfs.rst new file mode 100644 index 000000000000..8808731aa268 --- /dev/null +++ b/doc/ref/states/all/salt.states.zfs.rst @@ -0,0 +1,5 @@ +salt.states.zfs +=============== + +.. automodule:: salt.states.zfs + :members: diff --git a/doc/ref/states/all/salt.states.zk_concurrency.rst b/doc/ref/states/all/salt.states.zk_concurrency.rst new file mode 100644 index 000000000000..807b30848c03 --- /dev/null +++ b/doc/ref/states/all/salt.states.zk_concurrency.rst @@ -0,0 +1,5 @@ +salt.states.zk_concurrency +========================== + +.. automodule:: salt.states.zk_concurrency + :members: diff --git a/doc/ref/states/all/salt.states.zone.rst b/doc/ref/states/all/salt.states.zone.rst new file mode 100644 index 000000000000..a6c255d9ff27 --- /dev/null +++ b/doc/ref/states/all/salt.states.zone.rst @@ -0,0 +1,5 @@ +salt.states.zone +================ + +.. automodule:: salt.states.zone + :members: diff --git a/doc/ref/states/all/salt.states.zookeeper.rst b/doc/ref/states/all/salt.states.zookeeper.rst new file mode 100644 index 000000000000..5a0e29eaa00c --- /dev/null +++ b/doc/ref/states/all/salt.states.zookeeper.rst @@ -0,0 +1,5 @@ +salt.states.zookeeper +===================== + +.. automodule:: salt.states.zookeeper + :members: diff --git a/doc/ref/states/all/salt.states.zpool.rst b/doc/ref/states/all/salt.states.zpool.rst new file mode 100644 index 000000000000..7ba33af05b58 --- /dev/null +++ b/doc/ref/states/all/salt.states.zpool.rst @@ -0,0 +1,5 @@ +salt.states.zpool +================= + +.. automodule:: salt.states.zpool + :members: diff --git a/doc/ref/states/highstate.rst b/doc/ref/states/highstate.rst index 030e4e805aa6..e00aa3018782 100644 --- a/doc/ref/states/highstate.rst +++ b/doc/ref/states/highstate.rst @@ -66,7 +66,8 @@ a :ref:`name-declaration` or a :ref:`names-declaration`. Occurs on the top level or under the :ref:`extend-declaration`. Must be unique across entire state tree. If the same ID declaration is -used twice, then a compilation error will occur. +used twice, only the first one matched will be used. All subsequent +ID declarations with the same name will be ignored. .. note:: Naming gotchas diff --git a/doc/security/index.rst b/doc/security/index.rst index 0aa46ae290e1..408d9df40790 100644 --- a/doc/security/index.rst +++ b/doc/security/index.rst @@ -4,25 +4,123 @@ Security disclosure policy ========================== -The canonical Salt security policy, contact information and PGP public key -live in the ``SECURITY.md`` file at the root of the Salt source tree. +:email: saltproject-security.pdl@broadcom.com +:gpg key ID: 37654A06 +:gpg key fingerprint: ``99EF 26F2 6469 2D24 973A 7007 E8BF 76A7 3765 4A06`` -To avoid this page drifting out of sync with the live document, see: +**gpg public key:** -* `SECURITY.md on master `_ +.. code-block:: text -That file is the authoritative source for: + -----BEGIN PGP PUBLIC KEY BLOCK----- -* the security contact email -* the current GPG key ID and fingerprint -* the full ASCII-armored GPG public key -* the security response procedure + mQINBGZpxDsBEACz8yoRBXaJiifaWz3wd4FLSO18mgH7H/+0iNTbV1ZwhgGEtWTF + Z31HfrsbxVgICoMgFYt8WKnc4MHZLIgDfTuCFQpf7PV/VqRBAknZwQKEAjHfrYNz + Q1vy3CeKC1qcKQISEQr7VFf58sOC8GJ54jLLc2rCsg9cXI6yvUFtGwL9Qv7g/NZn + rtLjc4NZIKdIvSt+/PtooQtsz0jfLMdMpMFa41keH3MknIbydBUnGj7eC8ANN/iD + Re2QHAW2KfQh3Ocuh/DpJ0/dwbzXmXfMWHk30E+s31TfdLiFt1Iz5kZDF8iHrDMq + x39/GGmF10y5rfq43V1Ucxm+1tl5Km0JcX6GpPUtgRpfUYAxwxfGfezt4PjYRYH2 + mNxXXPLsnVTvdWPTvS0msSrcTHmnU5His38I6goXI7dLZm0saqoWi3sqEQ8TPS6/ + DkLtYjpb/+dql+KrXD7erd3j8KKflIXn7AEsv+luNk6czGOKgdG9agkklzOHfEPc + xOGmaFfe/1mu8HxgaCuhNAQWlk79ZC+GAm0sBZIQAQRtABgag5vWr16hVix7BPMG + Fp8+caOVv6qfQ7gBmJ3/aso6OzyOxsluVxQRt94EjPTm0xuwb1aYNJOhEj9cPkjQ + XBjo3KN0rwcAViR/fdUzrIV1sn2hms0v5WZ+TDtz1w0OpLZOwe23BDE1+QARAQAB + tEJTYWx0IFByb2plY3QgU2VjdXJpdHkgVGVhbSA8c2FsdHByb2plY3Qtc2VjdXJp + dHkucGRsQGJyb2FkY29tLmNvbT6JAlcEEwEKAEEWIQSZ7ybyZGktJJc6cAfov3an + N2VKBgUCZmnEOwIbAwUJB4TOAAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAK + CRDov3anN2VKBk7rD/9QdcYdNGfk96W906HlVpb3JCwT0t9T7ElP97Ot0YN6LqMj + vVQpxWYi7riUSyt1FtlCAM+hmghImzILF9LKDRCZ1H5UStI/u9T53cZpUZtVW/8R + bUNBCl495UcgioIZG5DsfZ/GdBOgY+hQfdgh7HC8a8A/owCt2hHbnth970NQ+LHb + /0ERLfOHRxozgPBhze8Vqf939KlteM5ljgTw/IkJJIsxJi4C6pQntSHvB3/Bq/Nw + Kf3vk3XYFtVibeQODSVvc6useo+SNGV/wsK/6kvh/vfP9Trv/GMOn/89Bj2aL1PR + M382E6sDB9d22p4ehVgbcOpkwHtr9DGerK9xzfG4aUjLu9qVD5Ep3gqKSsCe+P8z + bpADdVCnk+Vdp3Bi+KI7buSkqfbZ0m9vCY3ei1fMiDiTTjvNliL5QCO6PvYNYiDw + +LLImrQThv55ZRQsRRT7J6A94kwDoI6zcBEalv/aPws0nQHJtgWRUpmy5RcbVu9Z + QBXlUpCzCB+gGaGRE1u0hCfuvkbcG1pXFFBdSUuAK4o4ktiRALVUndELic/PU1nR + jwo/+j0SGw/jTwqVChUfLDZbiAQ2JICoVpZ+e1zQfsxa/yDu2e4D543SvNFHDsxh + bsBeCsopzJSA0n2HAdYvPxOPoWVvZv+U8ZV3EEVOUgsO5//cRJddCgLU89Q4DrkC + DQRmacQ7ARAAsz8jnpfw3DCRxdCVGiqWAtgj8r2gx5n1wJsKsgvyGQdKUtPwlX04 + 7w13lIDT2DwoXFozquYsTn9XkIoWbVckqo0NN/V7/QxIZIYTqRcFXouHTbXDJm5C + tsvfDlnTsaplyRawPU2mhYg39/lzIt8zIjvy5zo/pElkRP5m03nG+ItrsHN6CCvf + ZiRxme6EQdn+aoHh2GtICL8+c3HvQzTHYKxFn84Ibt3uNxwt+Mu6YhG9tkYMQQk5 + SkYA4CYAaw2Lc/g0ee36iqw/5d79M8YcQtHhy5zzqgdEvExjFPdowV1hhFIEkNkM + uqIAknXVesqLLw2hPeYmyhYQqeBKIrWmBhBKX9c0vMYkDDH3T/sSylVhH0QAXP6E + WmLja3E1ov6pt6j7j/wWzC9LSMFDJI2yWCeOE1oea5D89tH6XvsGRTiog62zF/9a + 77197iIa0+o91chp4iLkzDvuK8pVujPx8bNsK8jlJ+OW73NmliCVg+hecoFLNsri + /TsBngFNVcu79Q1XfyvoDdR2C09ItCBEZGt6LOlq/+ATUw1aBz6L1hvLBtiR3Hfu + X31YlbxdvVPjlzg6O6GXSfnokNTWv2mVXWTRIrP0RrKvMyiNPXVW7EunUuXI0Axk + Xg3E5kAjKXkBXzoCTCVz/sXPLjvjI0x3Z7obgPpcTi9h5DIX6PFyK/kAEQEAAYkC + PAQYAQoAJhYhBJnvJvJkaS0klzpwB+i/dqc3ZUoGBQJmacQ7AhsMBQkHhM4AAAoJ + EOi/dqc3ZUoGDeAQAKbyiHA1sl0fnvcZxoZ3mWA/Qesddp7Nv2aEW8I3hAJoTVml + ZvMxk8leZgsQJtSsVDNnxeyW+WCIUkhxmd95UlkTTj5mpyci1YrxAltPJ2TWioLe + F2doP8Y+4iGnaV+ApzWG33sLr95z37RKVdMuGk/O5nLMeWnSPA7HHWJCxECMm0SH + uI8aby8w2aBZ1kOMFB/ToEEzLBu9fk+zCzG3uH8QhdciMENVhsyBSULIrmwKglyI + VQwj2dXHyekQh7QEHV+CdKMfs3ZOANwm52OwjaK0dVb3IMFGvlUf4UXXfcXwLAkj + vW+Ju4kLGxVQpOlh1EBain9WOaHZGh6EGuTpjJO32PyRq8iSMNb8coeonoPFWrE/ + A5dy3z5x5CZhJ6kyNwYs/9951r30Ct9qNZo9WZwp8AGQVs+J9XEYnZIWXnO1hdKs + dRStPvY7VqS500t8eWqWRfCLgofZAb9Fv7SwTPQ2G7bOuTXmQKAIEkU9vzo5XACu + AtR/9bC9ghNnlNuH4xiViBclrq2dif/I2ZwItpQHjuCDeMKz9kdADRI0tuNPpRHe + QP1YpURW+I+PYZzNgbnwzl6Bxo7jCHFgG6BQ0ih5sVwEDhlXjSejd8CNMYEy3ElL + xJLUpltwXLZSrJEXYjtJtnh0om71NXes0OyWE1cL4+U6WA9Hho6xedjk2bai + =pPmt + -----END PGP PUBLIC KEY BLOCK----- + +The SaltStack Security Team is available at saltproject-security.pdl@broadcom.com for +security-related bug reports or questions. + +We request the disclosure of any security-related bugs or issues be reported +non-publicly until such time as the issue can be resolved and a security-fix +release can be prepared. At that time we will release the fix and make a public +announcement with upgrade instructions and download locations. + +Security response procedure +=========================== + +SaltStack takes security and the trust of our customers and users very +seriously. Our disclosure policy is intended to resolve security issues as +quickly and safely as is possible. + +1. A security report sent to saltproject-security.pdl@broadcom.com is assigned to a team + member. This person is the primary contact for questions and will + coordinate the fix, release, and announcement. + +2. The reported issue is reproduced and confirmed. A list of affected projects + and releases is made. + +3. Fixes are implemented for all affected projects and releases that are + actively supported. Back-ports of the fix are made to any old releases that + are actively supported. + +4. Packagers are notified via the `salt-packagers`_ mailing list that an issue + was reported and resolved, and that an announcement is incoming. + +5. A pre-announcement is sent out to the `salt-announce`_ mailing list approximately + a week before the CVE release. This announcement does not include details + of the vulnerability. The pre-announcement will include the date the release + will occur and the vulnerability rating. + +6. A new release is created and pushed to all affected repositories. The + release documentation provides a full description of the issue, plus any + upgrade instructions or other relevant details. + +7. An announcement is made to the `salt-users`_ and `salt-announce`_ mailing + lists. The announcement contains a description of the issue and a link to + the full release documentation and download locations. .. _saltstack_security_announcements: Receiving security announcements ================================ -For receiving security announcements, see the ``SECURITY.md`` file linked -above. Notifications are sent to the ``salt-packagers``, ``salt-users`` and -``salt-announce`` mailing lists. +The following mailing lists, per the previous tasks identified in our response +procedure, will receive security-relevant notifications: + +* `salt-packagers`_ +* `salt-users`_ +* `salt-announce`_ + +In addition to the mailing lists, SaltStack also provides the following resources: + +* `SaltStack Security Announcements `__ landing page +* `SaltStack Security RSS Feed `__ +* `Salt Project Discord Community `__ diff --git a/doc/topics/beacons/index.rst b/doc/topics/beacons/index.rst index 2d7eb40754bb..311c8549b46b 100644 --- a/doc/topics/beacons/index.rst +++ b/doc/topics/beacons/index.rst @@ -96,7 +96,7 @@ and 10-second intervals: /opt: mask: - modify - interval: 5 + - interval: 5 - disable_during_state_run: True load: - averages: @@ -109,7 +109,7 @@ and 10-second intervals: 15m: - 0.1 - 1.0 - interval: 10 + - interval: 10 .. _avoid-beacon-event-loops: diff --git a/doc/topics/cloud/dimensiondata.rst b/doc/topics/cloud/dimensiondata.rst index 4b3fd20ba5e4..cebc2ed46d55 100644 --- a/doc/topics/cloud/dimensiondata.rst +++ b/doc/topics/cloud/dimensiondata.rst @@ -7,7 +7,7 @@ Dimension Data provide IT-as-a-Service to customers around the globe on their cloud platform (Compute as a Service). The CaaS service is available either on one of the public cloud instances or as a private instance on premises. -https://services.global.ntt/en-us/services-and-products/cloud/cloud-platform +http://cloud.dimensiondata.com/ CaaS has its own non-standard API , SaltStack provides a wrapper on top of this API with common methods with other IaaS solutions and Public cloud providers. @@ -203,6 +203,4 @@ command: .. note:: - The Dimension Data MCP 2 REST API documentation portal at - ``community.opsourcecloud.net`` has been retired by NTT and is no longer - available online. + Dimension Data Cloud REST API documentation is available from `Dimension Data MCP 2 `_. diff --git a/doc/topics/cloud/parallels.rst b/doc/topics/cloud/parallels.rst index 0c066aa4cc81..33a2b3a4ec47 100644 --- a/doc/topics/cloud/parallels.rst +++ b/doc/topics/cloud/parallels.rst @@ -6,7 +6,7 @@ Parallels Cloud Server is a product by Parallels that delivers a cloud hosting solution. The PARALLELS module for Salt Cloud enables you to manage instances hosted using PCS. Further information can be found at: -https://www.parallels.com/products/ras/remote-application-server/ +http://www.parallels.com/products/pcs/ * Using the old format, set up the cloud configuration at ``/etc/salt/cloud``: diff --git a/doc/topics/cloud/vmware.rst b/doc/topics/cloud/vmware.rst index b9c205d5caed..e58b9b7ff909 100644 --- a/doc/topics/cloud/vmware.rst +++ b/doc/topics/cloud/vmware.rst @@ -19,11 +19,12 @@ available at PyPI: https://pypi.org/project/pyvmomi/ -This python module can be installed in the onedir package: +This package can be installed using `pip` or `easy_install`: .. code-block:: bash - salt-pip install pyvmomi + pip install pyvmomi + easy_install pyvmomi .. note:: diff --git a/doc/topics/cloud/windows.rst b/doc/topics/cloud/windows.rst index c136a32cfe12..30d7341b3567 100644 --- a/doc/topics/cloud/windows.rst +++ b/doc/topics/cloud/windows.rst @@ -30,6 +30,7 @@ which Salt Cloud is running. See `Windows - Salt install guide `_ for information about downloading and using the Salt Minion Windows installer. + Optionally WinRM can be used instead of `winexe` if the python module `pywinrm` is available and WinRM is supported on the target Windows version. Information on pywinrm can be found at the project home: @@ -53,18 +54,18 @@ Self Signed Certificates with WinRM Salt-Cloud can use versions of ``pywinrm<=0.1.1`` or ``pywinrm>=0.2.1``. -For versions greater than ``0.2.1``, ``winrm_verify_ssl`` needs to be set to -``False`` if the certificate is self signed and not verifiable. +For versions greater than `0.2.1`, ``winrm_verify_ssl`` needs to be set to +`False` if the certificate is self signed and not verifiable. Firewall Settings ================= -Because Salt Cloud makes use of ``smbclient`` and ``winexe``, port 445 must be -open on the target image. This port is not generally open by default on a -standard Windows distribution, and care must be taken to use an image in which -this port is open, or the Windows firewall is disabled. +Because Salt Cloud makes use of `smbclient` and `winexe`, port 445 must be open +on the target image. This port is not generally open by default on a standard +Windows distribution, and care must be taken to use an image in which this port +is open, or the Windows firewall is disabled. If supported by the cloud provider, a PowerShell script may be used to open up -this port automatically, using the cloud provider's ``userdata``. The following +this port automatically, using the cloud provider's `userdata`. The following script would open up port 445, and apply the changes: .. code-block:: text @@ -76,7 +77,7 @@ script would open up port 445, and apply the changes: For EC2, this script may be saved as a file, and specified in the provider or -profile configuration as ``userdata_file``. For instance: +profile configuration as `userdata_file`. For instance: .. code-block:: yaml @@ -156,9 +157,9 @@ the following userdata example: Restart-Service winrm -No certificate store is available by default on EC2 images and creating one does -not seem possible without an MMC (cannot be automated). To use the default EC2 -Windows images the above copies the RDP store. +No certificate store is available by default on EC2 images and creating +one does not seem possible without an MMC (cannot be automated). To use the +default EC2 Windows images the above copies the RDP store. Configuration ============= @@ -182,42 +183,23 @@ Setting the installer in ``/etc/salt/cloud.providers``: win_password: letmein smb_port: 445 -The default Windows user is ``Administrator``, and the default Windows password +The default Windows user is `Administrator`, and the default Windows password is blank. -If WinRM is to be used ``use_winrm`` needs to be set to ``True``. ``winrm_port`` +If WinRM is to be used ``use_winrm`` needs to be set to `True`. ``winrm_port`` can be used to specify a custom port (must be HTTPS listener). And -``winrm_verify_ssl`` can be set to ``False`` to use a self signed certificate. - -Two new options have been added to allow you to set some additional parameters -to pass to the installer. ``win_delay_start`` will set the minion service to -start delayed. ``win_install_dir`` will allow you to specify the Salt install -location. - -.. code-block:: yaml +``winrm_verify_ssl`` can be set to `False` to use a self signed certificate. - my-softlayer: - driver: softlayer - user: MYUSER1138 - apikey: 'e3b68aa711e6deadc62d5b76355674beef7cc3116062ddbacafe5f7e465bfdc9' - minion: - master: saltmaster.example.com - win_installer: /root/Salt-Minion-2014.7.0-AMD64-Setup.exe - win_delay_start: True - win_install_dir: D:\Program Files\Salt Project\Salt - win_username: Administrator - win_password: letmein - smb_port: 445 Auto-Generated Passwords on EC2 =============================== -On EC2, when the ``win_password`` is set to ``auto``, Salt Cloud will query EC2 -for an auto-generated password. This password is expected to take at least 4 -minutes to generate, adding additional time to the deploy process. +On EC2, when the `win_password` is set to `auto`, Salt Cloud will query EC2 for +an auto-generated password. This password is expected to take at least 4 minutes +to generate, adding additional time to the deploy process. When the EC2 API is queried for the auto-generated password, it will be returned -in a message encrypted with the specified ``keyname``. This requires that the -appropriate ``private_key`` file is also specified. Such a profile configuration +in a message encrypted with the specified `keyname`. This requires that the +appropriate `private_key` file is also specified. Such a profile configuration might look like: .. code-block:: yaml diff --git a/doc/topics/development/tests/index.rst b/doc/topics/development/tests/index.rst index e5c2613a47c3..a789bab089dc 100644 --- a/doc/topics/development/tests/index.rst +++ b/doc/topics/development/tests/index.rst @@ -323,10 +323,18 @@ failure is unrelated to the changes in question, core developers may merge the pull request despite the initial failure. As soon as the pull request is merged, the changes will be added to the -next branch test run on the Salt CI pipelines. +next branch test run on Jenkins. -For the current Salt CI configuration, see the workflows in -:blob:`.github/workflows/`. +For a full list of currently running test environments, go to +https://jenkins.saltproject.io. + + +Using Salt-Cloud on Jenkins +--------------------------- + +For testing Salt on Jenkins, SaltStack uses :ref:`Salt-Cloud` to +spin up virtual machines. The script using Salt-Cloud to accomplish this is +open source and can be found here: :blob:`tests/jenkins.py` Writing Tests diff --git a/doc/topics/event/master_events.rst b/doc/topics/event/master_events.rst index a01abba42843..c6ee9d076604 100644 --- a/doc/topics/event/master_events.rst +++ b/doc/topics/event/master_events.rst @@ -18,7 +18,7 @@ Authentication events :var id: The minion ID. :var act: The current status of the minion key: ``accept``, ``pend``, - ``reject``, ``full``, ``denied``, ``error``. + ``reject``. :var pub: The minion public key. @@ -78,25 +78,6 @@ Job events :var user: The name of the user that ran the command as defined in Salt's Publisher ACL or external auth. -.. salt:event:: salt/job//start/ - - Fired by a minion when it accepts a published job and is about to begin - executing the requested function. Opt-in per-job via the ``--start-event`` - CLI flag (or ``start_event=True`` in ``LocalClient`` kwargs); not fired - unless the caller requested it. - - Useful for confirming that a minion received and started a job without - waiting for the full return. - - :var id: The minion ID. - :var jid: The job ID. - :var fun: The function the minion is about to run. - :var tgt: The target of the job. - :var tgt_type: The type of targeting used. - :var user: The user that ran the command. - :var master_id: Origin master, when set on the published load. - :var metadata: Caller-supplied metadata, when set on the published load. - .. salt:event:: salt/job//ret/ Fired each time a minion returns data for a job. diff --git a/doc/topics/highavailability/index.rst b/doc/topics/highavailability/index.rst index 4177663f8452..864c67323129 100644 --- a/doc/topics/highavailability/index.rst +++ b/doc/topics/highavailability/index.rst @@ -14,21 +14,12 @@ Master Cluster .. versionadded:: 3007 -.. versionchanged:: 3008.0 - The shared-filesystem requirement was relaxed. Master clusters can now - run in *isolated filesystem* mode - (:conf_master:`cluster_isolated_filesystem`), in which each peer keeps - its own local ``cluster_pki_dir``, ``cachedir``, ``file_roots`` and - ``pillar_roots`` and the cluster transport carries the same content - in-band on join and via ``salt-run cluster.sync_roots``. - Salt masters can be configured to act as a cluster. All masters in a cluster -are peers. Job workloads are shared across the cluster. Master clusters +are peers. Job workloads are shared accross the cluster. Master clusters provide a way to scale masters horizontally. They do not require changes to -the minions' configuration to add more resources. Cluster implementations -need a load balancer in front of the masters' publish and request ports, -should run on a reliable network, and either share a filesystem between -peers or run in isolated-filesystem mode (3008.0+). +the minions' configuration to add more resources. Cluster implementations are +expected to use a load balancer, shared filesystem, and run on a reliable +network. :ref:`Master Cluster Tutorial ` diff --git a/doc/topics/jobs/index.rst b/doc/topics/jobs/index.rst index 85990d236b9a..2c55eac0ca99 100644 --- a/doc/topics/jobs/index.rst +++ b/doc/topics/jobs/index.rst @@ -129,7 +129,7 @@ arguments and provide a YAML dict of named arguments. job1: function: state.sls seconds: 3600 - job_args: + args: - httpd kwargs: test: True @@ -143,7 +143,7 @@ This will schedule the command: ``state.sls httpd test=True`` every 3600 seconds job1: function: state.sls seconds: 3600 - job_args: + args: - httpd kwargs: test: True @@ -158,7 +158,7 @@ This will schedule the command: ``state.sls httpd test=True`` every 3600 seconds job1: function: state.sls seconds: 3600 - job_args: + args: - httpd kwargs: test: True @@ -183,7 +183,7 @@ to be installed. schedule: job1: function: state.sls - job_args: + args: - httpd kwargs: test: True @@ -197,7 +197,7 @@ localtime. schedule: job1: function: state.sls - job_args: + args: - httpd kwargs: test: True @@ -216,7 +216,7 @@ Monday, Wednesday and Friday, and 3:00 PM on Tuesday and Thursday. schedule: job1: function: state.sls - job_args: + args: - httpd kwargs: test: True @@ -239,7 +239,7 @@ grain values. job1: function: state.sls seconds: 3600 - job_args: + args: - httpd kwargs: test: True @@ -257,7 +257,7 @@ be a dictionary with the date strings using the ``dateutil`` format. job1: function: state.sls seconds: 3600 - job_args: + args: - httpd kwargs: test: True @@ -326,7 +326,7 @@ Cron-like Schedule job1: function: state.sls cron: '*/15 * * * *' - job_args: + args: - httpd kwargs: test: True @@ -387,7 +387,7 @@ scheduler to skip this first run and wait until the next scheduled run: function: state.sls seconds: 3600 run_on_start: False - job_args: + args: - httpd kwargs: test: True @@ -404,7 +404,7 @@ Until and After function: state.sls seconds: 15 until: '12/31/2015 11:59pm' - job_args: + args: - httpd kwargs: test: True @@ -424,7 +424,7 @@ This requires the Python ``dateutil`` library to be installed. function: state.sls seconds: 15 after: '12/31/2015 11:59pm' - job_args: + args: - httpd kwargs: test: True @@ -444,7 +444,7 @@ Scheduling States log-loadavg: function: cmd.run seconds: 3660 - job_args: + args: - 'logger -t salt < /proc/loadavg' kwargs: stateful: False @@ -478,7 +478,7 @@ configuration file: function: state.orchestrate hours: 6 splay: 600 - job_args: + args: - orchestration.my_orch The above configuration is analogous to running diff --git a/doc/topics/metrics/index.rst b/doc/topics/metrics/index.rst deleted file mode 100644 index afbfcd1c2e56..000000000000 --- a/doc/topics/metrics/index.rst +++ /dev/null @@ -1,212 +0,0 @@ -.. _metrics: - -================================ -Metrics (OpenTelemetry) -================================ - -Salt can emit OpenTelemetry metrics — counters, histograms and -observable gauges — for the operational signals that operators most -often care about: job throughput, return latency, minion connectivity, -worker queue depth, file-descriptor pressure, and returner egress -health. - -Metrics complement the :ref:`distributed tracing ` story. -Traces answer "what happened during one job?"; metrics answer "what's -happening across the fleet right now?". - -The instrumentation is **disabled by default** and is a complete no-op -when not configured. No exporter is initialised, no background threads -are started, no Prometheus listener is bound, and no payload changes -land on the wire. - -Configuration -------------- - -Add a ``metrics`` block to the master and minion configs. Settings -are the same on both daemons. - -.. code-block:: yaml - - metrics: - enabled: true - exporter: otlp-http # otlp-http | otlp-grpc | prometheus | console - endpoint: "" # OTLP collector URL (empty = SDK default) - service_name: "" # empty = auto-derived from process role - resource_attributes: {} # extra OTel Resource attributes - insecure: true # gRPC TLS off (ignored for non-grpc) - headers: {} # OTLP auth headers - export_interval_seconds: 60 # PeriodicExportingMetricReader interval - prometheus: - host: 127.0.0.1 # localhost-bind by default - port: 9464 - histogram_boundaries: - salt.job.duration: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000] - salt.minion.exec.duration: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000] - salt.master.requests.duration: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000] - -``enabled`` - Master switch. ``false`` (the default) means everything in this - module is a no-op. - -``exporter`` - - ``otlp-http`` (default) — push OTLP protobuf over HTTP. - Pure-Python; ships in salt's base requirements. - - ``otlp-grpc`` — push OTLP over gRPC. Requires - ``opentelemetry-exporter-otlp-proto-grpc`` installed separately - (it pulls in ``grpcio``, which lacks prebuilt wheels for some - platform / interpreter combinations). - - ``prometheus`` — bind a local ``/metrics`` HTTP endpoint that - Prometheus can scrape. Operators who already run Prometheus can - skip the OTel Collector entirely. - - ``console`` — print metrics to stdout for debugging. - -``endpoint`` - OTLP collector URL when ``exporter`` is ``otlp-http`` or - ``otlp-grpc``. When empty, the OTel SDK default is used - (``http://localhost:4318/v1/metrics`` for HTTP, - ``http://localhost:4317`` for gRPC). - -``service_name`` - The ``service.name`` resource attribute. When empty, salt fills - this in automatically: ``salt-master``, ``salt-minion-``, - ``salt-cli``, ``salt-call``, ``salt-api``. - -``export_interval_seconds`` - How often the periodic exporter flushes to the collector. Ignored - for the Prometheus pull exporter (Prometheus controls cadence via - its scrape interval). - -``prometheus.host`` / ``prometheus.port`` - Where the Prometheus pull listener binds. Defaults to - localhost-only. In a multi-process master only the parent binds - this port; counters incremented inside MWorker children are not - visible through the parent's ``/metrics`` (use the OTLP push - exporter if you need worker-side counters in a Prometheus - deployment with multiple workers). - -``histogram_boundaries`` - Per-instrument explicit bucket boundaries. The defaults span - sub-millisecond to one minute for ``salt.job.duration`` and - sub-millisecond to ten seconds for ``salt.minion.exec.duration``. - -Instrument inventory --------------------- - -Counters -~~~~~~~~ - -- ``salt.jobs.published{fun}`` — jobs published from master to minions. -- ``salt.jobs.completed{fun,success}`` — returns received from minions. -- ``salt.auth.attempts{result}`` — master auth attempts; ``result`` is - one of ``success``, ``invalid_id``, ``max_minions``, ``rejected``, - ``error``. -- ``salt.master.requests.handled{cmd}`` — every request dispatched by - the master worker (clear-funcs + aes-funcs), labelled by the salt - ``cmd`` name (``publish``, ``_auth``, ``_return``, ``_serve_file``, - ``mine_get``, …). This is the OTel mirror of the per-command runs - counter that ``master_stats`` exposes via the event bus. -- ``salt.events.fired{tag_prefix}`` — events placed on the event bus, - labelled by the first non-``salt`` segment of the tag. -- ``salt.returners.calls{returner,status}`` — minion-side returner - invocations; ``status`` is ``ok``, ``missing``, or ``error``. - -Histograms -~~~~~~~~~~ - -- ``salt.job.duration{fun}`` (ms) — CLI-to-master-return wall-clock per - minion return. Recorded by ``LocalClient.get_iter_returns`` on each - return event. -- ``salt.minion.exec.duration{fun}`` (ms) — minion-side wall-clock for - a single function execution (the same window the - ``salt.minion.exec.`` trace span covers). -- ``salt.master.requests.duration{cmd}`` (ms) — per-command master - worker dispatcher latency, recorded in ``MWorker._handle_clear`` and - ``MWorker._handle_aes``. Together with the matching - ``salt.master.requests.handled`` counter this gives feature parity - with the legacy ``master_stats`` per-command ``runs`` + ``mean`` - surface, but live in OTel instead of fired as periodic events. - -Observable gauges -~~~~~~~~~~~~~~~~~ - -- ``salt.master.connected_minions.count`` — sourced from - :func:`salt.utils.minions.CkMinions.connected_ids`. Registered only - in the master parent process to avoid worker over-count. -- ``salt.master.workers.queue.depth{pool}`` — MWorker payloads in - flight, observed via a shared ``multiprocessing.Value`` that every - worker increments on ``_handle_payload`` entry and decrements on - exit. -- ``salt.process.open_fds`` (``{fd}``) — current file-descriptor count - from ``psutil.Process().num_fds()``. Registered separately in the - master parent and in the minion process. Returns no observations on - Windows where ``num_fds`` is unavailable. - -Label cardinality ------------------ - -Every label above has a bounded domain — ``fun`` (the salt module -namespace), ``result`` and ``status`` (small enums), ``returner`` (the -configured returner names), ``pool`` (the configured worker pool -names), ``tag_prefix`` (a small set of event tag namespaces). No -instrument uses ``minion_id``, ``jid``, or ``user`` as a label. -Operators adding their own instruments should follow the same -discipline — these belong as trace span attributes, not metric labels. - -Running a quick demo --------------------- - -OTLP / OpenTelemetry Collector:: - - docker run -d --name otelcol \ - -p 4318:4318 \ - otel/opentelemetry-collector-contrib - -Configure master + minion:: - - metrics: - enabled: true - exporter: otlp-http - endpoint: http://localhost:4318/v1/metrics - export_interval_seconds: 10 - -Start them, run a few ``salt '*' test.ping``\ s, and watch the collector -logs for ``salt.jobs.published``, ``salt.job.duration`` and friends. - -Prometheus pull:: - - metrics: - enabled: true - exporter: prometheus - prometheus: - host: 127.0.0.1 - port: 9464 - -``curl -s http://127.0.0.1:9464/metrics | grep '^salt_'`` then shows -the salt-namespaced metrics. - -Fork handling -------------- - -Like the tracing SDK, the OTel ``PeriodicExportingMetricReader`` -background thread does not survive ``fork()``. Salt rebuilds the -provider in every forked child the first time a metrics API is invoked, -so master workers and minion executor processes each get their own -functioning reader without any caller action. - -Observable gauges are registered exactly once — in the master parent for -master-side gauges, in the minion process for minion-side gauges — to -avoid forked-worker over-counting. - -Payload and CPU overhead ------------------------- - -Metric increments are zero-allocation when metrics are disabled (every -public function short-circuits before touching the OTel SDK). When -enabled, counter and histogram operations are sub-microsecond. The -``PeriodicExportingMetricReader`` background thread wakes on -``export_interval_seconds`` (default 60s). The Prometheus pull listener -binds a single local port and serves a few KiB of text per scrape. - -No metric instrumentation changes the on-the-wire format of any salt -request, event, or return — they are purely local to each daemon's -process. diff --git a/doc/topics/netapi/netapi-enable-clients.rst b/doc/topics/netapi/netapi-enable-clients.rst index 5d2f054fe21f..1846cb8ae011 100644 --- a/doc/topics/netapi/netapi-enable-clients.rst +++ b/doc/topics/netapi/netapi-enable-clients.rst @@ -81,8 +81,6 @@ in use, those should be listed in the Salt master config, under the Example configuration to enable only the local client interfaces: -.. code-block:: yaml - netapi_enable_clients: - local - local_async @@ -92,8 +90,6 @@ Example configuration to enable only the local client interfaces: Example configuration to enable local client functionality and runners: -.. code-block:: yaml - netapi_enable_clients: - local - local_async diff --git a/doc/topics/performance/index.rst b/doc/topics/performance/index.rst deleted file mode 100644 index 4eeaa3fb96ec..000000000000 --- a/doc/topics/performance/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -.. _performance: - -=========== -Performance -=========== - -This section covers various performance optimizations and scaling considerations -for Salt. - -.. toctree:: - :maxdepth: 1 - - mmap_cache - worker_pools diff --git a/doc/topics/performance/mmap_cache.rst b/doc/topics/performance/mmap_cache.rst deleted file mode 100644 index 88c63eb2eb2d..000000000000 --- a/doc/topics/performance/mmap_cache.rst +++ /dev/null @@ -1,258 +0,0 @@ -.. _mmap-cache: - -=========================== -Memory-Mapped Cache Backend -=========================== - -.. versionadded:: 3009.0 - -Salt's default cache backend (``localfs``) stores every entry as a file under -``cachedir``. On large fleets that turns into millions of inodes, slow -directory scans (``salt-key -L``, target matching by grain or pillar) and -high syscall overhead on every store/fetch. - -The ``mmap_cache`` family of backends replaces that file-per-entry layout -with a single memory-mapped hash-table file per bank, plus a segmented heap -for variable-size values. Reads become memory-bandwidth-bound; writes become -one ``mmap`` index store plus one heap append. - -Two backends ship together: - -* :py:mod:`~salt.cache.mmap_cache` — generic key/value store, drop-in for - ``localfs`` via the master :conf_master:`cache` setting. -* :py:mod:`~salt.cache.mmap_key` — specialised for the master's minion-key - store (``keys`` and ``denied_keys`` banks), wired through the - ``keys.cache_driver`` master setting. - -Both share the same ``salt.utils.mmap_cache.MmapCache`` index/heap/roster -implementation, so the performance and durability properties below apply to -either. - - -When to switch -============== - -Switch to ``mmap_cache`` when one or more of these is true on the master: - -* ``salt-key -L`` takes seconds (or longer) to enumerate accepted keys. -* Targeting by grain or pillar (``salt -G``, ``salt -I``) noticeably stalls - before publishes go out. -* The cache directory holds tens of thousands of files and ``ls`` / - ``find`` against it is painful. -* Backups, antivirus scanners or container snapshotters spend disproportionate - time iterating ``cachedir``. - -Switch to ``mmap_key`` (independently of the generic backend) when ``salt-key`` -operations or the master's authentication path are the bottleneck. - -Conversely, ``localfs`` is fine — and stays the default — for small -deployments (a few hundred minions), for environments that explicitly need -the directory-tree shape (audit tooling, third-party scrapers), or for any -backend whose data lifecycle isn't a hot path. - - -Performance characteristics -=========================== - -All numbers below are for a 10 000-minion fleet on a single master with -warm page cache; treat them as relative shape, not absolutes. - -.. list-table:: - :header-rows: 1 - :widths: 30 30 30 - - * - Operation - - ``localfs`` - - ``mmap_cache`` - * - ``store`` small - - ~25 µs - - ~8 µs - * - ``store`` large (10 KB) - - ~35 µs - - ~10 µs - * - ``fetch`` warm - - ~20 µs - - ~6 µs - * - ``fetch`` large - - ~25 µs - - ~10 µs - * - ``updated`` (mtime probe) - - ~7 µs - - ~2 µs - * - ``list`` whole bank - - O(N) ``listdir`` + N stat - - O(occupied) roster pass - * - ``salt-key -L`` (10 k keys) - - ~10 s - - ~5–10 ms - * - Grain target match (10 k) - - ~10 s - - ~250 ms - -Scaling shape: - -* ``get`` / ``contains`` / ``updated`` are O(1) average — open-addressing hash - probe in mmap'd memory. -* ``list`` and ``list_all`` are O(occupied) regardless of total table size, - because they iterate a packed roster file rather than scanning every slot. -* ``store`` and ``delete`` are O(1) average plus one append/rewrite of the - roster entry. -* The on-disk index is a fixed-size file (``size × slot_size`` bytes); the - heap is segmented and rolls a new segment at ``max_segment_bytes`` (1 GiB - default), so individual segments stay below filesystem-friendly limits. - - -Configuration -============= - -Generic cache backend ---------------------- - -Set the master ``cache`` option to ``mmap_cache``: - -.. code-block:: yaml - - cache: mmap_cache - -That's the only required change. Optional tunables (defaults shown) live -alongside the standard cache options: - -.. code-block:: yaml - - # Number of slots in each bank's index file. Pick ~2× the maximum - # number of keys you expect in any bank. - mmap_cache_size: 1000000 - - # Bytes per index slot. Must be at least 1 + key_size + 20. - mmap_cache_slot_size: 96 - - # Maximum bytes per heap segment before a new segment rolls. - mmap_cache_max_segment_bytes: 1073741824 # 1 GiB - - # Verify CRC on every heap read. Default True; set False to trade a - # small CRC check for ~1–2 % per-op throughput in trusted environments. - mmap_cache_verify_checksums: true - -Minion-key backend ------------------- - -The minion-key store has its own driver setting: - -.. code-block:: yaml - - keys.cache_driver: mmap_key - -Set this independently of ``cache``. The two backends share an -implementation but live in different bank trees, so you can switch one -without the other. - -``mmap_key`` is the recommended driver for isolated-filesystem master -clusters (:conf_master:`cluster_isolated_filesystem`). Its deterministic -per-bank layout makes the key files safe to push between peers as opaque -blobs over the cluster transport, which is what the cluster state-sync -relies on. See :ref:`tutorial-master-cluster` for the migration -walkthrough. - -Migrating an existing master -============================ - -Both backends ship migration runners that walk the existing on-disk store -and load it into the new format. Run them before flipping the relevant opt -in ``/etc/salt/master``; both are idempotent and safe to re-run. - -Generic cache (``localfs`` → ``mmap_cache``) --------------------------------------------- - -.. code-block:: bash - - # Preview — counts entries that would be migrated, writes nothing. - salt-run cache.migrate dry_run=True - - # Migrate every bank. - salt-run cache.migrate - - # Restrict to a single bank tree. - salt-run cache.migrate bank=minions - -After the migration completes, edit ``/etc/salt/master`` to set -``cache: mmap_cache`` and restart the master. - -Minion keys (``localfs_key`` → ``mmap_key``) --------------------------------------------- - -.. code-block:: bash - - # Preview — file-system count of accepted/pending/rejected/denied keys. - salt-run pki.migrate_to_mmap dry_run=True - - # Load the existing PKI tree into the mmap_key index. - salt-run pki.migrate_to_mmap - -Then set ``keys.cache_driver: mmap_key`` and restart the master. The -on-disk PKI files remain in place as the durable record; the mmap index is -the lookup accelerator. - - -Durability and concurrency -========================== - -Writes are durable. Every ``store`` / ``delete`` / ``atomic_rebuild`` call: - -#. Writes the heap record, ``flush()`` + ``fsync`` on the heap fd. -#. Updates the index slot, ``msync`` + ``fsync`` on the index fd. -#. Updates the roster, ``flush()`` + ``fsync`` on the roster fd. - -A crash between steps is recoverable: the index header tracks -``occupied_count``, the roster is rebuilt from the index on the next -``open(write=True)`` if their entry counts diverge. - -Multiple master worker processes can read and write the same bank -concurrently. Writers serialise through ``fcntl.flock`` on a per-bank -``.lock`` file; readers do not lock and use shared mmaps so they see -writes immediately through the page cache. Cross-process consistency was -hardened in 3009.0 — see the test suite under -``tests/pytests/functional/utils/test_mmap_cache.py::TestMultiProcess``. - - -Sizing the index -================ - -The index file is preallocated to ``size × slot_size`` bytes. Defaults -(``size=1_000_000``, ``slot_size=96``) reserve **96 MiB of virtual address -space per bank** — not resident RAM, the kernel pages it on demand. Pick -``size`` to be roughly twice the maximum number of keys you expect in any -bank: a 60 % load factor keeps probe chains short under linear probing. - -For very large fleets, raise ``mmap_cache_size`` rather than running multiple -caches. The heap-segment cap (``mmap_cache_max_segment_bytes``) applies -independently and rolls a new segment file when the active one fills, so the -total store can exceed any single segment's size. - - -Compaction -========== - -Deletes mark slots as DELETED and leave heap bytes as garbage. Over time -that grows the on-disk footprint without changing live data. - -The index header tracks live and deleted counts, so ``cache.get_stats(bank)`` -reports the fragmentation ratio. Run ``MmapCache.atomic_rebuild`` to -defragment — it writes a fresh index, heap, and roster, then atomically -swaps all three. A runner-level entry point for this is on the roadmap. - -For Raft and other consensus uses, log compaction maps directly to -``atomic_rebuild`` after a snapshot — the unused log-entry heap regions are -reclaimed in the same pass. - - -See also -======== - -* :py:mod:`salt.cache.mmap_cache` — generic backend module reference. -* :py:mod:`salt.cache.mmap_key` — minion-key backend module reference. -* :py:mod:`salt.utils.mmap_cache` — underlying ``MmapCache`` index/heap/roster - implementation. -* :conf_master:`cache` — master option to select the cache backend. -* :conf_master:`keys.cache_driver` — master option to select the keys backend. -* :ref:`tutorial-master-cluster` — isolated-filesystem master clusters use - ``mmap_key`` for the minion-key store. diff --git a/doc/topics/performance/worker_pools.rst b/doc/topics/performance/worker_pools.rst deleted file mode 100644 index 96ae1ab6cd11..000000000000 --- a/doc/topics/performance/worker_pools.rst +++ /dev/null @@ -1,281 +0,0 @@ -.. _tunable-worker-pools: - -==================== -Tunable Worker Pools -==================== - -.. versionadded:: 3008.0 - -The Salt Master dispatches every minion and API request to an ``MWorker`` -process. Historically all workers belong to a single pool sized by -:conf_master:`worker_threads`, which means a single slow or expensive command -can occupy every worker and delay time-critical work such as authentication or -job publication. - -Tunable worker pools let you partition the master's MWorkers into any number -of named pools and route specific commands to specific pools. This gives you -transport-agnostic, in-master Quality of Service without running a separate -master per workload. - - -When to use worker pools -======================== - -Worker pools solve problems that surface as minion *starvation* or -authentication timeouts under load: - -* A handful of minions run long state applies that hold MWorkers for minutes at - a time, blocking every other minion's returns and ``_auth`` requests behind - them. -* Runner or wheel calls issued from an orchestration engine or the salt-api - compete for workers with minion traffic. -* A noisy subset of minions (heavy returners, peer publish, beacons) needs to - be isolated so it can't crowd out the rest of the fleet. - -When pools are enabled, incoming requests are classified by their ``cmd`` -field and dispatched to the pool that owns that command. Each pool has its -own IPC RequestServer and its own MWorker processes, so work in one pool -cannot block work in another. - -Pools are a drop-in replacement for :conf_master:`worker_threads`. A master -with the default configuration uses a single "default" pool with five workers -and a catchall of ``*`` — byte-for-byte equivalent to the legacy -single-pool behavior. - - -Quick start -=========== - -The default configuration requires no changes and matches the legacy behavior -exactly. To carve a dedicated pool off for authentication, for example, add -the following to ``/etc/salt/master``: - -.. code-block:: yaml - - worker_pools: - auth: - worker_count: 2 - commands: - - _auth - default: - worker_count: 5 - commands: - - "*" - -With that configuration the master starts two pools: - -* ``auth`` — two MWorkers that only ever handle ``_auth`` requests. -* ``default`` — five MWorkers that handle every other command (thanks to the - catchall ``*``). - -Because ``_auth`` now has a dedicated pool it can never be starved by -long-running ``_return`` or ``_minion_event`` traffic in the default pool. - - -Configuration reference -======================= - -Worker pools are controlled by two master options: - -* :conf_master:`worker_pools_enabled` -* :conf_master:`worker_pools` - -See :ref:`the master configuration reference ` for -the authoritative description of each option. - -Per-pool settings ------------------ - -Each entry under ``worker_pools`` is a pool definition with the following -keys: - -``worker_count`` (integer, required) - The number of MWorker processes to start for the pool. Must be ``>= 1``. - -``commands`` (list of strings, required) - The commands routed to this pool. Each entry is matched against the - ``cmd`` field of the incoming payload. - - * An exact string (for example ``_auth`` or ``_return``) matches a single - command. - * A single ``"*"`` entry makes the pool a *catchall* that receives every - command no other pool has claimed. - - A command must be mapped to at most one pool. Exactly one pool must use - the ``"*"`` catchall entry so every command has a routing destination. - -The catchall pool ------------------ - -Every configuration must have a fallback for commands that are not -explicitly mapped. Designate one pool as the catchall by giving it -``commands: ["*"]`` (or by including ``"*"`` alongside explicit commands). - -The master refuses to start if no pool provides a catchall, or if multiple -pools declare one. - -Backward compatibility with ``worker_threads`` ----------------------------------------------- - -If ``worker_pools`` is *not* set but :conf_master:`worker_threads` is, the -master automatically builds a single catchall pool with -``worker_count == worker_threads``. Existing configurations therefore keep -working without any changes. - -To disable pooling entirely and use the old single-queue MWorker model, set -``worker_pools_enabled: False``. This is primarily useful for debugging or -for transports that do not yet support pooled routing natively. - - -Worked examples -=============== - -Isolate authentication ----------------------- - -The most common use case: guarantee ``_auth`` is never blocked behind slow -minion returns. - -.. code-block:: yaml - - worker_pools: - auth: - worker_count: 2 - commands: - - _auth - default: - worker_count: 8 - commands: - - "*" - -Separate minion returns, peer publish, and the rest ---------------------------------------------------- - -Large deployments frequently want to isolate high-volume return traffic from -the authentication and publish paths: - -.. code-block:: yaml - - worker_pools: - auth: - worker_count: 2 - commands: - - _auth - returns: - worker_count: 10 - commands: - - _return - - _syndic_return - peer: - worker_count: 4 - commands: - - _minion_event - - _master_tops - default: - worker_count: 4 - commands: - - "*" - - -Architecture -============ - -When :conf_master:`worker_pools_enabled` is ``True`` (the default) the master -wraps its external transport in a ``PoolRoutingChannel``: - -.. code-block:: text - - External transport (4506) - │ - ▼ - PoolRoutingChannel - │ route by payload['load']['cmd'] - ▼ - Per-pool IPC RequestServer ─► MWorker--0 - ─► MWorker--1 - ─► ... - -The routing channel inspects the ``cmd`` field of each incoming request -(decrypting first where required) and forwards the original payload over an -IPC channel to the target pool's RequestServer, which in turn dispatches it -to one of its MWorkers. Each pool has its own IPC socket (or TCP port in -``ipc_mode: tcp`` deployments), so backpressure and workload in one pool -stays local to that pool. - -Because routing is performed inside the routing process and the payload is -forwarded intact, the pool decision is made without modifying transports. -ZeroMQ, TCP, and WebSocket masters all benefit equally. - -MWorker naming --------------- - -When pools are active, MWorker process titles include their pool name and -index, for example ``MWorker-auth-0`` or ``MWorker-default-3``. This makes -per-pool resource usage easy to inspect with ``ps``, ``top``, or Salt's own -process metrics. - -Authentication execution path ------------------------------ - -``_auth`` is executed in exactly one place regardless of whether pooling is -enabled: - -* With pools enabled, ``_auth`` is routed like any other command to the pool - that owns it (or the catchall). The worker in that pool invokes - ``salt.master.ClearFuncs._auth`` directly. -* With pools disabled, the plain request server channel intercepts ``_auth`` - inline before any payload reaches a worker and handles it in-process. - -The two code paths are mutually exclusive. See the class docstrings on -``salt.channel.server.ReqServerChannel`` and -``salt.channel.server.PoolRoutingChannel`` for the full rationale. - - -Sizing guidance -=============== - -Worker pools shift the sizing question from "how many MWorkers in total" to -"how many MWorkers per workload". As a starting point: - -* Sum of ``worker_count`` across all pools should stay within about 1.5× the - available CPU cores, matching the historical - :conf_master:`worker_threads` guidance. -* Reserve a small, dedicated pool for ``_auth`` (2 workers is usually enough) - whenever you have workloads that can stall a pool for more than a few - seconds. -* Size the return/peer pools based on steady-state minion traffic. As a - rough rule of thumb, start with one worker per 200 actively returning - minions and adjust based on observed queue depth. -* Keep a catchall or explicit default pool big enough to absorb the - background noise of runners, wheels, and miscellaneous commands. - - -Validation and failure modes -============================ - -The master validates the pool configuration at startup and refuses to run if -any of the following are true: - -* ``worker_pools`` is not a dictionary or is empty. -* A pool name is not a string, is empty, contains a path separator - (``/`` or ``\``), begins with ``..``, or contains a null byte. -* A pool is missing ``worker_count`` or the value is not an integer ``>= 1``. -* A pool's ``commands`` field is missing, not a list, or empty. -* The same command is claimed by more than one pool. -* No pool, or more than one pool, uses the ``"*"`` catchall entry. - -Errors are reported with a consolidated message listing every problem the -validator found, making it straightforward to fix the configuration in a -single pass. - - -Observability -============= - -Every routing decision is counted per-pool inside the master. The pool name -is also embedded in the MWorker process title, so standard process -inspection tools give you a clear view of per-pool CPU and memory usage. - -Routing log lines are emitted at ``INFO`` level when pools come up and at -``DEBUG`` level for each routing decision. Enable debug logging on the -master if you need to trace which pool handled a specific request. diff --git a/doc/topics/releases/0.8.9.rst b/doc/topics/releases/0.8.9.rst index ab304d98dbb2..767e7b3ee853 100644 --- a/doc/topics/releases/0.8.9.rst +++ b/doc/topics/releases/0.8.9.rst @@ -100,7 +100,7 @@ moosefs ~~~~~~~ Initial support for reporting on aspects of the distributed file system, -MooseFS. For more information on MooseFS please see: https://moosefs.com +MooseFS. For more information on MooseFS please see: http://www.moosefs.org Thanks to Joseph Hall for his work on MooseFS support. diff --git a/doc/topics/releases/2017.7.0.rst b/doc/topics/releases/2017.7.0.rst index 30cabb16d332..9b8fa3881bc8 100644 --- a/doc/topics/releases/2017.7.0.rst +++ b/doc/topics/releases/2017.7.0.rst @@ -581,13 +581,14 @@ if so, the old container is stopped and destroyed, and the temporary container is renamed and started. Salt still needs to translate arguments into the format which docker-py -expects, but if it does not properly do so, the REMOVED DURRING MODULE -MIGRATION argument can be used to skip input translation on an -argument-by-argument basis, and you can then format your SLS file to pass the -data in the format that the docker-py expects. This allows you to work around -any changes in Docker's API or issues with the input translation, and continue -to manage your Docker containers using Salt. Read the documentation for REMOVED -DURRING MODULE MIGRATION for more information. +expects, but if it does not properly do so, the :ref:`skip_translate +` argument can be used to skip input +translation on an argument-by-argument basis, and you can then format your SLS +file to pass the data in the format that the docker-py expects. This allows you +to work around any changes in Docker's API or issues with the input +translation, and continue to manage your Docker containers using Salt. Read the +documentation for :ref:`skip_translate +` for more information. .. note:: When running the :py:func:`docker_container.running diff --git a/doc/topics/releases/2017.7.3.rst b/doc/topics/releases/2017.7.3.rst index dfb66a18ad6b..d08562f37dfd 100644 --- a/doc/topics/releases/2017.7.3.rst +++ b/doc/topics/releases/2017.7.3.rst @@ -1768,7 +1768,7 @@ Changelog for v2017.7.2..v2017.7.3 * 04b97bcfad Merge pull request `#44663`_ from whytewolf/ZD1777_ensure_understanding_of_minion_config_over_grains_file - * c9122e4b85 fixed pylint error, and updated description on at the top the module and state. + * c9122e4b85 fixed pylint error, and updated description on at the top the the module and state. * 7fb208b5ad Update note in topics/grains to reflect that not all grains are ignored. only those set in the minion config diff --git a/doc/topics/releases/2018.3.0.rst b/doc/topics/releases/2018.3.0.rst index ee173fedf44c..65e8e81e311c 100644 --- a/doc/topics/releases/2018.3.0.rst +++ b/doc/topics/releases/2018.3.0.rst @@ -54,7 +54,7 @@ Lots of Docker Improvements Much Improved Support for Docker Networking ******************************************* -The REMOVED DURRING MODULE MIGRATION +The :py:func:`docker_network.present ` state has undergone a full rewrite, which includes the following improvements: Full API Support for Network Management @@ -70,7 +70,7 @@ Custom Subnets ************** Custom subnets can now be configured. Both IPv4 and mixed IPv4/IPv6 networks -are supported. See REMOVED DURRING MODULE MIGRATION for +are supported. See :ref:`here ` for more information. Network Configuration in :py:func:`docker_container.running ` States @@ -78,7 +78,7 @@ Network Configuration in :py:func:`docker_container.running ` for more information. .. note:: diff --git a/doc/topics/releases/2019.2.1.rst b/doc/topics/releases/2019.2.1.rst index 22d40d68d444..5c84a644de27 100644 --- a/doc/topics/releases/2019.2.1.rst +++ b/doc/topics/releases/2019.2.1.rst @@ -5680,7 +5680,7 @@ Changelog for v2019.2.0..v2019.2.1 * 41ae390 Merge pull request `#51231`_ from terminalmage/issue51056 - * 4a61477 Clarify documentation for the gitfs "all_saltenvs" config param + * 4a61477 Clarify documentation for the the gitfs "all_saltenvs" config param * 0574476 Merge branch '2018.3' into fix_test_pkg diff --git a/doc/topics/releases/3001.rst b/doc/topics/releases/3001.rst index e514ee3ef51d..8172b7ec1301 100644 --- a/doc/topics/releases/3001.rst +++ b/doc/topics/releases/3001.rst @@ -8,8 +8,8 @@ Python 2 Dropped ================ Python 2 support has been dropped in Salt 3001. See -https://web.archive.org/web/20200617021808/https://community.saltstack.com/blog/sunsetting-python-2-support/ -for more info. +https://community.saltstack.com/blog/sunsetting-python-2-support/ for more +info. Salt mine updates diff --git a/doc/topics/releases/3008.0.md b/doc/topics/releases/3008.0.md deleted file mode 100644 index d0771940e3e4..000000000000 --- a/doc/topics/releases/3008.0.md +++ /dev/null @@ -1,370 +0,0 @@ -(release-3008.0)= -# Salt 3008.0 release notes - - - - - - -## Salt Resources - -Salt 3008.0 introduces **Salt Resources** — a first-class targeting -primitive for things a minion manages on behalf of the master, like SSH -hosts, virtual appliances, API endpoints, or any other "remote thing" -that can't or shouldn't run a minion of its own. - -A managing minion can manage many resources of many types. Each -resource is addressable by id, by type, or by per-resource grains — -the same targeting forms (`-G`, `-L`, `-C`, glob, list) work -identically against resources and minions. - -Highlights: - -- New compound targeting engine `T@[:]` selects resources - by type, by full SRN, or both. Grain targeting (`-G`, `G@`) and PCRE - grain targeting (`-P`, `P@`) augment matches with per-resource grain - dicts the master cached at registration time. -- The master keeps an mmap-backed registry of which minion manages - which resource (`salt.utils.resource_registry`). Lookups and inserts - are O(1); the registry survives master restarts. -- A managing minion builds a per-resource execution and state loader - keyed by resource type. Per-type modules live under - `salt/resources//{modules,states,grains}/` (or under any Salt - extension's `saltext//resources//` tree), with override - files winning their slot by directory order. Standard Salt modules - fill any slot a resource type doesn't override. -- Merge-mode `state.apply`/`state.highstate`/`state.sls` against - resource targets folds per-resource state results into one combined - block on the managing minion, prefixed by resource id, matching how - any other minion looks to the master. -- New escape-hatch dunder `__minion__` gives per-resource execution - and state modules explicit access to the managing minion's loader - when they need to do something on the host. -- New operator runners: `salt-run resource.list_grains`, - `salt-run resource.show_grains`, `salt-run resource.refresh`. -- New `salt-call` flags `-r/--resources`, `--tgt`, and `--tgt-type` - make resource dispatch available from masterless and local - troubleshooting workflows. - -Two reference resource types ship with Salt: `dummy` -(filesystem-backed, for tests and tutorials) and `ssh` (over the salt-ssh -transport). Extension authors can ship their own resource types via a -standard `salt.loader` entry point — no core changes required. - -See the [Salt Resources documentation](../resources/index) for the -conceptual overview, the [tutorial](../resources/tutorial) for a -10-minute walk-through, and the [authoring -guide](../resources/authoring/index) for shipping your own resource -type. - - -## Changelog - -### Removed - -- Remove commuity extensions from Salt codebase [#65970](https://github.com/saltstack/salt/issues/65970) -- Remove deprecated module search path priority (`features.enable_deprecated_module_search_path_priority`) [#66025](https://github.com/saltstack/salt/issues/66025) -- Remove the __orchestration__ key from salt.runner and salt.wheel return data. [#66151](https://github.com/saltstack/salt/issues/66151) -- Removed linode-python package dependency for retired Linode API v3 [#68871](https://github.com/saltstack/salt/issues/68871) -- Removed legacy ``salt.transport.ipc`` module and unused ``PushChannel`` / ``PullChannel`` factories; local events use ``ipc_publish_client`` / ``ipc_publish_server`` (TCP transport). [#69001](https://github.com/saltstack/salt/issues/69001) - - -### Deprecated - -- Deprecated the use of egrep in favor of grep -E [#65608](https://github.com/saltstack/salt/issues/65608) - - -### Changed - -- Make sure every auth event has the 'act' key set [#56200](https://github.com/saltstack/salt/issues/56200) -- Ansiblegate discover_playbooks was changed to find playbooks as either *.yml or *.yaml files [#66048](https://github.com/saltstack/salt/issues/66048) -- re-work the aptpkg module to remove system libraries that onedir and virtualenvs do not have access. Streamline testing, and code use to needed libraries only. [#66056](https://github.com/saltstack/salt/issues/66056) -- Made gpg modules respect user's GNUPGHOME if set in shell environment [#66313](https://github.com/saltstack/salt/issues/66313) -- Made `gpg.present` attempt to refresh keys if they are expired [#66314](https://github.com/saltstack/salt/issues/66314) -- Made x509_v2 the default x509 modules. Until they are removed in the next major release, you can still revert to the old modules by setting `features: {x509_v2: false}` in the configuration [#66384](https://github.com/saltstack/salt/issues/66384) -- Included Salt extensions in Salt-SSH thin archive [#66559](https://github.com/saltstack/salt/issues/66559) -- Add support for additional options in several mac_brew_pkg methods [#66611](https://github.com/saltstack/salt/issues/66611) -- Make test_pip and test_fileserver tests compatible with venv execution [#66703](https://github.com/saltstack/salt/issues/66703) -- Do not use `ssl.PROTOCOL_TLS` which has been [#66767](https://github.com/saltstack/salt/issues/66767) -- [deprecated](https://docs.python.org/3/library/ssl.html#ssl.PROTOCOL_TLS) in [#66767](https://github.com/saltstack/salt/issues/66767) -- Python 3.10 will be removed in the future. [#66767](https://github.com/saltstack/salt/issues/66767) -- Remove warning when running `slsutil.renderer` on non-SLS files [#67067](https://github.com/saltstack/salt/issues/67067) -- PillarCache: reimplement using salt.cache [#68030](https://github.com/saltstack/salt/issues/68030) -- fix minion data cache organization/move pillar and grains to dedicated cache banks [#68030](https://github.com/saltstack/salt/issues/68030) -- salt.cache: allow cache.store() to set expires per key [#68030](https://github.com/saltstack/salt/issues/68030) -- Provide token storage using the salt.cache interface [#68039](https://github.com/saltstack/salt/issues/68039) -- Update packaged python from 3.10 to 3.11 [#68148](https://github.com/saltstack/salt/issues/68148) -- Added ceph to the specialFSes to match on name for set_fstab [#68207](https://github.com/saltstack/salt/issues/68207) -- Removed `networkx` module dependency by adding MultiDiGraph implementation to `salt.utils.requisite` to avoid extra dependencies. [#68748](https://github.com/saltstack/salt/issues/68748) -- Expanded Thorium documentation with concrete examples and added unit coverage for the documented Thorium workflows. [#68857](https://github.com/saltstack/salt/issues/68857) -- Add stub 3008.0 release notes (and template) so ``tools docs man`` and CI ``prepare-release`` can resolve the current-release doc target. Exclude ``doc/topics/proposals/*.md`` from Sphinx so stand-alone proposal files do not fail strict man builds. [#68964](https://github.com/saltstack/salt/issues/68964) - - -### Fixed - -- Fixed recursive prereq requisites to report recursive requisite error. [#8210](https://github.com/saltstack/salt/issues/8210) -- Fixed erroneous recursive requisite error when a prereq is used in combination with onchanges_any. [#47154](https://github.com/saltstack/salt/issues/47154) -- Fixed an infinite loop in `requisite_any` when a requisite state was not found. [#50436](https://github.com/saltstack/salt/issues/50436) -- Refactoring the redis code obsoletes this issue as return values are either decoded directly or passed to salt.payload for parsing. [#54734](https://github.com/saltstack/salt/issues/54734) -- Fixed `OSError: The operation completed successfully` raised by `CreateProcessWithTokenW` on Windows when the underlying advapi32 call fails. The error code is now read from `ctypes.get_last_error()` (the ctypes-saved slot) instead of `win32api.GetLastError()` (the live Windows slot, which may be reset to 0 before it is read). [#57848](https://github.com/saltstack/salt/issues/57848) -- Fixed dependency resolution to not be quadratic. [#59123](https://github.com/saltstack/salt/issues/59123) -- Fix regex cache exception during sort in sweep function [#59437](https://github.com/saltstack/salt/issues/59437) -- Fixed requisites by parallel states on parallel states being evaluated synchronously (blocking state execution for other parallel states) [#59959](https://github.com/saltstack/salt/issues/59959) -- Fix bug when specifying template_source using net.load_template [#60515](https://github.com/saltstack/salt/issues/60515) -- During the redis refactor the documentation was updated to reference the Redis Cluster pip package. [#60899](https://github.com/saltstack/salt/issues/60899), [#66193](https://github.com/saltstack/salt/issues/66193) -- firewalld: normalize new rich rules before comparing to old ones [#61235](https://github.com/saltstack/salt/issues/61235) -- Fix regression that prevented salt-minion from running interval-based jobs on startup by default. [#61964](https://github.com/saltstack/salt/issues/61964) -- Fixed performance when state_aggregate is enabled. [#62439](https://github.com/saltstack/salt/issues/62439) -- Fixed LGPO ``get_policy_info`` incorrectly returning a "multiple policies" error when duplicate ADMX policy definitions (e.g. ``TerminalServer.admx`` and ``TerminalServer-Server.admx``) resolve to the same full path. [#62732](https://github.com/saltstack/salt/issues/62732) -- Fixed issue with salt-ssh hanging due to non-exposed host key acceptance prompt [#62782](https://github.com/saltstack/salt/issues/62782) -- Repaired zypper repositories being reconfigured without changes [#63402](https://github.com/saltstack/salt/issues/63402) -- Fix calculation of SLS context vars when trailing dots on targetted state [#63411](https://github.com/saltstack/salt/issues/63411) -- Catch StrictUndefined in salt jinja custom filters. [#64915](https://github.com/saltstack/salt/issues/64915) -- Put default `optimization_order` to LazyLoader to prevent possible fails on testing [#65266](https://github.com/saltstack/salt/issues/65266) -- Fixed aggregation to correctly honor requisites. [#65304](https://github.com/saltstack/salt/issues/65304) -- Fixed some instances of deprecated datetime.datetime.utcnow() [#65604](https://github.com/saltstack/salt/issues/65604) -- Introduce pruning option in file.keyvalue [#65631](https://github.com/saltstack/salt/issues/65631) -- fix 65703 by using OrderedDict instead of a index that breaks. . [#65703](https://github.com/saltstack/salt/issues/65703) -- Simplify timezone.compare_zone to primarily rely get_zone() [#65719](https://github.com/saltstack/salt/issues/65719) -- Handle regular expressions which do not not use grouping [#65722](https://github.com/saltstack/salt/issues/65722) -- fix consul.acl_create rule creation [#65788](https://github.com/saltstack/salt/issues/65788) -- Fix salt-cloud get_cloud_config_value for list objects [#65789](https://github.com/saltstack/salt/issues/65789) -- Prevent exceptions with fileserver.update when called via state [#65819](https://github.com/saltstack/salt/issues/65819) -- Fix granting of privileges on Postgres functions [#65839](https://github.com/saltstack/salt/issues/65839) -- Made Salt Cloud Hetzner module detect image architecture from instance type [#65888](https://github.com/saltstack/salt/issues/65888) -- Optimize async calls with using async wrapped method in thread only if io loop is already running [#65983](https://github.com/saltstack/salt/issues/65983) -- salt.auth.pam: fallback to use running Python in case /usr/bin/python3 is not found [#66035](https://github.com/saltstack/salt/issues/66035) -- Fix file.is_link hangs on paths that are hung mounts [#66096](https://github.com/saltstack/salt/issues/66096) -- Fix file.managed and file.serialize default tmp_dir to relative path [#66098](https://github.com/saltstack/salt/issues/66098) -- Make win_timezone recognize Qyzylorda timezone [#66176](https://github.com/saltstack/salt/issues/66176) -- Remove firing useless events with JID as a tag [#66279](https://github.com/saltstack/salt/issues/66279) -- Made gpg modules create GNUPGHOME if it does not exist [#66312](https://github.com/saltstack/salt/issues/66312) -- Fixed an issue where conflicting top level keys in the static grains file [#66445](https://github.com/saltstack/salt/issues/66445) -- (usually `/etc/salt/grains`) would break all grains states, and prevent static [#66445](https://github.com/saltstack/salt/issues/66445) -- grains from being loaded. [#66445](https://github.com/saltstack/salt/issues/66445) -- Fixed beacon delete not calling the beacon's close function, causing resource [#66449](https://github.com/saltstack/salt/issues/66449) -- leaks (e.g. inotify file descriptors) and CPU spin after deleting beacons at [#66449](https://github.com/saltstack/salt/issues/66449) -- runtime via ``beacons.delete``. Also fixed inotify file descriptor leak during [#66449](https://github.com/saltstack/salt/issues/66449) -- beacon refresh when the Beacon instance is replaced. [#66449](https://github.com/saltstack/salt/issues/66449) -- Fixed a regression where setting ``ipv6: true`` in the minion configuration [#66603](https://github.com/saltstack/salt/issues/66603) -- caused the minion to fail to start on Windows. Three IPC socket paths in the [#66603](https://github.com/saltstack/salt/issues/66603) -- TCP transport hardcoded ``AF_INET`` or ``127.0.0.1`` regardless of the IPv6 [#66603](https://github.com/saltstack/salt/issues/66603) -- setting: the IPC publish server/client addresses in ``salt.transport.base``, [#66603](https://github.com/saltstack/salt/issues/66603) -- the ``TCPPuller`` server socket, and the ``_TCPPubServerPublisher`` client [#66603](https://github.com/saltstack/salt/issues/66603) -- socket. On Windows, mixing an ``AF_INET6`` socket with the IPv4 loopback [#66603](https://github.com/saltstack/salt/issues/66603) -- address (or vice-versa) is rejected by the OS. All three paths now use [#66603](https://github.com/saltstack/salt/issues/66603) -- ``::1`` with ``AF_INET6`` when ``ipv6: true`` is set, and ``127.0.0.1`` [#66603](https://github.com/saltstack/salt/issues/66603) -- with ``AF_INET`` otherwise. [#66603](https://github.com/saltstack/salt/issues/66603) -- Make "status.diskusage" more robust and prevent crashes when stats cannot be obtained [#66646](https://github.com/saltstack/salt/issues/66646) -- Use `--cachedir` parameter for setting `extension_modules` with salt-call. [#66742](https://github.com/saltstack/salt/issues/66742) -- Don't schedule `__master_alive` jobs if `master_alive_interval` is not specified [#66757](https://github.com/saltstack/salt/issues/66757) -- Make x509 module compatible with `cryptography` module newer than `43.0.0` [#66818](https://github.com/saltstack/salt/issues/66818) -- Fixed Python 3.13 compatibility regarding urllib.parse module [#66898](https://github.com/saltstack/salt/issues/66898) -- make salt.channel.server.handle_message codepath more defensive [#66909](https://github.com/saltstack/salt/issues/66909) -- Fix the installation of pip modules with special characters in the module name [#66988](https://github.com/saltstack/salt/issues/66988) -- Repaired mount.fstab_present always returning pending changes [#67065](https://github.com/saltstack/salt/issues/67065) -- dictupdate.update: throw a TypeError when trying to merge a list with a mapping when ``merge_lists=True``. [#67092](https://github.com/saltstack/salt/issues/67092) -- Remove usage of spwd [#67119](https://github.com/saltstack/salt/issues/67119) -- Fixed order chunks not handling a state with both require and order first or last [#67120](https://github.com/saltstack/salt/issues/67120) -- Fixed pkg.install in test mode would not detect FreeBSD packages installed by their origin name [#67126](https://github.com/saltstack/salt/issues/67126) -- Fix virtual grains for VMs running on Nutanix AHV [#67180](https://github.com/saltstack/salt/issues/67180) -- The redis refactor fixed the incorrect handling of the cache.list function. [#67250](https://github.com/saltstack/salt/issues/67250) -- Fixed creating relative directory symlinks on Windows, ensured listing targets of symlinks in file_roots always produces POSIX-style paths [#67766](https://github.com/saltstack/salt/issues/67766) -- Avoid loading `salt.utils.crypt` module instead of `crypt` if it's missing in Python as it was deprecated and removed in Python 3.13. [#67797](https://github.com/saltstack/salt/issues/67797) -- Fixed docstring error in salt/modules/file.py that misnamed an option "user" when it should have been "owner". [#67911](https://github.com/saltstack/salt/issues/67911) -- salt.key: check_minion_cache performance optimization [#68030](https://github.com/saltstack/salt/issues/68030) -- when a file is managed, and the same file is cleaned, an incorrect message is displayed saying "removed: Removed due to clean" when the file isn't actually removed. Now the correct message is returned. [#68052](https://github.com/saltstack/salt/issues/68052) -- log_beacon - remove verbose minion log output [#68055](https://github.com/saltstack/salt/issues/68055) -- Fix that the state `saltmod.state` can be used on a masterless minion with salt-ssh like `saltmod.function` currently does. [#68116](https://github.com/saltstack/salt/issues/68116) -- Fixed ssh_known_hosts.present failure when ssh host keys changed [#68132](https://github.com/saltstack/salt/issues/68132) -- grains.disks: fix exception with incompatible output of Get-PhysicalDisk [#68184](https://github.com/saltstack/salt/issues/68184) -- Made osfinger report major&minor version for NixOS [#68230](https://github.com/saltstack/salt/issues/68230) -- Fix tests failing on AlmaLinux 10 and other clones [#68246](https://github.com/saltstack/salt/issues/68246) -- Speedup wheel key.finger call by removing redundant processing calls. [#68251](https://github.com/saltstack/salt/issues/68251) -- Fixed cp.cache_file when using Tornado > 6.4 [#68328](https://github.com/saltstack/salt/issues/68328) -- Stop mutating locals, which is unsupported in Py >=3.13 [#68445](https://github.com/saltstack/salt/issues/68445) -- Add `blockdev` state module back in to core [#68465](https://github.com/saltstack/salt/issues/68465) -- [#68465](https://github.com/saltstack/salt/issues/68465) -- Adds the `blockdev` state module back into the core Salt repo as it is critical functionality that shouldn't have been pulled out in the module migration [#68465](https://github.com/saltstack/salt/issues/68465) -- Adds `mdadm` and `lvm` grains modules back in to core. [#68470](https://github.com/saltstack/salt/issues/68470) -- [#68470](https://github.com/saltstack/salt/issues/68470) -- Restores the modules that had been removed as part of the community module [#68470](https://github.com/saltstack/salt/issues/68470) -- migration. They are core bits of functionality and the associated execution and [#68470](https://github.com/saltstack/salt/issues/68470) -- states modules had not been removed. [#68470](https://github.com/saltstack/salt/issues/68470) -- Fixed grains.list_present state to correctly handle multiple calls within the same state run. [#68520](https://github.com/saltstack/salt/issues/68520) -- Fixed `salt.utils.platform` to properly handle `__salt_system_encoding__` when synced as an extension module. [#68520](https://github.com/saltstack/salt/issues/68520) -- Improved `network.traceroute` parsing to be more robust across different traceroute versions. [#68520](https://github.com/saltstack/salt/issues/68520) -- Added retry logic to `saltutil.wheel` integration test to improve reliability in CI. [#68520](https://github.com/saltstack/salt/issues/68520) -- Improved architecture detection in `salt-ssh` to better support ARM64 platforms. [#68520](https://github.com/saltstack/salt/issues/68520) -- Fixed `salt-ssh` extension module syncing to avoid accidentally bundling core Salt modules and to correctly load wrapper modules. [#68520](https://github.com/saltstack/salt/issues/68520) -- Ensured `salt-ssh` relenv tests skip gracefully if the relenv tarball is unavailable in the test environment. [#68520](https://github.com/saltstack/salt/issues/68520) -- Fixed `mine.get` runner to correctly handle master's ID when ACLs are enabled. [#68520](https://github.com/saltstack/salt/issues/68520) -- Fixed `win_useradd.get_user_sid` to correctly handle non-string input. [#68520](https://github.com/saltstack/salt/issues/68520) -- Improved reliability of `state.running` integration test for `salt-ssh`. [#68520](https://github.com/saltstack/salt/issues/68520) -- Fixed high CPU usage in minion asynchronous authentication loop when masters are unreachable. [#68520](https://github.com/saltstack/salt/issues/68520) -- Added support for running Salt tools using `python -m tools`. [#68520](https://github.com/saltstack/salt/issues/68520) -- Adds `alias` state module back in to core. [#68574](https://github.com/saltstack/salt/issues/68574) -- [#68574](https://github.com/saltstack/salt/issues/68574) -- Restores the module that had been removed as part of the [#68574](https://github.com/saltstack/salt/issues/68574) -- community module migration. The associated execution module [#68574](https://github.com/saltstack/salt/issues/68574) -- had not been migrated. [#68574](https://github.com/saltstack/salt/issues/68574) -- Fixed mongodb tops module authentication to be compatible with pymongo v4+ by passing credentials directly to MongoClient instead of using the deprecated authenticate() method [#68659](https://github.com/saltstack/salt/issues/68659) -- Improved the rejected authentication warning message to include the minion ID, [#68671](https://github.com/saltstack/salt/issues/68671) -- making it easier for administrators to identify which minions need upgrading. [#68671](https://github.com/saltstack/salt/issues/68671) -- This PR fixes a bug where corrupted grains cache files cause unhandled [#68678](https://github.com/saltstack/salt/issues/68678) -- `SaltDeserializationError` exceptions, resulting in CRITICAL errors. [#68678](https://github.com/saltstack/salt/issues/68678) -- The fix adds proper exception handling to gracefully recover from corrupted [#68678](https://github.com/saltstack/salt/issues/68678) -- cache by regenerating grains. [#68678](https://github.com/saltstack/salt/issues/68678) -- Fix ansible.playbooks extra_vars quoting to prevent passing broken variables to ansible-playbook. [#68787](https://github.com/saltstack/salt/issues/68787) -- Make `x86_64_v2` to be handled properly with `salt.modules.yumpkg` module as a possible package architecture. [#68789](https://github.com/saltstack/salt/issues/68789) -- Make `salt-ssh` work without issues using `domain\user` notation for remote user with SSH. [#68790](https://github.com/saltstack/salt/issues/68790) -- Fixed source package builds (DEB/RPM) failing with ``LookupError: hatchling is already being built`` by adding ``hatchling`` to the ``--only-binary`` allow-list so pip uses its universal wheel instead of attempting a circular source build. [#68858](https://github.com/saltstack/salt/issues/68858) -- Use a 30 second ``salt`` CLI timeout in the reauth scenario tests so Windows CI does not time out on ``test.ping`` after master/minion restart (default was often 5s). [#68924](https://github.com/saltstack/salt/issues/68924) -- Fix logging in potentially dead process in reap_stray_processes fixture [#68927](https://github.com/saltstack/salt/issues/68927) -- Fix dynamic version discovery on a new release branch before the first ``v*`` tag exists: ``git describe`` still anchored on the previous line (e.g. ``v3007.13``) is lifted to the unreleased codename baseline (e.g. ``3008.0``) while keeping the commit offset and SHA. [#68964](https://github.com/saltstack/salt/issues/68964) -- Remove deprecations. [#68985](https://github.com/saltstack/salt/issues/68985) -- - salt/auth/pki.py (removed) [#68985](https://github.com/saltstack/salt/issues/68985) -- - salt/features.py (removed) [#68985](https://github.com/saltstack/salt/issues/68985) -- - salt/modules/nxos.py (modified) [#68985](https://github.com/saltstack/salt/issues/68985) -- debpkg include 0/1 as valid options when parsing bool values in deb822 [#68996](https://github.com/saltstack/salt/issues/68996) -- Drain cancelled tasks on PublishClient close so the TCP transport no longer prints `[ERROR ] Task was destroyed but it is pending!` at the end of every salt command. [#68998](https://github.com/saltstack/salt/issues/68998) -- Upgrade packaged python to 3.14 [#69014](https://github.com/saltstack/salt/issues/69014) -- ``LoadAuth.get_tok`` now distinguishes between corrupt token blobs (removed from the store) and transient backend errors such as Redis connection drops or NFS hangs (token kept, request treated as not-authenticated). Previously a single backend hiccup could log every authenticated user out by deleting valid tokens. [#69073](https://github.com/saltstack/salt/issues/69073) -- Fix pip install -e salt [#69101](https://github.com/saltstack/salt/issues/69101) -- * Relenv 0.22.11 [#69129](https://github.com/saltstack/salt/issues/69129) -- - Update python 3.14 to 3.14.5 [#69129](https://github.com/saltstack/salt/issues/69129) -- - Update sqlite to 3.53.1.0 (CVE-2025-70873) [#69129](https://github.com/saltstack/salt/issues/69129) -- - Update expat to 2.8.1 (CVE-2026-41080 and CVE-2026-45186) [#69129](https://github.com/saltstack/salt/issues/69129) -- Fix master crash when `presence_events: True` is set on Python 3.14 by skipping the shared `secrets` dict during `iter_transport_opts` deepcopy. [#69146](https://github.com/saltstack/salt/issues/69146) -- Fixed ``lgpo_reg.value_absent`` failing when the Registry.pol entry was already absent but the registry value still existed. ``lgpo_reg.delete_value`` was returning early before reaching the registry cleanup code, causing the state to see no changes and report failure. The registry value is now removed regardless of whether the pol entry was present. [#69203](https://github.com/saltstack/salt/issues/69203) -- Fixed `!!binary` YAML tag failing with "Incorrect padding" when base64 padding characters are omitted. Salt's YAML loader now tolerates unpadded base64 values, restoring behavior that worked on Salt 3006 (Python 3.10). [#69207](https://github.com/saltstack/salt/issues/69207) -- Fixed the ``yaml`` Jinja filter returning ``NULL`` when applied to Pillar [#69218](https://github.com/saltstack/salt/issues/69218) -- lists or dicts. Pillar containers are wrapped in ``MaskedDict`` / [#69218](https://github.com/saltstack/salt/issues/69218) -- ``MaskedList`` for repr redaction; representers are now registered so the [#69218](https://github.com/saltstack/salt/issues/69218) -- YAML dumper serializes them as their underlying list / dict. [#69218](https://github.com/saltstack/salt/issues/69218) - - -### Added - -- Added proxy option to `gitfs`, `git_pillar` and `winrepo` for specifying a proxy server used to connect to git repositories [#30990](https://github.com/saltstack/salt/issues/30990) -- Added ``shadow.verify_password`` to ``salt.modules.win_shadow``, which [#41347](https://github.com/saltstack/salt/issues/41347) -- validates a Windows user's password via ``LogonUser`` with [#41347](https://github.com/saltstack/salt/issues/41347) -- ``LOGON32_LOGON_NETWORK`` (Microsoft's recommended approach per [#41347](https://github.com/saltstack/salt/issues/41347) -- `KB180548 `_) without [#41347](https://github.com/saltstack/salt/issues/41347) -- creating an interactive session. If the check causes an account lockout, [#41347](https://github.com/saltstack/salt/issues/41347) -- the account is automatically unlocked. Updated ``user.present`` on Windows [#41347](https://github.com/saltstack/salt/issues/41347) -- to use ``shadow.verify_password`` so the password is only changed when it [#41347](https://github.com/saltstack/salt/issues/41347) -- differs from the current value, matching the idempotent behaviour on other [#41347](https://github.com/saltstack/salt/issues/41347) -- platforms. [#41347](https://github.com/saltstack/salt/issues/41347) -- Added support for limiting the number of parallel states executing at the same time via `state_max_parallel` [#49301](https://github.com/saltstack/salt/issues/49301) -- Added metalink to mod_repo in yumpkg and documented in pkgrepo state [#58931](https://github.com/saltstack/salt/issues/58931) -- Add 'show_changes' arg for file.append and file.prepend states to hide output [#59329](https://github.com/saltstack/salt/issues/59329) -- Added ssl and verify_ssl arguments to mongodb module and states. [#59927](https://github.com/saltstack/salt/issues/59927) -- Added two new options, ``win_delay_start`` and ``win_install_dir``, to pass to [#61318](https://github.com/saltstack/salt/issues/61318) -- the Windows installer in salt-cloud [#61318](https://github.com/saltstack/salt/issues/61318) -- Add context aware change handling for file state module [#63328](https://github.com/saltstack/salt/issues/63328) -- Added the ability to access already compiled pillar data during the pillar rendering process via the `__pillar__` global in templates and matchers. [#64043](https://github.com/saltstack/salt/issues/64043) -- Allow salt-call arguments --file-root, --pillar-root and --states-dir to be specified multiple times [#64486](https://github.com/saltstack/salt/issues/64486) -- Adds documentation notes to clarify that Salt's file module only supports numeric mode specifications and does not support symbolic modes. [#64624](https://github.com/saltstack/salt/issues/64624) -- Added management of SSH keys and certificates [#65197](https://github.com/saltstack/salt/issues/65197) -- Add option (auth_events_autosign_grains) to add autosign_grains to auth events [#65426](https://github.com/saltstack/salt/issues/65426) -- Added `use_os_truststore` configuration option (default `False`) that instructs Salt to use the native operating system certificate store (Windows Certificate Store, macOS Keychain, or Linux system trust) for SSL/TLS verification instead of the bundled certifi CA bundle. Requires the `truststore` package (Python 3.10+). Also adds the `ca_truststore` grain that reports which store is active (`certifi` or `os`). [#65439](https://github.com/saltstack/salt/issues/65439) -- Enable "KeepAlive" probes for Salt SSH executions [#65488](https://github.com/saltstack/salt/issues/65488) -- Add ability to show diff for new files in file.managed [#65546](https://github.com/saltstack/salt/issues/65546) -- Added Virtuozzo Linux to Redhat os_family [#65600](https://github.com/saltstack/salt/issues/65600) -- Pillar dunder is now available in extension modules during pillar render. [#65724](https://github.com/saltstack/salt/issues/65724) -- Added x509_v2 SSH wrapper module. In addition to the regular calls, it provides a function for statefully managing remote certificates, even when access to the event bus is required [#65728](https://github.com/saltstack/salt/issues/65728) -- Introduce fibre_channel_host grain [#65750](https://github.com/saltstack/salt/issues/65750) -- Make `salt-run jobs.master` return runner jobs that are currently running on a master. [#66007](https://github.com/saltstack/salt/issues/66007) -- Added file and plaintext sources to `gpg.present`, allowed to skip keyserver queries [#66173](https://github.com/saltstack/salt/issues/66173) -- added pkg.which to aptpkg, for finding which package installed a file. [#66201](https://github.com/saltstack/salt/issues/66201) -- Allow pre-connection scripts to be run on host before any ssh commands [#66210](https://github.com/saltstack/salt/issues/66210) -- Added port, tls, username and password to the `smtp` configuration of the highstate returner. [#66251](https://github.com/saltstack/salt/issues/66251) -- Improve macOS defaults support [#66466](https://github.com/saltstack/salt/issues/66466) -- Added support for specifying different signature verification backends in `file.managed`/`archive.extracted` [#66527](https://github.com/saltstack/salt/issues/66527) -- Added an `asymmetric` execution module for signing/verifying data using raw asymmetric algorithms [#66528](https://github.com/saltstack/salt/issues/66528) -- Added support in service Beacon for only fire matching configured running state [#66809](https://github.com/saltstack/salt/issues/66809) -- Add --relenv Option to salt-ssh for Using a Onedir Bundled Salt+Python [#66877](https://github.com/saltstack/salt/issues/66877) -- Add support for state.sls_exists when using salt-ssh [#66894](https://github.com/saltstack/salt/issues/66894) -- Add detection for OS grains when running in [AlmaLinux Kitten](https://wiki.almalinux.org/release-notes/kitten-10.html) [#66991](https://github.com/saltstack/salt/issues/66991) -- Added a `merge` option to `file.recurse`, which merges subpaths from all existing `source`s before managing the directory. Handy when using different saltenvs or the TOFS pattern. [#67072](https://github.com/saltstack/salt/issues/67072) -- Add `_auth` calls to the master stats [#67746](https://github.com/saltstack/salt/issues/67746) -- Added possibility to load data from multiple inventories with `ansible.targets`. [#67776](https://github.com/saltstack/salt/issues/67776) -- Detect openEuler as RedHat family OS. [#67796](https://github.com/saltstack/salt/issues/67796) -- refactored server-side PKI to support cache interface [#67799](https://github.com/saltstack/salt/issues/67799) -- optimization: check_compound_minions: defer _pki_minions fetch [#67799](https://github.com/saltstack/salt/issues/67799) -- refactor: push salt.utils.minions bits into salt.key / optimize matching [#67799](https://github.com/saltstack/salt/issues/67799) -- Add deb822 apt source format support to aptpkg module [#67956](https://github.com/saltstack/salt/issues/67956) -- Add subsystem filter to "udev.exportdb" execution module function [#68047](https://github.com/saltstack/salt/issues/68047) -- Implement SL Micro 6.2 detection to fill the grains with proper values. [#68247](https://github.com/saltstack/salt/issues/68247) -- Added booleans argument to selinux.booleans [#68323](https://github.com/saltstack/salt/issues/68323) -- Added mod_aggregate to selinux to combine boolean [#68323](https://github.com/saltstack/salt/issues/68323) -- Added some type hints to selinux module and made some minor changes to improve readability and performance slightly [#68323](https://github.com/saltstack/salt/issues/68323) -- Add support for minion_id in log formats [#68410](https://github.com/saltstack/salt/issues/68410) -- [#68410](https://github.com/saltstack/salt/issues/68410) -- Adds support for including `%(minion_id)s` in log formats. Where id is available log messages on the master will have that data added to allow easier correlation of messages to minions. [#68410](https://github.com/saltstack/salt/issues/68410) -- Added feature parity for relenv and thin dir with salt-ssh. All salt-ssh tests pass with both thin dir and relenv. [#68531](https://github.com/saltstack/salt/issues/68531) -- Added tunable worker pools: partition the master's MWorkers into named pools [#68532](https://github.com/saltstack/salt/issues/68532) -- and route specific commands (for example `_auth`) to dedicated pools so a [#68532](https://github.com/saltstack/salt/issues/68532) -- slow workload cannot starve time-critical traffic. Controlled by the new [#68532](https://github.com/saltstack/salt/issues/68532) -- `worker_pools` and `worker_pools_enabled` master settings; see the "Tunable [#68532](https://github.com/saltstack/salt/issues/68532) -- Worker Pools" topic guide for details. Existing `worker_threads` [#68532](https://github.com/saltstack/salt/issues/68532) -- configurations remain fully backward compatible. [#68532](https://github.com/saltstack/salt/issues/68532) -- Added TLS encryption optimization via disable_aes_with_tls config option that eliminates redundant AES encryption when TLS with mutual authentication is active, improving performance while maintaining security through certificate identity verification. [#68536](https://github.com/saltstack/salt/issues/68536) -- utils.dictdiffer: support diffing of dicts in lists [#68726](https://github.com/saltstack/salt/issues/68726) -- Add support for nix package manager. [#68752](https://github.com/saltstack/salt/issues/68752) -- Added a centralized, declarative system for managing Salt's optional dependencies and their version-specific requirements in ``salt/utils/versions.py``. [#68894](https://github.com/saltstack/salt/issues/68894) -- Pillar data is now wrapped in SafeDict/SafeList with Pydantic SecretStr/SecretBytes for safer logging and output; optional state `no_log` and automatic redaction of pillar literals in state returns and minion job logs. [#68907](https://github.com/saltstack/salt/issues/68907) -- Added a fast memory-mapped cache backend (``salt.cache.mmap_cache``): [#68936](https://github.com/saltstack/salt/issues/68936) -- an O(1) hash-table store with a segmented heap, durable and multi-process [#68936](https://github.com/saltstack/salt/issues/68936) -- safe, usable as a drop-in for ``localfs`` via the ``cache`` master setting. [#68936](https://github.com/saltstack/salt/issues/68936) -- A specialised variant (``salt.cache.mmap_key``) replaces linear ``pki_dir`` [#68936](https://github.com/saltstack/salt/issues/68936) -- scans for the master's minion-key store; select it with [#68936](https://github.com/saltstack/salt/issues/68936) -- ``keys.cache_driver: mmap_key``. Migrate existing data with [#68936](https://github.com/saltstack/salt/issues/68936) -- ``salt-run cache.migrate`` and ``salt-run pki.migrate_to_mmap``. [#68936](https://github.com/saltstack/salt/issues/68936) -- Batch mode now uses a single JID for the entire batch run instead of generating [#68941](https://github.com/saltstack/salt/issues/68941) -- a separate JID per batch iteration. This enables unified job tracking via [#68941](https://github.com/saltstack/salt/issues/68941) -- ``salt-run jobs.lookup_jid`` and consistent ``--show-jid`` output across all [#68941](https://github.com/saltstack/salt/issues/68941) -- batch slices. The job cache merges minion lists from each iteration so that [#68941](https://github.com/saltstack/salt/issues/68941) -- ``get_load`` returns the complete set of targeted minions. [#68941](https://github.com/saltstack/salt/issues/68941) -- Added OpenTelemetry distributed-tracing support across all Salt [#68999](https://github.com/saltstack/salt/issues/68999) -- inter-process hops (network and IPC). When `tracing.enabled` is true in the [#68999](https://github.com/saltstack/salt/issues/68999) -- master/minion config, salt emits W3C-TraceContext-propagated spans via an [#68999](https://github.com/saltstack/salt/issues/68999) -- OTLP exporter, covering the CLI, channel layer, master workers, minion [#68999](https://github.com/saltstack/salt/issues/68999) -- command execution, event bus, reactor, syndic forwarding, salt-ssh, and [#68999](https://github.com/saltstack/salt/issues/68999) -- salt-api. Trace context travels inside the AES-encrypted Salt envelope so [#68999](https://github.com/saltstack/salt/issues/68999) -- it remains opaque on the wire. Tracing is opt-in and a complete no-op when [#68999](https://github.com/saltstack/salt/issues/68999) -- disabled. [#68999](https://github.com/saltstack/salt/issues/68999) -- Added a per-job ``start_event`` opt-in (CLI flag ``--start-event``) that asks [#69019](https://github.com/saltstack/salt/issues/69019) -- targeted minions to fire a ``salt/job//start/`` event the [#69019](https://github.com/saltstack/salt/issues/69019) -- moment they accept the published job, before the function runs. The payload [#69019](https://github.com/saltstack/salt/issues/69019) -- mirrors the master's ``salt/job//new`` event minus the function [#69019](https://github.com/saltstack/salt/issues/69019) -- arguments, letting orchestrators confirm reachability without waiting for [#69019](https://github.com/saltstack/salt/issues/69019) -- the full return. [#69019](https://github.com/saltstack/salt/issues/69019) -- Added `state.graph` and `state.graph_highstate` execution modules and runners to generate a DOT representation of the state dependency graph. [#69091](https://github.com/saltstack/salt/issues/69091) -- Migrate Salt documentation to the PyData Sphinx theme. This update modernizes the documentation UI, improves navigation with a persistent sidebar tree, and fixes issues with embedded video playback. [#69185](https://github.com/saltstack/salt/issues/69185) -- Added OpenTelemetry metrics support alongside the existing tracing [#69200](https://github.com/saltstack/salt/issues/69200) -- integration. When ``metrics.enabled`` is true in the master/minion [#69200](https://github.com/saltstack/salt/issues/69200) -- config, salt daemons emit counters (``salt.jobs.published``, [#69200](https://github.com/saltstack/salt/issues/69200) -- ``salt.jobs.completed``, ``salt.auth.attempts``, ``salt.events.fired``, [#69200](https://github.com/saltstack/salt/issues/69200) -- ``salt.returners.calls``), histograms (``salt.job.duration``, [#69200](https://github.com/saltstack/salt/issues/69200) -- ``salt.minion.exec.duration``), and observable gauges [#69200](https://github.com/saltstack/salt/issues/69200) -- (``salt.master.connected_minions.count``, [#69200](https://github.com/saltstack/salt/issues/69200) -- ``salt.master.workers.queue.depth``, ``salt.process.open_fds``) via [#69200](https://github.com/saltstack/salt/issues/69200) -- OTLP push or a Prometheus pull endpoint. Metrics are opt-in and a [#69200](https://github.com/saltstack/salt/issues/69200) -- complete no-op when disabled. See ``doc/topics/metrics/index.rst`` [#69200](https://github.com/saltstack/salt/issues/69200) -- for the full configuration surface and instrument inventory. [#69200](https://github.com/saltstack/salt/issues/69200) -- Restore the ``pillarstack`` ext_pillar module (``salt.pillar.stack``) that was [#69201](https://github.com/saltstack/salt/issues/69201) -- removed when community extensions were purged. The module is reinstated as a [#69201](https://github.com/saltstack/salt/issues/69201) -- core ext_pillar so existing PillarStack-based pillar trees continue to work on [#69201](https://github.com/saltstack/salt/issues/69201) -- 3008.x. [#69201](https://github.com/saltstack/salt/issues/69201) -- Added ``lgpo_reg.get_rsop_value`` to query the Resultant Set of Policy (RSoP) for a registry key/value and detect whether it is managed by a Domain Group Policy Object. The ``lgpo_reg`` module functions ``set_value``, ``disable_value``, and ``delete_value`` now log a warning when a Domain GPO is detected for the target value. The ``lgpo_reg`` state functions ``value_present``, ``value_disabled``, and ``value_absent`` append the same warning to the state comment so it is visible in state output. [#69205](https://github.com/saltstack/salt/issues/69205) diff --git a/doc/topics/releases/3008.1.md b/doc/topics/releases/3008.1.md deleted file mode 100644 index f79b03a59049..000000000000 --- a/doc/topics/releases/3008.1.md +++ /dev/null @@ -1,117 +0,0 @@ -(release-3008.1)= -# Salt 3008.1 release notes - - - - - - - -## Changelog - -### Changed - -- Changed `salt.returners.redis_return` to enumerate the Redis keyspace [#69037](https://github.com/saltstack/salt/issues/69037) -- with `SCAN` instead of the blocking `KEYS pattern` command in both [#69037](https://github.com/saltstack/salt/issues/69037) -- `get_jids` and `clean_old_jobs`. `KEYS` walks the entire keyspace [#69037](https://github.com/saltstack/salt/issues/69037) -- synchronously and stalls the Redis server for the duration; on a [#69037](https://github.com/saltstack/salt/issues/69037) -- master with hundreds of thousands of jobs this can block all clients [#69037](https://github.com/saltstack/salt/issues/69037) -- of that Redis instance for seconds. `SCAN` is incremental and [#69037](https://github.com/saltstack/salt/issues/69037) -- non-blocking. Order of returned keys is no longer guaranteed (the [#69037](https://github.com/saltstack/salt/issues/69037) -- returner does not rely on order); operators with custom scripts that [#69037](https://github.com/saltstack/salt/issues/69037) -- read `ret:*` or `load:*` directly may see them in a different order. [#69037](https://github.com/saltstack/salt/issues/69037) - - -### Fixed - -- Fixed ``win_pkg`` functions ignoring the ``saltenv`` setting in minion configuration. All public functions (``refresh_db``, ``genrepo``, ``install``, ``remove``, ``list_pkgs``, ``latest_version``, ``upgrade_available``, ``list_upgrades``, ``list_available``, ``version``, ``get_repo_data``, ``get_package_info``) now fall back to ``__opts__["saltenv"]`` when ``saltenv`` is not passed explicitly, instead of always defaulting to ``base``. [#38551](https://github.com/saltstack/salt/issues/38551) -- Added ``encoding`` parameter to ``file.replace`` execution module and state to support UTF-16, UTF-32, and other multi-byte encoded files that would otherwise be incorrectly treated as binary. [#52793](https://github.com/saltstack/salt/issues/52793) -- Improved documentation for the `runas` and `password` parameters in `cmd.run`, `cmd.script`, and all `salt.modules.cmdmod` execution functions on Windows. The docs now accurately describe when a password is required: only when the salt-minion is **not** running as SYSTEM or as an elevated Administrator. Removed the inaccurate claim that the target user account must be in the Administrators group. Also changed `cmd.script` to log a warning instead of hard-failing when `runas` is used without a password on Windows, since a password is not always required. [#57951](https://github.com/saltstack/salt/issues/57951) -- Fixed `SSL: DECRYPTION_FAILED_OR_BAD_RECORD_MAC` errors in the VMware cloud driver by reconnecting when a cached vCenter service instance is found to be stale or corrupted (for example when inherited across a fork by salt-cloud's parallel provider queries). [#61983](https://github.com/saltstack/salt/issues/61983) -- Fixed event signature verification failing under ``minion_sign_messages``. The minion was signing the return load before ``salt.channel.client.AsyncReqChannel._package_load`` attached transport metadata (``nonce``, ``ts``, ``tok``, ``id``), so the bytes the master re-serialized to verify did not match what was signed and every signed return was dropped. Signing is now performed inside ``_package_load`` after the metadata is attached, against the same bytes the master verifies. [#68181](https://github.com/saltstack/salt/issues/68181) -- Fixed two distinct bugs in the `salt.engines.redis_sentinel` engine that [#69031](https://github.com/saltstack/salt/issues/69031) -- together prevented it from being usable. `start()` no longer raises [#69031](https://github.com/saltstack/salt/issues/69031) -- `AttributeError: 'dict_values' object has no attribute 'pop'` on Python 3 [#69031](https://github.com/saltstack/salt/issues/69031) -- (the dict.values() result is now wrapped in `list(...)`). `Listener` and [#69031](https://github.com/saltstack/salt/issues/69031) -- `start()` now accept an optional `password` argument and forward it to [#69031](https://github.com/saltstack/salt/issues/69031) -- the redis client, allowing the engine to authenticate against a Sentinel [#69031](https://github.com/saltstack/salt/issues/69031) -- that requires AUTH; the default of `None` keeps existing configurations [#69031](https://github.com/saltstack/salt/issues/69031) -- working unchanged. [#69031](https://github.com/saltstack/salt/issues/69031) -- Fixed `salt.returners.redis_return` silently ignoring the documented [#69032](https://github.com/saltstack/salt/issues/69032) -- `redis.password` configuration option. The returner now reads [#69032](https://github.com/saltstack/salt/issues/69032) -- `redis.password` from config (in both regular and proxy modes) and [#69032](https://github.com/saltstack/salt/issues/69032) -- forwards it to both the single-server `redis.StrictRedis` and the [#69032](https://github.com/saltstack/salt/issues/69032) -- `StrictRedisCluster` constructors. Operators with auth-protected Redis [#69032](https://github.com/saltstack/salt/issues/69032) -- no longer lose every job return to a hidden `NOAUTH Authentication [#69032](https://github.com/saltstack/salt/issues/69032) -- required` failure; deployments without a password are unaffected. [#69032](https://github.com/saltstack/salt/issues/69032) -- Fixed three closely-related bugs in `salt.cache.redis_cache` that [#69033](https://github.com/saltstack/salt/issues/69033) -- together broke hierarchical-bank semantics: [#69033](https://github.com/saltstack/salt/issues/69033) -- `_build_bank_hier` now registers each child bank name in both the [#69033](https://github.com/saltstack/salt/issues/69033) -- parent's `$BANK_` set (consumed by `flush()` tree traversal) and the [#69033](https://github.com/saltstack/salt/issues/69033) -- parent's `$BANKEYS_` set (consumed by `list_()`); `_get_banks_to_remove` [#69033](https://github.com/saltstack/salt/issues/69033) -- now decodes the bytes returned by `smembers` and skips the `"."` [#69033](https://github.com/saltstack/salt/issues/69033) -- placeholder, so recursive `flush()` of a parent bank actually descends [#69033](https://github.com/saltstack/salt/issues/69033) -- into sub-banks instead of corrupting the path; and `flush(bank)` of a [#69033](https://github.com/saltstack/salt/issues/69033) -- sub-bank now removes the flushed bank's own reference from its [#69033](https://github.com/saltstack/salt/issues/69033) -- parent's index sets so `list_(parent)` no longer reports it as [#69033](https://github.com/saltstack/salt/issues/69033) -- present. Together these fixes restore `cache.list("minions")`, [#69033](https://github.com/saltstack/salt/issues/69033) -- `salt-run manage.present` and `salt-run manage.up` for masters [#69033](https://github.com/saltstack/salt/issues/69033) -- configured with `cache: redis`. [#69033](https://github.com/saltstack/salt/issues/69033) -- Fixed `salt.tokens.rediscluster` being unable to retrieve any eauth [#69035](https://github.com/saltstack/salt/issues/69035) -- token. The cluster client was created with `decode_responses=True`, [#69035](https://github.com/saltstack/salt/issues/69035) -- which caused `redis_client.get()` to return `str` and broke [#69035](https://github.com/saltstack/salt/issues/69035) -- `salt.payload.loads` (msgpack rejects `str`); it also caused [#69035](https://github.com/saltstack/salt/issues/69035) -- `redis_client.keys()` to return `str` and broke [#69035](https://github.com/saltstack/salt/issues/69035) -- `[k.decode("utf8") for k in ...]` (`str` has no `.decode`). Both [#69035](https://github.com/saltstack/salt/issues/69035) -- errors were swallowed by broad `except Exception` handlers, so eauth [#69035](https://github.com/saltstack/salt/issues/69035) -- appeared to silently reject every token. `decode_responses=True` is [#69035](https://github.com/saltstack/salt/issues/69035) -- removed; values now round-trip as bytes through msgpack as the rest [#69035](https://github.com/saltstack/salt/issues/69035) -- of the module already expected. [#69035](https://github.com/saltstack/salt/issues/69035) -- Fixed `salt.returners.redis_return` leaking `:` last-jid [#69038](https://github.com/saltstack/salt/issues/69038) -- pointer keys indefinitely. The pointer was written with `pipeline.set` [#69038](https://github.com/saltstack/salt/issues/69038) -- and no `ex=` TTL, so any (minion, fun) pair that stopped running stuck [#69038](https://github.com/saltstack/salt/issues/69038) -- in Redis forever -- O(minions × distinct funcs) keys accumulating over [#69038](https://github.com/saltstack/salt/issues/69038) -- the lifetime of the master. The pointer now expires on the same TTL [#69038](https://github.com/saltstack/salt/issues/69038) -- as the rest of the returner data (`keep_jobs_seconds`). Operators with [#69038](https://github.com/saltstack/salt/issues/69038) -- external scripts reading these keys directly may observe them [#69038](https://github.com/saltstack/salt/issues/69038) -- expiring; the documentation never promised they would not. [#69038](https://github.com/saltstack/salt/issues/69038) -- Fixed `salt.returners.redis_return.get_fun` always returning an [#69039](https://github.com/saltstack/salt/issues/69039) -- empty dict. The function read return data from a `:` [#69039](https://github.com/saltstack/salt/issues/69039) -- key that no other code in the module ever wrote -- a leftover from [#69039](https://github.com/saltstack/salt/issues/69039) -- an older storage schema. It now reads from the canonical [#69039](https://github.com/saltstack/salt/issues/69039) -- `ret:` hash via `HGET ret: `, matching the [#69039](https://github.com/saltstack/salt/issues/69039) -- storage layout that `returner` actually produces and the read [#69039](https://github.com/saltstack/salt/issues/69039) -- pattern that `get_jid` already uses. [#69039](https://github.com/saltstack/salt/issues/69039) -- ``cmd.run`` and friends no longer include the ``env`` and ``stdin`` arguments in the ``CommandExecutionError`` raised when the underlying subprocess fails to start (typically ``ENOENT`` / binary not found). Both fields routinely carry credentials passed in by the caller (``env={"DB_PASSWORD": "..."}``, password piped via ``stdin``), and the error message ends up in master/minion logs and in event-bus return data visible to the API caller. [#69075](https://github.com/saltstack/salt/issues/69075) -- * Relenv 0.22.14 [#69129](https://github.com/saltstack/salt/issues/69129) -- - Update python 3.14 to 3.14.6 [#69129](https://github.com/saltstack/salt/issues/69129) -- - Update sqlite to 3.53.2.0 [#69129](https://github.com/saltstack/salt/issues/69129) -- - Update openssl to 3.5.7 [#69129](https://github.com/saltstack/salt/issues/69129) -- Fix pillar masking leaking ``**********`` into rendered pillar and state values. ``MaskedDict`` / ``MaskedList`` ``__repr__`` / ``__str__`` now consult the ``salt.utils.secret.mask_pillar`` ContextVar, so ``{{ pillar['list_or_dict_value'] }}`` interpolations on the minion return plain values inside a render bracket. Hoist the ``mask_pillar=False`` bracket from ``render_pillar`` to ``compile_pillar`` so ``ext_pillar`` handlers and the rest of the master-side pillar build also run unmasked. [#69160](https://github.com/saltstack/salt/issues/69160) -- Fixed Windows MSI self-upgrade via ``pkg.install`` failing with error 1603. The old product's ``DeleteConfig_DECAC`` custom action was unconditionally deleting ``ROOTDIR\var`` during ``RemoveExistingProducts``, destroying the MSI that ``pkg.install`` had cached to ``ROOTDIR\var\cache`` before launching the upgrade. Users who had ``REMOVE_CONFIG=1`` persisted in the registry (from checking "On uninstall" at install time) hit a worse variant where the entire ``ROOTDIR`` was deleted. The fix checks ``UPGRADINGPRODUCTCODE`` — set by Windows Installer whenever an uninstall is triggered by a major upgrade — and skips all ``ROOTDIR`` deletion during upgrades, matching the behaviour of the NSIS installer which has always preserved ``ROOTDIR`` during upgrades. [#69219](https://github.com/saltstack/salt/issues/69219) -- Fixed `TypeError: string indices must be integers` in the minion when the master returns a bare string error response (e.g. `"bad load"`, `"Some exception handling minion payload"`) for a pillar request. The minion now raises a clean `AuthenticationError` instead of crashing, allowing the caller to retry or fail gracefully. [#69228](https://github.com/saltstack/salt/issues/69228) -- pkg.list_patches in yumpkg.py parses tdnf output on Photon OS [#69229](https://github.com/saltstack/salt/issues/69229) -- Restore Python dependencies in the PyPI sdist by including ``requirements/*.in`` and ``requirements/**/*.lock`` in ``MANIFEST.in``. After the requirements ``.txt`` → ``.in`` rename, the sdist no longer shipped the files that ``setup.py`` reads to populate ``install_requires``, so ``pip install salt`` produced an installation with no dependencies. [#69244](https://github.com/saltstack/salt/issues/69244) -- Fix `salt-cloud` failing to start with `AttributeError: module 'salt' has no attribute 'minion'` by importing `salt.minion` in `salt.cloud`. [#69281](https://github.com/saltstack/salt/issues/69281) -- Ensure multiple masters have their own job/state queues [#69308](https://github.com/saltstack/salt/issues/69308) -- Fixed minion state queue replacing the master-assigned JID on queued state runs, so returns now come back tagged with the JID the master actually published. [#69386](https://github.com/saltstack/salt/issues/69386) -- Made the salt user's home directory and the relenv ``extras-`` directory configurable in the Linux packaging. The DEB preinst scripts now source ``/etc/default/salt-setup`` (and ``/etc/sysconfig/salt-minion-setup`` for cross-distro parity with RPM) before applying the ``SALT_HOME``/``SALT_USER``/``SALT_GROUP``/``SALT_NAME`` defaults, mirroring the long-standing RPM behavior. A new ``SALT_EXTRAS_DIR`` override is honored by both stacks so the extras tree can be relocated outside ``/opt/saltstack/salt`` and its ownership is correctly restored on upgrade. [#69402](https://github.com/saltstack/salt/issues/69402) - - -### Added - -- Added ``dsc_resource`` execution module and state module for invoking individual [#43718](https://github.com/saltstack/salt/issues/43718) -- PowerShell DSC resources directly via ``Invoke-DscResource``, without compiling [#43718](https://github.com/saltstack/salt/issues/43718) -- a MOF file or involving the Local Configuration Manager. The [#43718](https://github.com/saltstack/salt/issues/43718) -- ``dsc_resource.managed`` state provides idiomatic Salt state management for any [#43718](https://github.com/saltstack/salt/issues/43718) -- installed DSC resource module. [#43718](https://github.com/saltstack/salt/issues/43718) -- fix etcdv3 module authentification when using etcd3-py lib [#69202](https://github.com/saltstack/salt/issues/69202) diff --git a/doc/topics/releases/index.rst b/doc/topics/releases/index.rst index 59dfb2914b83..cf1981611d6e 100644 --- a/doc/topics/releases/index.rst +++ b/doc/topics/releases/index.rst @@ -19,7 +19,7 @@ Upcoming release :maxdepth: 1 :glob: - 3008.* + 3007.* See `Install a release candidate `_ for more information about installing an RC when one is available. @@ -31,7 +31,6 @@ Previous releases :maxdepth: 1 :glob: - 3007.* 3006.* 3005* 3004* diff --git a/doc/topics/releases/templates/3006.27.md.template b/doc/topics/releases/templates/3006.27.md.template deleted file mode 100644 index ac002a895d8b..000000000000 --- a/doc/topics/releases/templates/3006.27.md.template +++ /dev/null @@ -1,28 +0,0 @@ -(release-3006.27)= -# Salt 3006.27 release notes{{ unreleased }} -{{ warning }} - - - -## Python 3.11 upgrade - -Starting with Salt 3006.27, the bundled onedir packages (Linux, macOS, and -Windows) ship CPython 3.11.15 instead of 3.10.20. Python 3.10 reaches end of -security support in October 2026, while the 3006.x series must continue -shipping security fixes through July 2027; aligning the bundled interpreter -with 3.11 keeps Salt's security cadence on a supported CPython. - -Users upgrading from a previous 3006.x onedir package need to reinstall any -Salt extensions installed via `salt-pip`, because the onedir `extras-3.10` -directory is replaced by `extras-3.11`. See -[#69526](https://github.com/saltstack/salt/issues/69526) / -[#69527](https://github.com/saltstack/salt/pull/69527) for details. - - -## Changelog -{{ changelog }} diff --git a/doc/topics/releases/templates/3008.0.md.template b/doc/topics/releases/templates/3008.0.md.template deleted file mode 100644 index cf56e9d352bf..000000000000 --- a/doc/topics/releases/templates/3008.0.md.template +++ /dev/null @@ -1,65 +0,0 @@ -(release-3008.0)= -# Salt 3008.0 release notes{{ unreleased }} -{{ warning }} - - - -## Salt Resources - -Salt 3008.0 introduces **Salt Resources** — a first-class targeting -primitive for things a minion manages on behalf of the master, like SSH -hosts, virtual appliances, API endpoints, or any other "remote thing" -that can't or shouldn't run a minion of its own. - -A managing minion can manage many resources of many types. Each -resource is addressable by id, by type, or by per-resource grains — -the same targeting forms (`-G`, `-L`, `-C`, glob, list) work -identically against resources and minions. - -Highlights: - -- New compound targeting engine `T@[:]` selects resources - by type, by full SRN, or both. Grain targeting (`-G`, `G@`) and PCRE - grain targeting (`-P`, `P@`) augment matches with per-resource grain - dicts the master cached at registration time. -- The master keeps an mmap-backed registry of which minion manages - which resource (`salt.utils.resource_registry`). Lookups and inserts - are O(1); the registry survives master restarts. -- A managing minion builds a per-resource execution and state loader - keyed by resource type. Per-type modules live under - `salt/resources//{modules,states,grains}/` (or under any Salt - extension's `saltext//resources//` tree), with override - files winning their slot by directory order. Standard Salt modules - fill any slot a resource type doesn't override. -- Merge-mode `state.apply`/`state.highstate`/`state.sls` against - resource targets folds per-resource state results into one combined - block on the managing minion, prefixed by resource id, matching how - any other minion looks to the master. -- New escape-hatch dunder `__minion__` gives per-resource execution - and state modules explicit access to the managing minion's loader - when they need to do something on the host. -- New operator runners: `salt-run resource.list_grains`, - `salt-run resource.show_grains`, `salt-run resource.refresh`. -- New `salt-call` flags `-r/--resources`, `--tgt`, and `--tgt-type` - make resource dispatch available from masterless and local - troubleshooting workflows. - -Two reference resource types ship with Salt: `dummy` -(filesystem-backed, for tests and tutorials) and `ssh` (over the salt-ssh -transport). Extension authors can ship their own resource types via a -standard `salt.loader` entry point — no core changes required. - -See the [Salt Resources documentation](../resources/index) for the -conceptual overview, the [tutorial](../resources/tutorial) for a -10-minute walk-through, and the [authoring -guide](../resources/authoring/index) for shipping your own resource -type. - - -## Changelog -{{ changelog }} diff --git a/doc/topics/releases/templates/3008.1.md.template b/doc/topics/releases/templates/3008.1.md.template deleted file mode 100644 index 7288605cbed3..000000000000 --- a/doc/topics/releases/templates/3008.1.md.template +++ /dev/null @@ -1,14 +0,0 @@ -(release-3008.1)= -# Salt 3008.1 release notes{{ unreleased }} -{{ warning }} - - - - -## Changelog -{{ changelog }} diff --git a/doc/topics/resources/architecture.rst b/doc/topics/resources/architecture.rst deleted file mode 100644 index 04093f95b50f..000000000000 --- a/doc/topics/resources/architecture.rst +++ /dev/null @@ -1,248 +0,0 @@ -.. _resources-architecture: - -============ -Architecture -============ - -.. versionadded:: 3008.0 - -How Salt Resources actually work: where the data lives on the master, -how a publish reaches a resource, what runs where, and the seams an -operator or extension author can hook into. - - -Three sides, three responsibilities -=================================== - -* The **master** owns the system-of-record for which minion manages - which resource. It uses that record to expand targeting expressions - and to populate the job's wait list. -* The **managing minion** carries the connection plumbing, the - per-resource grain dicts, and the per-resource execution and state - loaders. It is the process that actually talks to the resource. -* The **resource type** is a Python package — in core Salt under - :py:mod:`salt.resources` or in any Salt extension under - ``saltext..resources.`` — that defines what *type of - thing* the resource is and what operations it understands. - - -Master side: the resource registry -================================== - -Source of record: :py:mod:`salt.utils.resource_registry` --------------------------------------------------------- - -The master keeps an mmap-backed index of every resource any minion -currently manages. Each entry is a composite key (``"type:id"``, written -**SRN** for *Salt Resource Name*) mapping to a small JSON payload: - -.. code-block:: json - - {"m": "", "t": ""} - -* The **primary index** (``by_id``) is a - :class:`~salt.utils.mmap_cache.MmapCache` file on disk. Lookups and - inserts are O(1) linear-probing hash operations. -* Two **derived secondaries** (``by_type``, ``by_minion``) are - materialised in-process on first access and rebuilt when the master - observes the primary file has been compacted. -* A separate ``resource_grains`` cache bank stores each resource's - per-resource grain dict (one msgpack blob per SRN). - -The registry is reused for three jobs: - -1. **Expanding compound targets**. ``T@ssh`` walks ``by_type["ssh"]``; - ``T@ssh:web-01`` reads ``by_id["ssh:web-01"]`` and gets back the - managing minion id. -2. **Augmenting grain matches**. - :func:`~salt.utils.minions.CkMinions._augment_grain_match_with_resource_grains` - walks the ``resource_grains`` bank to find resources whose grain - dict satisfies the operator's ``-G`` / ``G@`` expression and adds - them to the response wait list. -3. **Picking the managing minion for merge-mode functions**. When the - command is ``state.apply`` or another :ref:`merge fun - `, the master returns the *managing minion's* - id in the wait list instead of the resource id — the managing - minion runs the apply inline and returns one combined block. See - :ref:`resources-state-authoring`. - - -Writes ------- - -The only writer is the master worker's ``AESFuncs._register_resources`` -handler (called by the minion's ``_register_resources_with_master``). -Every register call: - -1. Diffs the minion's previous registration against the new payload. -2. Drops entries for resources the minion no longer manages. -3. Inserts or refreshes entries for resources the minion does manage. -4. Updates ``resource_grains`` for any resource whose grain dict - changed. - -Re-registration triggers — what causes the master's view to refresh — -are documented in :ref:`resources-operations`. - - -Minion side: per-resource loaders -================================= - -Discovery ---------- - -On startup and on every pillar refresh, the managing minion reads its -pillar subtree at :conf_minion:`resource_pillar_key` (default -``resources``). For each resource type ```` listed there, it: - -1. Imports the resource module — ``salt.resources.`` for - in-tree types, or ``saltext..resources.`` for - extension-shipped types. -2. Calls the module's ``init(opts)`` for each declared resource id — - establishing the connection or doing whatever per-resource setup the - type requires. -3. Calls the module's ``grains()`` for each resource and caches the - returned dict. -4. Reports the full set of ``(type, id, grains)`` triples back to the - master via ``_register_resources_with_master``. - -Per-type loader ---------------- - -When a publish arrives that targets a managed resource, the minion -selects (or builds) a *per-resource execution loader* keyed by the -resource type. This loader is constructed exactly like the standard -minion loader, except: - -* Loader dir search order is rewritten so that - ``salt/resources//modules/`` (and the equivalent path inside - any Salt extension that ships a resource type) sits **ahead of** the - standard ``salt/modules/``. A file at the per-type path wins its - slot; standard modules fill any slot the resource type doesn't - override. See :func:`salt.loader._module_dirs`. -* A few dunders are packed specifically for resource context. - ``__grains__`` is the resource's grain dict (not the managing - minion's). ``__resource__`` is ``{"type": ..., "id": ...}``. - ``__minion__`` is the managing minion's regular execution-module - loader, available as an explicit escape hatch when a resource module - needs to reach back to the host. See - :ref:`resources-state-authoring`. - -The state loader (``salt.loader.states``) is built the same way: it -discovers state modules from per-type ``states/`` directories first, -then falls back to ``salt/states/``. - - -Dispatch: how a publish becomes a resource job -============================================== - -This is the path a publish takes from the master's wire to a return. - -1. **Master publishes**. ``CkMinions`` produces a wait list combining - per-minion matches with per-resource matches read from the - ``resource_grains`` bank and the SRN registry. For ``T@ssh:web-01`` - the wait list is ``{"web-01"}``; for ``state.apply`` against - ``T@ssh:web-01`` it is ``{}`` (the merge-mode - remap — see :ref:`resources-arch-merge`). -2. **Minion accepts the load**. - :meth:`~salt.minion.Minion._target_load` runs - :meth:`~salt.minion.Minion._resolve_resource_targets` against the - target expression. The result is a list of - ``{"type": ..., "id": ...}`` dicts. -3. **Minion fans out**. For non-merge functions the minion calls - :meth:`~salt.minion.Minion._handle_decoded_payload` once per matched - resource, copying the load and setting ``load["resource_target"]`` - to that resource. Each copy runs in its own subprocess like any - other job. -4. **The job picks the per-resource loader**. - :meth:`~salt.minion.Minion._thread_return` reads - ``data["resource_target"]`` and selects the corresponding - :py:attr:`~salt.minion.Minion.resource_loaders` entry. The function - is executed against that loader, so ``__salt__["cmd.run"]`` (etc.) - dispatches to per-resource overrides when they exist and to - standard Salt modules otherwise. -5. **Return**. The result is published to the master with - ``ret["resource_id"]`` set; the master's ``_return`` handler - remaps ``load["id"] = load["resource_id"]`` so the CLI sees a return - keyed by the resource id. - -Special function classes ------------------------- - -Two sets of functions are treated specially: - -:py:attr:`~salt.minion.Minion._NO_RESOURCE_FUNS` - Internal minion housekeeping (job-status queries, module reloads, - ``saltutil.sync_*``, …). Never dispatched to resources — they - always run on the managing minion alone. - -.. _resources-arch-merge: - -:py:attr:`~salt.minion.Minion._MERGE_RESOURCE_FUNS` - ``state.apply``, ``state.highstate``, ``state.sls``, - ``state.sls_id``, ``state.single``. The managing minion runs the - per-resource state apply *inline* and folds each resource's state - IDs into a single response, prefixed with the resource id. The - master's wait list contains the managing minion's id, not the - resource ids; one combined block goes back to the operator. See - :ref:`resources-state-authoring` for the prefixing rules and the - ``__minion__`` escape hatch. - - -Per-type directory layout -========================= - -A resource type is a Python package whose tree mirrors Salt's own -loader trees. Every directory is optional except ``__init__.py``: - -.. code-block:: text - - salt/resources// - __init__.py # connection module — init, grains, helpers - modules/ # execution-module overrides (filename = slot) - cmd.py - pkg.py - state.py - ... - states/ # state-module overrides (filename = slot) - ... - grains/ # grain modules (per-resource grains, optional) - ... - -Salt extensions follow the same layout under their package path — -e.g. ``saltext//resources//modules/.py`` — and Salt's -loader picks them up automatically via setuptools entry-point -discovery. - -For an authoring guide see :ref:`resources-authoring`. - - -Why directory order instead of ``__virtual__``? ------------------------------------------------ - -Earlier iterations of this design used ``__virtualname__`` collisions -and ``__virtual__`` guards keyed on ``opts["resource_type"]`` to decide -which module won a slot. That approach had two failure modes: - -* It was easy for the *override* to opt **out** correctly while the - *standard* module was unaware of the resource context, leaving the - slot empty in the per-resource loader (the original "Gap 4" / "Gap 5" - bug class). -* It coupled every standard module to the resource framework — any new - resource type required edits to ``salt/modules/state.py``, - ``salt/modules/cmd.py``, etc. - -The directory-order approach inverts that. Standard modules know -nothing about resources. The loader picks the per-type version when -one exists and the standard version otherwise. Adding a resource type -requires no edits to core Salt — drop a directory in your extension -and you're done. - - -Cross-references -================ - -* Targeting reference: :ref:`resources-targeting` -* State authoring (merge mode, ``__minion__``): :ref:`resources-state-authoring` -* Operator commands: :ref:`resources-operations` -* Configuration options: :ref:`resources-configuration` -* Registry API: :py:mod:`salt.utils.resource_registry` diff --git a/doc/topics/resources/authoring/connection_module.rst b/doc/topics/resources/authoring/connection_module.rst deleted file mode 100644 index 725628de844b..000000000000 --- a/doc/topics/resources/authoring/connection_module.rst +++ /dev/null @@ -1,163 +0,0 @@ -.. _resources-authoring-connection: - -================= -Connection module -================= - -The ``__init__.py`` of a resource type is the **connection module**. -It owns three jobs: - -1. Tell the managing minion which resource ids it manages - (``discover``). -2. Establish whatever per-resource state is needed before any call - (``init`` + ``initialized``). -3. Produce per-resource grains (``grains`` + ``grains_refresh``). - -A handful of optional hooks (``ping``, ``shutdown``, custom execution -functions) round it out. - - -Required interface -================== - -Every connection module must define: - -``__virtual__()`` - Standard Salt loader hook. Return ``True`` (or your virtualname) if - the type can be loaded on this minion; return ``(False, "reason")`` - to opt out. Resource types typically return ``True`` unconditionally - — the loader is only consulted when the pillar names the type. - -``init(opts)`` - Called once when the type is loaded, before any per-resource - operations run. Reads the resource type's pillar subtree and seeds - any shared state in ``__context__``. Idempotent. - -``initialized()`` - Returns ``True`` if ``init()`` has run successfully. The framework - checks this before dispatching per-resource operations so a partial - ``init`` failure doesn't produce bogus results. - -``discover(opts)`` - Returns the list of bare resource ids (not full SRNs) that this - minion manages for this type. Called by ``saltutil.refresh_resources`` - and by minion startup. Read from pillar — typically the - ``resource_ids`` key under the type's subtree. - -``grains()`` - Returns the grain dict for the *currently dispatched* resource. The - current resource is in ``__resource__`` (see :ref:`Dunders - ` below). What you put here is what - ``-G key:value`` will match against; keep it small and tag-like - (env, role, region) rather than re-reading the world on every - target check. - - -Optional interface -================== - -``ping()`` - Reachability probe for the current resource. Used by - :py:func:`salt.modules.test.ping` overrides and operator tools. - -``grains_refresh()`` - Invalidate any cached grain state and recompute. The default loader - calls ``grains()`` again if you don't implement this; implement it - only if you maintain your own cache layer. - -``shutdown(opts)`` - Tear down type-level state from ``__context__``. Called when the - minion shuts down or unloads the type. - -Anything else you define in ``__init__.py`` is callable as a -per-resource execution function once the loader picks the module up. -Functions named with a verb-noun convention -(``service_start``, ``package_install``) tend to age well; if you want -them to take over a slot in the standard Salt module surface, put the -overrides in ``modules/`` instead (see -:ref:`resources-authoring-execution`). - - -.. _resources-authoring-dunders: - -Dunders available in connection-module code -============================================ - -The loader packs these into your module's globals before any call: - -``__opts__`` - The managing minion's opts dict — same as anywhere else in Salt. - -``__context__`` - Per-loader transient dict. Use it for connection caches and the - ``initialized`` flag. - -``__resource__`` - ``{"type": "", "id": ""}``. Set for every - per-resource dispatch (grains, ping, custom funcs). Not set during - ``init``, ``discover``, or ``shutdown`` — those run once per type, - not once per resource. - -``__pillar__``, ``__grains__`` - The *managing minion's* pillar and grains. The resource's own - grains aren't yet available inside the connection module itself — - they're what it produces. - -``__salt__`` - The managing minion's standard execution-module loader. Useful for - delegating to ``cmd.run`` or any other Salt function on the host - that's doing the connecting. - - -Pattern: filesystem-backed dummy -================================ - -The reference implementation in :py:mod:`salt.resources.dummy` is a -fully self-contained example. It persists per-resource state to a -cache file and is wired up so that ``salt -C 'T@dummy' state.apply`` -exercises every code path without needing real connectivity. Read it -when you start your own type. - - -Pattern: connection-per-resource cached in ``__context__`` -========================================================== - -For types that talk to a real remote service, build the connection -lazily and cache it keyed by resource id:: - - def _connect(resource_id): - conns = __context__.setdefault("widget", {}).setdefault("conns", {}) - if resource_id not in conns: - cfg = ( - salt.utils.resources.pillar_resources_tree(__opts__) - .get("widget", {}) - .get("hosts", {}) - .get(resource_id, {}) - ) - conns[resource_id] = WidgetClient(cfg) - return conns[resource_id] - - def ping(): - return _connect(__resource__["id"]).ping() - -The cache lives for the lifetime of the per-type loader (i.e. until -the minion reloads modules or the type is unregistered). The loader -calls ``shutdown(opts)`` on teardown so you can close connections -cleanly. - - -Mistakes to avoid -================= - -* **Doing slow work in** ``grains()``. ``grains()`` is called on every - registration and refresh. If you need to phone the resource to get - the value, cache it in ``__context__`` and refresh deliberately. -* **Using** ``__resource__`` **in** ``init()``. ``init`` runs once per - type, before any resource is selected; ``__resource__`` is unset. - Use ``opts`` and the pillar instead. -* **Mutating** ``opts``. The opts dict is shared across the managing - minion. Treat it as read-only — copy it if you need a per-resource - variant. -* **Leaking transient state in** ``__context__``. Whatever you put in - ``__context__`` persists across calls. Anything *connection-scoped* - (auth tokens with TTLs, etc.) should track its own expiry. diff --git a/doc/topics/resources/authoring/execution_modules.rst b/doc/topics/resources/authoring/execution_modules.rst deleted file mode 100644 index 325ee9fc2320..000000000000 --- a/doc/topics/resources/authoring/execution_modules.rst +++ /dev/null @@ -1,160 +0,0 @@ -.. _resources-authoring-execution: - -========================== -Execution module overrides -========================== - -The ``modules/`` directory under a resource type holds **execution -module overrides**. Each file replaces one slot in the standard Salt -execution-module surface — the file's name (minus ``.py``) becomes the -slot, so ``modules/cmd.py`` overrides the ``cmd`` virtualname, -``modules/pkg.py`` overrides ``pkg``, and so on. - -When a publish dispatches to a resource of this type, ``__salt__`` -first looks in ``/modules/`` and falls back to ``salt/modules/`` -for anything not overridden. This is the whole point of the framework -— you can give your type its own ``cmd.run`` (or ``pkg.installed``, -or anything else) without forking the standard modules and without -touching ``__virtual__``. - - -When to add an override -======================= - -Add an override when "the standard ``cmd.run`` would do the wrong -thing on this resource". Typically that means: - -* The resource isn't a local machine — ``cmd.run`` needs to dispatch - over SSH, an API, a serial connection, etc. -* The "package manager" semantics differ — a Kubernetes resource's - ``pkg.installed`` is really ``kubectl apply``. -* The standard module assumes filesystem layout that doesn't exist - on the resource — ``file.managed`` writing to ``/etc/`` of a thing - that has no ``/etc/``. - -Don't add an override when: - -* Your type-specific operation has a fundamentally new name. Just put - it as a function in ``__init__.py`` (or in your own - ``modules/widget.py``) — the framework loads everything you put - under ``modules/``, not just slots that exist in core. - - -Dunders inside an override module -================================= - -The same loader dunders as a connection module, plus a few resource -specifics: - -``__resource__`` - Always set in execution-module code — every call goes through the - per-resource dispatch path. ``{"type": ..., "id": ...}``. - -``__grains__`` - The **resource's** grain dict — what ``grains()`` returned for - *this* resource. Not the managing minion's grains. - -``__resource_funcs__`` - The connection module's namespace, indexed by ``.``. - So if the connection module defines ``def ping()``, you can call - it from inside ``modules/test.py`` as - ``__resource_funcs__["widget.ping"]()``. This is the canonical way - to reach the connection module's helpers from an override. - -``__minion__`` - The **managing minion's** standard execution-module loader. Use - this when an override genuinely needs to reach the host (read a - file on disk, run a local command, query the managing minion's - grains). For most overrides you won't need it. - -``__salt__`` - In an override, ``__salt__`` is the **merged per-resource loader** - — overrides for slots that exist plus standard modules for slots - that don't. Calling ``__salt__["cmd.run"]`` inside ``modules/pkg.py`` - dispatches to ``modules/cmd.py`` if you've overridden ``cmd``, or - to the standard ``salt.modules.cmdmod.run`` if you haven't. - - -Pattern: override one function in a standard module -==================================================== - -You want ``pkg.install`` to actually do something for your resource -type, but the rest of the ``pkg.*`` surface (``pkg.list_pkgs``, -``pkg.version``) is fine running on the managing minion. Put just the -function you need in ``modules/pkg.py``:: - - def install(name=None, pkgs=None, **kwargs): - return __resource_funcs__["widget.package_install"](name=name) - -Nothing else. Standard ``salt.modules.aptpkg`` continues to provide -the rest of the ``pkg.*`` surface for this resource. - -The slot precedence is per-function: directory order picks the -override if the function exists in the override module, otherwise the -standard module fills the slot. - - -Pattern: re-export the standard module -====================================== - -The reverse case — you want **most** of an override slot to mirror -the standard module, but the standard module is the one that needs to -run with the *per-resource* loader context. Use -:py:func:`salt.utils.functools.namespaced_function` to re-export -without copying:: - - # modules/state.py - import salt.utils.functools - import salt.modules.state as _src - - sls = salt.utils.functools.namespaced_function(_src.sls, globals()) - apply_ = salt.utils.functools.namespaced_function(_src.apply_, globals()) - highstate = salt.utils.functools.namespaced_function(_src.highstate, globals()) - single = salt.utils.functools.namespaced_function(_src.single, globals()) - - __func_alias__ = {"apply_": "apply"} - -``namespaced_function`` copies the function object into your module's -globals so ``__salt__``, ``__opts__``, and friends resolve to the -*per-resource* loader at call time. A naive ``from salt.modules.state -import sls`` keeps the original module's globals and runs against the -managing minion's loader — which is what you don't want. - -The :py:mod:`salt.resources.ssh.modules.state` module ships with Salt -as a worked example. - - -Pattern: delegate to the connection module -========================================== - -The simplest override is one line each, forwarding to functions you -wrote in ``__init__.py``:: - - # modules/test.py - def ping(): - return __resource_funcs__["widget.ping"]() - - def echo(text): - return __resource_funcs__["widget.echo"](text) - -This keeps the actual connection logic in one place (the connection -module) and the override file purely about *slot binding*. - - -Mistakes to avoid -================= - -* **Importing globals at module load time**. Dunders like ``__salt__`` - and ``__resource__`` are :py:class:`~salt.loader.context.NamedLoaderContext` - proxies; they're only valid inside function bodies. Don't capture - them at import time. -* **Re-defining** ``__virtualname__``. The file's location enforces - the slot — overriding the virtualname will only confuse the loader. - Just name the file after the slot you want. -* **Bypassing the loader.** ``import salt.modules.cmdmod`` and calling - ``cmdmod.run()`` directly skips the dunder injection and the - per-resource context. Always go through ``__salt__["cmd.run"]`` - inside an override. -* **Forgetting to forward kwargs.** State engines pass ``__pub_*`` - dunders into module calls; pure forwarding overrides should accept - ``**kwargs`` and pass them along. diff --git a/doc/topics/resources/authoring/index.rst b/doc/topics/resources/authoring/index.rst deleted file mode 100644 index 3e523c5d518a..000000000000 --- a/doc/topics/resources/authoring/index.rst +++ /dev/null @@ -1,100 +0,0 @@ -.. _resources-authoring: - -================================ -Authoring a Salt resource type -================================ - -.. versionadded:: 3008.0 - -This guide walks through the files, dunders, and conventions that make a -resource type work. The reference implementation it mirrors is -:py:mod:`salt.resources.dummy`, which exists for exactly this purpose. - -A resource type is just a Python package. Where you put it depends on -where you ship it: - -* **In core Salt** — under ``salt/resources//``. -* **In an extension** — under ``saltext//resources//`` in a - package that declares the ``salt.loader`` entry point. Salt's loader - discovers it automatically. - -The directory layout is the same either way: - -.. code-block:: text - - / - __init__.py # connection module: init, grains, helpers - modules/ # execution-module overrides - states/ # state-module overrides - grains/ # grain modules (rarely needed) - -Most of this guide is "drop a file at the right path with the right -function names". The framework does not require subclassing anything; -overrides win their slot by filename and directory order. The dunders -the loader packs into each module are documented below. - - -Topics -====== - -.. toctree:: - :maxdepth: 1 - - connection_module - execution_modules - state_modules - pillar - packaging - - -Quick start -=========== - -The shortest possible resource type. Three files: - -``saltext/myext/resources/widget/__init__.py``:: - - def __virtual__(): - return True - - def init(opts): - __context__["widget"] = {"initialized": True} - - def initialized(): - return __context__.get("widget", {}).get("initialized", False) - - def discover(opts): - import salt.utils.resources - return list( - salt.utils.resources.pillar_resources_tree(opts) - .get("widget", {}) - .get("resource_ids", []) - ) - - def grains(): - return {"widget_id": __resource__["id"]} - - def ping(): - return True - - def shutdown(opts): - __context__.pop("widget", None) - -``saltext/myext/resources/widget/modules/test.py``:: - - def ping(): - return "pong from widget" - -``saltext/myext/__init__.py``:: - - # empty, makes saltext.myext a package - -Plus a ``pyproject.toml`` entry point that points the Salt loader at -your package (see :doc:`packaging`). - -With that in place — and the minion's pillar containing -``resources: {widget: {resource_ids: [w1, w2]}}`` — ``salt -C -'T@widget' test.ping`` returns ``"pong from widget"`` for both -``w1`` and ``w2``. - -Read on for the full interface contract and the override mechanics. diff --git a/doc/topics/resources/authoring/packaging.rst b/doc/topics/resources/authoring/packaging.rst deleted file mode 100644 index d687ccbea798..000000000000 --- a/doc/topics/resources/authoring/packaging.rst +++ /dev/null @@ -1,113 +0,0 @@ -.. _resources-authoring-packaging: - -================================= -Packaging as a Salt extension -================================= - -Once your resource type is more than a quick experiment, ship it as a -Salt extension rather than carrying it inside core Salt. The framework -is designed so an extension's resource types are -indistinguishable-at-runtime from in-tree ones: same loader, same -targeting, same merge semantics. - - -Layout -====== - -Mirror the in-tree layout under your extension's package path. Given an -extension called ``saltext-widgets``:: - - saltext-widgets/ - pyproject.toml - src/ - saltext/ - widgets/ - __init__.py - resources/ - __init__.py - widget/ - __init__.py ← connection module - modules/ - __init__.py - test.py - states/ - __init__.py - widget.py - grains/ - __init__.py - widget.py - -Every directory containing Python files needs an ``__init__.py`` so -:py:func:`setuptools.find_packages` discovers them. Empty -``__init__.py`` is fine. - - -pyproject.toml entry point -========================== - -The loader discovers extensions via the ``salt.loader`` entry point:: - - [project] - name = "saltext-widgets" - version = "0.1.0" - requires-python = ">=3.10" - dependencies = ["salt>=3008"] - - [project.entry-points."salt.loader"] - saltext.widgets = "saltext.widgets" - -Salt's loader walks every package registered under ``salt.loader``, -looks for a ``resources/`` subdir, and includes any -``/resources//{modules,states,grains}/`` directories -in its loader search path *ahead of* the standard salt locations. - -No extra registration step. No "tell Salt about my type". Drop the -package on the managing minion, restart, and the type is discoverable. - - -Where the package gets installed -================================ - -Resource extensions need to be importable by **the managing minion's -Python interpreter** — the same one running ``salt-minion``. For an -onedir installation that's the bundled interpreter; for a system -install it's whatever ``salt-minion`` is shebanged with. - -* Onedir: ``salt-pip install saltext-widgets`` -* System: ``pip install saltext-widgets`` in the same environment as - ``salt-minion`` -* Container: bake the extension into your minion image; don't expect - ``saltutil.pillar_refresh`` to install new types at runtime. - - -Discovering what shipped -======================== - -Once installed, the managing minion will load the type the next time -its pillar refresh discovers a matching entry. On the master, -``salt-run resource.list_grains`` confirms the minion registered -resources of the new type. - - -Distribution checklist -====================== - -When publishing the extension: - -* ``pyproject.toml`` lists ``salt>=3008`` as a dependency. -* Each resource type ships at least one execution test — the dummy - type is a fine template. -* The README documents the pillar shape the type expects (key names, - required vs optional fields). -* If your type talks to a remote service, document the credentials - model and how to wire pillar so secrets are decrypted at compile - time, not stored in plaintext. - - -Working example -=============== - -`saltext-opsdev `_ is the -canonical real-world example: it ships two resource types -(``starting_state``, ``nimbus_testbed``), per-type execution and state -overrides, and integration tests. Mirror its layout when in doubt. diff --git a/doc/topics/resources/authoring/pillar.rst b/doc/topics/resources/authoring/pillar.rst deleted file mode 100644 index f4ab8a0ec4a0..000000000000 --- a/doc/topics/resources/authoring/pillar.rst +++ /dev/null @@ -1,131 +0,0 @@ -.. _resources-authoring-pillar: - -============== -Pillar layout -============== - -Resources live in pillar under a configurable top-level key. The -managing minion reads that subtree on startup and on every pillar -refresh to discover which resource types it manages, which resource -ids exist for each type, and any per-resource configuration the type -needs. - - -The pillar subtree -================== - -Default key: ``resources``. Configurable via -:conf_minion:`resource_pillar_key`. The subtree's first level is the -**resource type**, the second level is whatever the resource type -wants to define: - -.. code-block:: yaml - - resources: - ssh: - hosts: - web-01: - host: 10.0.0.10 - user: admin - priv: /etc/salt/keys/web-01 - web-02: - host: 10.0.0.11 - user: admin - priv: /etc/salt/keys/web-02 - - dummy: - resource_ids: - - dummy-01 - - dummy-02 - - widget: - hosts: - w1: - endpoint: https://widgets.example.com/w1 - token_pillar: widget_w1_token - -Each top-level key is the type's discovery namespace. The shape under -that key is **type-specific** — the framework doesn't impose one. Two -common conventions: - -``resource_ids: [...]`` - A flat list of ids. Best when nothing per-id is needed in pillar - (the resource type fetches per-id configuration from elsewhere, or - doesn't need any). - -``hosts: {id: {...}}`` *(or any single dict)* - A dict keyed by resource id, with per-id config alongside. Use this - when the connection module needs per-resource configuration in - pillar (credentials, endpoint URLs, paths). The shape under each - id is yours to design. - - -Reading pillar from a resource type -=================================== - -Use :py:func:`salt.utils.resources.pillar_resources_tree` to fetch the -configured subtree. It honours :conf_minion:`resource_pillar_key` -without you having to look it up:: - - import salt.utils.resources - - def discover(opts): - return list( - salt.utils.resources.pillar_resources_tree(opts) - .get("widget", {}) - .get("hosts", {}) - .keys() - ) - - def init(opts): - widget_pillar = salt.utils.resources.pillar_resources_tree(opts).get("widget", {}) - __context__["widget"] = { - "initialized": True, - "hosts": widget_pillar.get("hosts", {}), - } - - -Picking a custom pillar key -=========================== - -If the default ``resources`` key collides with existing pillar in your -environment, override it in the minion config: - -.. code-block:: yaml - - # /etc/salt/minion.d/resources.conf - resource_pillar_key: salt_resources - -Two rules: - -1. Use the *same key* on every minion in a master. Targeting on the - master assumes the master can read each minion's resources subtree - under one consistent key. -2. Use a non-empty string. Setting the key to ``""`` logs a warning - and falls back to the default. - - -Secrets and pillar masking -========================== - -.. versionchanged:: 3008.0 - -Salt 3008.0 introduced pillar masking: ``pillar.get`` from the CLI -returns ``'**********'`` for string values by default. Templates and -SLS files render with masking disabled so they see plain values, so -your resource type's ``init(opts)`` reading pillar via -:py:func:`salt.utils.resources.pillar_resources_tree` continues to see -real values. - -If you write an integration test that calls ``pillar.get`` from the -CLI and compares against a real value, pass ``unmask=True``:: - - salt-call pillar.get resources:widget:hosts unmask=True - - -Examples in the wild -==================== - -* ``salt/resources/dummy/__init__.py`` — ``resource_ids: [...]`` shape. -* ``salt/resources/ssh/__init__.py`` — ``hosts: {id: {...}}`` shape - with credentials and connection details per id. diff --git a/doc/topics/resources/authoring/state_modules.rst b/doc/topics/resources/authoring/state_modules.rst deleted file mode 100644 index 015db2990a99..000000000000 --- a/doc/topics/resources/authoring/state_modules.rst +++ /dev/null @@ -1,145 +0,0 @@ -.. _resources-authoring-states: - -====================== -State module overrides -====================== - -The ``states/`` directory of a resource type holds **state module -overrides**. The mechanics mirror :ref:`execution-module overrides -` exactly: filename = state slot, -directory order picks the override when one exists, standard -``salt/states/`` fills any slot you don't override. - -If you've followed the :ref:`architecture ` -discussion of merge-mode ``state.apply``, this is where authoring meets -that machinery. See :ref:`resources-state-authoring` for the runtime -semantics; this page is about *writing* the modules. - - -When to add a state override -============================ - -Most resource types do **not** need state overrides. The standard -state modules (``pkg.installed``, ``service.running``, ``file.managed``, -…) call into ``__salt__`` for their actual work. If your resource type -ships the right *execution-module* overrides — so ``__salt__["pkg.install"]`` -does the right thing on a widget — the standard ``pkg.installed`` -state module runs unchanged against your resource. - -Reach for a state override only when the *state semantics themselves* -differ — when "this resource is in state X" can't be expressed by -existing ``__salt__`` calls. - - -Dunders inside a state override -=============================== - -``__salt__`` - The per-resource execution loader. Calling ``__salt__["pkg.install"]`` - from inside a state runs the *per-resource* ``pkg.install`` if you - have one, otherwise the standard module. - -``__opts__`` - Read-only opts. ``opts["resource_type"]`` is set inside per-resource - state apply — handy for state code that wants to know whether it's - running against a resource at all. - -``__grains__`` - The **resource's** grain dict. - -``__minion__`` - The managing minion's execution-module loader. The escape hatch. - Use this when a state genuinely needs to do something on the host - — write a checkpoint to ``/var/lib/`` on the managing minion, - say, after each resource finishes. See - :ref:`resources-state-authoring` for when this is appropriate. - -``__resource__`` - ``{"type": ..., "id": ...}`` for the resource the state is running - against. - - -Pattern: forward to a connection-module function -================================================ - -A widget state that ensures a service is running on the widget:: - - # states/service.py - def running(name, **kwargs): - ret = { - "name": name, - "result": False, - "changes": {}, - "comment": "", - } - - current = __salt__["widget.service_status"](name) - if current.get("comment") == "running": - ret["result"] = True - ret["comment"] = f"Service {name} is already running" - return ret - - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Service {name} would be started" - ret["changes"] = {name: {"old": "stopped", "new": "running"}} - return ret - - __salt__["widget.service_start"](name) - ret["result"] = True - ret["comment"] = f"Service {name} started" - ret["changes"] = {name: {"old": "stopped", "new": "running"}} - return ret - - -Pattern: re-export a standard state module -========================================== - -Same trick as for execution modules. Re-export with -``namespaced_function`` so the standard state's dunder resolution -happens against the per-resource loader:: - - # states/file.py - import salt.utils.functools - import salt.states.file as _src - - managed = salt.utils.functools.namespaced_function(_src.managed, globals()) - absent = salt.utils.functools.namespaced_function(_src.absent, globals()) - directory = salt.utils.functools.namespaced_function(_src.directory, globals()) - -This is unusual — if your execution-module overrides are right, the -standard ``salt.states.file`` already works against the resource via -``__salt__``. Re-export only when the state module imports from -``salt.modules.*`` directly (some old state modules do) and you need -those imports rebound to the per-resource loader. - - -Merge mode and state IDs -======================== - -When the operator runs ``state.apply`` (or any other -:py:attr:`~salt.minion.Minion._MERGE_RESOURCE_FUNS` function) against -``T@[:]``, the managing minion runs each resource's state -apply inline and folds the per-resource state IDs into one combined -dict. The framework prefixes each state ID with the resource id so -operators see provenance in the output. - -You don't have to do anything special in your state code for this to -work — it's handled in ``Minion._thread_return``. See -:ref:`resources-state-authoring` for the prefixing scheme and how to -keep your state IDs stable across resources. - - -Mistakes to avoid -================= - -* **Calling** ``__minion__["..."]`` **by reflex**. ``__minion__`` is the - escape hatch. Most state code wants ``__salt__`` — which resolves - against the per-resource loader and gives you both resource - overrides and standard modules. Only reach for ``__minion__`` when - you genuinely need something to happen on the host, not the resource. -* **Returning** ``"changes"`` **that aren't dicts**. State returns - must follow Salt's state-return contract. Resources don't relax that. -* **Side-effecting in** ``test=True`` **mode**. Same contract as core - Salt — if ``__opts__["test"]`` is true, show the diff but don't - apply it. diff --git a/doc/topics/resources/configuration.rst b/doc/topics/resources/configuration.rst deleted file mode 100644 index 72b0f60087ef..000000000000 --- a/doc/topics/resources/configuration.rst +++ /dev/null @@ -1,140 +0,0 @@ -.. _resources-configuration: - -============= -Configuration -============= - -.. versionadded:: 3008.0 - -Configuration options that control the resource subsystem. All -options are read from the standard master/minion config files; the -side column indicates which daemon honours each one. - - -Minion options -============== - -.. conf_minion:: resource_pillar_key - -``resource_pillar_key`` ------------------------ - -Default: ``resources`` - -The pillar key under which the managing minion looks for resource -declarations. The minion's pillar must contain this key (a dict) for -any resource type to be discovered. - -.. code-block:: yaml - - # /etc/salt/minion.d/resources.conf - resource_pillar_key: salt_resources - -When set, the matching pillar key on each minion must use the same -name — the master assumes one canonical key when reading resource -declarations across minions. - -Setting this to an empty string logs a warning and falls back to the -default. - -See :ref:`resources-authoring-pillar` for the pillar layout under this -key. - - -Master options -============== - -.. conf_master:: resource_index_primary_capacity - -``resource_index_primary_capacity`` ------------------------------------- - -Default: ``2097152`` (``1 << 21``) - -The number of slots in the master's mmap-backed primary resource -index. Each slot holds one ``SRN → managing-minion`` mapping; the -index uses linear probing, so reserved capacity is also the upper -bound on resources the master can register before compaction is -required. - -Sizing rule of thumb: pick a capacity at least 4× your expected -peak resource count, round up to a power of two. The default -(2 097 152) fits ~500 K resources comfortably with room for the -hash-table fill factor to stay under 25 %. - -Increasing this option requires recreating the on-disk index file — -delete ``/resources/resource_index.by_id.mmap`` on the -master before restarting if you raise the capacity. - -.. conf_master:: resource_index_primary_slot_size - -``resource_index_primary_slot_size`` -------------------------------------- - -Default: ``128`` - -Per-slot byte budget in the primary resource index. Each slot stores -the SRN key, the JSON payload (``{"m": ..., "t": ...}``), and a small -header. 128 bytes accommodates ~80-character SRNs comfortably. - -Raise this only if your environment uses very long resource ids or -type names. Like ``resource_index_primary_capacity``, changing this -option requires recreating the on-disk index file. - - -``resource_pillar_key`` on the master -------------------------------------- - -The master reads :conf_minion:`resource_pillar_key` from its **own** -config to know how to read minion pillar caches when expanding -targets (the master compiles minion pillar on its side too — see -``_resource_ids_from_minion_pillar_cache``). Keep the value -consistent across master and all minions. - - -Pillar -====== - -Resource declarations live under :conf_minion:`resource_pillar_key` -on each minion's pillar. See :ref:`resources-authoring-pillar` for the -full shape; the relevant configuration aspect is that **every minion -managing resources of the same type must agree on the type's pillar -shape**. The connection module is the contract. - - -Inspection -========== - -To verify the master's view of registered resources: - -.. code-block:: bash - - salt-run resource.list_grains - salt-run resource.show_grains type=ssh id=web-01 - -To force a re-registration after a config change: - -.. code-block:: bash - - # On the managing minion - salt-call saltutil.refresh_pillar - - # Or from the master, targeted at one minion - salt-run resource.refresh minion= - -See :ref:`resources-operations` for more. - - -Sizing guidance -=============== - -Worked example: a master fleet of 1 000 minions, each managing -100 resources on average, with peak bursts up to 200 per minion. - -* Peak total = 200 × 1 000 = 200 000 resources. -* Capacity = 200 000 × 4 = 800 000 → round up to 1 048 576 (``1 << 20``). -* Default capacity (2 097 152) is already 2× that — leave it. - -Sizing the on-disk file: -``capacity × slot_size`` = 2 097 152 × 128 = 256 MiB at the default. -File grows as needed, but plan for it. diff --git a/doc/topics/resources/derived.rst b/doc/topics/resources/derived.rst deleted file mode 100644 index d4d1451ff9b5..000000000000 --- a/doc/topics/resources/derived.rst +++ /dev/null @@ -1,221 +0,0 @@ -.. _resources-derived: - -================== -Derived resources -================== - -.. note:: - - Derived resources are a **design pattern** — not a runtime feature - shipped in 3008.0. The framework that *enables* the pattern (the - per-type loader, merge-mode state apply, ``__minion__`` escape - hatch) is in 3008.0; the registry and lifecycle helpers described - below are documented here as a forward reference and to give - extension authors a stable target to design against. - -A **derived resource** is a resource whose configuration is only -knowable at runtime, as a consequence of another resource reaching a -desired state. - - -The problem -=========== - -Static resources — the ones declared in :ref:`pillar -` before Salt runs — work well when -connection details are known up-front. A Kubernetes cluster with a -fixed API endpoint, a router whose IP doesn't change: declare them -once, target them with ``T@``. - -Some resources don't fit that mould. Consider: - -* A short-lived sandbox environment whose IP/credentials only exist - after the ``starting_state.deployed`` state succeeds. -* A jump host that comes up as part of provisioning another resource, - with credentials that need to be fetched from the provisioning - system's API. -* A container that's launched by another resource's state apply, then - needs further configuration applied to it. - -Today's workaround is a multi-step recipe: apply the upstream state, -manually fetch the derived connection info, write it into a second -pillar file, apply the downstream state. Each step is a separate -``state.apply``; there's no single command that owns the full desired -state. - - -The pattern -=========== - -A derived resource is registered into a shared per-run registry by -the *upstream* state function — after it confirms the upstream -resource is healthy and connection info is fetchable. Subsequent -states in the same apply (and future runs) target the derived -resource using the same ``T@:`` syntax as any static -resource. The lifecycle: - -.. code-block:: text - - Run 1 — first apply - ─────────────────── - starting_state.deployed("env-01") - → environment reaches "succeeded" - → fetches connection_info() - → registers a derived resource: - type: ssh_host - id: jumphost-env-01 - config: - host: 10.20.30.40 - user: worker - password: … - source: starting_state/env-01 ← provenance - - ssh_host.state_applied("jumphost-env-01", mods="openvpn.init") - → resource is in the registry → executes normally - - Run 2+ — idempotent re-apply - ──────────────────────────── - starting_state.deployed("env-01") - → already succeeded, no-op - → re-registers derived resource (idempotent) - - ssh_host.state_applied(...) - → normal execution against cached registration - -A single ``state.apply`` drives the whole flow. - - -Registry scopes -=============== - -A derived-resource registry has two natural scopes: - -In-run scope (``__context__``) - Entries survive for the lifetime of a single ``state.apply``. - Sufficient when downstream states only run inside the same apply - that registers the resource. Lost on minion restart or pillar - refresh. - -Cross-run scope (cache file) - Entries persist to JSON in ``cachedir`` (e.g. - ``/var/cache/salt/minion/derived_resources.json``). Lets - ``T@ssh_host:jumphost-*`` work in later ``salt -C`` invocations - without re-running the upstream state. Cache is invalidated when - the source resource's grains change — typically when its - ``status`` grain transitions away from ``succeeded``. - -A working implementation would expose, at minimum:: - - salt.utils.derived_resources.register( - opts, - resource_type, resource_id, config, - source_srn=None, - ) - - salt.utils.derived_resources.get( - opts, - resource_type, resource_id, - ) - - salt.utils.derived_resources.invalidate( - opts, - source_srn, - ) - - -Integration points -================== - -For a resource type to support being *the source* or *the target* of a -derivation, two hooks need to know about the registry. - -``discover(opts)`` - Should check both pillar and the derived registry:: - - def discover(opts): - static = list( - salt.utils.resources.pillar_resources_tree(opts) - .get("ssh_host", {}) - .keys() - ) - derived = salt.utils.derived_resources.list_for_type( - opts, "ssh_host" - ) - return static + derived - -``init`` *(or wherever per-resource config is fetched)* - Should fall back to the registry when a resource id isn't in - pillar:: - - cfg = ( - salt.utils.resources.pillar_resources_tree(opts) - .get("ssh_host", {}) - .get(resource_id) - or salt.utils.derived_resources.get( - opts, "ssh_host", resource_id - ) - ) - if cfg is None: - raise RuntimeError(f"ssh_host {resource_id!r} unknown") - -The *registering* side is an upstream state module that calls -``salt.utils.derived_resources.register(...)`` after confirming the -upstream resource is healthy. Provenance is tracked via the -``source_srn`` argument so the cache can be invalidated when the -source resource's state changes. - - -Example SLS -=========== - -A single apply that provisions an environment, registers a derived -jump-host resource, configures OpenVPN on the jump host, and pulls -the resulting client config back: - -.. code-block:: yaml - - ensure_environment: - starting_state.deployed: - - name: env-01 - - register_resources: true - - configure_jumphost_vpn: - ssh_host.state_applied: - - name: jumphost-env-01 - - mods: openvpn.init - - require: - - starting_state: ensure_environment - - fetch_client_config: - ssh_host.fetch_file: - - name: jumphost-env-01 - - remote_path: /etc/openvpn/client.ovpn - - local_path: /root/env-01-vpn.ovpn - - require: - - ssh_host: configure_jumphost_vpn - - -Open questions -============== - -Some pieces of the pattern aren't settled in 3008.0: - -* **Cache invalidation granularity.** Per-SRN or per-grain-key? Only - the ``status`` transition typically matters; finer granularity - would avoid spurious invalidations on volatile grains. -* **Secret storage.** Derived resource configs often contain - credentials. The cross-run cache file needs the same handling as - any pillar secret: filesystem permissions, encryption at rest, - audit logging. -* **Ordering inside a single apply.** State requisites - (``require``, ``onchanges``) order *state chunks*. The framework - needs to make sure a derived resource registered by a chunk early - in the apply is visible to a chunk later in the same apply — the - ``discover`` cache may need to be refreshed mid-apply. -* **Orchestration.** ``salt-run state.orchestrate`` is a better fit - for multi-resource workflows that span minions. The registry API - should work the same from inside orchestrate. - -The :ref:`resources-architecture` page documents the runtime framework -the design above builds on. Track progress on the runtime helpers -under the ``derived-resources`` topic in the Salt issue tracker. diff --git a/doc/topics/resources/index.rst b/doc/topics/resources/index.rst deleted file mode 100644 index 3c8d5c42e146..000000000000 --- a/doc/topics/resources/index.rst +++ /dev/null @@ -1,153 +0,0 @@ -.. _resources: - -============== -Salt Resources -============== - -.. versionadded:: 3008.0 - -A *Salt resource* is something a minion manages on behalf of the master — -an SSH host, a virtual appliance, an external API endpoint, a cloud -account, a CI/CD pipeline — addressed by an id of the operator's -choosing. Resources let Salt drive things that can't (or shouldn't) run -a minion of their own, without giving up Salt's targeting, state, and -return-handling machinery. - -If you've used proxy minions, the conceptual leap is small: a resource is -the "thing being proxied for" expressed as a *first-class targeting -primitive* rather than a separate daemon. One Salt minion can manage -many resources of many types, each addressable individually. - - -Why resources? -============== - -The same problem keeps coming back: you need Salt's primitives — -targeting, states, pillar, returners — pointed at a thing that isn't a -Salt minion. - -* A pool of SSH-only hosts where you can't (or don't want to) install a - minion: routers, switches, jump hosts, locked-down appliances. -* A SaaS or cloud control plane where the "node" is an API endpoint - rather than a process: cloud accounts, Kubernetes clusters, Vault - instances, container registries. -* A short-lived environment that appears and vanishes as a side effect - of another state's success: ephemeral CI sandboxes, on-demand jump - hosts (see :ref:`resources-derived`). - -You could solve each of those with custom execution modules, with -``salt-ssh`` orchestration, with a proxy minion per thing, or with a -runner that wraps the call. Resources unify them: one targeting -expression, one return shape, one state-apply path, regardless of which -flavour of "remote thing" the operator is talking to. - - -When to reach for a resource (and when not to) -============================================== - -A resource is the right tool when *all* of these are true: - -* The thing has a stable identity an operator might want to address. -* You want to run Salt states or execution functions *against* it. -* You don't want to install a minion on it (or you can't). - -A resource is **not** the right tool when: - -* The thing already runs a Salt minion — just target the minion. -* You only need to read data once and act on the result locally — write - a runner or an execution module. -* The thing is a configuration setting on the *minion itself* (a file, - a service, a package) — that's already covered by the minion's normal - state tree. - - -Comparison with proxy minions and salt-ssh -========================================== - -.. list-table:: - :header-rows: 1 - :widths: 25 25 25 25 - - * - Aspect - - Proxy minion - - ``salt-ssh`` - - Salt resource - * - Process model - - One daemon per target - - Master-driven, no daemon - - One managing minion per N resources - * - Targeting - - By proxy id (a minion) - - By roster entry - - By resource id or type - (``T@[:]``) - * - State engine - - Local on the proxy - - Master-driven - - On the managing minion, - merged into one return - * - Pillar - - Per proxy - - Master's pillar - - Per-resource subtree under - :conf_minion:`resource_pillar_key` - * - Best for - - Network gear with a - persistent control plane - - Bootstrap, one-offs, - agentless tasks - - Fleets of remote things - managed alongside their - host minion - -A managing minion can also manage resources whose *transport* is -salt-ssh — the SSH resource type ships with Salt — so the choice isn't -exclusive. Resources give you the targeting and state primitives; -salt-ssh remains a perfectly good transport. - - -Mental model -============ - -* The **master** holds the system-of-record for which minion owns which - resource (the :ref:`resource registry `). - Targeting matchers (``T@``, ``G@``, ``L@``, wildcard globs, …) - consult that registry to expand expressions like ``T@ssh`` or - ``salt '*'`` into the union of minions and resources. -* The **managing minion** carries per-resource grain dicts, a - per-resource loader, and the connection plumbing. When a publish - arrives for ``T@ssh:web-01`` the managing minion dispatches the job - to the resource loader and returns a result keyed by ``web-01``. -* The **resource type** is just a Python package under - ``salt/resources//`` (or under any Salt extension's - ``saltext//resources//``). Its layout mirrors Salt's own - loader trees: ``modules/``, ``states/``, ``grains/`` — except the - files inside are *overrides* that win their slot when running in - that resource's context, and standard Salt modules fill the rest. - - -Documentation map -================= - -If you're new to resources, read :ref:`resources-tutorial` first — it -takes the bundled ``dummy`` type from scratch to a working -``salt -C 'T@dummy' state.apply`` in about ten minutes. From there: - -.. toctree:: - :maxdepth: 1 - - tutorial - architecture - targeting - state_authoring - derived - operations - configuration - authoring/index - -API reference (autodoc): - -.. toctree:: - :maxdepth: 1 - - /ref/resources/index diff --git a/doc/topics/resources/operations.rst b/doc/topics/resources/operations.rst deleted file mode 100644 index 74b4bd3d2f6a..000000000000 --- a/doc/topics/resources/operations.rst +++ /dev/null @@ -1,187 +0,0 @@ -.. _resources-operations: - -========== -Operations -========== - -.. versionadded:: 3008.0 - -Day-to-day operator reference: how to refresh the master's view, how to -inspect what's registered, how to run jobs from ``salt-call`` against -resources. - - -Refreshing the master's view -============================ - -The master only knows about resources a minion has registered. Three -events trigger a re-registration: - -1. **Minion start or reconnect**. ``tune_in`` calls - ``_register_resources_with_master`` after pillar is compiled. -2. **A pillar refresh on the minion**. - ``saltutil.refresh_pillar`` re-runs resource discovery and - re-publishes. -3. **The** ``resource_refresh`` **event on the minion event bus**. - Fired by the master-side ``resource.refresh`` runner — see below. - -Per-resource ``grains_refresh()`` calls inside the connection module -do **not** auto-propagate to the master. To force the master to pick -up new grains without waiting for a pillar refresh:: - - salt-run resource.refresh minion=resources-minion - -The runner publishes ``minion//resource_refresh`` on the master -event bus; the minion's handler re-runs ``_discover_resources`` and -publishes the full resource grain set. Use this when a resource's -underlying state changes out-of-band — e.g. you ran -``ss_env.refresh()`` directly on the host and the new metadata isn't -yet visible to ``salt -G``. - - -Inspecting the master's resource view -===================================== - -Two read-only runners surface what's in the registry. Useful for -"why didn't my ``-G`` target match this resource?" debugging. - -``salt-run resource.list_grains`` - Lists every SRN (``":"``) currently in the master's - ``resource_grains`` cache bank, with a one-line summary of each - resource's grain keys. - - .. code-block:: bash - - salt-run resource.list_grains - - # ssh:web-01: - # grain_count: 4 - # grain_keys: [env, host, role, ssh_user] - # dummy:dummy-01: - # grain_count: 4 - # grain_keys: [dummy_grain_1, dummy_grain_2, dummy_grain_3, resource_id] - - If a resource you expect to see is missing, the minion managing it - hasn't registered yet — check minion logs for a registration - failure or run ``resource.refresh`` against that minion. - -``salt-run resource.show_grains`` - Returns the full grain dict for one resource. Pair this with a - listing to inspect specific values. - - .. code-block:: bash - - salt-run resource.show_grains type=ssh id=web-01 - - -Targeting from the operator side -================================ - -Every targeting form Salt supports against minions works against -resources too. See :ref:`resources-targeting` for the full reference; -the highlights: - -.. code-block:: bash - - # Glob target — matches both the managing minion and its resources - salt '*' test.ping - - # Targeting a single resource by bare id - salt 'web-01' test.ping - - # All resources of a type - salt -C 'T@ssh' test.ping - - # One specific resource - salt -C 'T@ssh:web-01' state.apply mysls - - # By per-resource grain - salt -G 'env:prod' test.ping - salt -C 'G@env:prod and T@ssh' state.apply nginx - - -``salt-call`` and resources -=========================== - -By default ``salt-call`` runs functions on the **managing minion -only** — resources are not dispatched. This preserves single-bare-value -return semantics for the existing universe of ``salt-call`` callers. - -To opt **in** to resource dispatch from ``salt-call``, use the -``-r`` / ``--resources`` flag added in 3008.0: - -.. code-block:: bash - - # Default — managing minion only, single bare value - salt-call test.ping - # → True - - # Resources enabled — managing minion + every managed resource - salt-call -r test.ping - # → {: True, "web-01": True, "web-02": True, ...} - - # Resources enabled with a target - salt-call -r --tgt web-01 test.ping - # → True (single match → bare value) - - salt-call -r --tgt 'T@ssh' --tgt-type compound state.apply mysls - - salt-call -r --tgt 'env:prod' --tgt-type grain test.ping - -The ``--tgt`` and ``--tgt-type`` flags mirror the master CLI's -``-t``/``--target-type`` model. Default target is ``*`` (everything -the minion manages); default target type is ``glob``. - -For more on the supported target types, see :ref:`resources-targeting`. - - -Forcing re-registration without a master -========================================= - -A masterless ``salt-call`` can refresh its own resource view too. The -sequence is: - -.. code-block:: bash - - salt-call saltutil.refresh_pillar - salt-call -r --tgt '*' test.ping - -The pillar refresh re-runs each resource type's ``discover()`` and -``grains()``. In masterless mode there's no master registry to update, -but the *minion's own* view of which resources exist is rebuilt. - - -Common debugging recipes -======================== - -**Resource not matching** ``-G env:prod`` - 1. ``salt-run resource.list_grains`` — is the resource in the bank? - 2. If not: ``salt-run resource.refresh minion=``. - 3. If yes: ``salt-run resource.show_grains type= id=`` — does - ``env`` actually equal ``prod`` in the registered grains? The - managing minion's ``grains()`` is what produced this value; - check the connection module. - -**"Function X is not supported for resource type Y"** - The per-resource loader doesn't have function ``X`` for type ``Y``. - Either the type doesn't ship that function, or ``saltutil.sync_all`` - on the managing minion is overdue. Sync, refresh pillar, retry. - -**State output missing per-resource blocks** - For merge-mode functions, all results fold into a single combined - return on the managing minion. If you see only one block when you - expected several, check that the target expression actually - matched multiple resources (``salt-run resource.list_grains``). - -**Resource registry seems stale after a master restart** - The registry is on-disk and survives restarts. If a minion - restarted at the same time, give it 60 seconds to reconnect and - re-register, then re-check with ``salt-run resource.list_grains``. - - -Related -======= - -* :ref:`resources-targeting` -* :ref:`resources-configuration` -* :ref:`resources-architecture` diff --git a/doc/topics/resources/state_authoring.rst b/doc/topics/resources/state_authoring.rst deleted file mode 100644 index 53af07dcec20..000000000000 --- a/doc/topics/resources/state_authoring.rst +++ /dev/null @@ -1,189 +0,0 @@ -.. _resources-state-authoring: - -================================ -States against Salt Resources -================================ - -.. versionadded:: 3008.0 - -How ``state.apply``, ``state.highstate``, and ``state.sls`` behave when -they target resources. This page is the *runtime* counterpart to the -:ref:`state-module authoring guide ` — read -that one if you're writing state modules, this one if you're applying -states or trying to debug what's going on. - - -The merge-mode contract -======================= - -These functions are special: - -* ``state.apply`` -* ``state.highstate`` -* ``state.sls`` -* ``state.sls_id`` -* ``state.single`` - -(The full list lives in -:py:attr:`~salt.minion.Minion._MERGE_RESOURCE_FUNS`.) - -When the operator runs one of them against a target that includes -resources — ``salt -C 'T@ssh' state.apply mysls``, say — Salt does -**not** dispatch one independent return per resource. Instead: - -1. The master's wait list contains the *managing minion* id, not the - resource ids. -2. The managing minion runs the per-resource apply **inline** — - building one ``HighState`` per matched resource using that - resource's per-type loader. -3. The managing minion folds the per-resource state IDs into a single - ``ret["return"]`` dict and publishes one combined return. -4. The CLI prints one block + one Summary, with each state ID - prefixed by its resource id so provenance is visible. - -This matches how any other minion looks to the master: one publish, -one return, one block of output. The difference is invisible to -``state.show_lowstate`` and friends. - - -State-ID prefixing -================== - -Salt's state low keys are ``{module}_|-{id}_|-{name}_|-{function}``. -When the managing minion folds per-resource results into the parent -dict, it rewrites positions 1 and 2 (id and name) with the resource id -prepended. So a state declared as - -.. code-block:: yaml - - install_curl: - pkg.installed: - - name: curl - -apply'd against ``T@ssh:web-01`` and ``T@ssh:web-02`` produces two -keys:: - - pkg_|-web-01 install_curl_|-web-01 curl_|-installed - pkg_|-web-02 install_curl_|-web-02 curl_|-installed - -The ``{module}`` (``pkg``) and ``{function}`` (``installed``) -positions are left alone so the highstate formatter still shows -``Function: pkg.installed`` correctly; only the ID and Name are -relabelled to surface the resource. - -If a resource fails before the per-resource ``HighState`` produced any -chunks (e.g. the resource type couldn't fulfil the operation at all), -the framework inserts a synthetic chunk under -``no_|-{rid}_|-{rid}_|-None`` so the result is still visible in the -combined dict and still contributes to the overall pass/fail. - -You don't have to think about prefixing inside your states. Just keep -state IDs stable across resources and the output will be readable. - - -The managing minion is NOT a target (usually) -============================================== - -For ``T@`` and ``M@`` compound expressions that *only* address -resources (a "pure resource target"), the managing minion is *not* a -target for the function itself — its job is to run the resources -inline, not to apply the state to its own filesystem. - -The framework detects this case via ``data["pure_resource_target"]`` -in :py:meth:`~salt.minion.Minion._thread_return` and **skips** the -regular function execution on the managing minion. Without that skip -you'd see a spurious ``"state.apply not found"`` block from the -managing minion alongside the real per-resource results. - -If the target expression *also* matches the managing minion (a wildcard -glob like ``salt '*' state.apply``, or a grain match the host also -satisfies), the managing minion runs the apply against itself too — -its results appear in the combined dict alongside the per-resource -results. - - -The ``__minion__`` escape hatch -================================ - -Inside per-resource state code, ``__salt__`` is the *per-resource* -execution loader. That's almost always what you want — it gives you -both your overrides (where they exist) and the standard module set -(everywhere else). - -Occasionally a state needs to do something *on the managing minion -itself* — write a checkpoint to ``/var/lib/salt/`` after each resource -finishes, look up a credential from the host's keychain, etc. That's -what ``__minion__`` is for. It's the managing minion's regular -execution-module loader, packed into per-resource state and execution -modules as an explicit escape hatch:: - - def post_widget_apply(name, **kwargs): - ret = {"name": name, "result": True, "changes": {}, "comment": ""} - - widget_status = __salt__["widget.status"](name) - if widget_status.get("ok"): - __minion__["file.append"]( - "/var/lib/salt/widget-applied.log", - f"{name} applied successfully\n", - ) - ret["comment"] = "Recorded apply on managing minion" - else: - ret["result"] = False - ret["comment"] = "Widget not in OK state" - - return ret - -Reach for ``__minion__`` deliberately. It is a deliberate cross-context -call: the state is running in resource context but reaches back to the -host. State module authors should think of it the way they'd think of -running ``subprocess.run`` from a state function — fine when it's the -right answer, a smell when used by reflex. - - -When ``state.apply`` falls through to the standard module -========================================================== - -For resource types **without** a per-type override of the ``state`` -slot (no ``salt/resources//modules/state.py``), the standard -``salt.modules.state`` module runs in the per-resource loader. That -means: - -* ``__salt__["state.apply"]`` for that resource compiles the high - state on the **managing minion**, using the **per-resource - execution loader** for ``__salt__`` inside the rendered states. -* The states themselves run on the managing minion (because that's - where the state engine is) but every module call inside them - dispatches via the per-resource loader. - -For resource types that ship a per-type ``state.py`` (today: the -``ssh`` resource), ``state.apply`` runs *on the resource itself* via -whatever transport the override implements. - -Both shapes converge at the same return contract — per-resource state -results folded into the managing minion's combined return. - - -Debugging tips -============== - -* **Where is my state running?** Look at the ``minion`` field in the - highstate output. For resource types without a state override it's - the managing minion (with per-resource ``__salt__``). For types - with a state override it's "wherever the override sends it" — for - the SSH resource type, that's the remote host over SSH. -* **State ID collisions in output.** If two different resources - produce the same state ID *after* the rid prefix, you have a - prefixing bug — usually a state ID that contains characters Salt - treats specially in the low-key encoding. File a bug. -* **"State X is not available."** Means the per-resource loader has - no module providing slot ``X``. Either ship an override at - ``/modules/X.py`` or use a different state. - - -Related -======= - -* :ref:`resources-architecture` — registry, dispatch, merge mode. -* :ref:`resources-authoring-states` — how to write state overrides. -* :ref:`resources-authoring-execution` — how the per-resource ``__salt__`` - is built. diff --git a/doc/topics/resources/targeting.rst b/doc/topics/resources/targeting.rst deleted file mode 100644 index 1becb7318c9f..000000000000 --- a/doc/topics/resources/targeting.rst +++ /dev/null @@ -1,179 +0,0 @@ -.. _targeting-resources: -.. _resources-targeting: - -================================ -Targeting Salt Resources -================================ - -.. versionadded:: 3008.0 - -A *Salt resource* is something a minion manages on behalf of the master — -an SSH host, a virtual appliance, an external API endpoint — addressed -by an id of the operator's choosing. Resources extend Salt's targeting -system: every targeting expression that selects minions can also select -resources. - -This page is the targeting reference. For the conceptual introduction -see :ref:`resources`; for the registry and dispatch plumbing see -:ref:`resources-architecture`. - - -Targeting forms -=============== - -Every form below treats resources alongside minions: a single command -returns one entry per matched id, whether that id belongs to a minion -or a resource. - -Glob and exact-id ------------------ - -A wildcard glob automatically expands to include every resource managed -by every matched minion:: - - salt '*' test.ping - -A specific bare id matches a resource directly:: - - salt 'web-01' test.ping - -A specific minion id targets only the minion (not its resources):: - - salt 'minion-1' test.ping - - -Compound ``T@`` (resource type) -------------------------------- - -``T@`` matches every resource of the given type:: - - salt -C 'T@ssh' state.apply - -``T@:`` targets exactly one resource:: - - salt -C 'T@ssh:web-01' test.ping - - -Grain-based ``-G`` / ``G@`` ---------------------------- - -A resource carries its own grains, produced by the ``grains`` function -in the resource's connection module (e.g. -:func:`salt.resource.dummy.grains`). The master records each minion's -per-resource grain dicts in the ``resource_grains`` cache bank when the -minion registers, and ``salt -G`` matches against that bank in addition -to the per-minion grain bank:: - - salt -G 'environment:prod' test.ping - -Compound ``G@`` works the same way and supports the full boolean -algebra (``and``, ``or``, ``not``, parens):: - - salt -C 'G@environment:prod and G@role:web' state.apply - salt -C 'T@ssh and not G@environment:staging' test.ping - -The boolean form is evaluated **per resource**, so a compound matches a -resource iff that resource's identity and grains satisfy the entire -expression. - - -PCRE grain ``-P`` / ``P@`` --------------------------- - -Identical semantics to ``-G`` / ``G@`` but values are regex patterns:: - - salt -P 'environment:^production-.*' test.ping - salt -C 'P@environment:^production-.*' state.apply - - -List ``-L`` ------------ - -A bare resource id appearing in a list expression matches:: - - salt -L 'web-01,web-02,db-01' test.ping - - -Pillar ``-I`` / ``I@`` ----------------------- - -.. note:: - - Pillar-based targeting of resources is **not** wired up. Resources - do not carry per-resource pillar data today. ``-I`` and ``I@`` only - match minions; resources are skipped silently. This is tracked as - future work — see the gap notes in - :py:mod:`salt.utils.resource_registry`. - - -How master and minion split the work -==================================== - -Master side ------------ - -The master's ``CkMinions`` augments grain matches with resource ids -read from the ``resource_grains`` cache bank. The augment runs for -``-G``, ``-P``, and any ``G@`` / ``P@`` term inside a compound. The -matched bare resource ids are added to the response wait set so the -master accepts the corresponding returns. - -Minion side ------------ - -When a publish arrives, the minion's ``_resolve_resource_targets`` -walks every locally managed resource and decides, **per resource**, -whether the targeting expression matches. For glob / list / ``T@`` -this is a string match; for ``G@`` / ``P@`` the minion uses the -grains it cached during its last registration; for compound, the -minion evaluates the full boolean expression against each resource's -identity and grains. - -Each matched resource gets its own job dispatch with ``__grains__`` -swapped to the resource's grain dict (so ``salt 'web-01' grains.items`` -returns ``web-01``'s grains, not the managing minion's). - - -Freshness and refresh -===================== - -The master's ``resource_grains`` bank is updated only when a minion -re-registers via ``_register_resources_with_master``. Triggers that -re-register are: - -* Minion start / reconnect (``tune_in``); -* A ``saltutil.refresh_pillar`` (the minion's pillar refresh handler - re-discovers resources before re-registering); and -* The ``resource_refresh`` event on the minion event bus. - -A per-resource ``.grains_refresh()`` invocation does **not** -auto-propagate to the master. To force the master's view to refresh -without waiting for a pillar refresh, fire the ``resource_refresh`` -event for the relevant minion:: - - salt-run resource.refresh minion=resources-minion - -That runner publishes ``minion//resource_refresh`` on the master -event bus; the minion's handler re-runs resource discovery and -re-publishes its full grain set. - - -Operator inspection -=================== - -Two read-only runners expose what the master sees: - -.. code-block:: bash - - # Show every SRN currently in the resource_grains bank with a - # one-line summary (top-level grain keys + count). - salt-run resource.list_grains - - # Show the full grain dict for one resource. - salt-run resource.show_grains type=ssh id=web-01 - -When ``salt -G ':' test.ping`` returns less than expected, -``resource.list_grains`` is the first place to check: if a resource -isn't in the bank, the master will not match it, and the resource needs -a ``saltutil.refresh_pillar`` (or a ``resource.refresh``) on its -managing minion. diff --git a/doc/topics/resources/tutorial.rst b/doc/topics/resources/tutorial.rst deleted file mode 100644 index be2fa9f71dd8..000000000000 --- a/doc/topics/resources/tutorial.rst +++ /dev/null @@ -1,211 +0,0 @@ -.. _resources-tutorial: - -======================================== -Salt Resources: a 10-minute walk-through -======================================== - -.. versionadded:: 3008.0 - -This tutorial takes the bundled ``dummy`` resource type from "what is -this thing?" to a working ``state.apply`` against three dummy -resources, using nothing but a single masterless minion config. By -the end you'll have exercised every part of the framework: pillar -declaration, registration, targeting, per-resource execution, and -merge-mode state apply. - - -Why the dummy type? -=================== - -The dummy resource is a self-contained, filesystem-backed -implementation that lives in :py:mod:`salt.resources.dummy`. It needs -no external services, no SSH targets, no API tokens. It is the -resource analogue of the ``salt.proxy.dummy`` proxy module — built so -you can exercise the framework end-to-end without managing anything -real. - - -Setup -===== - -We'll run the whole thing with ``salt-call --local`` (masterless). One -config file, one pillar file. Adjust paths to taste. - -``/etc/salt/minion.d/tutorial.conf``: - -.. code-block:: yaml - - file_client: local - file_roots: - base: - - /srv/salt - pillar_roots: - base: - - /srv/pillar - -``/srv/pillar/top.sls``: - -.. code-block:: yaml - - base: - '*': - - resources - -``/srv/pillar/resources.sls``: - -.. code-block:: yaml - - resources: - dummy: - resource_ids: - - dummy-01 - - dummy-02 - - dummy-03 - -Confirm pillar reads correctly: - -.. code-block:: bash - - salt-call --local pillar.get resources unmask=True - # {'dummy': {'resource_ids': ['dummy-01', 'dummy-02', 'dummy-03']}} - -(The ``unmask=True`` is required in 3008.0+: pillar values are masked -by default at the CLI. SLS files render with masking disabled.) - - -Step 1 — confirm discovery -========================== - -The managing minion discovers resource types from the pillar on every -pillar refresh. The first run after editing pillar will pick them up: - -.. code-block:: bash - - salt-call --local saltutil.refresh_pillar - -In masterless mode there's no registry to populate, but the minion -caches its own view. You can confirm: - -.. code-block:: bash - - salt-call -r --tgt '*' test.ping - -If you see something like:: - - local: - ---------- - : True - dummy-01: True - dummy-02: True - dummy-03: True - -…the framework loaded the dummy resource type, called ``ping()`` -against each declared id, and folded the results. If you only see the -managing minion, double-check pillar. - - -Step 2 — per-resource targeting -=============================== - -Every targeting form Salt offers against minions works against -resources too. Try a few: - -.. code-block:: bash - - # All dummy resources by type - salt-call -r --tgt 'T@dummy' --tgt-type compound test.ping - - # One specific resource - salt-call -r --tgt 'dummy-02' test.ping - - # By per-resource grain - salt-call -r --tgt 'dummy_grain_1:one' --tgt-type grain test.ping - -The dummy resource publishes a small fixed grain dict -(``dummy_grain_1``, ``dummy_grain_2``, ``dummy_grain_3``, plus -``resource_id``). All four match the same set of three resources here. - - -Step 3 — inspect grains -======================= - -``grains.items`` works per-resource. Without ``-r`` you'd see the -managing minion's grains; with ``-r`` and a resource target you see -the resource's: - -.. code-block:: bash - - salt-call -r --tgt dummy-01 grains.items - - # local: - # ---------- - # dummy-01: - # ---------- - # dummy_grain_1: one - # dummy_grain_2: two - # dummy_grain_3: three - # resource_id: dummy-01 - - -Step 4 — exercise a state -========================= - -The dummy type ships an execution function ``test_from_state()``. We -can wrap it in a tiny state: - -``/srv/salt/dummy/test.sls``: - -.. code-block:: yaml - - say_hello: - cmd.run: - - name: echo "dummy resource state running" - - do_the_thing: - module.run: - - dummy.test_from_state: [] - -Apply it against all dummy resources at once: - -.. code-block:: bash - - salt-call -r --tgt 'T@dummy' --tgt-type compound state.apply dummy.test - -This exercises merge-mode ``state.apply``: the managing minion runs -the apply for each of the three resources inline and produces one -combined output. Look at the keys — each state ID is prefixed with -the resource id so provenance is visible: - -.. code-block:: text - - cmd_|-dummy-01 say_hello_|-dummy-01 echo "..."_|-run - cmd_|-dummy-02 say_hello_|-dummy-02 echo "..."_|-run - cmd_|-dummy-03 say_hello_|-dummy-03 echo "..."_|-run - module_|-dummy-01 do_the_thing_|-dummy-01 dummy.test_from_state_|-run - module_|-dummy-02 do_the_thing_|-dummy-02 dummy.test_from_state_|-run - module_|-dummy-03 do_the_thing_|-dummy-03 dummy.test_from_state_|-run - -One ``Summary`` line at the bottom shows the rolled-up pass/fail. See -:ref:`resources-state-authoring` for the prefixing rules. - - -Step 5 — write your own -======================= - -You've used a resource type — now write one. The dummy module under -:py:mod:`salt.resources.dummy` is ~300 lines and covers every hook the -framework expects. Mirror its shape under -``saltext//resources//`` to ship a type in an -extension; see :ref:`resources-authoring` for the interface contract -and :ref:`resources-authoring-packaging` for the entry-point wiring. - - -What's next -=========== - -* :ref:`resources-architecture` — what's actually going on behind the - ``-r --tgt`` flag. -* :ref:`resources-authoring` — write your own resource type. -* :ref:`resources-operations` — operator commands for inspecting, - refreshing, and debugging. -* :ref:`resources-configuration` — every ``resource_*`` option. diff --git a/doc/topics/sdb/index.rst b/doc/topics/sdb/index.rst index 55ccca730c46..8859ccbd20fa 100644 --- a/doc/topics/sdb/index.rst +++ b/doc/topics/sdb/index.rst @@ -101,6 +101,25 @@ To get SDB sub-keys in a state file, use this syntax: user1: id: sdb.get sdb://users:user1:id +.. warning:: + The ``vault`` driver previously only supported splitting the path and key with + a question mark. This has since been deprecated in favor of using the standard + / to split the path and key. The use of the questions mark will still be supported + to ensure backwards compatibility, but please use the preferred method using /. + The deprecated approach required the full path to where the key is stored, + followed by a question mark, followed by the key to be retrieved. If you were + using a profile called ``myvault``, you would use a URI that looks like: + + .. code-block:: bash + + salt-call sdb.get 'sdb://myvault/secret/salt?saltstack' + + Instead of the above please use the preferred URI using / instead: + + .. code-block:: bash + + salt-call sdb.get 'sdb://myvault/secret/salt/saltstack' + Setting a value uses the same URI as would be used to retrieve it, followed by the value as another argument. diff --git a/doc/topics/ssh/roster.rst b/doc/topics/ssh/roster.rst index d7f082d86797..80bb85825ea1 100644 --- a/doc/topics/ssh/roster.rst +++ b/doc/topics/ssh/roster.rst @@ -61,9 +61,6 @@ The information which can be stored in a roster ``target`` is the following: # components. Defaults to /tmp/salt-. cmd_umask: # umask to enforce for the salt-call command. Should be in # octal (so for 0o077 in YAML you would do 0077, or 63) - ssh_pre_hook: # Path to a script that will run on the host before all other - # salt-ssh commands. Runs every time salt-ssh is run. - # Added in 3008 Release ssh_pre_flight: # Path to a script that will run before all other salt-ssh # commands. Will only run the first time when the thin dir # does not exist, unless --pre-flight is passed to salt-ssh @@ -77,25 +74,6 @@ The information which can be stored in a roster ``target`` is the following: # Example: '$PATH:/usr/local/bin/'. Added in 3001 Release. ssh_options: # List of options (as 'option=argument') to pass to ssh. -.. _ssh_pre_hook: - -ssh_pre_hook ------------- - -Introduced in the 3008 release, the `ssh_pre_hook` is an option in the Salt-SSH roster that allows the execution of a script on the origin server before any SSH connection attempts are made. -This is particularly useful in environments where dynamic setup is required, such as signing SSH keys or configuring environment variables. - -The `ssh_pre_hook` script is specified in the roster file for each target or globally using `roster_defaults`. It runs every time `salt-ssh` is invoked, ensuring that all prerequisites are met before making an SSH connection. - -.. code-block:: yaml - - test: - host: 257.25.17.66 - ssh_pre_hook: /path/to/script param1 param2 - -If the script specified in `ssh_pre_hook` fails (returns a non-zero exit code), `salt-ssh` will halt further execution, preventing connection attempts to the target server. - -Usage of `ssh_pre_hook` provides a flexible mechanism to perform necessary preparations and checks, ensuring that the environment conforms to required conditions before proceeding with SSH operations. .. _ssh_pre_flight: diff --git a/doc/topics/targeting/index.rst b/doc/topics/targeting/index.rst index c1c8a1bf2209..ad4cd717cd52 100644 --- a/doc/topics/targeting/index.rst +++ b/doc/topics/targeting/index.rst @@ -111,8 +111,6 @@ There are many ways to target individual minions or groups of minions in Salt: nodegroups batch range - Salt Resources <../resources/targeting> - Loadable Matchers diff --git a/doc/topics/thorium/index.rst b/doc/topics/thorium/index.rst index b0598cf63fa0..4a452b90f0bc 100644 --- a/doc/topics/thorium/index.rst +++ b/doc/topics/thorium/index.rst @@ -282,15 +282,16 @@ Putting data in a register is useless if you don't do anything with it. The ``check`` module is designed to examine register data and determine whether it matches the given parameters. For instance, the ``check.contains`` function will return ``True`` if the given ``value`` is contained in the specified -register. This works especially well with ``reg.set``, which stores scalar -values in a ``set()``: +register: .. code-block:: yaml foo: - reg.set: + reg.list: - add: bar - match: my/custom/event + - stamp: True + - prune: 50 check.contains: - value: somedata @@ -321,16 +322,6 @@ different means of comparing values: * ``eq``: Check whether the register entry is equal to the given value * ``ne``: Check whether the register entry is not equal to the given value -When you are working with a ``list`` register, the ``len_*`` functions are -often more useful than the scalar comparisons: - -* ``len_gt``: Check whether the register contains more than the given number of entries -* ``len_gte``: Check whether the register contains at least the given number of entries -* ``len_lt``: Check whether the register contains fewer than the given number of entries -* ``len_lte``: Check whether the register contains at most the given number of entries -* ``len_eq``: Check whether the register contains exactly the given number of entries -* ``len_ne``: Check whether the register contains anything other than the given number of entries - There is also a function called ``check.event`` which does not examine the register. Instead, it looks directly at an event as it is coming in on the event bus, and returns ``True`` if that event's tag matches. For example: @@ -357,254 +348,3 @@ It is possible to persist the register data to disk when a master is stopped gracefully, and reload it from disk when the master starts up again. This functionality is provided by the returner subsystem, and is enabled whenever any returner containing a ``load_reg`` and a ``save_reg`` function is used. - -The built-in ``local_cache`` returner implements these hooks, so a simple way -to persist the register is: - -.. code-block:: yaml - - register_returner: local_cache - - -Concrete Thorium Patterns -========================= -The API reference explains each Thorium module in isolation, but Thorium -becomes much easier to understand when you think in terms of small pipelines: - -#. collect interesting events into a register -#. evaluate that register or the current event batch -#. trigger a local, runner, or wheel action - -The examples below are designed to show those patterns directly. - - -Trigger After Several Matching Events -------------------------------------- -One of Thorium's most useful patterns is reacting only after several related -events have occurred. This avoids reacting to every transient event -individually. - -The following example stores the most recent deployment failures in a register -and only fires once at least three failures have been seen: - -.. code-block:: yaml - - deploy_failures: - reg.list: - - add: - - id - - reason - - match: acme/deploy/failed - - stamp: True - - prune: 10 - - enough_failures: - check.len_gte: - - name: deploy_failures - - value: 3 - - notify_ops: - runner.cmd: - - func: manage.up - - require: - - check: enough_failures - -Thorium is doing three different jobs here: - -* ``reg.list`` collects the event payload fields you care about. -* ``check.len_gte`` turns that historical context into a gate. -* ``runner.cmd`` hands off to a master-side runner only when the gate is open. - -This is the general shape you want whenever you need "do something after N -events", "act on bursts", or "only react if the issue keeps happening". - - -Compute a Rolling Average Before Acting ---------------------------------------- -Thorium is not limited to one-off event matching. The combination of -``reg.list`` and ``calc.*`` can treat recent events as a sliding data set. - -The following example stores load samples from custom events and only triggers -an orchestration run when the mean of the last five samples is at least ``4``: - -.. code-block:: yaml - - load_samples: - reg.list: - - add: - - load - - minion - - match: acme/telemetry/load - - stamp: True - - prune: 20 - - sustained_high_load: - calc.mean: - - name: load_samples - - num: 5 - - ref: load - - minimum: 4 - - scale_out: - runner.cmd: - - func: state.orchestrate - - mods: orch.scale_out - - require: - - calc: sustained_high_load - -This pattern is useful when you want to react to trends instead of single -samples. The register keeps the recent window, and ``calc.mean`` computes the -decision value at runtime. - - -Throttle Reactions With ``timer.hold`` --------------------------------------- -Once a check starts returning ``True``, it will continue to do so until the -register changes. In practice you often want a cooldown so that the same action -is not launched on every Thorium loop. - -``timer.hold`` provides that flow-control primitive: - -.. code-block:: yaml - - service_failures: - reg.list: - - add: - - id - - service - - match: acme/service/down - - prune: 20 - - repeated_failures: - check.len_gte: - - name: service_failures - - value: 3 - - cooldown: - timer.hold: - - seconds: 900 - - require: - - check: repeated_failures - - restart_service: - local.cmd: - - tgt: 'G@roles:web' - - tgt_type: compound - - func: service.restart - - arg: - - nginx - - require: - - timer: cooldown - -The timer state remains ``False`` until the hold period has elapsed, and then -briefly returns ``True`` so the dependent action can run. This makes it a good -fit for rate limiting, cooldowns, and periodic rechecks. - - -Choose The Right Action Wrapper -------------------------------- -Thorium can react in three different places, and the right choice depends on -where the work needs to happen: - -* ``local.cmd`` runs an execution module on one or more minions. -* ``runner.cmd`` launches a runner on the master. -* ``wheel.cmd`` launches a wheel command for master maintenance tasks. - -These wrappers all fit naturally behind the same gate: - -.. code-block:: yaml - - important_event: - check.event - - verify_minions: - local.cmd: - - name: verify_minions - - tgt: '*' - - func: test.ping - - require: - - check: important_event - - orchestrate_response: - runner.cmd: - - func: state.orchestrate - - mods: orch.respond - - require: - - check: important_event - - reject_old_key: - wheel.cmd: - - fun: key.reject - - match: legacy-minion - - require: - - check: important_event - -If the reaction is "do something on minions", use ``local.cmd``. If the -reaction is "start a master-side workflow", use ``runner.cmd``. If the -reaction is "modify master metadata or PKI state", use ``wheel.cmd``. - - -Inspect And Persist The Register --------------------------------- -Thorium is much easier to debug when you can inspect the register directly. -This is especially helpful when you are first developing a formula. - -The following example saves a register snapshot to disk every time it changes: - -.. code-block:: yaml - - tracked_ids: - reg.set: - - add: id - - match: acme/custom/event - - tracked_ids_snapshot: - file.save: - - name: /tmp/tracked_ids.json - - filter: True - - require: - - reg: tracked_ids - -``filter: True`` is important when the register contains types such as -``set()`` that are not JSON-serializable by default. - -This pattern is useful for: - -* confirming that your event tag glob is matching what you expect -* inspecting the exact register shape before adding checks or calculations -* keeping a lightweight audit trail during Thorium development - - -Expanded Health Automation Example ----------------------------------- -The built-in ``status`` and ``key`` modules are often the first place people -encounter Thorium, but they are also a good example of multi-step automation. - -The following formula tracks status beacon events, snapshots the status -register, and rejects keys for minions that have not checked in recently: - -.. code-block:: yaml - - status_register: - status.reg - - status_snapshot: - file.save: - - name: status_snapshot - - require: - - status: status_register - - reject_stale_keys: - key.timeout: - - reject: 300 - - require: - - status: status_register - -``status.reg`` listens for ``salt/beacon/*/status/*`` events and stores the -latest payload and receive time for each minion. ``key.timeout`` then compares -those timestamps to the current accepted key list and deletes or rejects keys -that have gone silent. - -This pattern shows that Thorium is more than an event trigger. It can maintain -state across many events, compare that state to master data, and then take a -master-side action when the aggregate picture warrants it. diff --git a/doc/topics/tracing/index.rst b/doc/topics/tracing/index.rst deleted file mode 100644 index f82179a5edea..000000000000 --- a/doc/topics/tracing/index.rst +++ /dev/null @@ -1,160 +0,0 @@ -.. _tracing: - -=================================== -Distributed Tracing (OpenTelemetry) -=================================== - -Salt can emit OpenTelemetry spans for every inter-process hop, so a single -job (``salt '*' test.ping``) becomes a single distributed trace that crosses -the CLI, the master, the minion, the return path, and any reactor or syndic -forwarding in between. - -The implementation uses standard W3C TraceContext (``traceparent`` / -``tracestate``) for propagation and ships spans through an OTLP exporter. -Jaeger ingests OTLP natively, as do most modern tracing backends -(Tempo, Honeycomb, Datadog OTLP, etc.). - -Trace context propagates **inside** the AES-encrypted Salt envelope: an -attacker on the wire cannot see the trace headers, and authenticated -participants (master / minion / syndic) decode them after AES decryption. - -Tracing is **disabled by default** and is a complete no-op when not -configured. No spans are created, no exporter is initialised, and no -background threads are started. - -Configuration -------------- - -Add a ``tracing`` block to the master and minion configs. The block is -identical on both daemons, and applies to ``salt-cli``, ``salt-call``, -``salt-api`` and ``salt-ssh`` as well. - -.. code-block:: yaml - - tracing: - enabled: true - exporter: otlp-http # otlp-http | otlp-grpc | console - endpoint: "" # OTel SDK default endpoint when empty - service_name: "" # auto-derived when empty - sampler: parent_based # parent_based | always_on | always_off | trace_id_ratio - sampler_arg: 1.0 - resource_attributes: {} - insecure: true # gRPC TLS disabled (ignored for HTTP) - headers: {} # OTLP authentication headers - -``enabled`` - Master switch. When ``false`` (the default), everything in this module - is a no-op. - -``exporter`` - ``otlp-http`` (default) sends spans via HTTP/protobuf to ``endpoint``. - Pure-Python; ships in salt's base requirements; works on every - interpreter. - ``otlp-grpc`` sends via gRPC. Requires - ``opentelemetry-exporter-otlp-proto-grpc`` to be installed separately - (it pulls in ``grpcio``, which lacks prebuilt wheels for some - platform / interpreter combinations). - ``console`` prints spans to stdout for debugging. - -``endpoint`` - OTLP collector URL. When empty, the OTel SDK default is used - (``http://localhost:4318/v1/traces`` for HTTP, - ``http://localhost:4317`` for gRPC). - -``service_name`` - The ``service.name`` resource attribute. When empty, Salt fills this in - automatically: ``salt-master``, ``salt-minion-``, ``salt-cli``, - ``salt-call``, ``salt-api``. - -``sampler`` - Which sampler to install on the ``TracerProvider``. - - - ``parent_based`` (default): follow the parent's sample decision; root - spans are sampled. Use ``sampler_arg`` < 1.0 to apply a ratio to - root spans. - - ``always_on``: sample every span. - - ``always_off``: drop every span (testing only). - - ``trace_id_ratio``: sample ``sampler_arg`` fraction of trace IDs. - -``resource_attributes`` - Extra attributes merged into the OTel Resource (e.g. ``deployment.environment: prod``). - -``insecure`` - Disable gRPC TLS to the collector. Ignored for the HTTP exporter. - -``headers`` - Additional headers sent on every OTLP request, e.g. - ``Authorization: Bearer `` for a hosted collector. - -Hops covered ------------- - -A single ``salt '*' test.ping`` produces a trace spanning at least: - -1. ``salt.cli.test.ping`` — root span on the CLI. -2. ``salt.req.send.publish`` — CLI → master request. -3. ``salt.req.recv.publish`` — master receives the request. -4. ``salt.pub.send`` — master publishes the job. -5. ``salt.minion.recv.test.ping`` — minion receives the published command. -6. ``salt.minion.exec.test.ping`` — minion executes the function. -7. ``salt.req.send._return`` — minion returns to master. -8. ``salt.req.recv._return`` — master receives the return. - -Other instrumented hops: - -- Event bus (``fire_event`` / ``get_event``) — every IPC and TCP-IPC event - carries trace context in its data dict. -- Reactor — extracts trace context from incoming events and parents the - reaction span correctly. -- Syndic forwarding — both inbound (from upstream master) and outbound (to - downstream minions). -- Salt-SSH — propagates trace context as the ``TRACEPARENT`` environment - variable on the remote shim. -- Salt-API — extracts the ``traceparent`` HTTP header from incoming - requests; webhooks inject context into the events they fire. - -Running a quick demo --------------------- - -Spin up an all-in-one Jaeger: - -.. code-block:: bash - - docker run -d --name jaeger \ - -p 16686:16686 -p 4318:4318 \ - jaegertracing/all-in-one:latest - -Configure master + minion with: - -.. code-block:: yaml - - tracing: - enabled: true - exporter: otlp-http - endpoint: http://localhost:4318/v1/traces - sampler: always_on - -Start them, run ``salt '*' test.ping``, then visit -``http://localhost:16686`` and search for the ``salt-cli`` service. You -should see a single trace with spans hanging off three services: -``salt-cli``, ``salt-master`` and ``salt-minion-``. - -Fork handling -------------- - -The OTel ``BatchSpanProcessor`` runs a background thread that does not -survive ``fork()``. Salt rebuilds the provider in every forked child the -first time a tracing API is invoked, so worker processes spun up by the -master / minion get their own functioning exporter without any caller -action. Unflushed spans queued by the parent at the instant of fork may -be lost; for short-lived spans this is rarely visible, but if you observe -gaps consider lowering ``BatchSpanProcessor`` queue intervals via the OTel -environment variables. - -Payload overhead ----------------- - -When tracing is enabled and a recording span is active, every Salt request -and event grows by roughly 60 bytes (the W3C ``traceparent`` string). -When no recording span is active — for example, an internal periodic event -fired outside a request handler — no headers are added. diff --git a/doc/topics/transports/ssl.rst b/doc/topics/transports/ssl.rst index 792870735984..ae138d5543de 100644 --- a/doc/topics/transports/ssl.rst +++ b/doc/topics/transports/ssl.rst @@ -71,259 +71,3 @@ A Minion can be configured to present a client certificate to the master like th Specific options can be sent to the minion also, as defined in the Python `ssl.wrap_socket` function. - -.. _tls-encryption-optimization: - -TLS Encryption Optimization -=========================== - -.. versionadded:: 3008.0 - -When TLS is configured with mutual authentication (``cert_reqs: CERT_REQUIRED``), -the application-layer AES encryption becomes redundant. Salt 3008.0 introduces -an optional TLS encryption optimization that eliminates this redundant encryption, -improving performance while maintaining security. - -Overview --------- - -Salt traditionally performs double encryption: - -1. **Application layer**: AES-192/256-CBC + HMAC-SHA256 (via Crypticle) -2. **Transport layer**: TLS 1.2+ (when configured) - -With the TLS optimization enabled, Salt skips the application-layer AES encryption -when all security requirements are met, relying solely on TLS for encryption. - -Configuration -------------- - -To enable TLS encryption optimization, set ``disable_aes_with_tls`` to ``True`` -in both master and minion configurations: - -**Master configuration** (``/etc/salt/master.d/tls_optimization.conf``): - -.. code-block:: yaml - - transport: tcp # or 'ws' for WebSocket - - ssl: - certfile: /etc/pki/tls/certs/salt-master.crt - keyfile: /etc/pki/tls/private/salt-master.key - ca_certs: /etc/pki/tls/certs/ca-bundle.crt - cert_reqs: CERT_REQUIRED # Required for optimization - - disable_aes_with_tls: true - -**Minion configuration** (``/etc/salt/minion.d/tls_optimization.conf``): - -.. code-block:: yaml - - transport: tcp # Must match master - - ssl: - certfile: /etc/pki/tls/certs/minion.crt - keyfile: /etc/pki/tls/private/minion.key - ca_certs: /etc/pki/tls/certs/ca-bundle.crt - cert_reqs: CERT_REQUIRED # Required for optimization - - disable_aes_with_tls: true - -.. important:: - The minion certificate **must** contain the minion ID in either the - Common Name (CN) or Subject Alternative Name (SAN) field to prevent - impersonation attacks. - -Requirements ------------- - -The TLS optimization requires all of the following conditions: - -1. **Configuration opt-in**: ``disable_aes_with_tls: true`` on both master and minion -2. **SSL configured**: Valid ``ssl`` configuration dictionary -3. **Mutual authentication**: ``cert_reqs: CERT_REQUIRED`` -4. **TLS transport**: Transport must be ``tcp`` or ``ws`` (not ``zeromq``) -5. **Valid certificates**: Properly signed certificates from trusted CA -6. **Certificate identity**: Minion certificates must contain minion ID in CN or SAN - -If any requirement is not met, Salt automatically falls back to standard AES encryption. - -Certificate Identity Requirement --------------------------------- - -To prevent minion impersonation attacks, minion certificates must contain the -minion ID. This can be done in two ways: - -**Option 1: Minion ID in Common Name (CN)** - -.. code-block:: bash - - # Get minion ID - minion_id=$(salt-call --local grains.get id --out=txt | cut -d: -f2 | tr -d ' ') - - # Generate certificate with minion ID in CN - openssl req -new -key minion.key -out minion.csr \ - -subj "/C=US/O=MyOrg/CN=$minion_id" - -**Option 2: Minion ID in Subject Alternative Name (SAN)** - -.. code-block:: bash - - # Create SAN configuration - cat > san.cnf <` - Master configuration options diff --git a/doc/topics/troubleshooting/master.rst b/doc/topics/troubleshooting/master.rst index e7f040fc8d23..889bd668e4bb 100644 --- a/doc/topics/troubleshooting/master.rst +++ b/doc/topics/troubleshooting/master.rst @@ -257,10 +257,10 @@ service. .. note:: recon_default: - The average number of milliseconds to wait between reconnection attempts. + The average number of seconds to wait between reconnection attempts. recon_max: - The maximum number of milliseconds to wait between reconnection attempts. + The maximum number of seconds to wait between reconnection attempts. recon_randomize: A flag to indicate whether the recon_default value should be randomized. diff --git a/doc/topics/tutorials/cloud_controller.rst b/doc/topics/tutorials/cloud_controller.rst index c3f7b67293c0..3f42a2204bbf 100644 --- a/doc/topics/tutorials/cloud_controller.rst +++ b/doc/topics/tutorials/cloud_controller.rst @@ -160,7 +160,7 @@ prone to errors. Virtual Machine generation applications are available for many platforms: kiwi: (openSUSE, SLES, RHEL, CentOS) - https://osinside.github.io/kiwi/ + https://opensuse.github.io/kiwi/ vm-builder: https://wiki.debian.org/VMBuilder diff --git a/doc/topics/tutorials/esxi_proxy_minion.rst b/doc/topics/tutorials/esxi_proxy_minion.rst index 0c3c4b81c9a5..4a09b652a269 100644 --- a/doc/topics/tutorials/esxi_proxy_minion.rst +++ b/doc/topics/tutorials/esxi_proxy_minion.rst @@ -108,10 +108,12 @@ ESXCLI Currently, about a third of the functions used for the ESXi Proxy Minion require the ESXCLI package be installed on the machine running the Proxy Minion process. -The ESXCLI package is also referred to as the VMware vSphere CLI, or vCLI. See -the `VMware vSphere documentation`_ for vCLI package installation instructions. +The ESXCLI package is also referred to as the VMware vSphere CLI, or vCLI. VMware +provides vCLI package installation instructions for `vSphere 5.5`_ and +`vSphere 6.0`_. -.. _VMware vSphere documentation: https://techdocs.broadcom.com/us/en/vmware-cis/vcf/vcf-9-0-and-later/9-1/vsphere-supervisor-installation-and-configuration.html +.. _vSphere 5.5: http://pubs.vmware.com/vsphere-55/index.jsp#com.vmware.vcli.getstart.doc/cli_install.4.2.html +.. _vSphere 6.0: http://pubs.vmware.com/vsphere-60/index.jsp#com.vmware.vcli.getstart.doc/cli_install.4.2.html Once all of the required dependencies are in place and the vCLI package is installed, you can check to see if you can connect to your ESXi host by running diff --git a/doc/topics/tutorials/gitfs.rst b/doc/topics/tutorials/gitfs.rst index ecd97fbc99a8..dc00ab8ed406 100644 --- a/doc/topics/tutorials/gitfs.rst +++ b/doc/topics/tutorials/gitfs.rst @@ -27,17 +27,10 @@ Branches and tags become Salt fileserver environments. Installing Dependencies ======================= -Three providers are supported for the Python-to-git interface: pygit2_, -GitPython_, and ``gitcli`` (added in 3008.0). If :conf_master:`gitfs_provider` -is unset, Salt tries each in the order ``pygit2`` → ``gitpython`` → ``gitcli`` -and uses the first one that is available. Set -:conf_master:`gitfs_provider` explicitly to override. - -.. versionchanged:: 3008.0 - Added the ``gitcli`` provider. Before 3008.0, a master with neither - pygit2_ nor GitPython_ installed failed to start gitfs with - "No suitable gitfs provider module is installed"; 3008.0 masters now - fall back to ``gitcli`` (which only needs the system ``git`` binary). +Both pygit2_ and GitPython_ are supported Python interfaces to git. If +compatible versions of both are installed, pygit2_ will be preferred. In these +cases, GitPython_ can be forced using the :conf_master:`gitfs_provider` +parameter in the master config file. The versions tested in CI and shipped with the Salt onedir packages are: @@ -136,33 +129,6 @@ also be installed. On macOS, install Xcode_ command-line tools or use Homebrew. The Salt fileserver mitigates this by restarting the fileserver worker on a configurable interval (see :conf_master:`fileserver_interval`). -gitcli ------- - -.. versionadded:: 3008.0 - -The ``gitcli`` provider shells out to the system ``git`` binary and needs no -Python library. The only requirement is ``git`` version 2.3.0 or newer on -the master (``git --version`` to check). On most distros this is satisfied -by the base ``git`` package: - -.. code-block:: bash - - # yum install git # RHEL / Fedora / EPEL - # apt-get install git # Debian / Ubuntu - -Operational notes: - -* Repositories are cloned as bare repos with ``--depth 1`` by default to keep - the on-disk footprint small at scale. Configure depth per backend via - :conf_master:`gitfs_depth`, :conf_master:`git_pillar_depth`, and - :conf_master:`winrepo_depth`. -* ``gitcli`` does not support submodules. Use pygit2_ or GitPython_ if the - remote repo carries submodules you depend on. -* Authentication is environment-variable based — see the - :ref:`gitcli authentication ` section below for the supported - options and the deliberate gaps. - Simple Configuration ==================== @@ -307,7 +273,6 @@ configured gitfs remotes): * :conf_master:`gitfs_disable_saltenv_mapping` (new in 2018.3.0) * :conf_master:`gitfs_ref_types` (new in 2018.3.0) * :conf_master:`gitfs_update_interval` (new in 2018.3.0) -* :conf_master:`gitfs_proxy` (new in 3008.0) .. note:: pygit2 only supports disabling SSL verification in versions 0.23.2 and @@ -1088,46 +1053,6 @@ to the entry in ``~/.ssh/config`` However, this is generally regarded as insecure, and is not recommended. -.. _gitcli-auth: - -gitcli ------- - -.. versionadded:: 3008.0 - -The ``gitcli`` provider hands authentication off to the ``git`` binary via -environment variables. The full list of supported auth options is: - -.. list-table:: - :header-rows: 1 - :widths: 20 80 - - * - Option - - How it is applied - * - ``ssl_verify`` - - When set to ``False``, runs git with ``GIT_SSL_NO_VERIFY=true``. - * - ``proxy`` - - Exported to git as ``http_proxy`` and ``https_proxy``. - * - ``privkey`` - - Wrapped into a ``GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=no -i - `` invocation. - -The following per-remote auth parameters that work with pygit2_ or GitPython_ -are silently ignored by ``gitcli``: - -* ``user`` / ``password`` (HTTPS basic auth) — embed credentials directly in - the remote URL (``https://:@host/repo.git``) or use a - credential helper configured at the system ``git`` level. -* ``passphrase`` — ``gitcli`` cannot answer an interactive prompt; use a - passphraseless key. -* ``pubkey`` — not used; only ``privkey`` is honoured. -* ``insecure_auth`` — has no effect. Configure HTTP basic auth in the URL. - -.. warning:: - ``gitcli`` runs SSH with ``StrictHostKeyChecking=no`` whenever a - ``privkey`` is configured. Make sure the remote git endpoint is trusted - (private hosting, mTLS-fronted, etc.) before relying on it. - .. _gitfs-gitlab: GitLab diff --git a/doc/topics/tutorials/master-cluster.rst b/doc/topics/tutorials/master-cluster.rst index 4a9fb2532f3e..10910643ad9a 100644 --- a/doc/topics/tutorials/master-cluster.rst +++ b/doc/topics/tutorials/master-cluster.rst @@ -17,95 +17,20 @@ to handle larger numbers of minions and larger jobs. Minimum Requirements ==================== -A master cluster needs a tcp load balancer in front of each master's publish -and request server ports (typically 4505 / 4506) and a reliable local area -network between peers. Beyond that, each peer needs access to the same -identity material: ``cluster_pki_dir`` (the shared cluster public/private key -and minion keys), ``cachedir`` (job and grain cache), and the -:conf_master:`file_roots` / :conf_master:`pillar_roots` trees that the -cluster serves. - -That identity material can be provided in one of two ways: - -* **Shared filesystem (default).** Mount the same NFS/Gluster/etc. share at - ``cluster_pki_dir``, ``cachedir``, ``file_roots``, and ``pillar_roots`` on - every peer. This is the original master-cluster mode and the topology the - rest of this tutorial demonstrates with Gluster + HAProxy. - -* **Isolated filesystem (3008.0 and later).** Set - :conf_master:`cluster_isolated_filesystem` to ``True`` on each peer. Each - master keeps its own local ``cluster_pki_dir`` / ``cachedir`` / - ``file_roots`` / ``pillar_roots``; a joining master pulls keys, denied - keys, ``file_roots``, and ``pillar_roots`` from an existing peer in-band - over the cluster transport before being promoted to a Raft voter, and - job/cache state moves between masters via the Raft+HashRing layer. See - the :ref:`Topology section ` for a side-by-side - comparison. +Running a cluster master requires all nodes in the cluster to have a shared +filesystem. The `cluster_pki_dir`, `cache_dir`, `file_roots` and `pillar_roots` +must all be on a shared filesystem. Most implementations will also serve the +masters publish and request server ports via a tcp load balancer. All of the +masters in a cluster are assumed to be running on a reliable local area +network. Each master in a cluster maintains its own public and private key, and an in -memory aes key. Each cluster peer also has access to the ``cluster_pki_dir`` -where a cluster-wide public and private key are stored. In addition, the -cluster-wide aes key is generated and stored in the ``cluster_pki_dir``. -Further, when operating as a cluster, minion keys are stored in the -``cluster_pki_dir`` instead of the master's ``pki_dir``. - -.. _master-cluster-topology: - -Topology: shared filesystem vs. isolated filesystem -=================================================== - -.. versionadded:: 3008.0 - Isolated-filesystem mode (:conf_master:`cluster_isolated_filesystem`). - -The two topologies differ only in how the *content* a master needs is -provisioned to it; the wire protocol between cluster peers, the -:conf_master:`cluster_pool_port` Raft RPC, and the load-balancer setup are -identical. +memory aes key. Each cluster peer also has access to the `cluster_pki_dir` +where a cluster wide public and private key are stored. In addition, the cluster +wide aes key is generated and stored in the `cluster_pki_dir`. Further, +when operating as a cluster, minion keys are stored in the `cluster_pki_dir` +instead of the master's `pki_dir`. -.. list-table:: - :header-rows: 1 - :widths: 30 35 35 - - * - What - - Shared filesystem - - Isolated filesystem - * - ``cluster_pki_dir`` contents - - Same path on every peer - - Local on every peer - * - ``cachedir`` contents - - Same path on every peer - - Local on every peer - * - ``file_roots`` / ``pillar_roots`` - - Same path on every peer - - Local on every peer; pushed in-band on join and via - ``salt-run cluster.sync_roots`` - * - Job / grain cache routing - - Through the shared cachedir - - Through Raft + HashRing - * - Recommended :conf_master:`keys.cache_driver` - - ``localfs_key`` (default) - - ``mmap_key`` (see :ref:`mmap-cache`) - * - Adding a master - - Mount the share, start it - - Dynamic Join -- state-sync runs automatically - -When to pick which: - -* **Shared filesystem** is the right answer when you already operate a - reliable cluster filesystem and want a single place to edit - ``file_roots`` / ``pillar_roots``. Failures in the shared filesystem - fail the whole cluster. - -* **Isolated filesystem** removes the shared-storage dependency, so a - master can join a cluster from a vanilla box with only Salt installed. - The cost is that ``file_roots`` and ``pillar_roots`` edits made on one - master must be propagated explicitly (``salt-run cluster.sync_roots``) - rather than appearing instantly on every peer. - -Regardless of topology, ``cluster_pool_port`` carries Raft RPC between -peers, ``4505``/``4506`` carry minion publish and return traffic through -the load balancer, and ``cluster_secret`` authenticates new masters that -want to join. Reference Implementation ======================== @@ -172,140 +97,3 @@ Master Config: pillar_roots: base: - /my/gluster/share/srv/pillar - - -.. _master-cluster-dynamic-join: - -Dynamic Join -============ - -.. versionadded:: 3008.0 - -A new master can join a running cluster without reconfiguring the existing -peers. The joining master needs the same ``cluster_id``, -``cluster_pki_dir``, and ``cluster_secret`` as the cluster, plus at least -one reachable peer in its ``cluster_peers`` -- it does not need the full -peer list. On startup it runs a discover/join handshake against those -peers, and on success it receives the shared cluster public key and the -current in-memory AES session key and is added to every peer's -``cluster_peers``. - -Joining master config: - -.. code-block:: yaml - - id: 10.27.9.42 - cluster_id: master_cluster - cluster_peers: - - 10.27.12.13 - cluster_pki_dir: /my/gluster/share/pki - cluster_secret: "d8b4c2e1f07a4c3e8a1b5d0a9c7f3e42b6d9a1c4f8e2b7d0a3c6e9f1b4d7a0c3" - cachedir: /my/gluster/share/cache - -Add the new master to the load balancer's backend pools so publish/return -traffic starts reaching it. - -Security notes: - -* ``cluster_secret`` is what authenticates the join. Always set a - high-entropy value in production; an empty/unset secret matches an empty - secret on the peer and provides no authentication. -* Discover and join payloads are signed per-master, and ``cluster_secret``, - the AES session key, and the cluster key are encrypted to the - recipient's public key. Restrict the cluster transport to a trusted - network -- an attacker with ``cluster_secret`` and transport access can - still join. -* The joining master normally reads the cluster public key from the - shared ``cluster_pki_dir``. If that is not available, pin it with - :conf_master:`cluster_pub_fingerprint` on the joining master. - -To remove a peer, drop it from the load balancer, stop the master, delete -its ``cluster_pki_dir/peers/.pub``, and restart the remaining -masters. Rotate ``cluster_secret`` if you want to prevent the removed -peer from re-joining. - - -Migrating from a shared-filesystem cluster -========================================== - -.. versionadded:: 3008.0 - :conf_master:`cluster_isolated_filesystem`, - :py:func:`pki.migrate_to_mmap `, and - the cluster runners used below. - -These steps convert a running shared-filesystem cluster to isolated-FS mode -without minion-visible downtime, provided the load balancer keeps draining -one master at a time. - -1. **Switch the key-cache driver to mmap_key.** On a single master, while - the cluster is still on the shared filesystem, run: - - .. code-block:: bash - - salt-run pki.migrate_to_mmap - - This converts every accepted, pending, denied, and rejected minion key - from the on-disk ``localfs_key`` layout to the ``mmap_key`` layout - described in :ref:`mmap-cache`. The shared filesystem now contains - mmap blobs that every peer can read. - -2. **Update each peer's master config.** On every master, add: - - .. code-block:: yaml - - cluster_isolated_filesystem: True - keys.cache_driver: mmap_key - - Leave ``cluster_pki_dir``, ``cachedir``, ``file_roots``, and - ``pillar_roots`` pointing at the shared paths for now -- the next two - steps move them to local paths. - -3. **Roll the cluster one master at a time.** For each peer: - - a. Drain it from the load balancer (so minions stop sending it - traffic). - b. Stop ``salt-master``. - c. Copy ``cluster_pki_dir`` and ``cachedir`` from the shared mount to - local paths, and update the master config to point at the local - copies. Optionally also copy ``file_roots`` and ``pillar_roots`` - and switch them to local paths. - d. Start ``salt-master``. The master rejoins as a learner, runs the - in-band state-sync from a peer (keys, denied keys, ``file_roots``, - ``pillar_roots``), then gets promoted back to a voter. - e. Add it back to the load balancer. - - Repeat until every peer has been moved off the shared filesystem. - -4. **Verify the new topology.** On any master, run: - - .. code-block:: bash - - salt-run cluster.members - salt-run cluster.ring_info - - ``cluster.members`` shows every Raft voter (and any learners that - haven't caught up yet). ``cluster.ring_info`` shows the HashRing - state that routes job and grain cache to specific peers. Both should - list every peer with no stuck learners. - -5. **Drop the shared filesystem mount.** Once every peer is fully on - local paths and the cluster is healthy, you can unmount the shared - filesystem. Do this last so that step 3 can fall back to it on any - peer that hits trouble. - -After migration: - -* When you edit ``file_roots`` or ``pillar_roots`` on one master, push the - changes to peers explicitly: - - .. code-block:: bash - - salt-run cluster.sync_roots - - The runner fan-outs over the same encrypted cluster transport as the - join-time state-sync. Tail each peer's master log for the - ``state-sync ... installed N items`` lines to confirm delivery. - -* When you add a new master, the :ref:`Dynamic Join - ` flow handles the in-band state-sync - automatically -- no extra runner is required. diff --git a/doc/topics/tutorials/modules.rst b/doc/topics/tutorials/modules.rst index a4f452664f9b..64f0b9ef9926 100644 --- a/doc/topics/tutorials/modules.rst +++ b/doc/topics/tutorials/modules.rst @@ -91,7 +91,7 @@ Space-delimited arguments to the function: .. code-block:: bash - salt '*' cmd.exec_code python 'import sys; print(sys.version)' + salt '*' cmd.exec_code python 'import sys; print sys.version' Optional, keyword arguments are also supported: diff --git a/doc/topics/tutorials/multimaster_pki.rst b/doc/topics/tutorials/multimaster_pki.rst index 761f79e5b005..4cb3dc395022 100644 --- a/doc/topics/tutorials/multimaster_pki.rst +++ b/doc/topics/tutorials/multimaster_pki.rst @@ -300,8 +300,10 @@ To avoid that, the master can use a pre-created signature of its public-key. The signature is saved as a base64 encoded string which the master reads once when starting and attaches only that string to auth-replies. -This process turns each master into a "signing" master server that helps reduce overhead for auth-requests coming from minions. - +Enabling this also gives paranoid users the possibility, to have the signing +key-pair on a different system than the actual salt-master and create the public +keys signature there. Probably on a system with more restrictive firewall rules, +without internet access, less users, etc. That signature can be created with diff --git a/doc/topics/tutorials/quickstart.rst b/doc/topics/tutorials/quickstart.rst index e6932a5c639b..bbcd5bcabae2 100644 --- a/doc/topics/tutorials/quickstart.rst +++ b/doc/topics/tutorials/quickstart.rst @@ -31,7 +31,7 @@ for any OS with a Bourne shell: .. code-block:: bash - curl -L https://github.com/saltstack/salt-bootstrap/releases/latest/download/bootstrap-salt.sh -o bootstrap_salt.sh + curl -L https://bootstrap.saltstack.com -o bootstrap_salt.sh sudo sh bootstrap_salt.sh Before run the script, it is a good practice to verify the checksum of the downloaded @@ -40,7 +40,7 @@ file. You can verify the checksum with SHA256 by running this command: .. code-block:: bash test $(sha256sum bootstrap_salt.sh | awk '{print $1}') \ - = $(curl -sL https://github.com/saltstack/salt-bootstrap/releases/latest/download/bootstrap-salt.sh.sha256 | cat -) \ + = $(curl -sL https://bootstrap.saltproject.io/sha256 | cat -) \ && echo "OK" \ || echo "File does not match checksum" @@ -53,7 +53,7 @@ file. You can verify the checksum with SHA256 by running this command: .. code-block:: bash - curl -L https://github.com/saltstack/salt-bootstrap/releases/latest/download/bootstrap-salt.sh | sudo sh -s -- + curl -L https://bootstrap.saltproject.io | sudo sh -s -- See the `salt-bootstrap`_ documentation for other one liners. When using `Vagrant`_ to test out salt, the `Vagrant salt provisioner`_ will provision the VM for you. diff --git a/doc/topics/tutorials/starting_states.rst b/doc/topics/tutorials/starting_states.rst index b97d0e86fc28..35dddb59edbb 100644 --- a/doc/topics/tutorials/starting_states.rst +++ b/doc/topics/tutorials/starting_states.rst @@ -338,7 +338,7 @@ gives you a `"Pythonic"`_ interface to building state data. .. _`Jinja2`: https://jinja.palletsprojects.com/en/2.11.x/ .. _`Mako`: https://www.makotemplates.org/ -.. _`Wempy`: https://pypi.org/project/wempy/ +.. _`Wempy`: https://fossil.secution.com/u/gcw/wempy/doc/tip/README.wiki .. _`"Pythonic"`: https://legacy.python.org/dev/peps/pep-0008/ .. note:: diff --git a/doc/topics/windows/windows-package-manager.rst b/doc/topics/windows/windows-package-manager.rst index abaec3ea402e..c96c829e3112 100644 --- a/doc/topics/windows/windows-package-manager.rst +++ b/doc/topics/windows/windows-package-manager.rst @@ -191,7 +191,6 @@ master: - :conf_master:`winrepo_branch` - :conf_master:`winrepo_provider` - :conf_master:`winrepo_ssl_verify` -- :conf_master:`winrepo_proxy` See :ref:`here ` for detailed information on all master config options for winrepo. @@ -283,14 +282,6 @@ winrepo_ssl_verify Ignore SSL certificate errors when contacting remote repository. Default is ``False`` -winrepo_proxy -------------- - -:conf_master:`winrepo_proxy` (str) - -The proxy server used for connecting to remote repositories. Default is ``''``, -meaning no proxy will be used. - .. _master-config-pygit2: Master Configuration (pygit2) @@ -448,7 +439,7 @@ winrepo_dir_ng :conf_minion:`winrepo_dir_ng` (str) The location in the ``file_roots`` where the winrepo files are kept. The default -is ``C:\ProgramData\Salt Project\Salt\srv\salt\win\repo-ng``. +is ``C:\salt\srv\salt\win\repo-ng``. .. warning:: You can change the location of the winrepo directory. However, it must diff --git a/noxfile.py b/noxfile.py index d59730e38e8b..f0ac0cf7ccf5 100644 --- a/noxfile.py +++ b/noxfile.py @@ -292,18 +292,6 @@ def _install_requirements( env = os.environ.copy() env["PIP_CONSTRAINT"] = str(REPO_ROOT / "requirements" / "constraints.txt") - if onedir and IS_LINUX: - # bcrypt's PyPI wheels are tagged manylinux_2_28+ on the cpXY-abi3 - # variants pip prefers on a modern build host, but the resulting - # ``_bcrypt.abi3.so`` then fails to load on older-glibc test hosts - # (e.g. Amazon Linux 2 with GLIBC 2.26). Source-compile bcrypt - # against the relenv toolchain so the resulting binary is portable - # across every Linux test slug. ``RELENV_BUILDENV=1`` makes the - # source build use ppbt's portable GCC + low-GLIBC sysroot (no - # effect on packages still installed as wheels). - env["PIP_NO_BINARY"] = "bcrypt" - env["RELENV_BUILDENV"] = "1" - requirements_file = _get_pip_requirements_file( session, requirements_type=requirements_type ) @@ -336,15 +324,7 @@ def _install_coverage_requirement(session): env["PIP_CONSTRAINT"] = str(REPO_ROOT / "requirements" / "constraints.txt") coverage_requirement = COVERAGE_REQUIREMENT if coverage_requirement is None: - # 7.14.0 is the first version where the Python 3.14 CTracer - # wheel is mature. 7.3.1 (the prior pin) ships no CTracer - # for 3.14 and falls back to the pure-Python PyTracer, which - # is so slow on Salt's onedir (PyTracer × relenv runtime - # wrappers around sysconfig) that the functional zeromq 4 - # shard hits the 3-hour GHA step timeout. Avoid 7.11.1 - # through 7.11.3 — those have a known 2x performance - # regression on Python 3.14 (coveragepy issue #2082). - coverage_requirement = "coverage==7.14.0" + coverage_requirement = "coverage==7.3.1" if IS_LINUX: distro_slug = os.environ.get("TOOLS_DISTRO_SLUG") if distro_slug is not None and distro_slug in ( @@ -363,41 +343,6 @@ def _install_coverage_requirement(session): silent=PIP_INSTALL_SILENT, env=env, ) - # NOTE: this step runs unconditionally, including when - # ``SKIP_REQUIREMENTS_INSTALL`` is set — the CI test step re-uses a - # venv that was prepared in a *separate* nox step with installs - # enabled, so the install branch above is skipped here but the - # ``.pth`` file is already on disk and needs to be cleaned up. - # - # Coverage 7.14.0 ships an ``a1_coverage.pth`` that calls - # ``coverage.process_startup()`` during site init whenever - # ``COVERAGE_PROCESS_START`` is set in the environment. On the - # Salt onedir that runs *before* relenv's bootstrap - # ``setup_openssl()`` can load the host's FIPS provider, which - # leaves OpenSSL with no registered cipher implementations. The - # first call into ``ssl.create_default_context()`` (tornado - # imports it at module load) then raises:: - # - # ssl.SSLError: [SSL: LIBRARY_HAS_NO_CIPHERS] library has no - # ciphers (_ssl.c:3188) - # - # failing pytest collection on every Photon FIPS shard. - # Saltfactories' sitecustomize already calls - # ``coverage.process_startup()`` after relenv has finished its - # bootstrap (it's wrapped via ``site.execsitecustomize``), so this - # ``.pth`` is duplicative — removing it just preserves the existing - # ordering. Idempotent: a no-op once the file is gone. - session.run( - "python", - "-c", - ( - "import pathlib, sysconfig;" - "p = pathlib.Path(sysconfig.get_paths()['purelib']) / 'a1_coverage.pth';" - "p.exists() and p.unlink();" - "print('removed' if not p.exists() else 'present', p)" - ), - silent=True, - ) def _run_with_coverage(session, *test_cmd, env=None, on_rerun=False): @@ -1486,8 +1431,6 @@ def pre_archive_cleanup(session, pkg): session.install(*install_command, silent=PIP_INSTALL_SILENT) cmdline = [ - "python", - "-m", "tools", "pkg", "pre-archive-cleanup", diff --git a/pkg/common/conf/master b/pkg/common/conf/master index 42c6b74b1c61..9869057fea59 100644 --- a/pkg/common/conf/master +++ b/pkg/common/conf/master @@ -839,11 +839,6 @@ user: salt # - git://github.com/saltstack/salt-states.git # - file:///var/git/saltmaster # -# The gitfs_proxy option specifies the URL of the proxy server that will be -# used for contacting the gitfs backend. It defaults to the empty string, which -# means that no proxy server will be used. -#gitfs_proxy: '' -# # The gitfs_ssl_verify option specifies whether to ignore ssl certificate # errors when contacting the gitfs backend. You might want to set this to # false if you're using a git backend that uses a self-signed certificate but @@ -980,10 +975,6 @@ user: salt # and SLS files are located. #git_pillar_root: '' -# Specifies the URL of the proxy server that will be used for contacting the -# remote repository. -#git_pillar_proxy: '' - # Specifies whether or not to ignore SSL certificate errors when contacting # the remote repository. #git_pillar_ssl_verify: False @@ -1286,9 +1277,6 @@ user: salt # List of git repositories to include with the local repo: #winrepo_remotes_ng: # - 'https://github.com/saltstack/salt-winrepo-ng.git' -# -# Proxy server used for contacting the remote repository: -#winrepo_proxy: '' ##### Windows Software Repo settings - Pre 2015.8 ##### diff --git a/pkg/common/env-cleanup-rules.yml b/pkg/common/env-cleanup-rules.yml index 786e12bfff69..2618eae0993b 100644 --- a/pkg/common/env-cleanup-rules.yml +++ b/pkg/common/env-cleanup-rules.yml @@ -16,10 +16,6 @@ common: - "**/site-packages/*/tests" - "**/site-packages/ansible_collections/*/*/test" - "**/site-packages/ansible_collections/*/*/tests" - # cryptography sdist ships its top-level docs/ dir, which lands at - # site-packages/docs/ and includes Java/Rust test-vector sources that - # trip FIPS-compliance scanners (e.g. VerifyRSAOAEPSHA2.java). - - "**/site-packages/docs" # Bundled Tornado Test Suite file_patterns: &common_file_patterns diff --git a/pkg/debian/changelog b/pkg/debian/changelog index dbd3bf14e009..27fe800e427a 100644 --- a/pkg/debian/changelog +++ b/pkg/debian/changelog @@ -67,1389 +67,6 @@ salt (3006.27) stable; urgency=medium -- Salt Project Packaging Wed, 01 Jul 2026 06:57:37 +0000 -salt (3008.1) stable; urgency=medium - - - # Changed - - * Changed `salt.returners.redis_return` to enumerate the Redis keyspace - with `SCAN` instead of the blocking `KEYS pattern` command in both - `get_jids` and `clean_old_jobs`. `KEYS` walks the entire keyspace - synchronously and stalls the Redis server for the duration; on a - master with hundreds of thousands of jobs this can block all clients - of that Redis instance for seconds. `SCAN` is incremental and - non-blocking. Order of returned keys is no longer guaranteed (the - returner does not rely on order); operators with custom scripts that - read `ret:*` or `load:*` directly may see them in a different order. [#69037](https://github.com/saltstack/salt/issues/69037) - - # Fixed - - * Fixed ``win_pkg`` functions ignoring the ``saltenv`` setting in minion configuration. All public functions (``refresh_db``, ``genrepo``, ``install``, ``remove``, ``list_pkgs``, ``latest_version``, ``upgrade_available``, ``list_upgrades``, ``list_available``, ``version``, ``get_repo_data``, ``get_package_info``) now fall back to ``__opts__["saltenv"]`` when ``saltenv`` is not passed explicitly, instead of always defaulting to ``base``. [#38551](https://github.com/saltstack/salt/issues/38551) - * Added ``encoding`` parameter to ``file.replace`` execution module and state to support UTF-16, UTF-32, and other multi-byte encoded files that would otherwise be incorrectly treated as binary. [#52793](https://github.com/saltstack/salt/issues/52793) - * Improved documentation for the `runas` and `password` parameters in `cmd.run`, `cmd.script`, and all `salt.modules.cmdmod` execution functions on Windows. The docs now accurately describe when a password is required: only when the salt-minion is **not** running as SYSTEM or as an elevated Administrator. Removed the inaccurate claim that the target user account must be in the Administrators group. Also changed `cmd.script` to log a warning instead of hard-failing when `runas` is used without a password on Windows, since a password is not always required. [#57951](https://github.com/saltstack/salt/issues/57951) - * Fixed `SSL: DECRYPTION_FAILED_OR_BAD_RECORD_MAC` errors in the VMware cloud driver by reconnecting when a cached vCenter service instance is found to be stale or corrupted (for example when inherited across a fork by salt-cloud's parallel provider queries). [#61983](https://github.com/saltstack/salt/issues/61983) - * Fixed event signature verification failing under ``minion_sign_messages``. The minion was signing the return load before ``salt.channel.client.AsyncReqChannel._package_load`` attached transport metadata (``nonce``, ``ts``, ``tok``, ``id``), so the bytes the master re-serialized to verify did not match what was signed and every signed return was dropped. Signing is now performed inside ``_package_load`` after the metadata is attached, against the same bytes the master verifies. [#68181](https://github.com/saltstack/salt/issues/68181) - * Fixed two distinct bugs in the `salt.engines.redis_sentinel` engine that - together prevented it from being usable. `start()` no longer raises - `AttributeError: 'dict_values' object has no attribute 'pop'` on Python 3 - (the dict.values() result is now wrapped in `list(...)`). `Listener` and - `start()` now accept an optional `password` argument and forward it to - the redis client, allowing the engine to authenticate against a Sentinel - that requires AUTH; the default of `None` keeps existing configurations - working unchanged. [#69031](https://github.com/saltstack/salt/issues/69031) - * Fixed `salt.returners.redis_return` silently ignoring the documented - `redis.password` configuration option. The returner now reads - `redis.password` from config (in both regular and proxy modes) and - forwards it to both the single-server `redis.StrictRedis` and the - `StrictRedisCluster` constructors. Operators with auth-protected Redis - no longer lose every job return to a hidden `NOAUTH Authentication - required` failure; deployments without a password are unaffected. [#69032](https://github.com/saltstack/salt/issues/69032) - * Fixed three closely-related bugs in `salt.cache.redis_cache` that - together broke hierarchical-bank semantics: - `_build_bank_hier` now registers each child bank name in both the - parent's `$BANK_` set (consumed by `flush()` tree traversal) and the - parent's `$BANKEYS_` set (consumed by `list_()`); `_get_banks_to_remove` - now decodes the bytes returned by `smembers` and skips the `"."` - placeholder, so recursive `flush()` of a parent bank actually descends - into sub-banks instead of corrupting the path; and `flush(bank)` of a - sub-bank now removes the flushed bank's own reference from its - parent's index sets so `list_(parent)` no longer reports it as - present. Together these fixes restore `cache.list("minions")`, - `salt-run manage.present` and `salt-run manage.up` for masters - configured with `cache: redis`. [#69033](https://github.com/saltstack/salt/issues/69033) - * Fixed `salt.tokens.rediscluster` being unable to retrieve any eauth - token. The cluster client was created with `decode_responses=True`, - which caused `redis_client.get()` to return `str` and broke - `salt.payload.loads` (msgpack rejects `str`); it also caused - `redis_client.keys()` to return `str` and broke - `[k.decode("utf8") for k in ...]` (`str` has no `.decode`). Both - errors were swallowed by broad `except Exception` handlers, so eauth - appeared to silently reject every token. `decode_responses=True` is - removed; values now round-trip as bytes through msgpack as the rest - of the module already expected. [#69035](https://github.com/saltstack/salt/issues/69035) - * Fixed `salt.returners.redis_return` leaking `:` last-jid - pointer keys indefinitely. The pointer was written with `pipeline.set` - and no `ex=` TTL, so any (minion, fun) pair that stopped running stuck - in Redis forever -- O(minions × distinct funcs) keys accumulating over - the lifetime of the master. The pointer now expires on the same TTL - as the rest of the returner data (`keep_jobs_seconds`). Operators with - external scripts reading these keys directly may observe them - expiring; the documentation never promised they would not. [#69038](https://github.com/saltstack/salt/issues/69038) - * Fixed `salt.returners.redis_return.get_fun` always returning an - empty dict. The function read return data from a `:` - key that no other code in the module ever wrote -- a leftover from - an older storage schema. It now reads from the canonical - `ret:` hash via `HGET ret: `, matching the - storage layout that `returner` actually produces and the read - pattern that `get_jid` already uses. [#69039](https://github.com/saltstack/salt/issues/69039) - * ``cmd.run`` and friends no longer include the ``env`` and ``stdin`` arguments in the ``CommandExecutionError`` raised when the underlying subprocess fails to start (typically ``ENOENT`` / binary not found). Both fields routinely carry credentials passed in by the caller (``env={"DB_PASSWORD": "..."}``, password piped via ``stdin``), and the error message ends up in master/minion logs and in event-bus return data visible to the API caller. [#69075](https://github.com/saltstack/salt/issues/69075) - * * Relenv 0.22.14 - - Update python 3.14 to 3.14.6 - - Update sqlite to 3.53.2.0 - - Update openssl to 3.5.7 [#69129](https://github.com/saltstack/salt/issues/69129) - * Fix pillar masking leaking ``**********`` into rendered pillar and state values. ``MaskedDict`` / ``MaskedList`` ``__repr__`` / ``__str__`` now consult the ``salt.utils.secret.mask_pillar`` ContextVar, so ``{{ pillar['list_or_dict_value'] }}`` interpolations on the minion return plain values inside a render bracket. Hoist the ``mask_pillar=False`` bracket from ``render_pillar`` to ``compile_pillar`` so ``ext_pillar`` handlers and the rest of the master-side pillar build also run unmasked. [#69160](https://github.com/saltstack/salt/issues/69160) - * Fixed Windows MSI self-upgrade via ``pkg.install`` failing with error 1603. The old product's ``DeleteConfig_DECAC`` custom action was unconditionally deleting ``ROOTDIR\var`` during ``RemoveExistingProducts``, destroying the MSI that ``pkg.install`` had cached to ``ROOTDIR\var\cache`` before launching the upgrade. Users who had ``REMOVE_CONFIG=1`` persisted in the registry (from checking "On uninstall" at install time) hit a worse variant where the entire ``ROOTDIR`` was deleted. The fix checks ``UPGRADINGPRODUCTCODE`` — set by Windows Installer whenever an uninstall is triggered by a major upgrade — and skips all ``ROOTDIR`` deletion during upgrades, matching the behaviour of the NSIS installer which has always preserved ``ROOTDIR`` during upgrades. [#69219](https://github.com/saltstack/salt/issues/69219) - * Fixed `TypeError: string indices must be integers` in the minion when the master returns a bare string error response (e.g. `"bad load"`, `"Some exception handling minion payload"`) for a pillar request. The minion now raises a clean `AuthenticationError` instead of crashing, allowing the caller to retry or fail gracefully. [#69228](https://github.com/saltstack/salt/issues/69228) - * pkg.list_patches in yumpkg.py parses tdnf output on Photon OS [#69229](https://github.com/saltstack/salt/issues/69229) - * Restore Python dependencies in the PyPI sdist by including ``requirements/*.in`` and ``requirements/**/*.lock`` in ``MANIFEST.in``. After the requirements ``.txt`` → ``.in`` rename, the sdist no longer shipped the files that ``setup.py`` reads to populate ``install_requires``, so ``pip install salt`` produced an installation with no dependencies. [#69244](https://github.com/saltstack/salt/issues/69244) - * Fix `salt-cloud` failing to start with `AttributeError: module 'salt' has no attribute 'minion'` by importing `salt.minion` in `salt.cloud`. [#69281](https://github.com/saltstack/salt/issues/69281) - * Ensure multiple masters have their own job/state queues [#69308](https://github.com/saltstack/salt/issues/69308) - * Fixed minion state queue replacing the master-assigned JID on queued state runs, so returns now come back tagged with the JID the master actually published. [#69386](https://github.com/saltstack/salt/issues/69386) - * Made the salt user's home directory and the relenv ``extras-`` directory configurable in the Linux packaging. The DEB preinst scripts now source ``/etc/default/salt-setup`` (and ``/etc/sysconfig/salt-minion-setup`` for cross-distro parity with RPM) before applying the ``SALT_HOME``/``SALT_USER``/``SALT_GROUP``/``SALT_NAME`` defaults, mirroring the long-standing RPM behavior. A new ``SALT_EXTRAS_DIR`` override is honored by both stacks so the extras tree can be relocated outside ``/opt/saltstack/salt`` and its ownership is correctly restored on upgrade. [#69402](https://github.com/saltstack/salt/issues/69402) - - # Added - - * Added ``dsc_resource`` execution module and state module for invoking individual - PowerShell DSC resources directly via ``Invoke-DscResource``, without compiling - a MOF file or involving the Local Configuration Manager. The - ``dsc_resource.managed`` state provides idiomatic Salt state management for any - installed DSC resource module. [#43718](https://github.com/saltstack/salt/issues/43718) - * fix etcdv3 module authentification when using etcd3-py lib [#69202](https://github.com/saltstack/salt/issues/69202) - - - -- Salt Project Packaging Thu, 11 Jun 2026 11:55:12 +0000 - -salt (3008.0) stable; urgency=medium - - - # Removed - - * Remove commuity extensions from Salt codebase [#65970](https://github.com/saltstack/salt/issues/65970) - * Remove deprecated module search path priority (`features.enable_deprecated_module_search_path_priority`) [#66025](https://github.com/saltstack/salt/issues/66025) - * Remove the __orchestration__ key from salt.runner and salt.wheel return data. [#66151](https://github.com/saltstack/salt/issues/66151) - * Removed linode-python package dependency for retired Linode API v3 [#68871](https://github.com/saltstack/salt/issues/68871) - * Removed legacy ``salt.transport.ipc`` module and unused ``PushChannel`` / ``PullChannel`` factories; local events use ``ipc_publish_client`` / ``ipc_publish_server`` (TCP transport). [#69001](https://github.com/saltstack/salt/issues/69001) - - # Deprecated - - * Deprecated the use of egrep in favor of grep -E [#65608](https://github.com/saltstack/salt/issues/65608) - - # Changed - - * Make sure every auth event has the 'act' key set [#56200](https://github.com/saltstack/salt/issues/56200) - * Ansiblegate discover_playbooks was changed to find playbooks as either *.yml or *.yaml files [#66048](https://github.com/saltstack/salt/issues/66048) - * re-work the aptpkg module to remove system libraries that onedir and virtualenvs do not have access. Streamline testing, and code use to needed libraries only. [#66056](https://github.com/saltstack/salt/issues/66056) - * Made gpg modules respect user's GNUPGHOME if set in shell environment [#66313](https://github.com/saltstack/salt/issues/66313) - * Made `gpg.present` attempt to refresh keys if they are expired [#66314](https://github.com/saltstack/salt/issues/66314) - * Made x509_v2 the default x509 modules. Until they are removed in the next major release, you can still revert to the old modules by setting `features: {x509_v2: false}` in the configuration [#66384](https://github.com/saltstack/salt/issues/66384) - * Included Salt extensions in Salt-SSH thin archive [#66559](https://github.com/saltstack/salt/issues/66559) - * Add support for additional options in several mac_brew_pkg methods [#66611](https://github.com/saltstack/salt/issues/66611) - * Make test_pip and test_fileserver tests compatible with venv execution [#66703](https://github.com/saltstack/salt/issues/66703) - * Do not use `ssl.PROTOCOL_TLS` which has been - [deprecated](https://docs.python.org/3/library/ssl.html#ssl.PROTOCOL_TLS) in - Python 3.10 will be removed in the future. [#66767](https://github.com/saltstack/salt/issues/66767) - * Remove warning when running `slsutil.renderer` on non-SLS files [#67067](https://github.com/saltstack/salt/issues/67067) - * PillarCache: reimplement using salt.cache - fix minion data cache organization/move pillar and grains to dedicated cache banks - salt.cache: allow cache.store() to set expires per key [#68030](https://github.com/saltstack/salt/issues/68030) - * Provide token storage using the salt.cache interface [#68039](https://github.com/saltstack/salt/issues/68039) - * Update packaged python from 3.10 to 3.11 [#68148](https://github.com/saltstack/salt/issues/68148) - * Added ceph to the specialFSes to match on name for set_fstab [#68207](https://github.com/saltstack/salt/issues/68207) - * Removed `networkx` module dependency by adding MultiDiGraph implementation to `salt.utils.requisite` to avoid extra dependencies. [#68748](https://github.com/saltstack/salt/issues/68748) - * Expanded Thorium documentation with concrete examples and added unit coverage for the documented Thorium workflows. [#68857](https://github.com/saltstack/salt/issues/68857) - * Add stub 3008.0 release notes (and template) so ``tools docs man`` and CI ``prepare-release`` can resolve the current-release doc target. Exclude ``doc/topics/proposals/*.md`` from Sphinx so stand-alone proposal files do not fail strict man builds. [#68964](https://github.com/saltstack/salt/issues/68964) - - # Fixed - - * Fixed recursive prereq requisites to report recursive requisite error. [#8210](https://github.com/saltstack/salt/issues/8210) - * Fixed erroneous recursive requisite error when a prereq is used in combination with onchanges_any. [#47154](https://github.com/saltstack/salt/issues/47154) - * Fixed an infinite loop in `requisite_any` when a requisite state was not found. [#50436](https://github.com/saltstack/salt/issues/50436) - * Refactoring the redis code obsoletes this issue as return values are either decoded directly or passed to salt.payload for parsing. [#54734](https://github.com/saltstack/salt/issues/54734) - * Fixed `OSError: The operation completed successfully` raised by `CreateProcessWithTokenW` on Windows when the underlying advapi32 call fails. The error code is now read from `ctypes.get_last_error()` (the ctypes-saved slot) instead of `win32api.GetLastError()` (the live Windows slot, which may be reset to 0 before it is read). [#57848](https://github.com/saltstack/salt/issues/57848) - * Fixed dependency resolution to not be quadratic. [#59123](https://github.com/saltstack/salt/issues/59123) - * Fix regex cache exception during sort in sweep function [#59437](https://github.com/saltstack/salt/issues/59437) - * Fixed requisites by parallel states on parallel states being evaluated synchronously (blocking state execution for other parallel states) [#59959](https://github.com/saltstack/salt/issues/59959) - * Fix bug when specifying template_source using net.load_template [#60515](https://github.com/saltstack/salt/issues/60515) - * During the redis refactor the documentation was updated to reference the Redis Cluster pip package. [#60899](https://github.com/saltstack/salt/issues/60899), [#66193](https://github.com/saltstack/salt/issues/66193) - * firewalld: normalize new rich rules before comparing to old ones [#61235](https://github.com/saltstack/salt/issues/61235) - * Fix regression that prevented salt-minion from running interval-based jobs on startup by default. [#61964](https://github.com/saltstack/salt/issues/61964) - * Fixed performance when state_aggregate is enabled. [#62439](https://github.com/saltstack/salt/issues/62439) - * Fixed LGPO ``get_policy_info`` incorrectly returning a "multiple policies" error when duplicate ADMX policy definitions (e.g. ``TerminalServer.admx`` and ``TerminalServer-Server.admx``) resolve to the same full path. [#62732](https://github.com/saltstack/salt/issues/62732) - * Fixed issue with salt-ssh hanging due to non-exposed host key acceptance prompt [#62782](https://github.com/saltstack/salt/issues/62782) - * Repaired zypper repositories being reconfigured without changes [#63402](https://github.com/saltstack/salt/issues/63402) - * Fix calculation of SLS context vars when trailing dots on targetted state [#63411](https://github.com/saltstack/salt/issues/63411) - * Catch StrictUndefined in salt jinja custom filters. [#64915](https://github.com/saltstack/salt/issues/64915) - * Put default `optimization_order` to LazyLoader to prevent possible fails on testing [#65266](https://github.com/saltstack/salt/issues/65266) - * Fixed aggregation to correctly honor requisites. [#65304](https://github.com/saltstack/salt/issues/65304) - * Fixed some instances of deprecated datetime.datetime.utcnow() [#65604](https://github.com/saltstack/salt/issues/65604) - * Introduce pruning option in file.keyvalue [#65631](https://github.com/saltstack/salt/issues/65631) - * fix 65703 by using OrderedDict instead of a index that breaks. . [#65703](https://github.com/saltstack/salt/issues/65703) - * Simplify timezone.compare_zone to primarily rely get_zone() [#65719](https://github.com/saltstack/salt/issues/65719) - * Handle regular expressions which do not not use grouping [#65722](https://github.com/saltstack/salt/issues/65722) - * fix consul.acl_create rule creation [#65788](https://github.com/saltstack/salt/issues/65788) - * Fix salt-cloud get_cloud_config_value for list objects [#65789](https://github.com/saltstack/salt/issues/65789) - * Prevent exceptions with fileserver.update when called via state [#65819](https://github.com/saltstack/salt/issues/65819) - * Fix granting of privileges on Postgres functions [#65839](https://github.com/saltstack/salt/issues/65839) - * Made Salt Cloud Hetzner module detect image architecture from instance type [#65888](https://github.com/saltstack/salt/issues/65888) - * Optimize async calls with using async wrapped method in thread only if io loop is already running [#65983](https://github.com/saltstack/salt/issues/65983) - * salt.auth.pam: fallback to use running Python in case /usr/bin/python3 is not found [#66035](https://github.com/saltstack/salt/issues/66035) - * Fix file.is_link hangs on paths that are hung mounts [#66096](https://github.com/saltstack/salt/issues/66096) - * Fix file.managed and file.serialize default tmp_dir to relative path [#66098](https://github.com/saltstack/salt/issues/66098) - * Make win_timezone recognize Qyzylorda timezone [#66176](https://github.com/saltstack/salt/issues/66176) - * Remove firing useless events with JID as a tag [#66279](https://github.com/saltstack/salt/issues/66279) - * Made gpg modules create GNUPGHOME if it does not exist [#66312](https://github.com/saltstack/salt/issues/66312) - * Fixed an issue where conflicting top level keys in the static grains file - (usually `/etc/salt/grains`) would break all grains states, and prevent static - grains from being loaded. [#66445](https://github.com/saltstack/salt/issues/66445) - * Fixed beacon delete not calling the beacon's close function, causing resource - leaks (e.g. inotify file descriptors) and CPU spin after deleting beacons at - runtime via ``beacons.delete``. Also fixed inotify file descriptor leak during - beacon refresh when the Beacon instance is replaced. [#66449](https://github.com/saltstack/salt/issues/66449) - * Fixed a regression where setting ``ipv6: true`` in the minion configuration - caused the minion to fail to start on Windows. Three IPC socket paths in the - TCP transport hardcoded ``AF_INET`` or ``127.0.0.1`` regardless of the IPv6 - setting: the IPC publish server/client addresses in ``salt.transport.base``, - the ``TCPPuller`` server socket, and the ``_TCPPubServerPublisher`` client - socket. On Windows, mixing an ``AF_INET6`` socket with the IPv4 loopback - address (or vice-versa) is rejected by the OS. All three paths now use - ``::1`` with ``AF_INET6`` when ``ipv6: true`` is set, and ``127.0.0.1`` - with ``AF_INET`` otherwise. [#66603](https://github.com/saltstack/salt/issues/66603) - * Make "status.diskusage" more robust and prevent crashes when stats cannot be obtained [#66646](https://github.com/saltstack/salt/issues/66646) - * Use `--cachedir` parameter for setting `extension_modules` with salt-call. [#66742](https://github.com/saltstack/salt/issues/66742) - * Don't schedule `__master_alive` jobs if `master_alive_interval` is not specified [#66757](https://github.com/saltstack/salt/issues/66757) - * Make x509 module compatible with `cryptography` module newer than `43.0.0` [#66818](https://github.com/saltstack/salt/issues/66818) - * Fixed Python 3.13 compatibility regarding urllib.parse module [#66898](https://github.com/saltstack/salt/issues/66898) - * make salt.channel.server.handle_message codepath more defensive [#66909](https://github.com/saltstack/salt/issues/66909) - * Fix the installation of pip modules with special characters in the module name [#66988](https://github.com/saltstack/salt/issues/66988) - * Repaired mount.fstab_present always returning pending changes [#67065](https://github.com/saltstack/salt/issues/67065) - * dictupdate.update: throw a TypeError when trying to merge a list with a mapping when ``merge_lists=True``. [#67092](https://github.com/saltstack/salt/issues/67092) - * Remove usage of spwd [#67119](https://github.com/saltstack/salt/issues/67119) - * Fixed order chunks not handling a state with both require and order first or last [#67120](https://github.com/saltstack/salt/issues/67120) - * Fixed pkg.install in test mode would not detect FreeBSD packages installed by their origin name [#67126](https://github.com/saltstack/salt/issues/67126) - * Fix virtual grains for VMs running on Nutanix AHV [#67180](https://github.com/saltstack/salt/issues/67180) - * The redis refactor fixed the incorrect handling of the cache.list function. [#67250](https://github.com/saltstack/salt/issues/67250) - * Fixed creating relative directory symlinks on Windows, ensured listing targets of symlinks in file_roots always produces POSIX-style paths [#67766](https://github.com/saltstack/salt/issues/67766) - * Avoid loading `salt.utils.crypt` module instead of `crypt` if it's missing in Python as it was deprecated and removed in Python 3.13. [#67797](https://github.com/saltstack/salt/issues/67797) - * Fixed docstring error in salt/modules/file.py that misnamed an option "user" when it should have been "owner". [#67911](https://github.com/saltstack/salt/issues/67911) - * salt.key: check_minion_cache performance optimization [#68030](https://github.com/saltstack/salt/issues/68030) - * when a file is managed, and the same file is cleaned, an incorrect message is displayed saying "removed: Removed due to clean" when the file isn't actually removed. Now the correct message is returned. [#68052](https://github.com/saltstack/salt/issues/68052) - * log_beacon - remove verbose minion log output [#68055](https://github.com/saltstack/salt/issues/68055) - * Fix that the state `saltmod.state` can be used on a masterless minion with salt-ssh like `saltmod.function` currently does. [#68116](https://github.com/saltstack/salt/issues/68116) - * Fixed ssh_known_hosts.present failure when ssh host keys changed [#68132](https://github.com/saltstack/salt/issues/68132) - * grains.disks: fix exception with incompatible output of Get-PhysicalDisk [#68184](https://github.com/saltstack/salt/issues/68184) - * Made osfinger report major&minor version for NixOS [#68230](https://github.com/saltstack/salt/issues/68230) - * Fix tests failing on AlmaLinux 10 and other clones [#68246](https://github.com/saltstack/salt/issues/68246) - * Speedup wheel key.finger call by removing redundant processing calls. [#68251](https://github.com/saltstack/salt/issues/68251) - * Fixed cp.cache_file when using Tornado > 6.4 [#68328](https://github.com/saltstack/salt/issues/68328) - * Stop mutating locals, which is unsupported in Py >=3.13 [#68445](https://github.com/saltstack/salt/issues/68445) - * Add `blockdev` state module back in to core - - Adds the `blockdev` state module back into the core Salt repo as it is critical functionality that shouldn't have been pulled out in the module migration [#68465](https://github.com/saltstack/salt/issues/68465) - * Adds `mdadm` and `lvm` grains modules back in to core. - - Restores the modules that had been removed as part of the community module - migration. They are core bits of functionality and the associated execution and - states modules had not been removed. [#68470](https://github.com/saltstack/salt/issues/68470) - * Fixed grains.list_present state to correctly handle multiple calls within the same state run. - Fixed `salt.utils.platform` to properly handle `__salt_system_encoding__` when synced as an extension module. - Improved `network.traceroute` parsing to be more robust across different traceroute versions. - Added retry logic to `saltutil.wheel` integration test to improve reliability in CI. - Improved architecture detection in `salt-ssh` to better support ARM64 platforms. - Fixed `salt-ssh` extension module syncing to avoid accidentally bundling core Salt modules and to correctly load wrapper modules. - Ensured `salt-ssh` relenv tests skip gracefully if the relenv tarball is unavailable in the test environment. - Fixed `mine.get` runner to correctly handle master's ID when ACLs are enabled. - Fixed `win_useradd.get_user_sid` to correctly handle non-string input. - Improved reliability of `state.running` integration test for `salt-ssh`. - Fixed high CPU usage in minion asynchronous authentication loop when masters are unreachable. - Added support for running Salt tools using `python -m tools`. [#68520](https://github.com/saltstack/salt/issues/68520) - * Adds `alias` state module back in to core. - - Restores the module that had been removed as part of the - community module migration. The associated execution module - had not been migrated. [#68574](https://github.com/saltstack/salt/issues/68574) - * Fixed mongodb tops module authentication to be compatible with pymongo v4+ by passing credentials directly to MongoClient instead of using the deprecated authenticate() method [#68659](https://github.com/saltstack/salt/issues/68659) - * Improved the rejected authentication warning message to include the minion ID, - making it easier for administrators to identify which minions need upgrading. [#68671](https://github.com/saltstack/salt/issues/68671) - * This PR fixes a bug where corrupted grains cache files cause unhandled - `SaltDeserializationError` exceptions, resulting in CRITICAL errors. - The fix adds proper exception handling to gracefully recover from corrupted - cache by regenerating grains. [#68678](https://github.com/saltstack/salt/issues/68678) - * Fix ansible.playbooks extra_vars quoting to prevent passing broken variables to ansible-playbook. [#68787](https://github.com/saltstack/salt/issues/68787) - * Make `x86_64_v2` to be handled properly with `salt.modules.yumpkg` module as a possible package architecture. [#68789](https://github.com/saltstack/salt/issues/68789) - * Make `salt-ssh` work without issues using `domain\user` notation for remote user with SSH. [#68790](https://github.com/saltstack/salt/issues/68790) - * Fixed source package builds (DEB/RPM) failing with ``LookupError: hatchling is already being built`` by adding ``hatchling`` to the ``--only-binary`` allow-list so pip uses its universal wheel instead of attempting a circular source build. [#68858](https://github.com/saltstack/salt/issues/68858) - * Use a 30 second ``salt`` CLI timeout in the reauth scenario tests so Windows CI does not time out on ``test.ping`` after master/minion restart (default was often 5s). [#68924](https://github.com/saltstack/salt/issues/68924) - * Fix logging in potentially dead process in reap_stray_processes fixture [#68927](https://github.com/saltstack/salt/issues/68927) - * Fix dynamic version discovery on a new release branch before the first ``v*`` tag exists: ``git describe`` still anchored on the previous line (e.g. ``v3007.13``) is lifted to the unreleased codename baseline (e.g. ``3008.0``) while keeping the commit offset and SHA. [#68964](https://github.com/saltstack/salt/issues/68964) - * Remove deprecations. - - salt/auth/pki.py (removed) - - salt/features.py (removed) - - salt/modules/nxos.py (modified) [#68985](https://github.com/saltstack/salt/issues/68985) - * debpkg include 0/1 as valid options when parsing bool values in deb822 [#68996](https://github.com/saltstack/salt/issues/68996) - * Drain cancelled tasks on PublishClient close so the TCP transport no longer prints `[ERROR ] Task was destroyed but it is pending!` at the end of every salt command. [#68998](https://github.com/saltstack/salt/issues/68998) - * Upgrade packaged python to 3.14 [#69014](https://github.com/saltstack/salt/issues/69014) - * ``LoadAuth.get_tok`` now distinguishes between corrupt token blobs (removed from the store) and transient backend errors such as Redis connection drops or NFS hangs (token kept, request treated as not-authenticated). Previously a single backend hiccup could log every authenticated user out by deleting valid tokens. [#69073](https://github.com/saltstack/salt/issues/69073) - * Fix pip install -e salt [#69101](https://github.com/saltstack/salt/issues/69101) - * * Relenv 0.22.11 - - Update python 3.14 to 3.14.5 - - Update sqlite to 3.53.1.0 (CVE-2025-70873) - - Update expat to 2.8.1 (CVE-2026-41080 and CVE-2026-45186) [#69129](https://github.com/saltstack/salt/issues/69129) - * Fix master crash when `presence_events: True` is set on Python 3.14 by skipping the shared `secrets` dict during `iter_transport_opts` deepcopy. [#69146](https://github.com/saltstack/salt/issues/69146) - * Fixed ``lgpo_reg.value_absent`` failing when the Registry.pol entry was already absent but the registry value still existed. ``lgpo_reg.delete_value`` was returning early before reaching the registry cleanup code, causing the state to see no changes and report failure. The registry value is now removed regardless of whether the pol entry was present. [#69203](https://github.com/saltstack/salt/issues/69203) - * Fixed `!!binary` YAML tag failing with "Incorrect padding" when base64 padding characters are omitted. Salt's YAML loader now tolerates unpadded base64 values, restoring behavior that worked on Salt 3006 (Python 3.10). [#69207](https://github.com/saltstack/salt/issues/69207) - * Fixed the ``yaml`` Jinja filter returning ``NULL`` when applied to Pillar - lists or dicts. Pillar containers are wrapped in ``MaskedDict`` / - ``MaskedList`` for repr redaction; representers are now registered so the - YAML dumper serializes them as their underlying list / dict. [#69218](https://github.com/saltstack/salt/issues/69218) - - # Added - - * Added proxy option to `gitfs`, `git_pillar` and `winrepo` for specifying a proxy server used to connect to git repositories [#30990](https://github.com/saltstack/salt/issues/30990) - * Added ``shadow.verify_password`` to ``salt.modules.win_shadow``, which - validates a Windows user's password via ``LogonUser`` with - ``LOGON32_LOGON_NETWORK`` (Microsoft's recommended approach per - `KB180548 `_) without - creating an interactive session. If the check causes an account lockout, - the account is automatically unlocked. Updated ``user.present`` on Windows - to use ``shadow.verify_password`` so the password is only changed when it - differs from the current value, matching the idempotent behaviour on other - platforms. [#41347](https://github.com/saltstack/salt/issues/41347) - * Added support for limiting the number of parallel states executing at the same time via `state_max_parallel` [#49301](https://github.com/saltstack/salt/issues/49301) - * Added metalink to mod_repo in yumpkg and documented in pkgrepo state [#58931](https://github.com/saltstack/salt/issues/58931) - * Add 'show_changes' arg for file.append and file.prepend states to hide output [#59329](https://github.com/saltstack/salt/issues/59329) - * Added ssl and verify_ssl arguments to mongodb module and states. [#59927](https://github.com/saltstack/salt/issues/59927) - * Added two new options, ``win_delay_start`` and ``win_install_dir``, to pass to - the Windows installer in salt-cloud [#61318](https://github.com/saltstack/salt/issues/61318) - * Add context aware change handling for file state module [#63328](https://github.com/saltstack/salt/issues/63328) - * Added the ability to access already compiled pillar data during the pillar rendering process via the `__pillar__` global in templates and matchers. [#64043](https://github.com/saltstack/salt/issues/64043) - * Allow salt-call arguments --file-root, --pillar-root and --states-dir to be specified multiple times [#64486](https://github.com/saltstack/salt/issues/64486) - * Adds documentation notes to clarify that Salt's file module only supports numeric mode specifications and does not support symbolic modes. [#64624](https://github.com/saltstack/salt/issues/64624) - * Added management of SSH keys and certificates [#65197](https://github.com/saltstack/salt/issues/65197) - * Add option (auth_events_autosign_grains) to add autosign_grains to auth events [#65426](https://github.com/saltstack/salt/issues/65426) - * Added `use_os_truststore` configuration option (default `False`) that instructs Salt to use the native operating system certificate store (Windows Certificate Store, macOS Keychain, or Linux system trust) for SSL/TLS verification instead of the bundled certifi CA bundle. Requires the `truststore` package (Python 3.10+). Also adds the `ca_truststore` grain that reports which store is active (`certifi` or `os`). [#65439](https://github.com/saltstack/salt/issues/65439) - * Enable "KeepAlive" probes for Salt SSH executions [#65488](https://github.com/saltstack/salt/issues/65488) - * Add ability to show diff for new files in file.managed [#65546](https://github.com/saltstack/salt/issues/65546) - * Added Virtuozzo Linux to Redhat os_family [#65600](https://github.com/saltstack/salt/issues/65600) - * Pillar dunder is now available in extension modules during pillar render. [#65724](https://github.com/saltstack/salt/issues/65724) - * Added x509_v2 SSH wrapper module. In addition to the regular calls, it provides a function for statefully managing remote certificates, even when access to the event bus is required [#65728](https://github.com/saltstack/salt/issues/65728) - * Introduce fibre_channel_host grain [#65750](https://github.com/saltstack/salt/issues/65750) - * Make `salt-run jobs.master` return runner jobs that are currently running on a master. [#66007](https://github.com/saltstack/salt/issues/66007) - * Added file and plaintext sources to `gpg.present`, allowed to skip keyserver queries [#66173](https://github.com/saltstack/salt/issues/66173) - * added pkg.which to aptpkg, for finding which package installed a file. [#66201](https://github.com/saltstack/salt/issues/66201) - * Allow pre-connection scripts to be run on host before any ssh commands [#66210](https://github.com/saltstack/salt/issues/66210) - * Added port, tls, username and password to the `smtp` configuration of the highstate returner. [#66251](https://github.com/saltstack/salt/issues/66251) - * Improve macOS defaults support [#66466](https://github.com/saltstack/salt/issues/66466) - * Added support for specifying different signature verification backends in `file.managed`/`archive.extracted` [#66527](https://github.com/saltstack/salt/issues/66527) - * Added an `asymmetric` execution module for signing/verifying data using raw asymmetric algorithms [#66528](https://github.com/saltstack/salt/issues/66528) - * Added support in service Beacon for only fire matching configured running state [#66809](https://github.com/saltstack/salt/issues/66809) - * Add --relenv Option to salt-ssh for Using a Onedir Bundled Salt+Python [#66877](https://github.com/saltstack/salt/issues/66877) - * Add support for state.sls_exists when using salt-ssh [#66894](https://github.com/saltstack/salt/issues/66894) - * Add detection for OS grains when running in [AlmaLinux Kitten](https://wiki.almalinux.org/release-notes/kitten-10.html) [#66991](https://github.com/saltstack/salt/issues/66991) - * Added a `merge` option to `file.recurse`, which merges subpaths from all existing `source`s before managing the directory. Handy when using different saltenvs or the TOFS pattern. [#67072](https://github.com/saltstack/salt/issues/67072) - * Add `_auth` calls to the master stats [#67746](https://github.com/saltstack/salt/issues/67746) - * Added possibility to load data from multiple inventories with `ansible.targets`. [#67776](https://github.com/saltstack/salt/issues/67776) - * Detect openEuler as RedHat family OS. [#67796](https://github.com/saltstack/salt/issues/67796) - * refactored server-side PKI to support cache interface - optimization: check_compound_minions: defer _pki_minions fetch - refactor: push salt.utils.minions bits into salt.key / optimize matching [#67799](https://github.com/saltstack/salt/issues/67799) - * Add deb822 apt source format support to aptpkg module [#67956](https://github.com/saltstack/salt/issues/67956) - * Add subsystem filter to "udev.exportdb" execution module function [#68047](https://github.com/saltstack/salt/issues/68047) - * Implement SL Micro 6.2 detection to fill the grains with proper values. [#68247](https://github.com/saltstack/salt/issues/68247) - * Added booleans argument to selinux.booleans - Added mod_aggregate to selinux to combine boolean - Added some type hints to selinux module and made some minor changes to improve readability and performance slightly [#68323](https://github.com/saltstack/salt/issues/68323) - * Add support for minion_id in log formats - - Adds support for including `%(minion_id)s` in log formats. Where id is available log messages on the master will have that data added to allow easier correlation of messages to minions. [#68410](https://github.com/saltstack/salt/issues/68410) - * Added feature parity for relenv and thin dir with salt-ssh. All salt-ssh tests pass with both thin dir and relenv. [#68531](https://github.com/saltstack/salt/issues/68531) - * Added tunable worker pools: partition the master's MWorkers into named pools - and route specific commands (for example `_auth`) to dedicated pools so a - slow workload cannot starve time-critical traffic. Controlled by the new - `worker_pools` and `worker_pools_enabled` master settings; see the "Tunable - Worker Pools" topic guide for details. Existing `worker_threads` - configurations remain fully backward compatible. [#68532](https://github.com/saltstack/salt/issues/68532) - * Added TLS encryption optimization via disable_aes_with_tls config option that eliminates redundant AES encryption when TLS with mutual authentication is active, improving performance while maintaining security through certificate identity verification. [#68536](https://github.com/saltstack/salt/issues/68536) - * utils.dictdiffer: support diffing of dicts in lists [#68726](https://github.com/saltstack/salt/issues/68726) - * Add support for nix package manager. [#68752](https://github.com/saltstack/salt/issues/68752) - * Added a centralized, declarative system for managing Salt's optional dependencies and their version-specific requirements in ``salt/utils/versions.py``. [#68894](https://github.com/saltstack/salt/issues/68894) - * Pillar data is now wrapped in SafeDict/SafeList with Pydantic SecretStr/SecretBytes for safer logging and output; optional state `no_log` and automatic redaction of pillar literals in state returns and minion job logs. [#68907](https://github.com/saltstack/salt/issues/68907) - * Added a fast memory-mapped cache backend (``salt.cache.mmap_cache``): - an O(1) hash-table store with a segmented heap, durable and multi-process - safe, usable as a drop-in for ``localfs`` via the ``cache`` master setting. - A specialised variant (``salt.cache.mmap_key``) replaces linear ``pki_dir`` - scans for the master's minion-key store; select it with - ``keys.cache_driver: mmap_key``. Migrate existing data with - ``salt-run cache.migrate`` and ``salt-run pki.migrate_to_mmap``. [#68936](https://github.com/saltstack/salt/issues/68936) - * Batch mode now uses a single JID for the entire batch run instead of generating - a separate JID per batch iteration. This enables unified job tracking via - ``salt-run jobs.lookup_jid`` and consistent ``--show-jid`` output across all - batch slices. The job cache merges minion lists from each iteration so that - ``get_load`` returns the complete set of targeted minions. [#68941](https://github.com/saltstack/salt/issues/68941) - * Added OpenTelemetry distributed-tracing support across all Salt - inter-process hops (network and IPC). When `tracing.enabled` is true in the - master/minion config, salt emits W3C-TraceContext-propagated spans via an - OTLP exporter, covering the CLI, channel layer, master workers, minion - command execution, event bus, reactor, syndic forwarding, salt-ssh, and - salt-api. Trace context travels inside the AES-encrypted Salt envelope so - it remains opaque on the wire. Tracing is opt-in and a complete no-op when - disabled. [#68999](https://github.com/saltstack/salt/issues/68999) - * Added a per-job ``start_event`` opt-in (CLI flag ``--start-event``) that asks - targeted minions to fire a ``salt/job//start/`` event the - moment they accept the published job, before the function runs. The payload - mirrors the master's ``salt/job//new`` event minus the function - arguments, letting orchestrators confirm reachability without waiting for - the full return. [#69019](https://github.com/saltstack/salt/issues/69019) - * Added `state.graph` and `state.graph_highstate` execution modules and runners to generate a DOT representation of the state dependency graph. [#69091](https://github.com/saltstack/salt/issues/69091) - * Migrate Salt documentation to the PyData Sphinx theme. This update modernizes the documentation UI, improves navigation with a persistent sidebar tree, and fixes issues with embedded video playback. [#69185](https://github.com/saltstack/salt/issues/69185) - * Added OpenTelemetry metrics support alongside the existing tracing - integration. When ``metrics.enabled`` is true in the master/minion - config, salt daemons emit counters (``salt.jobs.published``, - ``salt.jobs.completed``, ``salt.auth.attempts``, ``salt.events.fired``, - ``salt.returners.calls``), histograms (``salt.job.duration``, - ``salt.minion.exec.duration``), and observable gauges - (``salt.master.connected_minions.count``, - ``salt.master.workers.queue.depth``, ``salt.process.open_fds``) via - OTLP push or a Prometheus pull endpoint. Metrics are opt-in and a - complete no-op when disabled. See ``doc/topics/metrics/index.rst`` - for the full configuration surface and instrument inventory. [#69200](https://github.com/saltstack/salt/issues/69200) - * Restore the ``pillarstack`` ext_pillar module (``salt.pillar.stack``) that was - removed when community extensions were purged. The module is reinstated as a - core ext_pillar so existing PillarStack-based pillar trees continue to work on - 3008.x. [#69201](https://github.com/saltstack/salt/issues/69201) - * Added ``lgpo_reg.get_rsop_value`` to query the Resultant Set of Policy (RSoP) for a registry key/value and detect whether it is managed by a Domain Group Policy Object. The ``lgpo_reg`` module functions ``set_value``, ``disable_value``, and ``delete_value`` now log a warning when a Domain GPO is detected for the target value. The ``lgpo_reg`` state functions ``value_present``, ``value_disabled``, and ``value_absent`` append the same warning to the state comment so it is visible in state output. [#69205](https://github.com/saltstack/salt/issues/69205) - - - -- Salt Project Packaging Wed, 27 May 2026 10:08:12 +0000 - -salt (3008.0~rc4) stable; urgency=medium - - - # Removed - - * Remove commuity extensions from Salt codebase [#65970](https://github.com/saltstack/salt/issues/65970) - * Remove deprecated module search path priority (`features.enable_deprecated_module_search_path_priority`) [#66025](https://github.com/saltstack/salt/issues/66025) - * Remove the __orchestration__ key from salt.runner and salt.wheel return data. [#66151](https://github.com/saltstack/salt/issues/66151) - * Removed linode-python package dependency for retired Linode API v3 [#68871](https://github.com/saltstack/salt/issues/68871) - * Removed legacy ``salt.transport.ipc`` module and unused ``PushChannel`` / ``PullChannel`` factories; local events use ``ipc_publish_client`` / ``ipc_publish_server`` (TCP transport). [#69001](https://github.com/saltstack/salt/issues/69001) - - # Deprecated - - * Deprecated the use of egrep in favor of grep -E [#65608](https://github.com/saltstack/salt/issues/65608) - - # Changed - - * Make sure every auth event has the 'act' key set [#56200](https://github.com/saltstack/salt/issues/56200) - * Ansiblegate discover_playbooks was changed to find playbooks as either *.yml or *.yaml files [#66048](https://github.com/saltstack/salt/issues/66048) - * re-work the aptpkg module to remove system libraries that onedir and virtualenvs do not have access. Streamline testing, and code use to needed libraries only. [#66056](https://github.com/saltstack/salt/issues/66056) - * Made gpg modules respect user's GNUPGHOME if set in shell environment [#66313](https://github.com/saltstack/salt/issues/66313) - * Made `gpg.present` attempt to refresh keys if they are expired [#66314](https://github.com/saltstack/salt/issues/66314) - * Made x509_v2 the default x509 modules. Until they are removed in the next major release, you can still revert to the old modules by setting `features: {x509_v2: false}` in the configuration [#66384](https://github.com/saltstack/salt/issues/66384) - * Included Salt extensions in Salt-SSH thin archive [#66559](https://github.com/saltstack/salt/issues/66559) - * Add support for additional options in several mac_brew_pkg methods [#66611](https://github.com/saltstack/salt/issues/66611) - * Make test_pip and test_fileserver tests compatible with venv execution [#66703](https://github.com/saltstack/salt/issues/66703) - * Do not use `ssl.PROTOCOL_TLS` which has been - [deprecated](https://docs.python.org/3/library/ssl.html#ssl.PROTOCOL_TLS) in - Python 3.10 will be removed in the future. [#66767](https://github.com/saltstack/salt/issues/66767) - * Remove warning when running `slsutil.renderer` on non-SLS files [#67067](https://github.com/saltstack/salt/issues/67067) - * PillarCache: reimplement using salt.cache - fix minion data cache organization/move pillar and grains to dedicated cache banks - salt.cache: allow cache.store() to set expires per key [#68030](https://github.com/saltstack/salt/issues/68030) - * Provide token storage using the salt.cache interface [#68039](https://github.com/saltstack/salt/issues/68039) - * Update packaged python from 3.10 to 3.11 [#68148](https://github.com/saltstack/salt/issues/68148) - * Added ceph to the specialFSes to match on name for set_fstab [#68207](https://github.com/saltstack/salt/issues/68207) - * Removed `networkx` module dependency by adding MultiDiGraph implementation to `salt.utils.requisite` to avoid extra dependencies. [#68748](https://github.com/saltstack/salt/issues/68748) - * Expanded Thorium documentation with concrete examples and added unit coverage for the documented Thorium workflows. [#68857](https://github.com/saltstack/salt/issues/68857) - * Add stub 3008.0 release notes (and template) so ``tools docs man`` and CI ``prepare-release`` can resolve the current-release doc target. Exclude ``doc/topics/proposals/*.md`` from Sphinx so stand-alone proposal files do not fail strict man builds. [#68964](https://github.com/saltstack/salt/issues/68964) - - # Fixed - - * Fixed recursive prereq requisites to report recursive requisite error. [#8210](https://github.com/saltstack/salt/issues/8210) - * Fixed erroneous recursive requisite error when a prereq is used in combination with onchanges_any. [#47154](https://github.com/saltstack/salt/issues/47154) - * Fixed an infinite loop in `requisite_any` when a requisite state was not found. [#50436](https://github.com/saltstack/salt/issues/50436) - * Fixed dependency resolution to not be quadratic. [#59123](https://github.com/saltstack/salt/issues/59123) - * Fix regex cache exception during sort in sweep function [#59437](https://github.com/saltstack/salt/issues/59437) - * Fixed requisites by parallel states on parallel states being evaluated synchronously (blocking state execution for other parallel states) [#59959](https://github.com/saltstack/salt/issues/59959) - * Fix bug when specifying template_source using net.load_template [#60515](https://github.com/saltstack/salt/issues/60515) - * firewalld: normalize new rich rules before comparing to old ones [#61235](https://github.com/saltstack/salt/issues/61235) - * Fix regression that prevented salt-minion from running interval-based jobs on startup by default. [#61964](https://github.com/saltstack/salt/issues/61964) - * Fixed performance when state_aggregate is enabled. [#62439](https://github.com/saltstack/salt/issues/62439) - * Fixed issue with salt-ssh hanging due to non-exposed host key acceptance prompt [#62782](https://github.com/saltstack/salt/issues/62782) - * Repaired zypper repositories being reconfigured without changes [#63402](https://github.com/saltstack/salt/issues/63402) - * Fix calculation of SLS context vars when trailing dots on targetted state [#63411](https://github.com/saltstack/salt/issues/63411) - * Put default `optimization_order` to LazyLoader to prevent possible fails on testing [#65266](https://github.com/saltstack/salt/issues/65266) - * Fixed aggregation to correctly honor requisites. [#65304](https://github.com/saltstack/salt/issues/65304) - * Fixed some instances of deprecated datetime.datetime.utcnow() [#65604](https://github.com/saltstack/salt/issues/65604) - * Introduce pruning option in file.keyvalue [#65631](https://github.com/saltstack/salt/issues/65631) - * fix 65703 by using OrderedDict instead of a index that breaks. . [#65703](https://github.com/saltstack/salt/issues/65703) - * Simplify timezone.compare_zone to primarily rely get_zone() [#65719](https://github.com/saltstack/salt/issues/65719) - * Handle regular expressions which do not not use grouping [#65722](https://github.com/saltstack/salt/issues/65722) - * fix consul.acl_create rule creation [#65788](https://github.com/saltstack/salt/issues/65788) - * Fix salt-cloud get_cloud_config_value for list objects [#65789](https://github.com/saltstack/salt/issues/65789) - * Prevent exceptions with fileserver.update when called via state [#65819](https://github.com/saltstack/salt/issues/65819) - * Fix granting of privileges on Postgres functions [#65839](https://github.com/saltstack/salt/issues/65839) - * Made Salt Cloud Hetzner module detect image architecture from instance type [#65888](https://github.com/saltstack/salt/issues/65888) - * Optimize async calls with using async wrapped method in thread only if io loop is already running [#65983](https://github.com/saltstack/salt/issues/65983) - * salt.auth.pam: fallback to use running Python in case /usr/bin/python3 is not found [#66035](https://github.com/saltstack/salt/issues/66035) - * Fix file.is_link hangs on paths that are hung mounts [#66096](https://github.com/saltstack/salt/issues/66096) - * Fix file.managed and file.serialize default tmp_dir to relative path [#66098](https://github.com/saltstack/salt/issues/66098) - * Make win_timezone recognize Qyzylorda timezone [#66176](https://github.com/saltstack/salt/issues/66176) - * Remove firing useless events with JID as a tag [#66279](https://github.com/saltstack/salt/issues/66279) - * Made gpg modules create GNUPGHOME if it does not exist [#66312](https://github.com/saltstack/salt/issues/66312) - * Fixed an issue where conflicting top level keys in the static grains file - (usually `/etc/salt/grains`) would break all grains states, and prevent static - grains from being loaded. [#66445](https://github.com/saltstack/salt/issues/66445) - * Fixed beacon delete not calling the beacon's close function, causing resource - leaks (e.g. inotify file descriptors) and CPU spin after deleting beacons at - runtime via ``beacons.delete``. Also fixed inotify file descriptor leak during - beacon refresh when the Beacon instance is replaced. [#66449](https://github.com/saltstack/salt/issues/66449) - * Make "status.diskusage" more robust and prevent crashes when stats cannot be obtained [#66646](https://github.com/saltstack/salt/issues/66646) - * Use `--cachedir` parameter for setting `extension_modules` with salt-call. [#66742](https://github.com/saltstack/salt/issues/66742) - * Don't schedule `__master_alive` jobs if `master_alive_interval` is not specified [#66757](https://github.com/saltstack/salt/issues/66757) - * Make x509 module compatible with `cryptography` module newer than `43.0.0` [#66818](https://github.com/saltstack/salt/issues/66818) - * Fixed Python 3.13 compatibility regarding urllib.parse module [#66898](https://github.com/saltstack/salt/issues/66898) - * make salt.channel.server.handle_message codepath more defensive [#66909](https://github.com/saltstack/salt/issues/66909) - * Fix the installation of pip modules with special characters in the module name [#66988](https://github.com/saltstack/salt/issues/66988) - * Repaired mount.fstab_present always returning pending changes [#67065](https://github.com/saltstack/salt/issues/67065) - * dictupdate.update: throw a TypeError when trying to merge a list with a mapping when ``merge_lists=True``. [#67092](https://github.com/saltstack/salt/issues/67092) - * Remove usage of spwd [#67119](https://github.com/saltstack/salt/issues/67119) - * Fixed order chunks not handling a state with both require and order first or last [#67120](https://github.com/saltstack/salt/issues/67120) - * Fixed pkg.install in test mode would not detect FreeBSD packages installed by their origin name [#67126](https://github.com/saltstack/salt/issues/67126) - * Fix virtual grains for VMs running on Nutanix AHV [#67180](https://github.com/saltstack/salt/issues/67180) - * Fixed creating relative directory symlinks on Windows, ensured listing targets of symlinks in file_roots always produces POSIX-style paths [#67766](https://github.com/saltstack/salt/issues/67766) - * Avoid loading `salt.utils.crypt` module instead of `crypt` if it's missing in Python as it was deprecated and removed in Python 3.13. [#67797](https://github.com/saltstack/salt/issues/67797) - * Fixed docstring error in salt/modules/file.py that misnamed an option "user" when it should have been "owner". [#67911](https://github.com/saltstack/salt/issues/67911) - * salt.key: check_minion_cache performance optimization [#68030](https://github.com/saltstack/salt/issues/68030) - * when a file is managed, and the same file is cleaned, an incorrect message is displayed saying "removed: Removed due to clean" when the file isn't actually removed. Now the correct message is returned. [#68052](https://github.com/saltstack/salt/issues/68052) - * log_beacon - remove verbose minion log output [#68055](https://github.com/saltstack/salt/issues/68055) - * Fix that the state `saltmod.state` can be used on a masterless minion with salt-ssh like `saltmod.function` currently does. [#68116](https://github.com/saltstack/salt/issues/68116) - * Fixed ssh_known_hosts.present failure when ssh host keys changed [#68132](https://github.com/saltstack/salt/issues/68132) - * grains.disks: fix exception with incompatible output of Get-PhysicalDisk [#68184](https://github.com/saltstack/salt/issues/68184) - * Made osfinger report major&minor version for NixOS [#68230](https://github.com/saltstack/salt/issues/68230) - * Fix tests failing on AlmaLinux 10 and other clones [#68246](https://github.com/saltstack/salt/issues/68246) - * Speedup wheel key.finger call by removing redundant processing calls. [#68251](https://github.com/saltstack/salt/issues/68251) - * Fixed cp.cache_file when using Tornado > 6.4 [#68328](https://github.com/saltstack/salt/issues/68328) - * Fixed multiline powershell -Command { } blocks failing with "Missing closing - '}'" when used in a cmd.run state on Windows. Salt now collapses embedded - newlines and re-encodes the script block as -EncodedCommand, ensuring correct - execution and suppressing CLIXML noise from stderr. [#68397](https://github.com/saltstack/salt/issues/68397) - * Stop mutating locals, which is unsupported in Py >=3.13 [#68445](https://github.com/saltstack/salt/issues/68445) - * Add `blockdev` state module back in to core - - Adds the `blockdev` state module back into the core Salt repo as it is critical functionality that shouldn't have been pulled out in the module migration [#68465](https://github.com/saltstack/salt/issues/68465) - * Adds `mdadm` and `lvm` grains modules back in to core. - - Restores the modules that had been removed as part of the community module - migration. They are core bits of functionality and the associated execution and - states modules had not been removed. [#68470](https://github.com/saltstack/salt/issues/68470) - * Fixed grains.list_present state to correctly handle multiple calls within the same state run. - Fixed `salt.utils.platform` to properly handle `__salt_system_encoding__` when synced as an extension module. - Improved `network.traceroute` parsing to be more robust across different traceroute versions. - Added retry logic to `saltutil.wheel` integration test to improve reliability in CI. - Improved architecture detection in `salt-ssh` to better support ARM64 platforms. - Fixed `salt-ssh` extension module syncing to avoid accidentally bundling core Salt modules and to correctly load wrapper modules. - Ensured `salt-ssh` relenv tests skip gracefully if the relenv tarball is unavailable in the test environment. - Fixed `mine.get` runner to correctly handle master's ID when ACLs are enabled. - Fixed `win_useradd.get_user_sid` to correctly handle non-string input. - Improved reliability of `state.running` integration test for `salt-ssh`. - Fixed high CPU usage in minion asynchronous authentication loop when masters are unreachable. - Added support for running Salt tools using `python -m tools`. [#68520](https://github.com/saltstack/salt/issues/68520) - * Adds `alias` state module back in to core. - - Restores the module that had been removed as part of the - community module migration. The associated execution module - had not been migrated. [#68574](https://github.com/saltstack/salt/issues/68574) - * Fixed mongodb tops module authentication to be compatible with pymongo v4+ by passing credentials directly to MongoClient instead of using the deprecated authenticate() method [#68659](https://github.com/saltstack/salt/issues/68659) - * Improved the rejected authentication warning message to include the minion ID, - making it easier for administrators to identify which minions need upgrading. [#68671](https://github.com/saltstack/salt/issues/68671) - * This PR fixes a bug where corrupted grains cache files cause unhandled - `SaltDeserializationError` exceptions, resulting in CRITICAL errors. - The fix adds proper exception handling to gracefully recover from corrupted - cache by regenerating grains. [#68678](https://github.com/saltstack/salt/issues/68678) - * Fix ansible.playbooks extra_vars quoting to prevent passing broken variables to ansible-playbook. [#68787](https://github.com/saltstack/salt/issues/68787) - * Make `x86_64_v2` to be handled properly with `salt.modules.yumpkg` module as a possible package architecture. [#68789](https://github.com/saltstack/salt/issues/68789) - * Make `salt-ssh` work without issues using `domain\user` notation for remote user with SSH. [#68790](https://github.com/saltstack/salt/issues/68790) - * Fixed source package builds (DEB/RPM) failing with ``LookupError: hatchling is already being built`` by adding ``hatchling`` to the ``--only-binary`` allow-list so pip uses its universal wheel instead of attempting a circular source build. [#68858](https://github.com/saltstack/salt/issues/68858) - * Use a 30 second ``salt`` CLI timeout in the reauth scenario tests so Windows CI does not time out on ``test.ping`` after master/minion restart (default was often 5s). [#68924](https://github.com/saltstack/salt/issues/68924) - * Fix logging in potentially dead process in reap_stray_processes fixture [#68927](https://github.com/saltstack/salt/issues/68927) - * Fixed a regression in win_pkg where msiexec install flags containing - Windows-style quoting (e.g. ``MYPROPERTY="C:\some file.txt"``) were - mangled into ``"MYPROPERTY=C:\some file.txt"`` causing msiexec to hang. - Restored the pre-regression behaviour where ``shlex_split`` is not applied - to command strings on Windows, preserving Windows-style argument quoting - when the command is passed directly to ``CreateProcess``. [#68950](https://github.com/saltstack/salt/issues/68950) - * Fix dynamic version discovery on a new release branch before the first ``v*`` tag exists: ``git describe`` still anchored on the previous line (e.g. ``v3007.13``) is lifted to the unreleased codename baseline (e.g. ``3008.0``) while keeping the commit offset and SHA. [#68964](https://github.com/saltstack/salt/issues/68964) - * Remove deprecations. - - salt/auth/pki.py (removed) - - salt/features.py (removed) - - salt/modules/nxos.py (modified) [#68985](https://github.com/saltstack/salt/issues/68985) - * Upgrade packaged python to 3.14 [#69014](https://github.com/saltstack/salt/issues/69014) - * Fix pip install -e salt [#69101](https://github.com/saltstack/salt/issues/69101) - * * Relenv 0.22.11 - - Update python 3.14 to 3.14.5 - - Update sqlite to 3.53.1.0 (CVE-2025-70873) - - Update expat to 2.8.1 (CVE-2026-41080 and CVE-2026-45186) [#69129](https://github.com/saltstack/salt/issues/69129) - * Fix master crash when `presence_events: True` is set on Python 3.14 by skipping the shared `secrets` dict during `iter_transport_opts` deepcopy. [#69146](https://github.com/saltstack/salt/issues/69146) - - # Added - - * Added proxy option to `gitfs`, `git_pillar` and `winrepo` for specifying a proxy server used to connect to git repositories [#30990](https://github.com/saltstack/salt/issues/30990) - * Added ``shadow.verify_password`` to ``salt.modules.win_shadow``, which - validates a Windows user's password via ``LogonUser`` with - ``LOGON32_LOGON_NETWORK`` (Microsoft's recommended approach per - `KB180548 `_) without - creating an interactive session. If the check causes an account lockout, - the account is automatically unlocked. Updated ``user.present`` on Windows - to use ``shadow.verify_password`` so the password is only changed when it - differs from the current value, matching the idempotent behaviour on other - platforms. [#41347](https://github.com/saltstack/salt/issues/41347) - * Added support for limiting the number of parallel states executing at the same time via `state_max_parallel` [#49301](https://github.com/saltstack/salt/issues/49301) - * Added metalink to mod_repo in yumpkg and documented in pkgrepo state [#58931](https://github.com/saltstack/salt/issues/58931) - * Added ssl and verify_ssl arguments to mongodb module and states. [#59927](https://github.com/saltstack/salt/issues/59927) - * Added two new options, ``win_delay_start`` and ``win_install_dir``, to pass to - the Windows installer in salt-cloud [#61318](https://github.com/saltstack/salt/issues/61318) - * Add context aware change handling for file state module [#63328](https://github.com/saltstack/salt/issues/63328) - * Added the ability to access already compiled pillar data during the pillar rendering process via the `__pillar__` global in templates and matchers. [#64043](https://github.com/saltstack/salt/issues/64043) - * Allow salt-call arguments --file-root, --pillar-root and --states-dir to be specified multiple times [#64486](https://github.com/saltstack/salt/issues/64486) - * Adds documentation notes to clarify that Salt's file module only supports numeric mode specifications and does not support symbolic modes. [#64624](https://github.com/saltstack/salt/issues/64624) - * Added management of SSH keys and certificates [#65197](https://github.com/saltstack/salt/issues/65197) - * Add option (auth_events_autosign_grains) to add autosign_grains to auth events [#65426](https://github.com/saltstack/salt/issues/65426) - * Enable "KeepAlive" probes for Salt SSH executions [#65488](https://github.com/saltstack/salt/issues/65488) - * Add ability to show diff for new files in file.managed [#65546](https://github.com/saltstack/salt/issues/65546) - * Added Virtuozzo Linux to Redhat os_family [#65600](https://github.com/saltstack/salt/issues/65600) - * Pillar dunder is now available in extension modules during pillar render. [#65724](https://github.com/saltstack/salt/issues/65724) - * Added x509_v2 SSH wrapper module. In addition to the regular calls, it provides a function for statefully managing remote certificates, even when access to the event bus is required [#65728](https://github.com/saltstack/salt/issues/65728) - * Introduce fibre_channel_host grain [#65750](https://github.com/saltstack/salt/issues/65750) - * Make `salt-run jobs.master` return runner jobs that are currently running on a master. [#66007](https://github.com/saltstack/salt/issues/66007) - * Added file and plaintext sources to `gpg.present`, allowed to skip keyserver queries [#66173](https://github.com/saltstack/salt/issues/66173) - * added pkg.which to aptpkg, for finding which package installed a file. [#66201](https://github.com/saltstack/salt/issues/66201) - * Allow pre-connection scripts to be run on host before any ssh commands [#66210](https://github.com/saltstack/salt/issues/66210) - * Added port, tls, username and password to the `smtp` configuration of the highstate returner. [#66251](https://github.com/saltstack/salt/issues/66251) - * Improve macOS defaults support [#66466](https://github.com/saltstack/salt/issues/66466) - * Added support for specifying different signature verification backends in `file.managed`/`archive.extracted` [#66527](https://github.com/saltstack/salt/issues/66527) - * Added an `asymmetric` execution module for signing/verifying data using raw asymmetric algorithms [#66528](https://github.com/saltstack/salt/issues/66528) - * Added support in service Beacon for only fire matching configured running state [#66809](https://github.com/saltstack/salt/issues/66809) - * Add --relenv Option to salt-ssh for Using a Onedir Bundled Salt+Python [#66877](https://github.com/saltstack/salt/issues/66877) - * Add support for state.sls_exists when using salt-ssh [#66894](https://github.com/saltstack/salt/issues/66894) - * Add detection for OS grains when running in [AlmaLinux Kitten](https://wiki.almalinux.org/release-notes/kitten-10.html) [#66991](https://github.com/saltstack/salt/issues/66991) - * Added a `merge` option to `file.recurse`, which merges subpaths from all existing `source`s before managing the directory. Handy when using different saltenvs or the TOFS pattern. [#67072](https://github.com/saltstack/salt/issues/67072) - * Add `_auth` calls to the master stats [#67746](https://github.com/saltstack/salt/issues/67746) - * Added possibility to load data from multiple inventories with `ansible.targets`. [#67776](https://github.com/saltstack/salt/issues/67776) - * Detect openEuler as RedHat family OS. [#67796](https://github.com/saltstack/salt/issues/67796) - * refactored server-side PKI to support cache interface - optimization: check_compound_minions: defer _pki_minions fetch - refactor: push salt.utils.minions bits into salt.key / optimize matching [#67799](https://github.com/saltstack/salt/issues/67799) - * Add deb822 apt source format support to aptpkg module [#67956](https://github.com/saltstack/salt/issues/67956) - * Add subsystem filter to "udev.exportdb" execution module function [#68047](https://github.com/saltstack/salt/issues/68047) - * Implement SL Micro 6.2 detection to fill the grains with proper values. [#68247](https://github.com/saltstack/salt/issues/68247) - * Added booleans argument to selinux.booleans - Added mod_aggregate to selinux to combine boolean - Added some type hints to selinux module and made some minor changes to improve readability and performance slightly [#68323](https://github.com/saltstack/salt/issues/68323) - * Add support for minion_id in log formats - - Adds support for including `%(minion_id)s` in log formats. Where id is available log messages on the master will have that data added to allow easier correlation of messages to minions. [#68410](https://github.com/saltstack/salt/issues/68410) - * Added feature parity for relenv and thin dir with salt-ssh. All salt-ssh tests pass with both thin dir and relenv. [#68531](https://github.com/saltstack/salt/issues/68531) - * Added tunable worker pools: partition the master's MWorkers into named pools - and route specific commands (for example `_auth`) to dedicated pools so a - slow workload cannot starve time-critical traffic. Controlled by the new - `worker_pools` and `worker_pools_enabled` master settings; see the "Tunable - Worker Pools" topic guide for details. Existing `worker_threads` - configurations remain fully backward compatible. [#68532](https://github.com/saltstack/salt/issues/68532) - * Added TLS encryption optimization via disable_aes_with_tls config option that eliminates redundant AES encryption when TLS with mutual authentication is active, improving performance while maintaining security through certificate identity verification. [#68536](https://github.com/saltstack/salt/issues/68536) - * utils.dictdiffer: support diffing of dicts in lists [#68726](https://github.com/saltstack/salt/issues/68726) - * Add support for nix package manager. [#68752](https://github.com/saltstack/salt/issues/68752) - * Added a centralized, declarative system for managing Salt's optional dependencies and their version-specific requirements in ``salt/utils/versions.py``. [#68894](https://github.com/saltstack/salt/issues/68894) - * Pillar data is now wrapped in SafeDict/SafeList with Pydantic SecretStr/SecretBytes for safer logging and output; optional state `no_log` and automatic redaction of pillar literals in state returns and minion job logs. [#68907](https://github.com/saltstack/salt/issues/68907) - * Added a fast memory-mapped cache backend (``salt.cache.mmap_cache``): - an O(1) hash-table store with a segmented heap, durable and multi-process - safe, usable as a drop-in for ``localfs`` via the ``cache`` master setting. - A specialised variant (``salt.cache.mmap_key``) replaces linear ``pki_dir`` - scans for the master's minion-key store; select it with - ``keys.cache_driver: mmap_key``. Migrate existing data with - ``salt-run cache.migrate`` and ``salt-run pki.migrate_to_mmap``. [#68936](https://github.com/saltstack/salt/issues/68936) - * Batch mode now uses a single JID for the entire batch run instead of generating - a separate JID per batch iteration. This enables unified job tracking via - ``salt-run jobs.lookup_jid`` and consistent ``--show-jid`` output across all - batch slices. The job cache merges minion lists from each iteration so that - ``get_load`` returns the complete set of targeted minions. [#68941](https://github.com/saltstack/salt/issues/68941) - * Added a per-job ``start_event`` opt-in (CLI flag ``--start-event``) that asks - targeted minions to fire a ``salt/job//start/`` event the - moment they accept the published job, before the function runs. The payload - mirrors the master's ``salt/job//new`` event minus the function - arguments, letting orchestrators confirm reachability without waiting for - the full return. [#69019](https://github.com/saltstack/salt/issues/69019) - * Added `state.graph` and `state.graph_highstate` execution modules and runners to generate a DOT representation of the state dependency graph. [#69091](https://github.com/saltstack/salt/issues/69091) - - - -- Salt Project Packaging Fri, 15 May 2026 11:27:33 +0000 - -salt (3008.0~rc3) stable; urgency=medium - - - # Removed - - * Remove commuity extensions from Salt codebase [#65970](https://github.com/saltstack/salt/issues/65970) - * Remove deprecated module search path priority (`features.enable_deprecated_module_search_path_priority`) [#66025](https://github.com/saltstack/salt/issues/66025) - * Remove the __orchestration__ key from salt.runner and salt.wheel return data. [#66151](https://github.com/saltstack/salt/issues/66151) - * Removed linode-python package dependency for retired Linode API v3 [#68871](https://github.com/saltstack/salt/issues/68871) - * Removed legacy ``salt.transport.ipc`` module and unused ``PushChannel`` / ``PullChannel`` factories; local events use ``ipc_publish_client`` / ``ipc_publish_server`` (TCP transport). [#69001](https://github.com/saltstack/salt/issues/69001) - - # Deprecated - - * Deprecated the use of egrep in favor of grep -E [#65608](https://github.com/saltstack/salt/issues/65608) - - # Changed - - * Make sure every auth event has the 'act' key set [#56200](https://github.com/saltstack/salt/issues/56200) - * Ansiblegate discover_playbooks was changed to find playbooks as either *.yml or *.yaml files [#66048](https://github.com/saltstack/salt/issues/66048) - * re-work the aptpkg module to remove system libraries that onedir and virtualenvs do not have access. Streamline testing, and code use to needed libraries only. [#66056](https://github.com/saltstack/salt/issues/66056) - * Made gpg modules respect user's GNUPGHOME if set in shell environment [#66313](https://github.com/saltstack/salt/issues/66313) - * Made `gpg.present` attempt to refresh keys if they are expired [#66314](https://github.com/saltstack/salt/issues/66314) - * Made x509_v2 the default x509 modules. Until they are removed in the next major release, you can still revert to the old modules by setting `features: {x509_v2: false}` in the configuration [#66384](https://github.com/saltstack/salt/issues/66384) - * Included Salt extensions in Salt-SSH thin archive [#66559](https://github.com/saltstack/salt/issues/66559) - * Add support for additional options in several mac_brew_pkg methods [#66611](https://github.com/saltstack/salt/issues/66611) - * Make test_pip and test_fileserver tests compatible with venv execution [#66703](https://github.com/saltstack/salt/issues/66703) - * Do not use `ssl.PROTOCOL_TLS` which has been - [deprecated](https://docs.python.org/3/library/ssl.html#ssl.PROTOCOL_TLS) in - Python 3.10 will be removed in the future. [#66767](https://github.com/saltstack/salt/issues/66767) - * Remove warning when running `slsutil.renderer` on non-SLS files [#67067](https://github.com/saltstack/salt/issues/67067) - * PillarCache: reimplement using salt.cache - fix minion data cache organization/move pillar and grains to dedicated cache banks - salt.cache: allow cache.store() to set expires per key [#68030](https://github.com/saltstack/salt/issues/68030) - * Provide token storage using the salt.cache interface [#68039](https://github.com/saltstack/salt/issues/68039) - * Update packaged python from 3.10 to 3.11 [#68148](https://github.com/saltstack/salt/issues/68148) - * Added ceph to the specialFSes to match on name for set_fstab [#68207](https://github.com/saltstack/salt/issues/68207) - * Removed `networkx` module dependency by adding MultiDiGraph implementation to `salt.utils.requisite` to avoid extra dependencies. [#68748](https://github.com/saltstack/salt/issues/68748) - * Expanded Thorium documentation with concrete examples and added unit coverage for the documented Thorium workflows. [#68857](https://github.com/saltstack/salt/issues/68857) - * Add stub 3008.0 release notes (and template) so ``tools docs man`` and CI ``prepare-release`` can resolve the current-release doc target. Exclude ``doc/topics/proposals/*.md`` from Sphinx so stand-alone proposal files do not fail strict man builds. [#68964](https://github.com/saltstack/salt/issues/68964) - - # Fixed - - * Fixed recursive prereq requisites to report recursive requisite error. [#8210](https://github.com/saltstack/salt/issues/8210) - * Fixed erroneous recursive requisite error when a prereq is used in combination with onchanges_any. [#47154](https://github.com/saltstack/salt/issues/47154) - * Fixed an infinite loop in `requisite_any` when a requisite state was not found. [#50436](https://github.com/saltstack/salt/issues/50436) - * Fixed dependency resolution to not be quadratic. [#59123](https://github.com/saltstack/salt/issues/59123) - * Fix regex cache exception during sort in sweep function [#59437](https://github.com/saltstack/salt/issues/59437) - * Fixed requisites by parallel states on parallel states being evaluated synchronously (blocking state execution for other parallel states) [#59959](https://github.com/saltstack/salt/issues/59959) - * Fix bug when specifying template_source using net.load_template [#60515](https://github.com/saltstack/salt/issues/60515) - * firewalld: normalize new rich rules before comparing to old ones [#61235](https://github.com/saltstack/salt/issues/61235) - * Fix regression that prevented salt-minion from running interval-based jobs on startup by default. [#61964](https://github.com/saltstack/salt/issues/61964) - * Fixed performance when state_aggregate is enabled. [#62439](https://github.com/saltstack/salt/issues/62439) - * Fixed issue with salt-ssh hanging due to non-exposed host key acceptance prompt [#62782](https://github.com/saltstack/salt/issues/62782) - * Repaired zypper repositories being reconfigured without changes [#63402](https://github.com/saltstack/salt/issues/63402) - * Fix calculation of SLS context vars when trailing dots on targetted state [#63411](https://github.com/saltstack/salt/issues/63411) - * Put default `optimization_order` to LazyLoader to prevent possible fails on testing [#65266](https://github.com/saltstack/salt/issues/65266) - * Fixed aggregation to correctly honor requisites. [#65304](https://github.com/saltstack/salt/issues/65304) - * Fixed some instances of deprecated datetime.datetime.utcnow() [#65604](https://github.com/saltstack/salt/issues/65604) - * Introduce pruning option in file.keyvalue [#65631](https://github.com/saltstack/salt/issues/65631) - * fix 65703 by using OrderedDict instead of a index that breaks. . [#65703](https://github.com/saltstack/salt/issues/65703) - * Simplify timezone.compare_zone to primarily rely get_zone() [#65719](https://github.com/saltstack/salt/issues/65719) - * Handle regular expressions which do not not use grouping [#65722](https://github.com/saltstack/salt/issues/65722) - * fix consul.acl_create rule creation [#65788](https://github.com/saltstack/salt/issues/65788) - * Fix salt-cloud get_cloud_config_value for list objects [#65789](https://github.com/saltstack/salt/issues/65789) - * Prevent exceptions with fileserver.update when called via state [#65819](https://github.com/saltstack/salt/issues/65819) - * Fix granting of privileges on Postgres functions [#65839](https://github.com/saltstack/salt/issues/65839) - * Made Salt Cloud Hetzner module detect image architecture from instance type [#65888](https://github.com/saltstack/salt/issues/65888) - * Optimize async calls with using async wrapped method in thread only if io loop is already running [#65983](https://github.com/saltstack/salt/issues/65983) - * salt.auth.pam: fallback to use running Python in case /usr/bin/python3 is not found [#66035](https://github.com/saltstack/salt/issues/66035) - * Fix file.is_link hangs on paths that are hung mounts [#66096](https://github.com/saltstack/salt/issues/66096) - * Fix file.managed and file.serialize default tmp_dir to relative path [#66098](https://github.com/saltstack/salt/issues/66098) - * Make win_timezone recognize Qyzylorda timezone [#66176](https://github.com/saltstack/salt/issues/66176) - * Remove firing useless events with JID as a tag [#66279](https://github.com/saltstack/salt/issues/66279) - * Made gpg modules create GNUPGHOME if it does not exist [#66312](https://github.com/saltstack/salt/issues/66312) - * Fixed an issue where conflicting top level keys in the static grains file - (usually `/etc/salt/grains`) would break all grains states, and prevent static - grains from being loaded. [#66445](https://github.com/saltstack/salt/issues/66445) - * Fixed beacon delete not calling the beacon's close function, causing resource - leaks (e.g. inotify file descriptors) and CPU spin after deleting beacons at - runtime via ``beacons.delete``. Also fixed inotify file descriptor leak during - beacon refresh when the Beacon instance is replaced. [#66449](https://github.com/saltstack/salt/issues/66449) - * Make "status.diskusage" more robust and prevent crashes when stats cannot be obtained [#66646](https://github.com/saltstack/salt/issues/66646) - * Use `--cachedir` parameter for setting `extension_modules` with salt-call. [#66742](https://github.com/saltstack/salt/issues/66742) - * Don't schedule `__master_alive` jobs if `master_alive_interval` is not specified [#66757](https://github.com/saltstack/salt/issues/66757) - * Make x509 module compatible with `cryptography` module newer than `43.0.0` [#66818](https://github.com/saltstack/salt/issues/66818) - * Fixed Python 3.13 compatibility regarding urllib.parse module [#66898](https://github.com/saltstack/salt/issues/66898) - * make salt.channel.server.handle_message codepath more defensive [#66909](https://github.com/saltstack/salt/issues/66909) - * Fix the installation of pip modules with special characters in the module name [#66988](https://github.com/saltstack/salt/issues/66988) - * Repaired mount.fstab_present always returning pending changes [#67065](https://github.com/saltstack/salt/issues/67065) - * dictupdate.update: throw a TypeError when trying to merge a list with a mapping when ``merge_lists=True``. [#67092](https://github.com/saltstack/salt/issues/67092) - * Remove usage of spwd [#67119](https://github.com/saltstack/salt/issues/67119) - * Fixed order chunks not handling a state with both require and order first or last [#67120](https://github.com/saltstack/salt/issues/67120) - * Fixed pkg.install in test mode would not detect FreeBSD packages installed by their origin name [#67126](https://github.com/saltstack/salt/issues/67126) - * Fix virtual grains for VMs running on Nutanix AHV [#67180](https://github.com/saltstack/salt/issues/67180) - * Fixed creating relative directory symlinks on Windows, ensured listing targets of symlinks in file_roots always produces POSIX-style paths [#67766](https://github.com/saltstack/salt/issues/67766) - * Avoid loading `salt.utils.crypt` module instead of `crypt` if it's missing in Python as it was deprecated and removed in Python 3.13. [#67797](https://github.com/saltstack/salt/issues/67797) - * Fixed docstring error in salt/modules/file.py that misnamed an option "user" when it should have been "owner". [#67911](https://github.com/saltstack/salt/issues/67911) - * salt.key: check_minion_cache performance optimization [#68030](https://github.com/saltstack/salt/issues/68030) - * when a file is managed, and the same file is cleaned, an incorrect message is displayed saying "removed: Removed due to clean" when the file isn't actually removed. Now the correct message is returned. [#68052](https://github.com/saltstack/salt/issues/68052) - * log_beacon - remove verbose minion log output [#68055](https://github.com/saltstack/salt/issues/68055) - * Fix that the state `saltmod.state` can be used on a masterless minion with salt-ssh like `saltmod.function` currently does. [#68116](https://github.com/saltstack/salt/issues/68116) - * Fixed ssh_known_hosts.present failure when ssh host keys changed [#68132](https://github.com/saltstack/salt/issues/68132) - * grains.disks: fix exception with incompatible output of Get-PhysicalDisk [#68184](https://github.com/saltstack/salt/issues/68184) - * Made osfinger report major&minor version for NixOS [#68230](https://github.com/saltstack/salt/issues/68230) - * Fix tests failing on AlmaLinux 10 and other clones [#68246](https://github.com/saltstack/salt/issues/68246) - * Speedup wheel key.finger call by removing redundant processing calls. [#68251](https://github.com/saltstack/salt/issues/68251) - * Fixed cp.cache_file when using Tornado > 6.4 [#68328](https://github.com/saltstack/salt/issues/68328) - * Fixed multiline powershell -Command { } blocks failing with "Missing closing - '}'" when used in a cmd.run state on Windows. Salt now collapses embedded - newlines and re-encodes the script block as -EncodedCommand, ensuring correct - execution and suppressing CLIXML noise from stderr. [#68397](https://github.com/saltstack/salt/issues/68397) - * Stop mutating locals, which is unsupported in Py >=3.13 [#68445](https://github.com/saltstack/salt/issues/68445) - * Add `blockdev` state module back in to core - - Adds the `blockdev` state module back into the core Salt repo as it is critical functionality that shouldn't have been pulled out in the module migration [#68465](https://github.com/saltstack/salt/issues/68465) - * Adds `mdadm` and `lvm` grains modules back in to core. - - Restores the modules that had been removed as part of the community module - migration. They are core bits of functionality and the associated execution and - states modules had not been removed. [#68470](https://github.com/saltstack/salt/issues/68470) - * Fixed grains.list_present state to correctly handle multiple calls within the same state run. - Fixed `salt.utils.platform` to properly handle `__salt_system_encoding__` when synced as an extension module. - Improved `network.traceroute` parsing to be more robust across different traceroute versions. - Added retry logic to `saltutil.wheel` integration test to improve reliability in CI. - Improved architecture detection in `salt-ssh` to better support ARM64 platforms. - Fixed `salt-ssh` extension module syncing to avoid accidentally bundling core Salt modules and to correctly load wrapper modules. - Ensured `salt-ssh` relenv tests skip gracefully if the relenv tarball is unavailable in the test environment. - Fixed `mine.get` runner to correctly handle master's ID when ACLs are enabled. - Fixed `win_useradd.get_user_sid` to correctly handle non-string input. - Improved reliability of `state.running` integration test for `salt-ssh`. - Fixed high CPU usage in minion asynchronous authentication loop when masters are unreachable. - Added support for running Salt tools using `python -m tools`. [#68520](https://github.com/saltstack/salt/issues/68520) - * Adds `alias` state module back in to core. - - Restores the module that had been removed as part of the - community module migration. The associated execution module - had not been migrated. [#68574](https://github.com/saltstack/salt/issues/68574) - * Fixed mongodb tops module authentication to be compatible with pymongo v4+ by passing credentials directly to MongoClient instead of using the deprecated authenticate() method [#68659](https://github.com/saltstack/salt/issues/68659) - * Improved the rejected authentication warning message to include the minion ID, - making it easier for administrators to identify which minions need upgrading. [#68671](https://github.com/saltstack/salt/issues/68671) - * This PR fixes a bug where corrupted grains cache files cause unhandled - `SaltDeserializationError` exceptions, resulting in CRITICAL errors. - The fix adds proper exception handling to gracefully recover from corrupted - cache by regenerating grains. [#68678](https://github.com/saltstack/salt/issues/68678) - * Fix ansible.playbooks extra_vars quoting to prevent passing broken variables to ansible-playbook. [#68787](https://github.com/saltstack/salt/issues/68787) - * Make `x86_64_v2` to be handled properly with `salt.modules.yumpkg` module as a possible package architecture. [#68789](https://github.com/saltstack/salt/issues/68789) - * Make `salt-ssh` work without issues using `domain\user` notation for remote user with SSH. [#68790](https://github.com/saltstack/salt/issues/68790) - * Fixed source package builds (DEB/RPM) failing with ``LookupError: hatchling is already being built`` by adding ``hatchling`` to the ``--only-binary`` allow-list so pip uses its universal wheel instead of attempting a circular source build. [#68858](https://github.com/saltstack/salt/issues/68858) - * Use a 30 second ``salt`` CLI timeout in the reauth scenario tests so Windows CI does not time out on ``test.ping`` after master/minion restart (default was often 5s). [#68924](https://github.com/saltstack/salt/issues/68924) - * Fix logging in potentially dead process in reap_stray_processes fixture [#68927](https://github.com/saltstack/salt/issues/68927) - * Fixed a regression in win_pkg where msiexec install flags containing - Windows-style quoting (e.g. ``MYPROPERTY="C:\some file.txt"``) were - mangled into ``"MYPROPERTY=C:\some file.txt"`` causing msiexec to hang. - Restored the pre-regression behaviour where ``shlex_split`` is not applied - to command strings on Windows, preserving Windows-style argument quoting - when the command is passed directly to ``CreateProcess``. [#68950](https://github.com/saltstack/salt/issues/68950) - * Fix dynamic version discovery on a new release branch before the first ``v*`` tag exists: ``git describe`` still anchored on the previous line (e.g. ``v3007.13``) is lifted to the unreleased codename baseline (e.g. ``3008.0``) while keeping the commit offset and SHA. [#68964](https://github.com/saltstack/salt/issues/68964) - * Remove deprecations. - - salt/auth/pki.py (removed) - - salt/features.py (removed) - - salt/modules/nxos.py (modified) [#68985](https://github.com/saltstack/salt/issues/68985) - * Upgrade packaged python to 3.14 [#69014](https://github.com/saltstack/salt/issues/69014) - * Fix pip install -e salt [#69101](https://github.com/saltstack/salt/issues/69101) - * * Relenv 0.22.11 - - Update python 3.14 to 3.14.5 - - Update sqlite to 3.53.1.0 (CVE-2025-70873) - - Update expat to 2.8.1 (CVE-2026-41080 and CVE-2026-45186) [#69129](https://github.com/saltstack/salt/issues/69129) - - # Added - - * Added proxy option to `gitfs`, `git_pillar` and `winrepo` for specifying a proxy server used to connect to git repositories [#30990](https://github.com/saltstack/salt/issues/30990) - * Added support for limiting the number of parallel states executing at the same time via `state_max_parallel` [#49301](https://github.com/saltstack/salt/issues/49301) - * Added metalink to mod_repo in yumpkg and documented in pkgrepo state [#58931](https://github.com/saltstack/salt/issues/58931) - * Added ssl and verify_ssl arguments to mongodb module and states. [#59927](https://github.com/saltstack/salt/issues/59927) - * Added two new options, ``win_delay_start`` and ``win_install_dir``, to pass to - the Windows installer in salt-cloud [#61318](https://github.com/saltstack/salt/issues/61318) - * Add context aware change handling for file state module [#63328](https://github.com/saltstack/salt/issues/63328) - * Added the ability to access already compiled pillar data during the pillar rendering process via the `__pillar__` global in templates and matchers. [#64043](https://github.com/saltstack/salt/issues/64043) - * Allow salt-call arguments --file-root, --pillar-root and --states-dir to be specified multiple times [#64486](https://github.com/saltstack/salt/issues/64486) - * Adds documentation notes to clarify that Salt's file module only supports numeric mode specifications and does not support symbolic modes. [#64624](https://github.com/saltstack/salt/issues/64624) - * Added management of SSH keys and certificates [#65197](https://github.com/saltstack/salt/issues/65197) - * Add option (auth_events_autosign_grains) to add autosign_grains to auth events [#65426](https://github.com/saltstack/salt/issues/65426) - * Enable "KeepAlive" probes for Salt SSH executions [#65488](https://github.com/saltstack/salt/issues/65488) - * Add ability to show diff for new files in file.managed [#65546](https://github.com/saltstack/salt/issues/65546) - * Added Virtuozzo Linux to Redhat os_family [#65600](https://github.com/saltstack/salt/issues/65600) - * Pillar dunder is now available in extension modules during pillar render. [#65724](https://github.com/saltstack/salt/issues/65724) - * Added x509_v2 SSH wrapper module. In addition to the regular calls, it provides a function for statefully managing remote certificates, even when access to the event bus is required [#65728](https://github.com/saltstack/salt/issues/65728) - * Introduce fibre_channel_host grain [#65750](https://github.com/saltstack/salt/issues/65750) - * Make `salt-run jobs.master` return runner jobs that are currently running on a master. [#66007](https://github.com/saltstack/salt/issues/66007) - * Added file and plaintext sources to `gpg.present`, allowed to skip keyserver queries [#66173](https://github.com/saltstack/salt/issues/66173) - * added pkg.which to aptpkg, for finding which package installed a file. [#66201](https://github.com/saltstack/salt/issues/66201) - * Allow pre-connection scripts to be run on host before any ssh commands [#66210](https://github.com/saltstack/salt/issues/66210) - * Added port, tls, username and password to the `smtp` configuration of the highstate returner. [#66251](https://github.com/saltstack/salt/issues/66251) - * Improve macOS defaults support [#66466](https://github.com/saltstack/salt/issues/66466) - * Added support for specifying different signature verification backends in `file.managed`/`archive.extracted` [#66527](https://github.com/saltstack/salt/issues/66527) - * Added an `asymmetric` execution module for signing/verifying data using raw asymmetric algorithms [#66528](https://github.com/saltstack/salt/issues/66528) - * Added support in service Beacon for only fire matching configured running state [#66809](https://github.com/saltstack/salt/issues/66809) - * Add --relenv Option to salt-ssh for Using a Onedir Bundled Salt+Python [#66877](https://github.com/saltstack/salt/issues/66877) - * Add support for state.sls_exists when using salt-ssh [#66894](https://github.com/saltstack/salt/issues/66894) - * Add detection for OS grains when running in [AlmaLinux Kitten](https://wiki.almalinux.org/release-notes/kitten-10.html) [#66991](https://github.com/saltstack/salt/issues/66991) - * Added a `merge` option to `file.recurse`, which merges subpaths from all existing `source`s before managing the directory. Handy when using different saltenvs or the TOFS pattern. [#67072](https://github.com/saltstack/salt/issues/67072) - * Add `_auth` calls to the master stats [#67746](https://github.com/saltstack/salt/issues/67746) - * Added possibility to load data from multiple inventories with `ansible.targets`. [#67776](https://github.com/saltstack/salt/issues/67776) - * Detect openEuler as RedHat family OS. [#67796](https://github.com/saltstack/salt/issues/67796) - * refactored server-side PKI to support cache interface - optimization: check_compound_minions: defer _pki_minions fetch - refactor: push salt.utils.minions bits into salt.key / optimize matching [#67799](https://github.com/saltstack/salt/issues/67799) - * Add deb822 apt source format support to aptpkg module [#67956](https://github.com/saltstack/salt/issues/67956) - * Add subsystem filter to "udev.exportdb" execution module function [#68047](https://github.com/saltstack/salt/issues/68047) - * Implement SL Micro 6.2 detection to fill the grains with proper values. [#68247](https://github.com/saltstack/salt/issues/68247) - * Added booleans argument to selinux.booleans - Added mod_aggregate to selinux to combine boolean - Added some type hints to selinux module and made some minor changes to improve readability and performance slightly [#68323](https://github.com/saltstack/salt/issues/68323) - * Add support for minion_id in log formats - - Adds support for including `%(minion_id)s` in log formats. Where id is available log messages on the master will have that data added to allow easier correlation of messages to minions. [#68410](https://github.com/saltstack/salt/issues/68410) - * Added feature parity for relenv and thin dir with salt-ssh. All salt-ssh tests pass with both thin dir and relenv. [#68531](https://github.com/saltstack/salt/issues/68531) - * Added tunable worker pools: partition the master's MWorkers into named pools - and route specific commands (for example `_auth`) to dedicated pools so a - slow workload cannot starve time-critical traffic. Controlled by the new - `worker_pools` and `worker_pools_enabled` master settings; see the "Tunable - Worker Pools" topic guide for details. Existing `worker_threads` - configurations remain fully backward compatible. [#68532](https://github.com/saltstack/salt/issues/68532) - * Added TLS encryption optimization via disable_aes_with_tls config option that eliminates redundant AES encryption when TLS with mutual authentication is active, improving performance while maintaining security through certificate identity verification. [#68536](https://github.com/saltstack/salt/issues/68536) - * utils.dictdiffer: support diffing of dicts in lists [#68726](https://github.com/saltstack/salt/issues/68726) - * Add support for nix package manager. [#68752](https://github.com/saltstack/salt/issues/68752) - * Added a centralized, declarative system for managing Salt's optional dependencies and their version-specific requirements in ``salt/utils/versions.py``. [#68894](https://github.com/saltstack/salt/issues/68894) - * Pillar data is now wrapped in SafeDict/SafeList with Pydantic SecretStr/SecretBytes for safer logging and output; optional state `no_log` and automatic redaction of pillar literals in state returns and minion job logs. [#68907](https://github.com/saltstack/salt/issues/68907) - * Added a fast memory-mapped cache backend (``salt.cache.mmap_cache``): - an O(1) hash-table store with a segmented heap, durable and multi-process - safe, usable as a drop-in for ``localfs`` via the ``cache`` master setting. - A specialised variant (``salt.cache.mmap_key``) replaces linear ``pki_dir`` - scans for the master's minion-key store; select it with - ``keys.cache_driver: mmap_key``. Migrate existing data with - ``salt-run cache.migrate`` and ``salt-run pki.migrate_to_mmap``. [#68936](https://github.com/saltstack/salt/issues/68936) - * Batch mode now uses a single JID for the entire batch run instead of generating - a separate JID per batch iteration. This enables unified job tracking via - ``salt-run jobs.lookup_jid`` and consistent ``--show-jid`` output across all - batch slices. The job cache merges minion lists from each iteration so that - ``get_load`` returns the complete set of targeted minions. [#68941](https://github.com/saltstack/salt/issues/68941) - * Added a per-job ``start_event`` opt-in (CLI flag ``--start-event``) that asks - targeted minions to fire a ``salt/job//start/`` event the - moment they accept the published job, before the function runs. The payload - mirrors the master's ``salt/job//new`` event minus the function - arguments, letting orchestrators confirm reachability without waiting for - the full return. [#69019](https://github.com/saltstack/salt/issues/69019) - * Added `state.graph` and `state.graph_highstate` execution modules and runners to generate a DOT representation of the state dependency graph. [#69091](https://github.com/saltstack/salt/issues/69091) - - - -- Salt Project Packaging Wed, 13 May 2026 10:33:51 +0000 - -salt (3008.0~rc2) stable; urgency=medium - - - # Removed - - * Remove commuity extensions from Salt codebase [#65970](https://github.com/saltstack/salt/issues/65970) - * Remove deprecated module search path priority (`features.enable_deprecated_module_search_path_priority`) [#66025](https://github.com/saltstack/salt/issues/66025) - * Remove the __orchestration__ key from salt.runner and salt.wheel return data. [#66151](https://github.com/saltstack/salt/issues/66151) - * Removed linode-python package dependency for retired Linode API v3 [#68871](https://github.com/saltstack/salt/issues/68871) - * Removed legacy ``salt.transport.ipc`` module and unused ``PushChannel`` / ``PullChannel`` factories; local events use ``ipc_publish_client`` / ``ipc_publish_server`` (TCP transport). [#69001](https://github.com/saltstack/salt/issues/69001) - - # Deprecated - - * Deprecated the use of egrep in favor of grep -E [#65608](https://github.com/saltstack/salt/issues/65608) - - # Changed - - * Make sure every auth event has the 'act' key set [#56200](https://github.com/saltstack/salt/issues/56200) - * Ansiblegate discover_playbooks was changed to find playbooks as either *.yml or *.yaml files [#66048](https://github.com/saltstack/salt/issues/66048) - * re-work the aptpkg module to remove system libraries that onedir and virtualenvs do not have access. Streamline testing, and code use to needed libraries only. [#66056](https://github.com/saltstack/salt/issues/66056) - * Made gpg modules respect user's GNUPGHOME if set in shell environment [#66313](https://github.com/saltstack/salt/issues/66313) - * Made `gpg.present` attempt to refresh keys if they are expired [#66314](https://github.com/saltstack/salt/issues/66314) - * Made x509_v2 the default x509 modules. Until they are removed in the next major release, you can still revert to the old modules by setting `features: {x509_v2: false}` in the configuration [#66384](https://github.com/saltstack/salt/issues/66384) - * Included Salt extensions in Salt-SSH thin archive [#66559](https://github.com/saltstack/salt/issues/66559) - * Add support for additional options in several mac_brew_pkg methods [#66611](https://github.com/saltstack/salt/issues/66611) - * Make test_pip and test_fileserver tests compatible with venv execution [#66703](https://github.com/saltstack/salt/issues/66703) - * Do not use `ssl.PROTOCOL_TLS` which has been - [deprecated](https://docs.python.org/3/library/ssl.html#ssl.PROTOCOL_TLS) in - Python 3.10 will be removed in the future. [#66767](https://github.com/saltstack/salt/issues/66767) - * Remove warning when running `slsutil.renderer` on non-SLS files [#67067](https://github.com/saltstack/salt/issues/67067) - * PillarCache: reimplement using salt.cache - fix minion data cache organization/move pillar and grains to dedicated cache banks - salt.cache: allow cache.store() to set expires per key [#68030](https://github.com/saltstack/salt/issues/68030) - * Provide token storage using the salt.cache interface [#68039](https://github.com/saltstack/salt/issues/68039) - * Update packaged python from 3.10 to 3.11 [#68148](https://github.com/saltstack/salt/issues/68148) - * Added ceph to the specialFSes to match on name for set_fstab [#68207](https://github.com/saltstack/salt/issues/68207) - * Removed `networkx` module dependency by adding MultiDiGraph implementation to `salt.utils.requisite` to avoid extra dependencies. [#68748](https://github.com/saltstack/salt/issues/68748) - * Expanded Thorium documentation with concrete examples and added unit coverage for the documented Thorium workflows. [#68857](https://github.com/saltstack/salt/issues/68857) - * Add stub 3008.0 release notes (and template) so ``tools docs man`` and CI ``prepare-release`` can resolve the current-release doc target. Exclude ``doc/topics/proposals/*.md`` from Sphinx so stand-alone proposal files do not fail strict man builds. [#68964](https://github.com/saltstack/salt/issues/68964) - - # Fixed - - * Fixed recursive prereq requisites to report recursive requisite error. [#8210](https://github.com/saltstack/salt/issues/8210) - * Fixed erroneous recursive requisite error when a prereq is used in combination with onchanges_any. [#47154](https://github.com/saltstack/salt/issues/47154) - * Fixed an infinite loop in `requisite_any` when a requisite state was not found. [#50436](https://github.com/saltstack/salt/issues/50436) - * Fixed dependency resolution to not be quadratic. [#59123](https://github.com/saltstack/salt/issues/59123) - * Fix regex cache exception during sort in sweep function [#59437](https://github.com/saltstack/salt/issues/59437) - * Fixed requisites by parallel states on parallel states being evaluated synchronously (blocking state execution for other parallel states) [#59959](https://github.com/saltstack/salt/issues/59959) - * Fix bug when specifying template_source using net.load_template [#60515](https://github.com/saltstack/salt/issues/60515) - * firewalld: normalize new rich rules before comparing to old ones [#61235](https://github.com/saltstack/salt/issues/61235) - * Fix regression that prevented salt-minion from running interval-based jobs on startup by default. [#61964](https://github.com/saltstack/salt/issues/61964) - * Fixed performance when state_aggregate is enabled. [#62439](https://github.com/saltstack/salt/issues/62439) - * Fixed issue with salt-ssh hanging due to non-exposed host key acceptance prompt [#62782](https://github.com/saltstack/salt/issues/62782) - * Repaired zypper repositories being reconfigured without changes [#63402](https://github.com/saltstack/salt/issues/63402) - * Fix calculation of SLS context vars when trailing dots on targetted state [#63411](https://github.com/saltstack/salt/issues/63411) - * Put default `optimization_order` to LazyLoader to prevent possible fails on testing [#65266](https://github.com/saltstack/salt/issues/65266) - * Fixed aggregation to correctly honor requisites. [#65304](https://github.com/saltstack/salt/issues/65304) - * Fixed some instances of deprecated datetime.datetime.utcnow() [#65604](https://github.com/saltstack/salt/issues/65604) - * Introduce pruning option in file.keyvalue [#65631](https://github.com/saltstack/salt/issues/65631) - * fix 65703 by using OrderedDict instead of a index that breaks. . [#65703](https://github.com/saltstack/salt/issues/65703) - * Simplify timezone.compare_zone to primarily rely get_zone() [#65719](https://github.com/saltstack/salt/issues/65719) - * Handle regular expressions which do not not use grouping [#65722](https://github.com/saltstack/salt/issues/65722) - * fix consul.acl_create rule creation [#65788](https://github.com/saltstack/salt/issues/65788) - * Fix salt-cloud get_cloud_config_value for list objects [#65789](https://github.com/saltstack/salt/issues/65789) - * Prevent exceptions with fileserver.update when called via state [#65819](https://github.com/saltstack/salt/issues/65819) - * Fix granting of privileges on Postgres functions [#65839](https://github.com/saltstack/salt/issues/65839) - * Made Salt Cloud Hetzner module detect image architecture from instance type [#65888](https://github.com/saltstack/salt/issues/65888) - * Optimize async calls with using async wrapped method in thread only if io loop is already running [#65983](https://github.com/saltstack/salt/issues/65983) - * salt.auth.pam: fallback to use running Python in case /usr/bin/python3 is not found [#66035](https://github.com/saltstack/salt/issues/66035) - * Fix file.is_link hangs on paths that are hung mounts [#66096](https://github.com/saltstack/salt/issues/66096) - * Fix file.managed and file.serialize default tmp_dir to relative path [#66098](https://github.com/saltstack/salt/issues/66098) - * Make win_timezone recognize Qyzylorda timezone [#66176](https://github.com/saltstack/salt/issues/66176) - * Remove firing useless events with JID as a tag [#66279](https://github.com/saltstack/salt/issues/66279) - * Made gpg modules create GNUPGHOME if it does not exist [#66312](https://github.com/saltstack/salt/issues/66312) - * Fixed an issue where conflicting top level keys in the static grains file - (usually `/etc/salt/grains`) would break all grains states, and prevent static - grains from being loaded. [#66445](https://github.com/saltstack/salt/issues/66445) - * Fixed beacon delete not calling the beacon's close function, causing resource - leaks (e.g. inotify file descriptors) and CPU spin after deleting beacons at - runtime via ``beacons.delete``. Also fixed inotify file descriptor leak during - beacon refresh when the Beacon instance is replaced. [#66449](https://github.com/saltstack/salt/issues/66449) - * Make "status.diskusage" more robust and prevent crashes when stats cannot be obtained [#66646](https://github.com/saltstack/salt/issues/66646) - * Use `--cachedir` parameter for setting `extension_modules` with salt-call. [#66742](https://github.com/saltstack/salt/issues/66742) - * Don't schedule `__master_alive` jobs if `master_alive_interval` is not specified [#66757](https://github.com/saltstack/salt/issues/66757) - * Make x509 module compatible with `cryptography` module newer than `43.0.0` [#66818](https://github.com/saltstack/salt/issues/66818) - * Fixed Python 3.13 compatibility regarding urllib.parse module [#66898](https://github.com/saltstack/salt/issues/66898) - * make salt.channel.server.handle_message codepath more defensive [#66909](https://github.com/saltstack/salt/issues/66909) - * Fix the installation of pip modules with special characters in the module name [#66988](https://github.com/saltstack/salt/issues/66988) - * Repaired mount.fstab_present always returning pending changes [#67065](https://github.com/saltstack/salt/issues/67065) - * dictupdate.update: throw a TypeError when trying to merge a list with a mapping when ``merge_lists=True``. [#67092](https://github.com/saltstack/salt/issues/67092) - * Remove usage of spwd [#67119](https://github.com/saltstack/salt/issues/67119) - * Fixed order chunks not handling a state with both require and order first or last [#67120](https://github.com/saltstack/salt/issues/67120) - * Fixed pkg.install in test mode would not detect FreeBSD packages installed by their origin name [#67126](https://github.com/saltstack/salt/issues/67126) - * Fix virtual grains for VMs running on Nutanix AHV [#67180](https://github.com/saltstack/salt/issues/67180) - * Fixed creating relative directory symlinks on Windows, ensured listing targets of symlinks in file_roots always produces POSIX-style paths [#67766](https://github.com/saltstack/salt/issues/67766) - * Avoid loading `salt.utils.crypt` module instead of `crypt` if it's missing in Python as it was deprecated and removed in Python 3.13. [#67797](https://github.com/saltstack/salt/issues/67797) - * Fixed docstring error in salt/modules/file.py that misnamed an option "user" when it should have been "owner". [#67911](https://github.com/saltstack/salt/issues/67911) - * salt.key: check_minion_cache performance optimization [#68030](https://github.com/saltstack/salt/issues/68030) - * when a file is managed, and the same file is cleaned, an incorrect message is displayed saying "removed: Removed due to clean" when the file isn't actually removed. Now the correct message is returned. [#68052](https://github.com/saltstack/salt/issues/68052) - * log_beacon - remove verbose minion log output [#68055](https://github.com/saltstack/salt/issues/68055) - * Fix that the state `saltmod.state` can be used on a masterless minion with salt-ssh like `saltmod.function` currently does. [#68116](https://github.com/saltstack/salt/issues/68116) - * Fixed ssh_known_hosts.present failure when ssh host keys changed [#68132](https://github.com/saltstack/salt/issues/68132) - * grains.disks: fix exception with incompatible output of Get-PhysicalDisk [#68184](https://github.com/saltstack/salt/issues/68184) - * Made osfinger report major&minor version for NixOS [#68230](https://github.com/saltstack/salt/issues/68230) - * Fix tests failing on AlmaLinux 10 and other clones [#68246](https://github.com/saltstack/salt/issues/68246) - * Speedup wheel key.finger call by removing redundant processing calls. [#68251](https://github.com/saltstack/salt/issues/68251) - * Fixed cp.cache_file when using Tornado > 6.4 [#68328](https://github.com/saltstack/salt/issues/68328) - * Fixed multiline powershell -Command { } blocks failing with "Missing closing - '}'" when used in a cmd.run state on Windows. Salt now collapses embedded - newlines and re-encodes the script block as -EncodedCommand, ensuring correct - execution and suppressing CLIXML noise from stderr. [#68397](https://github.com/saltstack/salt/issues/68397) - * Stop mutating locals, which is unsupported in Py >=3.13 [#68445](https://github.com/saltstack/salt/issues/68445) - * Add `blockdev` state module back in to core - - Adds the `blockdev` state module back into the core Salt repo as it is critical functionality that shouldn't have been pulled out in the module migration [#68465](https://github.com/saltstack/salt/issues/68465) - * Adds `mdadm` and `lvm` grains modules back in to core. - - Restores the modules that had been removed as part of the community module - migration. They are core bits of functionality and the associated execution and - states modules had not been removed. [#68470](https://github.com/saltstack/salt/issues/68470) - * Fixed grains.list_present state to correctly handle multiple calls within the same state run. - Fixed `salt.utils.platform` to properly handle `__salt_system_encoding__` when synced as an extension module. - Improved `network.traceroute` parsing to be more robust across different traceroute versions. - Added retry logic to `saltutil.wheel` integration test to improve reliability in CI. - Improved architecture detection in `salt-ssh` to better support ARM64 platforms. - Fixed `salt-ssh` extension module syncing to avoid accidentally bundling core Salt modules and to correctly load wrapper modules. - Ensured `salt-ssh` relenv tests skip gracefully if the relenv tarball is unavailable in the test environment. - Fixed `mine.get` runner to correctly handle master's ID when ACLs are enabled. - Fixed `win_useradd.get_user_sid` to correctly handle non-string input. - Improved reliability of `state.running` integration test for `salt-ssh`. - Fixed high CPU usage in minion asynchronous authentication loop when masters are unreachable. - Added support for running Salt tools using `python -m tools`. [#68520](https://github.com/saltstack/salt/issues/68520) - * Adds `alias` state module back in to core. - - Restores the module that had been removed as part of the - community module migration. The associated execution module - had not been migrated. [#68574](https://github.com/saltstack/salt/issues/68574) - * Fixed mongodb tops module authentication to be compatible with pymongo v4+ by passing credentials directly to MongoClient instead of using the deprecated authenticate() method [#68659](https://github.com/saltstack/salt/issues/68659) - * Improved the rejected authentication warning message to include the minion ID, - making it easier for administrators to identify which minions need upgrading. [#68671](https://github.com/saltstack/salt/issues/68671) - * This PR fixes a bug where corrupted grains cache files cause unhandled - `SaltDeserializationError` exceptions, resulting in CRITICAL errors. - The fix adds proper exception handling to gracefully recover from corrupted - cache by regenerating grains. [#68678](https://github.com/saltstack/salt/issues/68678) - * Fix ansible.playbooks extra_vars quoting to prevent passing broken variables to ansible-playbook. [#68787](https://github.com/saltstack/salt/issues/68787) - * Make `x86_64_v2` to be handled properly with `salt.modules.yumpkg` module as a possible package architecture. [#68789](https://github.com/saltstack/salt/issues/68789) - * Make `salt-ssh` work without issues using `domain\user` notation for remote user with SSH. [#68790](https://github.com/saltstack/salt/issues/68790) - * Fixed source package builds (DEB/RPM) failing with ``LookupError: hatchling is already being built`` by adding ``hatchling`` to the ``--only-binary`` allow-list so pip uses its universal wheel instead of attempting a circular source build. [#68858](https://github.com/saltstack/salt/issues/68858) - * Use a 30 second ``salt`` CLI timeout in the reauth scenario tests so Windows CI does not time out on ``test.ping`` after master/minion restart (default was often 5s). [#68924](https://github.com/saltstack/salt/issues/68924) - * Fix logging in potentially dead process in reap_stray_processes fixture [#68927](https://github.com/saltstack/salt/issues/68927) - * Fixed a regression in win_pkg where msiexec install flags containing - Windows-style quoting (e.g. ``MYPROPERTY="C:\some file.txt"``) were - mangled into ``"MYPROPERTY=C:\some file.txt"`` causing msiexec to hang. - Restored the pre-regression behaviour where ``shlex_split`` is not applied - to command strings on Windows, preserving Windows-style argument quoting - when the command is passed directly to ``CreateProcess``. [#68950](https://github.com/saltstack/salt/issues/68950) - * Fix dynamic version discovery on a new release branch before the first ``v*`` tag exists: ``git describe`` still anchored on the previous line (e.g. ``v3007.13``) is lifted to the unreleased codename baseline (e.g. ``3008.0``) while keeping the commit offset and SHA. [#68964](https://github.com/saltstack/salt/issues/68964) - * Remove deprecations. - - salt/auth/pki.py (removed) - - salt/features.py (removed) - - salt/modules/nxos.py (modified) [#68985](https://github.com/saltstack/salt/issues/68985) - * Fixed on the ``3008.x`` release line: Salt NetAPI rest_tornado header parsing without ``cgi.parse_header`` (removed in Python 3.13). Integration ``salt_minion`` / ``salt_sub_minion`` fixtures now call ``saltutil.sync_all`` with ``saltenv=base`` to avoid long master round-trips from top-file environment discovery during Windows CI. Salt factories use a 120 second daemon start timeout when ``ONEDIR_TESTRUN`` is set so Windows onedir runs match CI and avoid flaky minion start event waits. [#69014](https://github.com/saltstack/salt/issues/69014) - - # Added - - * Added proxy option to `gitfs`, `git_pillar` and `winrepo` for specifying a proxy server used to connect to git repositories [#30990](https://github.com/saltstack/salt/issues/30990) - * Added support for limiting the number of parallel states executing at the same time via `state_max_parallel` [#49301](https://github.com/saltstack/salt/issues/49301) - * Added metalink to mod_repo in yumpkg and documented in pkgrepo state [#58931](https://github.com/saltstack/salt/issues/58931) - * Added ssl and verify_ssl arguments to mongodb module and states. [#59927](https://github.com/saltstack/salt/issues/59927) - * Added two new options, ``win_delay_start`` and ``win_install_dir``, to pass to - the Windows installer in salt-cloud [#61318](https://github.com/saltstack/salt/issues/61318) - * Add context aware change handling for file state module [#63328](https://github.com/saltstack/salt/issues/63328) - * Added the ability to access already compiled pillar data during the pillar rendering process via the `__pillar__` global in templates and matchers. [#64043](https://github.com/saltstack/salt/issues/64043) - * Allow salt-call arguments --file-root, --pillar-root and --states-dir to be specified multiple times [#64486](https://github.com/saltstack/salt/issues/64486) - * Adds documentation notes to clarify that Salt's file module only supports numeric mode specifications and does not support symbolic modes. [#64624](https://github.com/saltstack/salt/issues/64624) - * Added management of SSH keys and certificates [#65197](https://github.com/saltstack/salt/issues/65197) - * Add option (auth_events_autosign_grains) to add autosign_grains to auth events [#65426](https://github.com/saltstack/salt/issues/65426) - * Enable "KeepAlive" probes for Salt SSH executions [#65488](https://github.com/saltstack/salt/issues/65488) - * Add ability to show diff for new files in file.managed [#65546](https://github.com/saltstack/salt/issues/65546) - * Added Virtuozzo Linux to Redhat os_family [#65600](https://github.com/saltstack/salt/issues/65600) - * Pillar dunder is now available in extension modules during pillar render. [#65724](https://github.com/saltstack/salt/issues/65724) - * Added x509_v2 SSH wrapper module. In addition to the regular calls, it provides a function for statefully managing remote certificates, even when access to the event bus is required [#65728](https://github.com/saltstack/salt/issues/65728) - * Introduce fibre_channel_host grain [#65750](https://github.com/saltstack/salt/issues/65750) - * Make `salt-run jobs.master` return runner jobs that are currently running on a master. [#66007](https://github.com/saltstack/salt/issues/66007) - * Added file and plaintext sources to `gpg.present`, allowed to skip keyserver queries [#66173](https://github.com/saltstack/salt/issues/66173) - * added pkg.which to aptpkg, for finding which package installed a file. [#66201](https://github.com/saltstack/salt/issues/66201) - * Allow pre-connection scripts to be run on host before any ssh commands [#66210](https://github.com/saltstack/salt/issues/66210) - * Added port, tls, username and password to the `smtp` configuration of the highstate returner. [#66251](https://github.com/saltstack/salt/issues/66251) - * Improve macOS defaults support [#66466](https://github.com/saltstack/salt/issues/66466) - * Added support for specifying different signature verification backends in `file.managed`/`archive.extracted` [#66527](https://github.com/saltstack/salt/issues/66527) - * Added an `asymmetric` execution module for signing/verifying data using raw asymmetric algorithms [#66528](https://github.com/saltstack/salt/issues/66528) - * Added support in service Beacon for only fire matching configured running state [#66809](https://github.com/saltstack/salt/issues/66809) - * Add --relenv Option to salt-ssh for Using a Onedir Bundled Salt+Python [#66877](https://github.com/saltstack/salt/issues/66877) - * Add support for state.sls_exists when using salt-ssh [#66894](https://github.com/saltstack/salt/issues/66894) - * Add detection for OS grains when running in [AlmaLinux Kitten](https://wiki.almalinux.org/release-notes/kitten-10.html) [#66991](https://github.com/saltstack/salt/issues/66991) - * Added a `merge` option to `file.recurse`, which merges subpaths from all existing `source`s before managing the directory. Handy when using different saltenvs or the TOFS pattern. [#67072](https://github.com/saltstack/salt/issues/67072) - * Add `_auth` calls to the master stats [#67746](https://github.com/saltstack/salt/issues/67746) - * Added possibility to load data from multiple inventories with `ansible.targets`. [#67776](https://github.com/saltstack/salt/issues/67776) - * Detect openEuler as RedHat family OS. [#67796](https://github.com/saltstack/salt/issues/67796) - * refactored server-side PKI to support cache interface - optimization: check_compound_minions: defer _pki_minions fetch - refactor: push salt.utils.minions bits into salt.key / optimize matching [#67799](https://github.com/saltstack/salt/issues/67799) - * Add deb822 apt source format support to aptpkg module [#67956](https://github.com/saltstack/salt/issues/67956) - * Add subsystem filter to "udev.exportdb" execution module function [#68047](https://github.com/saltstack/salt/issues/68047) - * Implement SL Micro 6.2 detection to fill the grains with proper values. [#68247](https://github.com/saltstack/salt/issues/68247) - * Added booleans argument to selinux.booleans - Added mod_aggregate to selinux to combine boolean - Added some type hints to selinux module and made some minor changes to improve readability and performance slightly [#68323](https://github.com/saltstack/salt/issues/68323) - * Add support for minion_id in log formats - - Adds support for including `%(minion_id)s` in log formats. Where id is available log messages on the master will have that data added to allow easier correlation of messages to minions. [#68410](https://github.com/saltstack/salt/issues/68410) - * Added feature parity for relenv and thin dir with salt-ssh. All salt-ssh tests pass with both thin dir and relenv. [#68531](https://github.com/saltstack/salt/issues/68531) - * Added tunable worker pools: partition the master's MWorkers into named pools - and route specific commands (for example `_auth`) to dedicated pools so a - slow workload cannot starve time-critical traffic. Controlled by the new - `worker_pools` and `worker_pools_enabled` master settings; see the "Tunable - Worker Pools" topic guide for details. Existing `worker_threads` - configurations remain fully backward compatible. [#68532](https://github.com/saltstack/salt/issues/68532) - * Added TLS encryption optimization via disable_aes_with_tls config option that eliminates redundant AES encryption when TLS with mutual authentication is active, improving performance while maintaining security through certificate identity verification. [#68536](https://github.com/saltstack/salt/issues/68536) - * utils.dictdiffer: support diffing of dicts in lists [#68726](https://github.com/saltstack/salt/issues/68726) - * Add support for nix package manager. [#68752](https://github.com/saltstack/salt/issues/68752) - * Added a centralized, declarative system for managing Salt's optional dependencies and their version-specific requirements in ``salt/utils/versions.py``. [#68894](https://github.com/saltstack/salt/issues/68894) - * Added a fast memory-mapped cache backend (``salt.cache.mmap_cache``): - an O(1) hash-table store with a segmented heap, durable and multi-process - safe, usable as a drop-in for ``localfs`` via the ``cache`` master setting. - The minion public-key index (``salt.cache.mmap_key`` / - ``salt.utils.pki.PkiIndex``) is built on it; it replaces linear ``pki_dir`` - scans for large fleets and is opt-in via ``pki_index_enabled``. Migrate - existing keys with ``salt-run pki.migrate_to_mmap``. [#68936](https://github.com/saltstack/salt/issues/68936) - * Batch mode now uses a single JID for the entire batch run instead of generating - a separate JID per batch iteration. This enables unified job tracking via - ``salt-run jobs.lookup_jid`` and consistent ``--show-jid`` output across all - batch slices. The job cache merges minion lists from each iteration so that - ``get_load`` returns the complete set of targeted minions. [#68941](https://github.com/saltstack/salt/issues/68941) - * Added a per-job ``start_event`` opt-in (CLI flag ``--start-event``) that asks - targeted minions to fire a ``salt/job//start/`` event the - moment they accept the published job, before the function runs. The payload - mirrors the master's ``salt/job//new`` event minus the function - arguments, letting orchestrators confirm reachability without waiting for - the full return. [#69019](https://github.com/saltstack/salt/issues/69019) - - - -- Salt Project Packaging Wed, 06 May 2026 17:42:55 +0000 - -salt (3008.0~rc1) stable; urgency=medium - - - # Removed - - * Remove commuity extensions from Salt codebase [#65970](https://github.com/saltstack/salt/issues/65970) - * Remove deprecated module search path priority (`features.enable_deprecated_module_search_path_priority`) [#66025](https://github.com/saltstack/salt/issues/66025) - * Remove the __orchestration__ key from salt.runner and salt.wheel return data. [#66151](https://github.com/saltstack/salt/issues/66151) - * Removed linode-python package dependency for retired Linode API v3 [#68871](https://github.com/saltstack/salt/issues/68871) - - # Deprecated - - * Deprecated the use of egrep in favor of grep -E [#65608](https://github.com/saltstack/salt/issues/65608) - - # Changed - - * Make sure every auth event has the 'act' key set [#56200](https://github.com/saltstack/salt/issues/56200) - * Ansiblegate discover_playbooks was changed to find playbooks as either *.yml or *.yaml files [#66048](https://github.com/saltstack/salt/issues/66048) - * re-work the aptpkg module to remove system libraries that onedir and virtualenvs do not have access. Streamline testing, and code use to needed libraries only. [#66056](https://github.com/saltstack/salt/issues/66056) - * Made gpg modules respect user's GNUPGHOME if set in shell environment [#66313](https://github.com/saltstack/salt/issues/66313) - * Made `gpg.present` attempt to refresh keys if they are expired [#66314](https://github.com/saltstack/salt/issues/66314) - * Made x509_v2 the default x509 modules. Until they are removed in the next major release, you can still revert to the old modules by setting `features: {x509_v2: false}` in the configuration [#66384](https://github.com/saltstack/salt/issues/66384) - * Included Salt extensions in Salt-SSH thin archive [#66559](https://github.com/saltstack/salt/issues/66559) - * Add support for additional options in several mac_brew_pkg methods [#66611](https://github.com/saltstack/salt/issues/66611) - * Make test_pip and test_fileserver tests compatible with venv execution [#66703](https://github.com/saltstack/salt/issues/66703) - * Do not use `ssl.PROTOCOL_TLS` which has been - [deprecated](https://docs.python.org/3/library/ssl.html#ssl.PROTOCOL_TLS) in - Python 3.10 will be removed in the future. [#66767](https://github.com/saltstack/salt/issues/66767) - * Remove warning when running `slsutil.renderer` on non-SLS files [#67067](https://github.com/saltstack/salt/issues/67067) - * PillarCache: reimplement using salt.cache - fix minion data cache organization/move pillar and grains to dedicated cache banks - salt.cache: allow cache.store() to set expires per key [#68030](https://github.com/saltstack/salt/issues/68030) - * Provide token storage using the salt.cache interface [#68039](https://github.com/saltstack/salt/issues/68039) - * Update packaged python from 3.10 to 3.11 [#68148](https://github.com/saltstack/salt/issues/68148) - * Added ceph to the specialFSes to match on name for set_fstab [#68207](https://github.com/saltstack/salt/issues/68207) - * Removed `networkx` module dependency by adding MultiDiGraph implementation to `salt.utils.requisite` to avoid extra dependencies. [#68748](https://github.com/saltstack/salt/issues/68748) - * Expanded Thorium documentation with concrete examples and added unit coverage for the documented Thorium workflows. [#68857](https://github.com/saltstack/salt/issues/68857) - * Add stub 3008.0 release notes (and template) so ``tools docs man`` and CI ``prepare-release`` can resolve the current-release doc target. Exclude ``doc/topics/proposals/*.md`` from Sphinx so stand-alone proposal files do not fail strict man builds. [#68964](https://github.com/saltstack/salt/issues/68964) - - # Fixed - - * Fixed recursive prereq requisites to report recursive requisite error. [#8210](https://github.com/saltstack/salt/issues/8210) - * Fixed erroneous recursive requisite error when a prereq is used in combination with onchanges_any. [#47154](https://github.com/saltstack/salt/issues/47154) - * Fixed an infinite loop in `requisite_any` when a requisite state was not found. [#50436](https://github.com/saltstack/salt/issues/50436) - * Fixed dependency resolution to not be quadratic. [#59123](https://github.com/saltstack/salt/issues/59123) - * Fix regex cache exception during sort in sweep function [#59437](https://github.com/saltstack/salt/issues/59437) - * Fixed requisites by parallel states on parallel states being evaluated synchronously (blocking state execution for other parallel states) [#59959](https://github.com/saltstack/salt/issues/59959) - * Fix bug when specifying template_source using net.load_template [#60515](https://github.com/saltstack/salt/issues/60515) - * firewalld: normalize new rich rules before comparing to old ones [#61235](https://github.com/saltstack/salt/issues/61235) - * Fix regression that prevented salt-minion from running interval-based jobs on startup by default. [#61964](https://github.com/saltstack/salt/issues/61964) - * Fixed performance when state_aggregate is enabled. [#62439](https://github.com/saltstack/salt/issues/62439) - * Fixed issue with salt-ssh hanging due to non-exposed host key acceptance prompt [#62782](https://github.com/saltstack/salt/issues/62782) - * Repaired zypper repositories being reconfigured without changes [#63402](https://github.com/saltstack/salt/issues/63402) - * Fix calculation of SLS context vars when trailing dots on targetted state [#63411](https://github.com/saltstack/salt/issues/63411) - * Put default `optimization_order` to LazyLoader to prevent possible fails on testing [#65266](https://github.com/saltstack/salt/issues/65266) - * Fixed aggregation to correctly honor requisites. [#65304](https://github.com/saltstack/salt/issues/65304) - * Fixed some instances of deprecated datetime.datetime.utcnow() [#65604](https://github.com/saltstack/salt/issues/65604) - * Introduce pruning option in file.keyvalue [#65631](https://github.com/saltstack/salt/issues/65631) - * fix 65703 by using OrderedDict instead of a index that breaks. . [#65703](https://github.com/saltstack/salt/issues/65703) - * Simplify timezone.compare_zone to primarily rely get_zone() [#65719](https://github.com/saltstack/salt/issues/65719) - * Handle regular expressions which do not not use grouping [#65722](https://github.com/saltstack/salt/issues/65722) - * fix consul.acl_create rule creation [#65788](https://github.com/saltstack/salt/issues/65788) - * Fix salt-cloud get_cloud_config_value for list objects [#65789](https://github.com/saltstack/salt/issues/65789) - * Prevent exceptions with fileserver.update when called via state [#65819](https://github.com/saltstack/salt/issues/65819) - * Fix granting of privileges on Postgres functions [#65839](https://github.com/saltstack/salt/issues/65839) - * Made Salt Cloud Hetzner module detect image architecture from instance type [#65888](https://github.com/saltstack/salt/issues/65888) - * Optimize async calls with using async wrapped method in thread only if io loop is already running [#65983](https://github.com/saltstack/salt/issues/65983) - * salt.auth.pam: fallback to use running Python in case /usr/bin/python3 is not found [#66035](https://github.com/saltstack/salt/issues/66035) - * Fix file.is_link hangs on paths that are hung mounts [#66096](https://github.com/saltstack/salt/issues/66096) - * Fix file.managed and file.serialize default tmp_dir to relative path [#66098](https://github.com/saltstack/salt/issues/66098) - * Make win_timezone recognize Qyzylorda timezone [#66176](https://github.com/saltstack/salt/issues/66176) - * Remove firing useless events with JID as a tag [#66279](https://github.com/saltstack/salt/issues/66279) - * Made gpg modules create GNUPGHOME if it does not exist [#66312](https://github.com/saltstack/salt/issues/66312) - * Fixed an issue where conflicting top level keys in the static grains file - (usually `/etc/salt/grains`) would break all grains states, and prevent static - grains from being loaded. [#66445](https://github.com/saltstack/salt/issues/66445) - * Fixed beacon delete not calling the beacon's close function, causing resource - leaks (e.g. inotify file descriptors) and CPU spin after deleting beacons at - runtime via ``beacons.delete``. Also fixed inotify file descriptor leak during - beacon refresh when the Beacon instance is replaced. [#66449](https://github.com/saltstack/salt/issues/66449) - * Make "status.diskusage" more robust and prevent crashes when stats cannot be obtained [#66646](https://github.com/saltstack/salt/issues/66646) - * Use `--cachedir` parameter for setting `extension_modules` with salt-call. [#66742](https://github.com/saltstack/salt/issues/66742) - * Don't schedule `__master_alive` jobs if `master_alive_interval` is not specified [#66757](https://github.com/saltstack/salt/issues/66757) - * Make x509 module compatible with `cryptography` module newer than `43.0.0` [#66818](https://github.com/saltstack/salt/issues/66818) - * Fixed Python 3.13 compatibility regarding urllib.parse module [#66898](https://github.com/saltstack/salt/issues/66898) - * make salt.channel.server.handle_message codepath more defensive [#66909](https://github.com/saltstack/salt/issues/66909) - * Fix the installation of pip modules with special characters in the module name [#66988](https://github.com/saltstack/salt/issues/66988) - * Repaired mount.fstab_present always returning pending changes [#67065](https://github.com/saltstack/salt/issues/67065) - * dictupdate.update: throw a TypeError when trying to merge a list with a mapping when ``merge_lists=True``. [#67092](https://github.com/saltstack/salt/issues/67092) - * Remove usage of spwd [#67119](https://github.com/saltstack/salt/issues/67119) - * Fixed order chunks not handling a state with both require and order first or last [#67120](https://github.com/saltstack/salt/issues/67120) - * Fixed pkg.install in test mode would not detect FreeBSD packages installed by their origin name [#67126](https://github.com/saltstack/salt/issues/67126) - * Fix virtual grains for VMs running on Nutanix AHV [#67180](https://github.com/saltstack/salt/issues/67180) - * Fixed creating relative directory symlinks on Windows, ensured listing targets of symlinks in file_roots always produces POSIX-style paths [#67766](https://github.com/saltstack/salt/issues/67766) - * Avoid loading `salt.utils.crypt` module instead of `crypt` if it's missing in Python as it was deprecated and removed in Python 3.13. [#67797](https://github.com/saltstack/salt/issues/67797) - * Fixed docstring error in salt/modules/file.py that misnamed an option "user" when it should have been "owner". [#67911](https://github.com/saltstack/salt/issues/67911) - * salt.key: check_minion_cache performance optimization [#68030](https://github.com/saltstack/salt/issues/68030) - * when a file is managed, and the same file is cleaned, an incorrect message is displayed saying "removed: Removed due to clean" when the file isn't actually removed. Now the correct message is returned. [#68052](https://github.com/saltstack/salt/issues/68052) - * log_beacon - remove verbose minion log output [#68055](https://github.com/saltstack/salt/issues/68055) - * Fix that the state `saltmod.state` can be used on a masterless minion with salt-ssh like `saltmod.function` currently does. [#68116](https://github.com/saltstack/salt/issues/68116) - * Fixed ssh_known_hosts.present failure when ssh host keys changed [#68132](https://github.com/saltstack/salt/issues/68132) - * grains.disks: fix exception with incompatible output of Get-PhysicalDisk [#68184](https://github.com/saltstack/salt/issues/68184) - * Made osfinger report major&minor version for NixOS [#68230](https://github.com/saltstack/salt/issues/68230) - * Fix tests failing on AlmaLinux 10 and other clones [#68246](https://github.com/saltstack/salt/issues/68246) - * Speedup wheel key.finger call by removing redundant processing calls. [#68251](https://github.com/saltstack/salt/issues/68251) - * Fixed cp.cache_file when using Tornado > 6.4 [#68328](https://github.com/saltstack/salt/issues/68328) - * Stop mutating locals, which is unsupported in Py >=3.13 [#68445](https://github.com/saltstack/salt/issues/68445) - * Add `blockdev` state module back in to core - - Adds the `blockdev` state module back into the core Salt repo as it is critical functionality that shouldn't have been pulled out in the module migration [#68465](https://github.com/saltstack/salt/issues/68465) - * Adds `mdadm` and `lvm` grains modules back in to core. - - Restores the modules that had been removed as part of the community module - migration. They are core bits of functionality and the associated execution and - states modules had not been removed. [#68470](https://github.com/saltstack/salt/issues/68470) - * Fixed grains.list_present state to correctly handle multiple calls within the same state run. - Fixed `salt.utils.platform` to properly handle `__salt_system_encoding__` when synced as an extension module. - Improved `network.traceroute` parsing to be more robust across different traceroute versions. - Added retry logic to `saltutil.wheel` integration test to improve reliability in CI. - Improved architecture detection in `salt-ssh` to better support ARM64 platforms. - Fixed `salt-ssh` extension module syncing to avoid accidentally bundling core Salt modules and to correctly load wrapper modules. - Ensured `salt-ssh` relenv tests skip gracefully if the relenv tarball is unavailable in the test environment. - Fixed `mine.get` runner to correctly handle master's ID when ACLs are enabled. - Fixed `win_useradd.get_user_sid` to correctly handle non-string input. - Improved reliability of `state.running` integration test for `salt-ssh`. - Fixed high CPU usage in minion asynchronous authentication loop when masters are unreachable. - Added support for running Salt tools using `python -m tools`. [#68520](https://github.com/saltstack/salt/issues/68520) - * Adds `alias` state module back in to core. - - Restores the module that had been removed as part of the - community module migration. The associated execution module - had not been migrated. [#68574](https://github.com/saltstack/salt/issues/68574) - * Fixed mongodb tops module authentication to be compatible with pymongo v4+ by passing credentials directly to MongoClient instead of using the deprecated authenticate() method [#68659](https://github.com/saltstack/salt/issues/68659) - * Improved the rejected authentication warning message to include the minion ID, - making it easier for administrators to identify which minions need upgrading. [#68671](https://github.com/saltstack/salt/issues/68671) - * This PR fixes a bug where corrupted grains cache files cause unhandled - `SaltDeserializationError` exceptions, resulting in CRITICAL errors. - The fix adds proper exception handling to gracefully recover from corrupted - cache by regenerating grains. [#68678](https://github.com/saltstack/salt/issues/68678) - * Fix `mac_brew_pkg.list_pkgs` crashing or producing incorrect results when - Homebrew returns `null` values for cask metadata: - - - When the installed version of a cask is `null` (e.g. Homebrew cannot - determine the installed version), it is now reported as `"unknown"` - instead of raising an error. - - When `full_token` is `null`, it is now filtered out so that `None` - is never used as a package name key in the returned dictionary. [#68763](https://github.com/saltstack/salt/issues/68763) - * Fix ansible.playbooks extra_vars quoting to prevent passing broken variables to ansible-playbook. [#68787](https://github.com/saltstack/salt/issues/68787) - * Make `x86_64_v2` to be handled properly with `salt.modules.yumpkg` module as a possible package architecture. [#68789](https://github.com/saltstack/salt/issues/68789) - * Make `salt-ssh` work without issues using `domain\user` notation for remote user with SSH. [#68790](https://github.com/saltstack/salt/issues/68790) - * Fixed source package builds (DEB/RPM) failing with ``LookupError: hatchling is already being built`` by adding ``hatchling`` to the ``--only-binary`` allow-list so pip uses its universal wheel instead of attempting a circular source build. [#68858](https://github.com/saltstack/salt/issues/68858) - * Use a 30 second ``salt`` CLI timeout in the reauth scenario tests so Windows CI does not time out on ``test.ping`` after master/minion restart (default was often 5s). [#68924](https://github.com/saltstack/salt/issues/68924) - * Fix logging in potentially dead process in reap_stray_processes fixture [#68927](https://github.com/saltstack/salt/issues/68927) - * Fix dynamic version discovery on a new release branch before the first ``v*`` tag exists: ``git describe`` still anchored on the previous line (e.g. ``v3007.13``) is lifted to the unreleased codename baseline (e.g. ``3008.0``) while keeping the commit offset and SHA. [#68964](https://github.com/saltstack/salt/issues/68964) - * Remove deprecations. - - salt/auth/pki.py (removed) - - salt/features.py (removed) - - salt/modules/nxos.py (modified) [#68985](https://github.com/saltstack/salt/issues/68985) - - # Added - - * Added proxy option to `gitfs`, `git_pillar` and `winrepo` for specifying a proxy server used to connect to git repositories [#30990](https://github.com/saltstack/salt/issues/30990) - * Added support for limiting the number of parallel states executing at the same time via `state_max_parallel` [#49301](https://github.com/saltstack/salt/issues/49301) - * Added metalink to mod_repo in yumpkg and documented in pkgrepo state [#58931](https://github.com/saltstack/salt/issues/58931) - * Added ssl and verify_ssl arguments to mongodb module and states. [#59927](https://github.com/saltstack/salt/issues/59927) - * Added two new options, ``win_delay_start`` and ``win_install_dir``, to pass to - the Windows installer in salt-cloud [#61318](https://github.com/saltstack/salt/issues/61318) - * Add context aware change handling for file state module [#63328](https://github.com/saltstack/salt/issues/63328) - * Added the ability to access already compiled pillar data during the pillar rendering process via the `__pillar__` global in templates and matchers. [#64043](https://github.com/saltstack/salt/issues/64043) - * Allow salt-call arguments --file-root, --pillar-root and --states-dir to be specified multiple times [#64486](https://github.com/saltstack/salt/issues/64486) - * Adds documentation notes to clarify that Salt's file module only supports numeric mode specifications and does not support symbolic modes. [#64624](https://github.com/saltstack/salt/issues/64624) - * Added management of SSH keys and certificates [#65197](https://github.com/saltstack/salt/issues/65197) - * Add option (auth_events_autosign_grains) to add autosign_grains to auth events [#65426](https://github.com/saltstack/salt/issues/65426) - * Enable "KeepAlive" probes for Salt SSH executions [#65488](https://github.com/saltstack/salt/issues/65488) - * Add ability to show diff for new files in file.managed [#65546](https://github.com/saltstack/salt/issues/65546) - * Added Virtuozzo Linux to Redhat os_family [#65600](https://github.com/saltstack/salt/issues/65600) - * Pillar dunder is now available in extension modules during pillar render. [#65724](https://github.com/saltstack/salt/issues/65724) - * Added x509_v2 SSH wrapper module. In addition to the regular calls, it provides a function for statefully managing remote certificates, even when access to the event bus is required [#65728](https://github.com/saltstack/salt/issues/65728) - * Introduce fibre_channel_host grain [#65750](https://github.com/saltstack/salt/issues/65750) - * Make `salt-run jobs.master` return runner jobs that are currently running on a master. [#66007](https://github.com/saltstack/salt/issues/66007) - * Added file and plaintext sources to `gpg.present`, allowed to skip keyserver queries [#66173](https://github.com/saltstack/salt/issues/66173) - * added pkg.which to aptpkg, for finding which package installed a file. [#66201](https://github.com/saltstack/salt/issues/66201) - * Allow pre-connection scripts to be run on host before any ssh commands [#66210](https://github.com/saltstack/salt/issues/66210) - * Added port, tls, username and password to the `smtp` configuration of the highstate returner. [#66251](https://github.com/saltstack/salt/issues/66251) - * Improve macOS defaults support [#66466](https://github.com/saltstack/salt/issues/66466) - * Added support for specifying different signature verification backends in `file.managed`/`archive.extracted` [#66527](https://github.com/saltstack/salt/issues/66527) - * Added an `asymmetric` execution module for signing/verifying data using raw asymmetric algorithms [#66528](https://github.com/saltstack/salt/issues/66528) - * Added support in service Beacon for only fire matching configured running state [#66809](https://github.com/saltstack/salt/issues/66809) - * Add --relenv Option to salt-ssh for Using a Onedir Bundled Salt+Python [#66877](https://github.com/saltstack/salt/issues/66877) - * Add support for state.sls_exists when using salt-ssh [#66894](https://github.com/saltstack/salt/issues/66894) - * Add detection for OS grains when running in [AlmaLinux Kitten](https://wiki.almalinux.org/release-notes/kitten-10.html) [#66991](https://github.com/saltstack/salt/issues/66991) - * Added a `merge` option to `file.recurse`, which merges subpaths from all existing `source`s before managing the directory. Handy when using different saltenvs or the TOFS pattern. [#67072](https://github.com/saltstack/salt/issues/67072) - * Add `_auth` calls to the master stats [#67746](https://github.com/saltstack/salt/issues/67746) - * Added possibility to load data from multiple inventories with `ansible.targets`. [#67776](https://github.com/saltstack/salt/issues/67776) - * Detect openEuler as RedHat family OS. [#67796](https://github.com/saltstack/salt/issues/67796) - * refactored server-side PKI to support cache interface - optimization: check_compound_minions: defer _pki_minions fetch - refactor: push salt.utils.minions bits into salt.key / optimize matching [#67799](https://github.com/saltstack/salt/issues/67799) - * Add deb822 apt source format support to aptpkg module [#67956](https://github.com/saltstack/salt/issues/67956) - * Add subsystem filter to "udev.exportdb" execution module function [#68047](https://github.com/saltstack/salt/issues/68047) - * Implement SL Micro 6.2 detection to fill the grains with proper values. [#68247](https://github.com/saltstack/salt/issues/68247) - * Added booleans argument to selinux.booleans - Added mod_aggregate to selinux to combine boolean - Added some type hints to selinux module and made some minor changes to improve readability and performance slightly [#68323](https://github.com/saltstack/salt/issues/68323) - * Add support for minion_id in log formats - - Adds support for including `%(minion_id)s` in log formats. Where id is available log messages on the master will have that data added to allow easier correlation of messages to minions. [#68410](https://github.com/saltstack/salt/issues/68410) - * Added feature parity for relenv and thin dir with salt-ssh. All salt-ssh tests pass with both thin dir and relenv. [#68531](https://github.com/saltstack/salt/issues/68531) - * Added tunable worker pools: partition the master's MWorkers into named pools - and route specific commands (for example `_auth`) to dedicated pools so a - slow workload cannot starve time-critical traffic. Controlled by the new - `worker_pools` and `worker_pools_enabled` master settings; see the "Tunable - Worker Pools" topic guide for details. Existing `worker_threads` - configurations remain fully backward compatible. [#68532](https://github.com/saltstack/salt/issues/68532) - * Added TLS encryption optimization via disable_aes_with_tls config option that eliminates redundant AES encryption when TLS with mutual authentication is active, improving performance while maintaining security through certificate identity verification. [#68536](https://github.com/saltstack/salt/issues/68536) - * utils.dictdiffer: support diffing of dicts in lists [#68726](https://github.com/saltstack/salt/issues/68726) - * Add support for nix package manager. [#68752](https://github.com/saltstack/salt/issues/68752) - * Added a centralized, declarative system for managing Salt's optional dependencies and their version-specific requirements in ``salt/utils/versions.py``. [#68894](https://github.com/saltstack/salt/issues/68894) - * Implemented an O(1) memory-mapped PKI index to optimize minion public key lookups. This optimization substantially reduces master disk I/O and publication overhead in large-scale environments by replacing linear directory scans with constant-time hash table lookups. The feature is opt-in via the `pki_index_enabled` master configuration setting. [#68936](https://github.com/saltstack/salt/issues/68936) - - - -- Salt Project Packaging Thu, 23 Apr 2026 23:02:39 +0000 - salt (3007.14) stable; urgency=medium diff --git a/pkg/debian/salt-master.preinst b/pkg/debian/salt-master.preinst index fcf7b0b991bf..50b53b039533 100644 --- a/pkg/debian/salt-master.preinst +++ b/pkg/debian/salt-master.preinst @@ -47,29 +47,8 @@ case "$1" in upgrade) . /usr/share/debconf/confmodule - # Determine the current master user. The configured user in - # /etc/salt/master (or a drop-in under /etc/salt/master.d) is the - # authoritative source; only fall back to filesystem ownership and - # then the package default if no user is configured. The previous - # logic looked only at filesystem ownership of /run/salt-master.pid, - # which caused upgrades to reset state directory ownership (and the - # debconf-stored user) back to the package default when the master - # was configured to run as a non-root user but the pid file was - # absent or root-owned between systemd restarts. - CFG_USER="" - if [ -f /etc/salt/master ]; then - CFG_USER=$(grep -E "^[[:space:]]*user:" /etc/salt/master 2>/dev/null \ - | head -1 | cut -d ':' -f 2 | tr -d '[:space:]') - fi - if [ -z "$CFG_USER" ] && [ -d /etc/salt/master.d ]; then - CFG_USER=$(grep -r -h -E "^[[:space:]]*user:" /etc/salt/master.d/ 2>/dev/null \ - | head -1 | cut -d ':' -f 2 | tr -d '[:space:]') - fi - - if [ -n "$CFG_USER" ]; then - CUR_USER=$CFG_USER - CUR_GROUP=$(id -gn "$CFG_USER" 2>/dev/null || echo "$CFG_USER") - elif [ -f /run/salt-master.pid ]; then + if [ -f /run/salt-master.pid ] + then CUR_USER=$(ls -dl /run/salt-master.pid | cut -d ' ' -f 3) CUR_GROUP=$(ls -dl /run/salt-master.pid | cut -d ' ' -f 4) else diff --git a/pkg/debian/salt-minion.preinst b/pkg/debian/salt-minion.preinst index 9a9ec4420a14..2fe807439cbc 100644 --- a/pkg/debian/salt-minion.preinst +++ b/pkg/debian/salt-minion.preinst @@ -47,27 +47,11 @@ case "$1" in upgrade) . /usr/share/debconf/confmodule + PY_VER=$(/opt/saltstack/salt/bin/python3 -c "import sys; sys.stdout.write('{}.{}'.format(*sys.version_info)); sys.stdout.flush();") - # Determine the current minion user. The configured user in - # /etc/salt/minion (or a drop-in under /etc/salt/minion.d) is the - # authoritative source; only fall back to filesystem ownership if no - # user is configured. The previous logic looked only at filesystem - # ownership, which caused upgrades to reset state directories to the - # wrong user when the minion was configured to run as a non-root user. - CFG_USER="" - if [ -f /etc/salt/minion ]; then - CFG_USER=$(grep -E "^[[:space:]]*user:" /etc/salt/minion 2>/dev/null \ - | head -1 | cut -d ':' -f 2 | tr -d '[:space:]') - fi - if [ -z "$CFG_USER" ] && [ -d /etc/salt/minion.d ]; then - CFG_USER=$(grep -r -h -E "^[[:space:]]*user:" /etc/salt/minion.d/ 2>/dev/null \ - | head -1 | cut -d ':' -f 2 | tr -d '[:space:]') - fi - - if [ -n "$CFG_USER" ]; then - CUR_USER=$CFG_USER - CUR_GROUP=$(id -gn "$CFG_USER" 2>/dev/null || echo "$CFG_USER") - elif [ -f /run/salt-minion.pid ]; then + # Reset permissions to fix previous installs + if [ -f /run/salt-minion.pid ] + then CUR_USER=$(ls -dl /run/salt-minion.pid | cut -d ' ' -f 3) CUR_GROUP=$(ls -dl /run/salt-minion.pid | cut -d ' ' -f 4) elif [ -d /etc/salt/pki/minion ]; then diff --git a/pkg/old/shar/build_shar.sh b/pkg/old/shar/build_shar.sh index c281a9115e3f..ac2ac860d9ec 100755 --- a/pkg/old/shar/build_shar.sh +++ b/pkg/old/shar/build_shar.sh @@ -238,7 +238,7 @@ output=`pip install --upgrade pip` _log "$output" # Check if wheel is supported in current version of pip -pip help install 2>/dev/null | grep -E --quiet '(--)no-use-wheel' && PIP_OPTS='--no-use-wheel' || PIP_OPTS='' +pip help install 2>/dev/null | egrep --quiet '(--)no-use-wheel' && PIP_OPTS='--no-use-wheel' || PIP_OPTS='' # Make sure swig is available test -z "$SWIG" && SWIG=`command -v swig` @@ -310,7 +310,7 @@ for dep in "${deps[@]}"; do else _display "Bundled ZeroMQ detected" fi - zeromq_version=`grep -E '^Version' "$zeromq_spec" | awk '{print $2}'` + zeromq_version=`egrep '^Version' "$zeromq_spec" | awk '{print $2}'` _display "ZeroMQ version: $zeromq_version" fi _display "Installing $src" diff --git a/pkg/old/shar/salt.sh b/pkg/old/shar/salt.sh index 166220e3c45a..034b55ee193b 100644 --- a/pkg/old/shar/salt.sh +++ b/pkg/old/shar/salt.sh @@ -16,7 +16,7 @@ if test -z "$pyver"; then # Detect RHEL 5 and Arch, operating systems for which "/usr/bin/env python" # refers to a python version <2.6 or >=3.0. if test -f /etc/redhat-release; then - osmajor=`grep -Eo '[0-9]+\.[0-9]+' /etc/redhat-release | cut -f1 -d.` + osmajor=`egrep -o '[0-9]+\.[0-9]+' /etc/redhat-release | cut -f1 -d.` test "$osmajor" -eq 5 && pyver=2.6 elif test -f /etc/arch-release; then python=python2 diff --git a/pkg/rpm/salt.spec b/pkg/rpm/salt.spec index 8e03d479cbd2..97c9c94006da 100644 --- a/pkg/rpm/salt.spec +++ b/pkg/rpm/salt.spec @@ -40,7 +40,7 @@ %define fish_dir %{_datadir}/fish/vendor_functions.d Name: salt -Version: 3008.1 +Version: 3007.14 Release: 0 Summary: A parallel remote execution system Group: System Environment/Daemons @@ -467,32 +467,11 @@ usermod -c "$SALT_NAME" \ %pre master if [ $1 -gt 1 ] ; then - # Determine the current master user. The configured user in - # /etc/salt/master (or a drop-in under /etc/salt/master.d) is the - # authoritative source; only fall back to filesystem ownership if no - # user is configured. Without this, upgrades reset state directory - # ownership to whatever happened to own /run/salt/master at upgrade - # time, which for systemd-managed installs is often root. The old - # `%%global _MS_CUR_USER ...` lines were dead code: `%%global` is an - # rpm parse-time directive and the macros referenced were never - # defined as rpm macros. - CFG_USER="" - if [ -f /etc/salt/master ]; then - CFG_USER=$(grep -E "^[[:space:]]*user:" /etc/salt/master 2>/dev/null \ - | head -1 | cut -d ':' -f 2 | tr -d '[:space:]') - fi - if [ -z "$CFG_USER" ] && [ -d /etc/salt/master.d ]; then - CFG_USER=$(grep -r -h -E "^[[:space:]]*user:" /etc/salt/master.d/ 2>/dev/null \ - | head -1 | cut -d ':' -f 2 | tr -d '[:space:]') - fi - - if [ -n "$CFG_USER" ]; then - CUR_USER=$CFG_USER - CUR_GROUP=$(id -gn "$CFG_USER" 2>/dev/null || echo "$CFG_USER") - elif [ -d /run/salt/master ]; then - CUR_USER=$(ls -dl /run/salt/master | cut -d ' ' -f 3) - CUR_GROUP=$(ls -dl /run/salt/master | cut -d ' ' -f 4) - fi + # Reset permissions to match previous installs - performing upgrade + _MS_LCUR_USER=$(ls -dl /run/salt/master | cut -d ' ' -f 3) + _MS_LCUR_GROUP=$(ls -dl /run/salt/master | cut -d ' ' -f 4) + %global _MS_CUR_USER %{_MS_LCUR_USER} + %global _MS_CUR_GROUP %{_MS_LCUR_GROUP} fi %pre syndic @@ -567,23 +546,23 @@ if [ $1 -gt 1 ] ; then /bin/systemctl stop salt-minion.service >/dev/null 2>&1 || : fi - # Check if minion config specifies a non-root user. The configured - # user in /etc/salt/minion (or a drop-in under /etc/salt/minion.d) - # is the authoritative source; filesystem ownership is only used as - # a fallback when no user is configured. The state transfer to - # %%post minion happens via the marker file at - # /tmp/.salt-minion-upgrade-ownership. + # Check if minion config specifies a non-root user MINION_USER="" - if [ -f "/etc/salt/minion" ]; then - MINION_USER=$(grep -E "^[[:space:]]*user:" /etc/salt/minion 2>/dev/null | head -1 | cut -d ':' -f 2 | tr -d '[:space:]') - fi - if [ -z "$MINION_USER" ] && [ -d "/etc/salt/minion.d" ]; then - MINION_USER=$(grep -r -h -E "^[[:space:]]*user:" /etc/salt/minion.d/ 2>/dev/null | head -1 | cut -d ':' -f 2 | tr -d '[:space:]' || true) + if [ -f "/etc/salt/minion" ] || [ -d "/etc/salt/minion.d" ]; then + # Try to get user from main config + if [ -f "/etc/salt/minion" ]; then + MINION_USER=$(grep -E "^user:" /etc/salt/minion | cut -d ':' -f 2 | tr -d ' ') + fi + # Try to get user from minion.d configs + if [ -z "$MINION_USER" ] && [ -d "/etc/salt/minion.d" ]; then + MINION_USER=$(grep -r -h -E "^user:" /etc/salt/minion.d/ | head -1 | cut -d ':' -f 2 | tr -d ' ' || true) + fi fi if [ -n "$MINION_USER" ] && [ "$MINION_USER" != "root" ]; then - MINION_GROUP=$(id -gn "$MINION_USER" 2>/dev/null || echo "$MINION_USER") - echo "$MINION_USER:$MINION_GROUP" > /tmp/.salt-minion-upgrade-ownership + echo "$MINION_USER:$MINION_USER" > /tmp/.salt-minion-upgrade-ownership + %global _MN_CUR_USER %{MINION_USER} + %global _MN_CUR_GROUP %{MINION_USER} else # Fallback to checking multiple directories for ownership if [ -d "/run/salt/minion" ]; then @@ -591,18 +570,24 @@ if [ $1 -gt 1 ] ; then _MN_LCUR_GROUP=$(ls -dl /run/salt/minion | cut -d ' ' -f 4) if [ "$_MN_LCUR_USER" != "root" ]; then echo "$_MN_LCUR_USER:$_MN_LCUR_GROUP" > /tmp/.salt-minion-upgrade-ownership + %global _MN_CUR_USER %{_MN_LCUR_USER} + %global _MN_CUR_GROUP %{_MN_LCUR_GROUP} fi elif [ -d "/etc/salt/pki/minion" ]; then _MN_LCUR_USER=$(ls -dl /etc/salt/pki/minion | cut -d ' ' -f 3) _MN_LCUR_GROUP=$(ls -dl /etc/salt/pki/minion | cut -d ' ' -f 4) if [ "$_MN_LCUR_USER" != "root" ]; then echo "$_MN_LCUR_USER:$_MN_LCUR_GROUP" > /tmp/.salt-minion-upgrade-ownership + %global _MN_CUR_USER %{_MN_LCUR_USER} + %global _MN_CUR_GROUP %{_MN_LCUR_GROUP} fi elif [ -d "/var/cache/salt/minion" ]; then _MN_LCUR_USER=$(ls -dl /var/cache/salt/minion | cut -d ' ' -f 3) _MN_LCUR_GROUP=$(ls -dl /var/cache/salt/minion | cut -d ' ' -f 4) if [ "$_MN_LCUR_USER" != "root" ]; then echo "$_MN_LCUR_USER:$_MN_LCUR_GROUP" > /tmp/.salt-minion-upgrade-ownership + %global _MN_CUR_USER %{_MN_LCUR_USER} + %global _MN_CUR_GROUP %{_MN_LCUR_GROUP} fi fi fi @@ -1419,105 +1404,6 @@ fi - Added ``lgpo_reg.get_rsop_value`` to query the Resultant Set of Policy (RSoP) for a registry key/value and detect whether it is managed by a Domain Group Policy Object. The ``lgpo_reg`` module functions ``set_value``, ``disable_value``, and ``delete_value`` now log a warning when a Domain GPO is detected for the target value. The ``lgpo_reg`` state functions ``value_present``, ``value_disabled``, and ``value_absent`` append the same warning to the state comment so it is visible in state output. [#69205](https://github.com/saltstack/salt/issues/69205) -* Thu Jun 11 2026 Salt Project Packaging - 3008.1 - -# Changed - -- Changed `salt.returners.redis_return` to enumerate the Redis keyspace - with `SCAN` instead of the blocking `KEYS pattern` command in both - `get_jids` and `clean_old_jobs`. `KEYS` walks the entire keyspace - synchronously and stalls the Redis server for the duration; on a - master with hundreds of thousands of jobs this can block all clients - of that Redis instance for seconds. `SCAN` is incremental and - non-blocking. Order of returned keys is no longer guaranteed (the - returner does not rely on order); operators with custom scripts that - read `ret:*` or `load:*` directly may see them in a different order. [#69037](https://github.com/saltstack/salt/issues/69037) - -# Fixed - -- Fixed ``win_pkg`` functions ignoring the ``saltenv`` setting in minion configuration. All public functions (``refresh_db``, ``genrepo``, ``install``, ``remove``, ``list_pkgs``, ``latest_version``, ``upgrade_available``, ``list_upgrades``, ``list_available``, ``version``, ``get_repo_data``, ``get_package_info``) now fall back to ``__opts__["saltenv"]`` when ``saltenv`` is not passed explicitly, instead of always defaulting to ``base``. [#38551](https://github.com/saltstack/salt/issues/38551) -- Added ``encoding`` parameter to ``file.replace`` execution module and state to support UTF-16, UTF-32, and other multi-byte encoded files that would otherwise be incorrectly treated as binary. [#52793](https://github.com/saltstack/salt/issues/52793) -- Improved documentation for the `runas` and `password` parameters in `cmd.run`, `cmd.script`, and all `salt.modules.cmdmod` execution functions on Windows. The docs now accurately describe when a password is required: only when the salt-minion is **not** running as SYSTEM or as an elevated Administrator. Removed the inaccurate claim that the target user account must be in the Administrators group. Also changed `cmd.script` to log a warning instead of hard-failing when `runas` is used without a password on Windows, since a password is not always required. [#57951](https://github.com/saltstack/salt/issues/57951) -- Fixed `SSL: DECRYPTION_FAILED_OR_BAD_RECORD_MAC` errors in the VMware cloud driver by reconnecting when a cached vCenter service instance is found to be stale or corrupted (for example when inherited across a fork by salt-cloud's parallel provider queries). [#61983](https://github.com/saltstack/salt/issues/61983) -- Fixed event signature verification failing under ``minion_sign_messages``. The minion was signing the return load before ``salt.channel.client.AsyncReqChannel._package_load`` attached transport metadata (``nonce``, ``ts``, ``tok``, ``id``), so the bytes the master re-serialized to verify did not match what was signed and every signed return was dropped. Signing is now performed inside ``_package_load`` after the metadata is attached, against the same bytes the master verifies. [#68181](https://github.com/saltstack/salt/issues/68181) -- Fixed two distinct bugs in the `salt.engines.redis_sentinel` engine that - together prevented it from being usable. `start()` no longer raises - `AttributeError: 'dict_values' object has no attribute 'pop'` on Python 3 - (the dict.values() result is now wrapped in `list(...)`). `Listener` and - `start()` now accept an optional `password` argument and forward it to - the redis client, allowing the engine to authenticate against a Sentinel - that requires AUTH; the default of `None` keeps existing configurations - working unchanged. [#69031](https://github.com/saltstack/salt/issues/69031) -- Fixed `salt.returners.redis_return` silently ignoring the documented - `redis.password` configuration option. The returner now reads - `redis.password` from config (in both regular and proxy modes) and - forwards it to both the single-server `redis.StrictRedis` and the - `StrictRedisCluster` constructors. Operators with auth-protected Redis - no longer lose every job return to a hidden `NOAUTH Authentication - required` failure; deployments without a password are unaffected. [#69032](https://github.com/saltstack/salt/issues/69032) -- Fixed three closely-related bugs in `salt.cache.redis_cache` that - together broke hierarchical-bank semantics: - `_build_bank_hier` now registers each child bank name in both the - parent's `$BANK_` set (consumed by `flush()` tree traversal) and the - parent's `$BANKEYS_` set (consumed by `list_()`); `_get_banks_to_remove` - now decodes the bytes returned by `smembers` and skips the `"."` - placeholder, so recursive `flush()` of a parent bank actually descends - into sub-banks instead of corrupting the path; and `flush(bank)` of a - sub-bank now removes the flushed bank's own reference from its - parent's index sets so `list_(parent)` no longer reports it as - present. Together these fixes restore `cache.list("minions")`, - `salt-run manage.present` and `salt-run manage.up` for masters - configured with `cache: redis`. [#69033](https://github.com/saltstack/salt/issues/69033) -- Fixed `salt.tokens.rediscluster` being unable to retrieve any eauth - token. The cluster client was created with `decode_responses=True`, - which caused `redis_client.get()` to return `str` and broke - `salt.payload.loads` (msgpack rejects `str`); it also caused - `redis_client.keys()` to return `str` and broke - `[k.decode("utf8") for k in ...]` (`str` has no `.decode`). Both - errors were swallowed by broad `except Exception` handlers, so eauth - appeared to silently reject every token. `decode_responses=True` is - removed; values now round-trip as bytes through msgpack as the rest - of the module already expected. [#69035](https://github.com/saltstack/salt/issues/69035) -- Fixed `salt.returners.redis_return` leaking `:` last-jid - pointer keys indefinitely. The pointer was written with `pipeline.set` - and no `ex=` TTL, so any (minion, fun) pair that stopped running stuck - in Redis forever -- O(minions × distinct funcs) keys accumulating over - the lifetime of the master. The pointer now expires on the same TTL - as the rest of the returner data (`keep_jobs_seconds`). Operators with - external scripts reading these keys directly may observe them - expiring; the documentation never promised they would not. [#69038](https://github.com/saltstack/salt/issues/69038) -- Fixed `salt.returners.redis_return.get_fun` always returning an - empty dict. The function read return data from a `:` - key that no other code in the module ever wrote -- a leftover from - an older storage schema. It now reads from the canonical - `ret:` hash via `HGET ret: `, matching the - storage layout that `returner` actually produces and the read - pattern that `get_jid` already uses. [#69039](https://github.com/saltstack/salt/issues/69039) -- ``cmd.run`` and friends no longer include the ``env`` and ``stdin`` arguments in the ``CommandExecutionError`` raised when the underlying subprocess fails to start (typically ``ENOENT`` / binary not found). Both fields routinely carry credentials passed in by the caller (``env={"DB_PASSWORD": "..."}``, password piped via ``stdin``), and the error message ends up in master/minion logs and in event-bus return data visible to the API caller. [#69075](https://github.com/saltstack/salt/issues/69075) -- * Relenv 0.22.14 - - Update python 3.14 to 3.14.6 - - Update sqlite to 3.53.2.0 - - Update openssl to 3.5.7 [#69129](https://github.com/saltstack/salt/issues/69129) -- Fix pillar masking leaking ``**********`` into rendered pillar and state values. ``MaskedDict`` / ``MaskedList`` ``__repr__`` / ``__str__`` now consult the ``salt.utils.secret.mask_pillar`` ContextVar, so ``{{ pillar['list_or_dict_value'] }}`` interpolations on the minion return plain values inside a render bracket. Hoist the ``mask_pillar=False`` bracket from ``render_pillar`` to ``compile_pillar`` so ``ext_pillar`` handlers and the rest of the master-side pillar build also run unmasked. [#69160](https://github.com/saltstack/salt/issues/69160) -- Fixed Windows MSI self-upgrade via ``pkg.install`` failing with error 1603. The old product's ``DeleteConfig_DECAC`` custom action was unconditionally deleting ``ROOTDIR\var`` during ``RemoveExistingProducts``, destroying the MSI that ``pkg.install`` had cached to ``ROOTDIR\var\cache`` before launching the upgrade. Users who had ``REMOVE_CONFIG=1`` persisted in the registry (from checking "On uninstall" at install time) hit a worse variant where the entire ``ROOTDIR`` was deleted. The fix checks ``UPGRADINGPRODUCTCODE`` — set by Windows Installer whenever an uninstall is triggered by a major upgrade — and skips all ``ROOTDIR`` deletion during upgrades, matching the behaviour of the NSIS installer which has always preserved ``ROOTDIR`` during upgrades. [#69219](https://github.com/saltstack/salt/issues/69219) -- Fixed `TypeError: string indices must be integers` in the minion when the master returns a bare string error response (e.g. `"bad load"`, `"Some exception handling minion payload"`) for a pillar request. The minion now raises a clean `AuthenticationError` instead of crashing, allowing the caller to retry or fail gracefully. [#69228](https://github.com/saltstack/salt/issues/69228) -- pkg.list_patches in yumpkg.py parses tdnf output on Photon OS [#69229](https://github.com/saltstack/salt/issues/69229) -- Restore Python dependencies in the PyPI sdist by including ``requirements/*.in`` and ``requirements/**/*.lock`` in ``MANIFEST.in``. After the requirements ``.txt`` → ``.in`` rename, the sdist no longer shipped the files that ``setup.py`` reads to populate ``install_requires``, so ``pip install salt`` produced an installation with no dependencies. [#69244](https://github.com/saltstack/salt/issues/69244) -- Fix `salt-cloud` failing to start with `AttributeError: module 'salt' has no attribute 'minion'` by importing `salt.minion` in `salt.cloud`. [#69281](https://github.com/saltstack/salt/issues/69281) -- Ensure multiple masters have their own job/state queues [#69308](https://github.com/saltstack/salt/issues/69308) -- Fixed minion state queue replacing the master-assigned JID on queued state runs, so returns now come back tagged with the JID the master actually published. [#69386](https://github.com/saltstack/salt/issues/69386) -- Made the salt user's home directory and the relenv ``extras-`` directory configurable in the Linux packaging. The DEB preinst scripts now source ``/etc/default/salt-setup`` (and ``/etc/sysconfig/salt-minion-setup`` for cross-distro parity with RPM) before applying the ``SALT_HOME``/``SALT_USER``/``SALT_GROUP``/``SALT_NAME`` defaults, mirroring the long-standing RPM behavior. A new ``SALT_EXTRAS_DIR`` override is honored by both stacks so the extras tree can be relocated outside ``/opt/saltstack/salt`` and its ownership is correctly restored on upgrade. [#69402](https://github.com/saltstack/salt/issues/69402) - -# Added - -- Added ``dsc_resource`` execution module and state module for invoking individual - PowerShell DSC resources directly via ``Invoke-DscResource``, without compiling - a MOF file or involving the Local Configuration Manager. The - ``dsc_resource.managed`` state provides idiomatic Salt state management for any - installed DSC resource module. [#43718](https://github.com/saltstack/salt/issues/43718) -- fix etcdv3 module authentification when using etcd3-py lib [#69202](https://github.com/saltstack/salt/issues/69202) - - * Wed Apr 29 2026 Salt Project Packaging - 3007.14 # Fixed diff --git a/pkg/windows/nsis/installer/Salt-Minion-Setup.nsi b/pkg/windows/nsis/installer/Salt-Minion-Setup.nsi index 67cf43e3904b..ebedef478d43 100644 --- a/pkg/windows/nsis/installer/Salt-Minion-Setup.nsi +++ b/pkg/windows/nsis/installer/Salt-Minion-Setup.nsi @@ -243,7 +243,6 @@ Var SSMBin Var SysDrive Var ExistingInstallation Var CustomLocation -Var SvcInstallTries ############################################################################### @@ -1078,36 +1077,10 @@ Section -Post SetRegView 32 # Set it back to the 32 bit portion of the registry # Register the Salt-Minion Service - # - # "ssm install" calls CreateService, which can transiently fail with - # ERROR_SERVICE_MARKED_FOR_DELETE (1072) or ERROR_SERVICE_EXISTS (1073) - # when a previous salt-minion deletion is still pending in the Service - # Control Manager. This shows up on uninstall/reinstall and on back-to-back - # test iterations: the uninstaller's SimpleSC::RemoveService marks the - # service for deletion, but the SCM does not actually remove it until every - # open handle is closed -- which happens asynchronously, after the - # uninstaller has already exited. CreateService for the new service then - # races that pending delete and fails, which used to Abort the install - # (NSIS error level 2 -- the intermittent installer failure). - # - # The condition is self-clearing within a second or two once the handles - # close, so retry a handful of times before giving up rather than aborting - # on the first failure. ${LogMsg} "Registering the salt-minion service" - StrCpy $SvcInstallTries 0 - retry_svc_install: nsExec::ExecToStack `"$INSTDIR\ssm.exe" install salt-minion "$INSTDIR\salt-minion.exe" -c """$RootDir\conf""" -l quiet` pop $0 # ExitCode pop $1 # StdOut - ${If} $0 != 0 - ${AndIf} $SvcInstallTries < 5 - IntOp $SvcInstallTries $SvcInstallTries + 1 - ${LogMsg} "Service registration failed (ExitCode: $0). \ - Retry $SvcInstallTries/5 in 2s (SCM delete may still be pending)" - ${LogMsg} "StdOut: $1" - Sleep 2000 - Goto retry_svc_install - ${EndIf} ${IfNot} $0 == 0 StrCpy $msg "Failed to register the salt minion service.$\n\ ExitCode: $0$\n\ @@ -1199,8 +1172,7 @@ Function .onInstSuccess # This eliminates the cross-thread deadlock that the old Exec approach # caused: since no background process is left alive, the NSIS exec thread # returns cleanly from this function without interfering with the message - # loop. The TerminateProcess call below remains as belt-and-suspenders for - # silent mode. + # loop. ExitProcess below remains as belt-and-suspenders for silent mode. ${If} $StartMinion == 1 ${LogMsg} "Starting the salt-minion service" SimpleSC::StartService "salt-minion" "" 30 @@ -1214,21 +1186,15 @@ Function .onInstSuccess ${LogMsg} "Salt installation complete" - # In silent mode, exit immediately rather than letting NSIS advance to the - # finish page. The finish page exists only for interactive checkbox state - # (StartMinion / StartMinionDelayed), which is already set from command-line - # parsing and does not need to be re-read from UI controls. - # - # Use TerminateProcess on our own process (pseudo-handle -1), NOT - # ExitProcess. ExitProcess runs orderly DLL_PROCESS_DETACH for every loaded - # plugin DLL on the calling thread while holding the loader lock; if a plugin - # worker thread was terminated mid-loader-lock or is otherwise stuck, that - # detach deadlocks and the process never exits -- the intermittent - # silent-mode hang. TerminateProcess kills immediately with no DLL detach - # and no loader-lock dependency: the native, Win11-safe equivalent of the - # old wmic force-kill workaround. + # In silent mode, exit immediately via ExitProcess rather than letting + # NSIS advance to the finish page. The finish page exists only for + # interactive checkbox state (StartMinion / StartMinionDelayed), which is + # already set from command-line parsing and does not need to be re-read + # from UI controls. ExitProcess bypasses the NSIS message loop entirely, + # avoiding any cross-thread deadlock between the exec thread and the main + # UI thread during page transition. ${If} ${Silent} - System::Call "kernel32::TerminateProcess(i -1, i 0)" + System::Call "kernel32::ExitProcess(i 0)" ${EndIf} FunctionEnd @@ -1343,33 +1309,6 @@ Function ${un}uninstallSalt Pop $0 ${LogMsg} "Done (exit $0)" - # Wait (bounded) for the SCM to actually remove the service key. - # SimpleSC::RemoveService only *marks* the service for deletion; the SCM - # does not remove the HKLM\...\Services\salt-minion key until every open - # handle is closed, which only happens once the taskkills above have - # fully torn down salt-minion.exe and ssm.exe. Waiting until the key is - # gone here makes a reinstall (or the next test iteration) far less - # likely to hit ERROR_SERVICE_MARKED_FOR_DELETE from CreateService. - # - # This only narrows the window -- the install-side retry around - # "ssm install" remains the authoritative guard, since in the field - # uninstall and reinstall are separate processes and nothing here can - # constrain a future installer invocation. - ${LogMsg} "Waiting for SCM to remove the salt-minion service key" - StrCpy $R0 0 - wait_svc_deleted: - ClearErrors - ReadRegDWORD $R1 HKLM "SYSTEM\CurrentControlSet\Services\salt-minion" "Type" - ${If} ${Errors} - ${LogMsg} "Service key removed" - ${ElseIf} $R0 < 20 - IntOp $R0 $R0 + 1 - Sleep 500 - Goto wait_svc_deleted - ${Else} - ${LogMsg} "Service key still present after 10s — continuing anyway" - ${EndIf} - ${Else} ${LogMsg} "ssm.exe not found" @@ -1637,13 +1576,11 @@ Function un.onUninstSuccess ${LogMsg} $msg MessageBox MB_OK|MB_USERICON $msg /SD IDOK - # Same issue as .onInstSuccess: Quit posts WM_QUIT but the message loop may - # be stuck. TerminateProcess on our own process (pseudo-handle -1) kills the - # uninstaller immediately with no DLL_PROCESS_DETACH and no loader-lock - # dependency. ExitProcess would run orderly per-DLL detach under the loader - # lock and could deadlock if a plugin worker thread is stuck holding it. + # Same issue as .onInstSuccess: Quit posts WM_QUIT but the message loop + # may be stuck with background processes alive. Call ExitProcess directly + # to terminate the uninstaller process immediately. ${If} ${Silent} - System::Call "kernel32::TerminateProcess(i -1, i 0)" + System::Call "kernel32::ExitProcess(i 0)" ${EndIf} FunctionEnd diff --git a/requirements/base.txt b/requirements/base.txt index 70ac70941fd7..a5572c5a7934 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -11,7 +11,7 @@ cffi>=2.0.0 cheroot>=11.1.2 cherrypy>=18.10.0 # We need contextvars for salt-ssh -contextvars; python_version < '3.7' +contextvars croniter!=0.3.22,>=6.2.2; sys_platform != 'win32' # cryptography 48.0.0 drops support for Python 3.9.0 and 3.9.1 # (only >3.9.1 is accepted), but the py3.9 lock files are compiled @@ -24,11 +24,9 @@ frozenlist>=1.8.0; python_version < '3.11' frozenlist>=1.5.0; python_version >= '3.11' gitpython>=3.1.50 idna>=3.18 -immutables>=0.21; python_version < '3.7' -# importlib-metadata 9.x drops py3.9 support. Cap on py3.9, allow 8.7+ on -# py3.10, and let py>=3.11 use the existing 8.7+ floor. -importlib-metadata>=3.3.0,<9.0.0; python_version < '3.10' -importlib-metadata>=8.7.0; python_version >= '3.10' +immutables>=0.21 +importlib-metadata>=8.7.0,<9.0.0; python_version < '3.10' +importlib-metadata>=9.0.0; python_version >= '3.10' # jaraco.functools 4.5.0 and jaraco.context 6.1.2 drop Python 3.9; keep the # last 3.9-compatible releases there and let py>=3.10 float forward. jaraco.functools>=4.4.0,<4.5.0; python_version < '3.10' @@ -40,53 +38,37 @@ Jinja2>=3.1.6 jmespath>=1.1.0 looseversion lxml>=6.1.1; sys_platform == 'win32' -MarkupSafe>=3.0.3 +MarkupSafe<4.0.0 more-itertools>=10.8.0,<11.0.0; python_version < '3.10' -more-itertools>=11.1.0; python_version >= '3.10' -# msgpack 1.2.1 drops Python 3.9; keep the last 3.9-compatible release there. -msgpack>=1.1.2,<1.2.1 ; python_version < '3.10' -msgpack>=1.1.2 ; python_version >= '3.10' and python_version < '3.13' -msgpack>=1.1.0 ; python_version >= '3.13' +more-itertools>=10.8.0; python_version >= '3.10' # multidict 6.0.4 fails to source-build under clang 17+ with strict int/pointer # conversion checks (macOS 15 onedir builds compile from sdist via # --no-binary=:all:). 6.6+ fixed the C source compatibility. multidict>=6.6.0 -# opentelemetry 1.43.0 (and exporter-prometheus 0.64b0) drop Python 3.9; keep -# the last 3.9-compatible releases there and let py>=3.10 float forward. -opentelemetry-api>=1.41.1,<1.43.0; python_version < '3.10' -opentelemetry-api>=1.41.1; python_version >= '3.10' -opentelemetry-sdk>=1.41.1,<1.43.0; python_version < '3.10' -opentelemetry-sdk>=1.41.1; python_version >= '3.10' -opentelemetry-exporter-otlp-proto-http>=1.41.1,<1.43.0; python_version < '3.10' -opentelemetry-exporter-otlp-proto-http>=1.41.1; python_version >= '3.10' -opentelemetry-exporter-prometheus>=0.62b1,<0.64b0; python_version < '3.10' -opentelemetry-exporter-prometheus>=0.62b1; python_version >= '3.10' -# xxhash 3.8.0 drops Python 3.9; keep the last 3.9-compatible release there. -xxhash>=3.7.0,<3.8.0; python_version < '3.10' -xxhash>=3.7.0; python_version >= '3.10' +# msgpack 1.2.1 drops Python 3.9; keep the last 3.9-compatible release there. +msgpack>=1.1.2,<1.2.1; python_version < '3.10' +msgpack>=1.1.2; python_version >= '3.10' # Packaging 24.1 imports annotations from __future__ which breaks salt ssh # tests on target hosts with older python versions. -packaging>=26.2; python_version < '3.11' -packaging==24.0; python_version >= '3.11' +packaging==26.2 psutil<6.0.0; python_version <= '3.9' psutil>=5.0.0; python_version >= '3.10' pyasn1>=0.6.3 -pycparser>=2.23,<3.0; python_version < '3.10' +pycparser>=2.23; python_version < '3.10' pycparser>=3.0; python_version >= '3.10' -# pymssql 2.3.12+ dropped win32 (32-bit Windows) wheels; 3008.x still -# builds a Windows x86 onedir so keep the pin at the last release that +# pymssql 2.3.12+ dropped win32 (32-bit Windows) wheels; salt 3007.x +# still builds a Windows x86 onedir, so pin to the last release that # ships cp3X-win32 wheels. -pymssql>=2.2.1,<=2.3.11; sys_platform == 'win32' and python_version < '3.11' -pymssql==2.3.11; sys_platform == 'win32' and python_version >= '3.11' +pymssql==2.3.11; sys_platform == 'win32' +pymysql>=1.2.0; sys_platform == 'win32' # pyopenssl 26.3.0 requires cryptography>=49 which drops Python 3.9; keep the # last 3.9-compatible release there and let py>=3.10 float forward. pyopenssl>=26.2.0,<26.3.0; python_version < '3.10' pyopenssl>=26.2.0; python_version >= '3.10' python-dateutil>=2.9.0.post0 python-gnupg>=0.5.6 -pythonnet>=3.0.1; sys_platform == 'win32' and python_version < '3.11' -pythonnet>=3.0.4; sys_platform == 'win32' and python_version >= '3.11' and python_version < '3.13' -pythonnet>=3.1.0rc0; sys_platform == 'win32' and python_version >= '3.13' +pythonnet>=3.0.5; sys_platform == 'win32' +tzdata; sys_platform == 'win32' pywin32>=312; sys_platform == 'win32' pycryptodomex>=3.23.0 PyYAML>=6.0.3 @@ -94,12 +76,9 @@ requests>=2.32.5; python_version < '3.10' requests<2.32.0 ; python_version >= '3.10' and python_version < '3.11' requests>=2.32.5 ; python_version >= '3.11' setproctitle>=1.3.7 -timelib>=0.3.0; python_version < '3.11' -timelib>=0.3.0; python_version >= '3.11' tornado>=6.5.6 -truststore>=0.10.0; python_version >= "3.10" # Python 3.9 stays on urllib3 1.26.x because botocore on py3.9 hard -# requires urllib3 < 2 and Salt 3008.x still ships py3.9 lockfiles. +# requires urllib3 < 2 and Salt 3007.x still builds a py3.9 onedir. # The Python 3.10+ floor carries the urllib3 2.6.3 CVE backports # (CVE-2025-66418, CVE-2026-21441). urllib3>=1.26.20,<2.0.0; python_version < '3.10' diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 3fa7e6e23ca0..9c6a7f990dd5 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -4,11 +4,12 @@ wheel >= 0.47.0 # Floor at the CVE fix: 78.1.1 patches GHSA-5rjg-fvgr-3xxf # (PYSEC-2025-49) -- path traversal in setuptools.PackageIndex.download. -# A higher floor (e.g. 80.x) makes the PEP 517 build-env install fail -# with ResolutionImpossible when pip 25.2 source-builds packages whose -# isolated build env asks for ``setuptools == 78.1.1`` (the version pip -# bootstraps build envs with), e.g. yarl on Python 3.14 where no cp314 -# wheel is available under salt's ``--no-binary=:all:`` policy. +# A higher floor (e.g. 80.x) makes the 3.13 onedir build fail with +# ResolutionImpossible: relenv ships pip 25.2 in the 3.13 onedir, and +# pip 25.2 bootstraps PEP 517 build envs with ``setuptools == 78.1.1``. +# Host-side pre-commit hooks (uv-resolved) still pick the latest +# setuptools; this floor only relaxes what the onedir-bundled pip is +# allowed to use. setuptools >= 78.1.1 # Cap setuptools-scm < 10 in PEP 517 build envs. 10.1.1 (2026-06-22) split # version inference out into the ``vcs-versioning`` package; that path raises @@ -18,30 +19,9 @@ setuptools >= 78.1.1 # propagates to PEP 517 build envs since pip 22.1, so capping here keeps # build envs on the pre-split 9.x series for every source build. setuptools-scm < 10 -# pip 25.2 is the version that relenv's onedir ships with, and that -# tools/pkg/build.py downloads + patches in pkg/patches/pip-urllib3/. -# Bumping past 25.2 here causes the noxfile bootstrap pip install in -# the lint-pre-commit hook to upgrade the just-installed 25.2 inside -# the pre-commit hook venv on Python 3.14, which leaves the venv in a -# corrupted state because pip 26.0.1's vendored pygments wheel is -# missing the modeline submodule on cpython 3.14. Stay on 25.2. -pip == 25.2 +pip == 26.0.1 markdown-it-py < 3.0.0; python_version == "3.9" # myst-docutils 4.x (the latest supporting Python 3.10) requires # markdown-it-py ~=3.0; the 5.x line that pairs with markdown-it-py 4.x # only supports Python >=3.11. markdown-it-py < 4.0.0; python_version == "3.10" -# Onedir shipped on 3006.x is Python 3.11; cap a few transitive deps that -# changed behaviour between the prior 3.10 lockfile and the latest 3.11 -# resolution. Keeping these aligned with the prior 3.10 floors avoids -# test surface regressions from the python bump: -# - jsonschema 4.x rewrote validator error messages that -# tests/unit/utils/test_schema.py asserts on verbatim. -# - bcrypt 5.x rejects secrets > 72 bytes that passlib's CryptContext -# backend probe emits, breaking salt.utils.pycrypto tests. -# - junos-eznc 2.7.x dropped its yamlordereddictloader dep, leaving -# salt.modules.junos's optional import block setting HAS_JUNOS=False -# and breaking ~30 patched-attribute test_junos.py cases. -jsonschema < 4; python_version == "3.11" -bcrypt < 5; python_version == "3.11" -junos-eznc < 2.7; python_version == "3.11" diff --git a/requirements/pytest.txt b/requirements/pytest.txt index a2dfe03c488a..88f224650aaf 100644 --- a/requirements/pytest.txt +++ b/requirements/pytest.txt @@ -17,4 +17,3 @@ pytest-skip-markers >= 1.5.2 ; python_version >= '3.8' pytest-skip-markers <= 1.5.1 ; python_version < '3.8' pytest-shell-utilities <= 1.9.0; python_version <= '3.9' pytest-shell-utilities >= 1.9.7; python_version >= '3.10' -pytest-benchmark diff --git a/requirements/static/ci/common.txt b/requirements/static/ci/common.txt index 6f9ec9a480d1..e86d1560ddaf 100644 --- a/requirements/static/ci/common.txt +++ b/requirements/static/ci/common.txt @@ -34,11 +34,8 @@ google-auth==2.35.0; python_version == '3.9' jmespath>=1.1.0 jsonschema junos-eznc; sys_platform != 'win32' -ncclient>=0.7.1; sys_platform != 'win32' junit-xml>=1.9 jxmlease; sys_platform != 'win32' -# salt.modules.junos imports this; junos-eznc no longer declares it on PyPI -yamlordereddictloader; sys_platform != 'win32' kazoo; sys_platform != 'win32' and sys_platform != 'darwin' keyring==25.7.0 pyasn1-modules==0.4.0; python_version == '3.9' @@ -59,7 +56,6 @@ pynacl>=1.5.0 pyinotify>=0.9.6; sys_platform != 'win32' and sys_platform != 'darwin' and platform_system != "openbsd" python-etcd>=0.4.5 pyvmomi -rfc3339-validator>=0.1.4 rfc3987 sqlparse>=0.5.5 strict_rfc3339>=0.7 diff --git a/requirements/static/ci/lint.txt b/requirements/static/ci/lint.txt index 9afd4f354bb5..248b5c8a0b9e 100644 --- a/requirements/static/ci/lint.txt +++ b/requirements/static/ci/lint.txt @@ -2,11 +2,12 @@ docker >= 7.1.0; python_version >= '3.8' docker < 7.1.0; python_version < '3.8' -# pylint 4 introduces new default-on E0606/E0601/E0602 checks -# (possibly-used-before-assignment / used-before-assignment) that the -# Salt 3008.x codebase has not been audited for; bumping to 4.x turns -# pre-existing warnings into hard CI failures across salt/, tools/, -# and tests/. Cap to ~=3.1.0 until the codebase is audited. +# pylint 4 introduces new default-on E0606/E0601/E0602 checks that the +# Salt 3007.x codebase has not been audited for; the lint job logs are +# full of pre-existing possibly-used-before-assignment warnings now +# turning into errors across salt/, tools/, and tests/. Stay on the +# 3.1.x line for 3007.x. (pylint 4 also requires Python>=3.10, so the +# 3.x line is the only choice on the py3.9 onedir target anyway.) pylint~=3.1.0 SaltPyLint>=2024.2.5 toml diff --git a/requirements/static/ci/linux.txt b/requirements/static/ci/linux.txt index a4873b2686a4..802652180620 100644 --- a/requirements/static/ci/linux.txt +++ b/requirements/static/ci/linux.txt @@ -4,19 +4,11 @@ pygit2>=1.13.1,<1.18.0; python_version < '3.11' pygit2>=1.19.2; python_version >= '3.11' pymysql>=1.2.0 # ansible release lines support different Python versions: -# ansible-core / ansible 10.x — Python 3.10+ (controller and managed) -# ansible 11.x (ansible-core 2.18) — Python 3.11+ controller; managed- -# node interpreter discovery is stricter and no longer probes -# /usr/bin/python on Amazon Linux 2-class targets, so -# ``ansible.legacy.setup`` fails fact-gathering against AL2 in CI -# ansible 12.x (ansible-core 2.19) — Python 3.11+, drops the implicit -# /usr/bin/python interpreter discovery fallback for managed nodes +# ansible-core / ansible 10.x — Python 3.10+ +# ansible 12.x — Python 3.11+ # ansible 14.x — Python 3.12+ -# Hold the 3.11 lockfile on the 10.x / ansible-core 2.17 line that still -# discovers the right python on AL2-class targets. 10.x supports -# Python 3.10+ as a controller and installs cleanly on the py3.11 onedir. -# See PR #69527 / issue #69526. -ansible>=10.7.0,<11.0.0; python_version >= '3.10' and python_version < '3.12' +ansible>=10.7.0,<11.0.0; python_version >= '3.10' and python_version < '3.11' +ansible>=12.3.0,<13.0.0; python_version >= '3.11' and python_version < '3.12' ansible>=14.0.0; python_version >= '3.12' twilio>=9.10.9 python-telegram-bot>=20.3,<22.0; python_version < '3.10' @@ -24,6 +16,6 @@ python-telegram-bot>=22.7; python_version >= '3.10' yamllint mercurial>=7.2.2 hglib -redis +redis-py-cluster python-consul slack-bolt diff --git a/requirements/static/ci/py3.10/changelog.lock b/requirements/static/ci/py3.10/changelog.lock index 8be94e243c79..0839ab56ef6a 100644 --- a/requirements/static/ci/py3.10/changelog.lock +++ b/requirements/static/ci/py3.10/changelog.lock @@ -10,7 +10,7 @@ looseversion==1.3.0 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/changelog.txt -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.10/linux.lock # jinja2 diff --git a/requirements/static/ci/py3.10/cloud.lock b/requirements/static/ci/py3.10/cloud.lock index 1d2ba7f382a7..157071985a8a 100644 --- a/requirements/static/ci/py3.10/cloud.lock +++ b/requirements/static/ci/py3.10/cloud.lock @@ -77,7 +77,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock @@ -123,6 +123,11 @@ clustershell==1.9.1 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.10/linux.lock + # -c requirements/static/pkg/py3.10/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.10/linux.lock @@ -215,11 +220,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.10/linux.lock @@ -229,6 +229,12 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.10/linux.lock + # -c requirements/static/pkg/py3.10/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -302,7 +308,7 @@ junit-xml==1.9 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.6.7 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/common.txt @@ -347,7 +353,7 @@ markdown-it-py==3.0.0 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock @@ -393,59 +399,19 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 +ncclient==0.6.13 # via # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc netaddr==0.8.0 - # via -r requirements/static/ci/cloud.txt -oauthlib==3.3.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 # via # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 + # -r requirements/static/ci/cloud.txt + # junos-eznc +oauthlib==3.3.1 # via # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-sdk + # requests-oauthlib oscrypto==1.3.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -460,6 +426,7 @@ paramiko==5.0.0 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -483,23 +450,12 @@ portend==3.1.0 # cherrypy profitbricks==4.1.3 # via -r requirements/static/ci/cloud.txt -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.10/linux.lock @@ -508,10 +464,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/ci/py3.10/linux.lock @@ -579,7 +531,6 @@ pytest==8.4.2 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -588,10 +539,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -681,7 +628,7 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamlordereddictloader -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock @@ -697,7 +644,6 @@ requests==2.31.0 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # profitbricks # pywinrm # requests-ntlm @@ -714,10 +660,6 @@ responses==0.23.1 # via # -c requirements/static/ci/py3.10/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.10/linux.lock @@ -764,11 +706,11 @@ six==1.16.0 # junos-eznc # kazoo # kubernetes + # ncclient # profitbricks # python-dateutil # pyvmomi # pywinrm - # rfc3339-validator # textfsm # transitions # vcert @@ -800,9 +742,7 @@ textfsm==1.1.3 # -r requirements/static/ci/common.txt timelib==0.3.0 # via - # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -825,11 +765,6 @@ trustme==1.1.0 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/ci/py3.10/linux.lock @@ -852,10 +787,6 @@ typing-extensions==4.14.1 # aiosignal # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics # virtualenv @@ -909,15 +840,9 @@ xmltodict==0.13.0 # -c requirements/static/ci/py3.10/linux.lock # moto # pywinrm -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt yamlordereddictloader==0.4.0 # via # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.20.1 # via diff --git a/requirements/static/ci/py3.10/darwin.lock b/requirements/static/ci/py3.10/darwin.lock index 26b4cefc5991..f194f7fa3ce9 100644 --- a/requirements/static/ci/py3.10/darwin.lock +++ b/requirements/static/ci/py3.10/darwin.lock @@ -63,7 +63,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.10/darwin.lock # -r requirements/base.txt @@ -97,6 +97,10 @@ cherrypy==18.10.0 # -r requirements/static/ci/common.txt clustershell==1.9.1 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.10/darwin.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.10/darwin.lock @@ -162,10 +166,6 @@ gitpython==3.1.50 # -r requirements/base.txt # -r requirements/static/ci/common.txt # -r requirements/static/ci/darwin.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/darwin.txt idna==3.18 @@ -176,15 +176,18 @@ idna==3.18 # requests # trustme # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -c requirements/static/pkg/py3.10/darwin.lock + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.10/darwin.lock # -r requirements/base.txt # keyring iniconfig==2.0.0 # via pytest -invoke==3.0.3 - # via paramiko jaraco-classes==3.4.0 # via keyring jaraco-collections==4.1.0 @@ -226,7 +229,7 @@ jsonschema==3.2.0 # via -r requirements/static/ci/common.txt junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.6.7 # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt @@ -250,7 +253,7 @@ markdown-it-py==3.0.0 # -c requirements/constraints.txt # -c requirements/static/pkg/py3.10/darwin.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.10/darwin.lock # -r requirements/base.txt @@ -288,47 +291,12 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.6.13 + # via junos-eznc +netaddr==0.8.0 + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator packaging==26.2 @@ -336,8 +304,9 @@ packaging==26.2 # -c requirements/static/pkg/py3.10/darwin.lock # -r requirements/base.txt # pytest -paramiko==5.0.0 +paramiko==3.4.0 # via + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -355,20 +324,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.10/darwin.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.10/darwin.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.10/darwin.lock @@ -376,8 +336,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.10/darwin.lock @@ -394,7 +352,7 @@ pycryptodomex==3.23.0 # -r requirements/static/ci/common.txt pyfakefs==5.3.1 # via -r requirements/pytest.txt -pygit2==1.17.0 +pygit2==1.13.1 # via -r requirements/static/ci/darwin.txt pygments==2.20.0 # via @@ -419,7 +377,6 @@ pyserial==3.5 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -428,8 +385,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -492,7 +447,7 @@ pyyaml==6.0.3 # responses # yamllint # yamlordereddictloader -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.10/darwin.lock # -r requirements/zeromq.txt @@ -506,7 +461,6 @@ requests==2.31.0 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -514,8 +468,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.23.1 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -544,9 +496,9 @@ six==1.17.0 # junit-xml # junos-eznc # kubernetes + # ncclient # python-dateutil # pyvmomi - # rfc3339-validator # textfsm # transitions # vcert @@ -564,10 +516,6 @@ tempora==5.3.0 # portend textfsm==1.1.3 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tomli==2.2.1 @@ -580,10 +528,6 @@ transitions==0.9.0 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/pkg/py3.10/darwin.lock @@ -601,10 +545,6 @@ typing-extensions==4.14.1 # aiosignal # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics # virtualenv @@ -643,16 +583,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==0.13.0 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.10/darwin.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/darwin.txt yamlordereddictloader==0.4.0 - # via - # -r requirements/static/ci/common.txt - # junos-eznc + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.10/darwin.lock diff --git a/requirements/static/ci/py3.10/docs.lock b/requirements/static/ci/py3.10/docs.lock index cc363397e4c8..3f7603350d15 100644 --- a/requirements/static/ci/py3.10/docs.lock +++ b/requirements/static/ci/py3.10/docs.lock @@ -42,7 +42,7 @@ backports-tarfile==1.2.0 # jaraco-context beautifulsoup4==4.14.3 # via pydata-sphinx-theme -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt @@ -66,6 +66,10 @@ cherrypy==18.10.0 # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt # -r requirements/static/ci/docs.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.10/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.10/linux.lock @@ -107,10 +111,6 @@ gitpython==3.1.50 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.10/linux.lock @@ -119,6 +119,11 @@ idna==3.18 # yarl imagesize==1.4.1 # via sphinx +immutables==0.21 + # via + # -c requirements/static/ci/py3.10/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -168,7 +173,7 @@ markdown-it-py==3.0.0 # mdit-py-plugins # myst-docutils # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt @@ -200,41 +205,6 @@ multidict==6.7.1 # yarl myst-docutils==4.0.1 # via -r requirements/static/ci/docs.txt -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # opentelemetry-sdk packaging==26.2 # via # -c requirements/static/ci/py3.10/linux.lock @@ -249,20 +219,11 @@ portend==3.1.0 # via # -c requirements/static/ci/py3.10/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.10/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.10/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.10/linux.lock @@ -318,7 +279,7 @@ pyyaml==6.0.3 # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt # myst-docutils -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/zeromq.txt @@ -327,7 +288,6 @@ requests==2.31.0 # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http # sphinx rich==15.0.0 # via @@ -379,18 +339,10 @@ tempora==5.3.0 # via # -c requirements/static/ci/py3.10/linux.lock # portend -timelib==0.3.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/base.txt tornado==6.5.7 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/ci/py3.10/linux.lock @@ -407,10 +359,6 @@ typing-extensions==4.14.1 # beautifulsoup4 # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pydata-sphinx-theme # pyopenssl # virtualenv @@ -425,10 +373,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/base.txt -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/base.txt yarl==1.20.1 # via # -c requirements/static/ci/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/freebsd.lock b/requirements/static/ci/py3.10/freebsd.lock index b9e7444fecc2..b3f8182130f3 100644 --- a/requirements/static/ci/py3.10/freebsd.lock +++ b/requirements/static/ci/py3.10/freebsd.lock @@ -48,7 +48,6 @@ backports-tarfile==1.2.0 ; python_full_version < '3.12' # jaraco-context bcrypt==4.0.1 # via - # -c requirements/constraints.txt # -r requirements/static/ci/common.txt # paramiko boto==2.49.0 @@ -63,7 +62,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt @@ -98,7 +97,7 @@ cherrypy==18.10.0 # -r requirements/base.txt # -r requirements/static/ci/common.txt # -r requirements/static/pkg/freebsd.txt -clr-loader==0.3.1 ; sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.10/freebsd.lock # pythonnet @@ -109,6 +108,10 @@ colorama==0.4.6 ; sys_platform == 'win32' # -c requirements/static/pkg/py3.10/freebsd.lock # pytest # typer +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.10/freebsd.lock + # -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via # -c requirements/static/pkg/py3.10/freebsd.lock @@ -176,10 +179,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/freebsd.txt idna==3.18 @@ -190,6 +189,11 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.10/freebsd.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.10/freebsd.lock @@ -242,19 +246,11 @@ jmespath==1.1.0 # boto3 # botocore jsonschema==3.2.0 - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt + # via -r requirements/static/ci/common.txt junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.5.4 ; python_full_version == '3.11.*' and sys_platform != 'win32' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 ; python_full_version != '3.11.*' and sys_platform != 'win32' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +junos-eznc==2.6.7 ; sys_platform != 'win32' + # via -r requirements/static/ci/common.txt jxmlease==1.0.3 ; sys_platform != 'win32' # via -r requirements/static/ci/common.txt kazoo==2.9.0 ; sys_platform != 'darwin' and sys_platform != 'win32' @@ -265,6 +261,10 @@ kubernetes==36.0.2 # via -r requirements/static/ci/common.txt libnacl==2.1.0 ; sys_platform != 'darwin' and sys_platform != 'win32' # via -r requirements/static/ci/common.txt +linode-python==1.1.1 + # via + # -c requirements/static/pkg/py3.10/freebsd.lock + # -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via # -c requirements/static/pkg/py3.10/freebsd.lock @@ -290,7 +290,7 @@ markdown-it-py==4.2.0 ; python_full_version >= '3.11' # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt @@ -328,57 +328,15 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc -netaddr==1.3.0 ; python_full_version == '3.11.*' and sys_platform != 'win32' +ncclient==0.6.13 ; sys_platform != 'win32' + # via junos-eznc +netaddr==0.8.0 ; sys_platform != 'win32' # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # opentelemetry-sdk oscrypto==1.3.0 ; sys_platform != 'win32' # via certvalidator -packaging==24.0 ; python_full_version >= '3.11' - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # -r requirements/base.txt - # pytest -packaging==26.2 ; python_full_version < '3.11' +packaging==26.2 # via # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt @@ -404,20 +362,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.10/freebsd.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.10/freebsd.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.10/freebsd.lock @@ -425,8 +374,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.10/freebsd.lock @@ -455,6 +402,10 @@ pymssql==2.3.11 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.10/freebsd.lock + # -r requirements/base.txt pynacl==1.5.0 # via # -r requirements/static/ci/common.txt @@ -474,7 +425,6 @@ pyserial==3.5 ; sys_platform != 'win32' pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -483,8 +433,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -532,7 +480,7 @@ python-gnupg==0.5.6 # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.1.0 ; sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt @@ -562,7 +510,12 @@ pyyaml==6.0.3 # responses # yamllint # yamlordereddictloader -pyzmq==27.1.0 +pyzmq==25.1.2 ; python_full_version < '3.13' + # via + # -c requirements/static/pkg/py3.10/freebsd.lock + # -r requirements/zeromq.txt + # pytest-salt-factories +pyzmq==27.1.0 ; python_full_version >= '3.13' # via # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/zeromq.txt @@ -576,7 +529,6 @@ requests==2.31.0 ; python_full_version < '3.11' # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -589,7 +541,6 @@ requests==2.33.1 ; python_full_version >= '3.11' # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -597,8 +548,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.23.1 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -631,9 +580,9 @@ six==1.16.0 # junos-eznc # kazoo # kubernetes + # ncclient # python-dateutil # pyvmomi - # rfc3339-validator # textfsm # transitions # vcert @@ -654,7 +603,6 @@ textfsm==1.1.3 timelib==0.3.0 # via # -c requirements/static/pkg/py3.10/freebsd.lock - # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt toml==0.10.2 # via -r requirements/static/ci/common.txt @@ -668,10 +616,6 @@ transitions==0.9.0 ; sys_platform != 'win32' # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/pkg/py3.10/freebsd.lock @@ -689,13 +633,13 @@ typing-extensions==4.14.1 # aiosignal # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics # virtualenv +tzdata==2026.2 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.10/freebsd.lock + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.10/freebsd.lock @@ -738,16 +682,10 @@ xmltodict==1.0.4 # -c requirements/static/pkg/py3.10/freebsd.lock # -r requirements/base.txt # moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.10/freebsd.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/freebsd.txt yamlordereddictloader==0.4.0 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.10/freebsd.lock diff --git a/requirements/static/ci/py3.10/lint.lock b/requirements/static/ci/py3.10/lint.lock index 70d4e00d7cb1..170c66c2460b 100644 --- a/requirements/static/ci/py3.10/lint.lock +++ b/requirements/static/ci/py3.10/lint.lock @@ -59,7 +59,6 @@ async-timeout==4.0.3 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # aiohttp - # redis attrs==23.2.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -92,7 +91,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock @@ -141,6 +140,11 @@ clustershell==1.9.1 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.10/linux.lock + # -c requirements/static/pkg/py3.10/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.10/linux.lock @@ -227,11 +231,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.16.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -258,6 +257,12 @@ idna==3.18 # httpx # requests # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.10/linux.lock + # -c requirements/static/pkg/py3.10/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -330,7 +335,7 @@ junit-xml==1.9 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.6.7 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/common.txt @@ -375,7 +380,7 @@ markdown-it-py==3.0.0 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock @@ -421,57 +426,18 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 +ncclient==0.6.13 # via # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc -oauthlib==3.3.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 +netaddr==0.8.0 # via # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 + # junos-eznc +oauthlib==3.3.1 # via # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-sdk + # requests-oauthlib oscrypto==1.3.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -486,6 +452,7 @@ paramiko==5.0.0 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -508,23 +475,12 @@ portend==3.1.0 # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.10/linux.lock @@ -548,7 +504,7 @@ pycryptodomex==3.23.0 # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -pygit2==1.17.0 +pygit2==1.13.1 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/linux.txt @@ -655,12 +611,16 @@ pyyaml==6.0.3 # responses # yamllint # yamlordereddictloader -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/zeromq.txt -redis==7.4.0 +redis==3.5.3 + # via + # -c requirements/static/ci/py3.10/linux.lock + # redis-py-cluster +redis-py-cluster==2.1.3 # via # -c requirements/static/ci/py3.10/linux.lock # -r requirements/static/ci/linux.txt @@ -674,7 +634,6 @@ requests==2.31.0 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -692,10 +651,6 @@ responses==0.23.1 # via # -c requirements/static/ci/py3.10/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.10/linux.lock @@ -744,10 +699,10 @@ six==1.16.0 # junos-eznc # kazoo # kubernetes + # ncclient # python-consul # python-dateutil # pyvmomi - # rfc3339-validator # textfsm # transitions # vcert @@ -787,9 +742,7 @@ textfsm==1.1.3 # -r requirements/static/ci/common.txt timelib==0.3.0 # via - # -c requirements/static/ci/py3.10/linux.lock # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -811,11 +764,6 @@ transitions==0.9.0 # via # -c requirements/static/ci/py3.10/linux.lock # junos-eznc -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt twilio==9.10.9 # via # -c requirements/static/ci/py3.10/linux.lock @@ -843,10 +791,6 @@ typing-extensions==4.14.1 # astroid # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyjwt # pyopenssl # virtualenv @@ -897,11 +841,6 @@ xmltodict==0.13.0 # via # -c requirements/static/ci/py3.10/linux.lock # moto -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.10/linux.lock - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt yamllint==1.32.0 # via # -c requirements/static/ci/py3.10/linux.lock @@ -909,7 +848,6 @@ yamllint==1.32.0 yamlordereddictloader==0.4.0 # via # -c requirements/static/ci/py3.10/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.20.1 # via diff --git a/requirements/static/ci/py3.10/linux.lock b/requirements/static/ci/py3.10/linux.lock index 00a3cba24286..d8c26708e7f3 100644 --- a/requirements/static/ci/py3.10/linux.lock +++ b/requirements/static/ci/py3.10/linux.lock @@ -42,7 +42,6 @@ async-timeout==4.0.3 # via # -c requirements/static/pkg/py3.10/linux.lock # aiohttp - # redis attrs==23.2.0 # via # -c requirements/static/pkg/py3.10/linux.lock @@ -73,7 +72,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt @@ -109,6 +108,10 @@ cherrypy==18.10.0 # -r requirements/static/ci/common.txt clustershell==1.9.1 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.10/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.10/linux.lock @@ -177,10 +180,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.16.0 # via httpcore hglib==2.6.2 @@ -199,6 +198,11 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.10/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.10/linux.lock @@ -254,7 +258,7 @@ jsonschema==3.2.0 # via -r requirements/static/ci/common.txt junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.6.7 # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt @@ -282,7 +286,7 @@ markdown-it-py==3.0.0 # -c requirements/constraints.txt # -c requirements/static/pkg/py3.10/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/base.txt @@ -320,47 +324,12 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.6.13 + # via junos-eznc +netaddr==0.8.0 + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator packaging==26.2 @@ -372,6 +341,7 @@ packaging==26.2 paramiko==5.0.0 # via # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -389,20 +359,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.10/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.10/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.10/linux.lock @@ -410,8 +371,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.10/linux.lock @@ -428,7 +387,7 @@ pycryptodomex==3.23.0 # -r requirements/static/ci/common.txt pyfakefs==5.3.1 # via -r requirements/pytest.txt -pygit2==1.17.0 +pygit2==1.13.1 # via -r requirements/static/ci/linux.txt pygments==2.20.0 # via @@ -461,7 +420,6 @@ pyserial==3.5 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -470,8 +428,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -539,12 +495,14 @@ pyyaml==6.0.3 # responses # yamllint # yamlordereddictloader -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.10/linux.lock # -r requirements/zeromq.txt # pytest-salt-factories -redis==7.4.0 +redis==3.5.3 + # via redis-py-cluster +redis-py-cluster==2.1.3 # via -r requirements/static/ci/linux.txt requests==2.31.0 # via @@ -555,7 +513,6 @@ requests==2.31.0 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -567,8 +524,6 @@ resolvelib==1.0.1 # via ansible-core responses==0.23.1 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -600,10 +555,10 @@ six==1.16.0 # junos-eznc # kazoo # kubernetes + # ncclient # python-consul # python-dateutil # pyvmomi - # rfc3339-validator # textfsm # transitions # vcert @@ -627,10 +582,6 @@ tempora==5.3.0 # portend textfsm==1.1.3 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tomli==2.2.1 @@ -643,10 +594,6 @@ transitions==0.9.0 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt twilio==9.10.9 # via -r requirements/static/ci/linux.txt typer==0.26.7 @@ -666,10 +613,6 @@ typing-extensions==4.14.1 # aiosignal # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyjwt # pyopenssl # pytest-system-statistics @@ -709,16 +652,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==0.13.0 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.10/linux.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/linux.txt yamlordereddictloader==0.4.0 - # via - # -r requirements/static/ci/common.txt - # junos-eznc + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.10/linux.lock diff --git a/requirements/static/ci/py3.10/tools.lock b/requirements/static/ci/py3.10/tools.lock index ca2f9818d060..9cabdc1a93e4 100644 --- a/requirements/static/ci/py3.10/tools.lock +++ b/requirements/static/ci/py3.10/tools.lock @@ -31,7 +31,9 @@ markdown-it-py==3.0.0 # -c requirements/constraints.txt # rich markupsafe==2.1.5 - # via jinja2 + # via + # -r requirements/static/ci/tools.txt + # jinja2 mdurl==0.1.2 # via markdown-it-py packaging==23.1 diff --git a/requirements/static/ci/py3.10/windows.lock b/requirements/static/ci/py3.10/windows.lock index 12bb068a7062..28c77244e4ab 100644 --- a/requirements/static/ci/py3.10/windows.lock +++ b/requirements/static/ci/py3.10/windows.lock @@ -55,7 +55,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.10/windows.lock # -r requirements/base.txt @@ -101,6 +101,10 @@ colorama==0.4.6 # -c requirements/static/pkg/py3.10/windows.lock # click # pytest +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.10/windows.lock + # -r requirements/base.txt cryptography==48.0.0 # via # -c requirements/static/pkg/py3.10/windows.lock @@ -163,10 +167,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.10/windows.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/pkg/py3.10/windows.lock @@ -175,7 +175,12 @@ idna==3.18 # requests # trustme # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -c requirements/static/pkg/py3.10/windows.lock + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.10/windows.lock # -r requirements/base.txt @@ -242,7 +247,7 @@ markdown-it-py==3.0.0 # -c requirements/constraints.txt # -c requirements/static/pkg/py3.10/windows.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.10/windows.lock # -r requirements/base.txt @@ -255,7 +260,7 @@ mdurl==0.1.2 # markdown-it-py mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.10/windows.lock # -r requirements/base.txt @@ -280,41 +285,6 @@ multidict==6.7.1 # yarl oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # opentelemetry-sdk packaging==26.2 # via # -c requirements/static/pkg/py3.10/windows.lock @@ -337,20 +307,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.10/windows.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.10/windows.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.10/windows.lock @@ -358,8 +319,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.10/windows.lock @@ -387,6 +346,10 @@ pymssql==2.3.11 # via # -c requirements/static/pkg/py3.10/windows.lock # -r requirements/base.txt +pymysql==1.2.0 + # via + # -c requirements/static/pkg/py3.10/windows.lock + # -r requirements/base.txt pynacl==1.5.0 # via -r requirements/static/ci/common.txt pyopenssl==26.2.0 @@ -401,7 +364,6 @@ pyspnego==0.12.0 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -410,8 +372,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -482,7 +442,7 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.10/windows.lock # -r requirements/zeromq.txt @@ -496,7 +456,6 @@ requests==2.31.0 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # pywinrm # requests-ntlm # requests-oauthlib @@ -507,8 +466,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.23.1 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==14.3.3 @@ -538,7 +495,6 @@ six==1.17.0 # kubernetes # python-dateutil # pyvmomi - # rfc3339-validator # textfsm smmap==5.0.2 # via @@ -556,10 +512,6 @@ tempora==5.8.1 # portend textfsm==1.1.3 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tomli==2.2.1 @@ -570,10 +522,6 @@ tornado==6.5.7 # -r requirements/base.txt trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # -r requirements/base.txt typer==0.24.1 # via # -c requirements/static/pkg/py3.10/windows.lock @@ -591,13 +539,13 @@ typing-extensions==4.15.0 # aiosignal # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics # virtualenv +tzdata==2026.2 + # via + # -c requirements/static/pkg/py3.10/windows.lock + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.10/windows.lock @@ -639,10 +587,6 @@ xmltodict==1.0.4 # -r requirements/base.txt # moto # pywinrm -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.10/windows.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/windows.txt yarl==1.23.0 diff --git a/requirements/static/ci/py3.11/changelog.lock b/requirements/static/ci/py3.11/changelog.lock index fe28b1f2224b..038a083cdd73 100644 --- a/requirements/static/ci/py3.11/changelog.lock +++ b/requirements/static/ci/py3.11/changelog.lock @@ -10,11 +10,11 @@ looseversion==1.3.0 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/changelog.txt -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.11/linux.lock # jinja2 -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/changelog.txt diff --git a/requirements/static/ci/py3.11/cloud.lock b/requirements/static/ci/py3.11/cloud.lock index 841e07d0499e..c297cd16c69a 100644 --- a/requirements/static/ci/py3.11/cloud.lock +++ b/requirements/static/ci/py3.11/cloud.lock @@ -45,14 +45,14 @@ attrs==23.2.0 # pytest-shell-utilities # pytest-skip-markers # pytest-system-statistics + # referencing backports-tarfile==1.2.0 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # jaraco-context -bcrypt==4.3.0 +bcrypt==5.0.0 # via - # -c requirements/constraints.txt # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/common.txt # paramiko @@ -72,7 +72,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock @@ -118,6 +118,11 @@ clustershell==1.9.3 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.11/linux.lock + # -c requirements/static/pkg/py3.11/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.11/linux.lock @@ -202,11 +207,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.11/linux.lock @@ -216,6 +216,12 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.11/linux.lock + # -c requirements/static/pkg/py3.11/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -281,18 +287,20 @@ jmespath==1.1.0 # -r requirements/static/ci/common.txt # boto3 # botocore -jsonschema==3.2.0 +jsonschema==4.26.0 # via - # -c requirements/constraints.txt # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/common.txt +jsonschema-specifications==2025.9.1 + # via + # -c requirements/static/ci/py3.11/linux.lock + # jsonschema junit-xml==1.9 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.5.4 +junos-eznc==2.7.6 # via - # -c requirements/constraints.txt # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/common.txt jxmlease==1.0.3 @@ -335,7 +343,7 @@ markdown-it-py==4.2.0 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock @@ -381,67 +389,21 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/static/ci/common.txt - # junos-eznc -netaddr==1.3.0 +ncclient==0.7.0 # via # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/static/ci/cloud.txt # junos-eznc +netaddr==0.8.0 + # via -r requirements/static/ci/cloud.txt oauthlib==3.3.1 # via # -c requirements/static/ci/py3.11/linux.lock # requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via # -c requirements/static/ci/py3.11/linux.lock # certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock @@ -475,23 +437,12 @@ portend==3.1.0 # cherrypy profitbricks==4.1.3 # via -r requirements/static/ci/cloud.txt -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.11/linux.lock @@ -500,10 +451,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/ci/py3.11/linux.lock @@ -554,10 +501,6 @@ pyparsing==3.3.2 # junos-eznc pypsexec==0.3.0 # via -r requirements/static/ci/cloud.txt -pyrsistent==0.20.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # jsonschema pyserial==3.5 # via # -c requirements/static/ci/py3.11/linux.lock @@ -571,7 +514,6 @@ pytest==8.4.2 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -580,10 +522,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -672,13 +610,18 @@ pyyaml==6.0.3 # kubernetes # pytest-salt-factories # responses - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/zeromq.txt # pytest-salt-factories +referencing==0.37.0 + # via + # -c requirements/static/ci/py3.11/linux.lock + # jsonschema + # jsonschema-specifications requests==2.33.1 # via # -c requirements/static/ci/py3.11/linux.lock @@ -689,7 +632,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # profitbricks # pywinrm # requests-ntlm @@ -706,10 +648,6 @@ responses==0.25.8 # via # -c requirements/static/ci/py3.11/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.11/linux.lock @@ -719,6 +657,11 @@ rich==15.0.0 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # typer +rpds-py==0.30.0 + # via + # -c requirements/static/ci/py3.11/linux.lock + # jsonschema + # referencing s3transfer==0.18.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -751,14 +694,12 @@ six==1.16.0 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # etcd3-py - # jsonschema # junit-xml # junos-eznc # kubernetes # profitbricks # python-dateutil # pywinrm - # rfc3339-validator # transitions # vcert smbprotocol==1.10.1 @@ -789,9 +730,7 @@ textfsm==2.1.0 # -r requirements/static/ci/common.txt timelib==0.3.0 # via - # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -810,11 +749,6 @@ trustme==1.1.0 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/ci/py3.11/linux.lock @@ -831,12 +765,9 @@ typing-extensions==4.14.1 # -c requirements/static/pkg/py3.11/linux.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics + # referencing urllib3==2.7.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -887,15 +818,9 @@ xmltodict==1.0.2 # -c requirements/static/ci/py3.11/linux.lock # moto # pywinrm -xxhash==3.7.0 +yamlloader==1.6.0 # via # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt -yamlordereddictloader==0.4.2 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.20.1 # via diff --git a/requirements/static/ci/py3.11/darwin.lock b/requirements/static/ci/py3.11/darwin.lock index f79dea884cc0..34f739865151 100644 --- a/requirements/static/ci/py3.11/darwin.lock +++ b/requirements/static/ci/py3.11/darwin.lock @@ -38,13 +38,13 @@ attrs==23.2.0 # pytest-shell-utilities # pytest-skip-markers # pytest-system-statistics + # referencing backports-tarfile==1.2.0 # via # -c requirements/static/pkg/py3.11/darwin.lock # jaraco-context -bcrypt==4.3.0 +bcrypt==5.0.0 # via - # -c requirements/constraints.txt # -r requirements/static/ci/common.txt # paramiko boto==2.49.0 @@ -59,7 +59,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.11/darwin.lock # -r requirements/base.txt @@ -93,6 +93,10 @@ cherrypy==18.10.0 # -r requirements/static/ci/common.txt clustershell==1.9.3 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.11/darwin.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.11/darwin.lock @@ -154,10 +158,6 @@ gitpython==3.1.50 # -r requirements/base.txt # -r requirements/static/ci/common.txt # -r requirements/static/ci/darwin.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/darwin.txt idna==3.18 @@ -168,7 +168,12 @@ idna==3.18 # requests # trustme # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -c requirements/static/pkg/py3.11/darwin.lock + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.11/darwin.lock # -r requirements/base.txt @@ -214,16 +219,14 @@ jmespath==1.1.0 # -r requirements/static/ci/common.txt # boto3 # botocore -jsonschema==3.2.0 - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +jsonschema==4.26.0 + # via -r requirements/static/ci/common.txt +jsonschema-specifications==2025.9.1 + # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.5.4 - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +junos-eznc==2.7.6 + # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt keyring==25.7.0 @@ -245,7 +248,7 @@ markdown-it-py==4.2.0 # via # -c requirements/static/pkg/py3.11/darwin.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.11/darwin.lock # -r requirements/base.txt @@ -260,7 +263,7 @@ mercurial==7.2.2 # via -r requirements/static/ci/darwin.txt mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.11/darwin.lock # -r requirements/base.txt @@ -283,52 +286,13 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -r requirements/static/ci/common.txt - # junos-eznc -netaddr==1.3.0 +ncclient==0.7.0 # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.11/darwin.lock # -r requirements/base.txt @@ -353,20 +317,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.11/darwin.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.11/darwin.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.11/darwin.lock @@ -374,8 +329,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.11/darwin.lock @@ -410,14 +363,11 @@ pyopenssl==26.2.0 # etcd3-py pyparsing==3.3.2 # via junos-eznc -pyrsistent==0.20.0 - # via jsonschema pyserial==3.5 # via junos-eznc pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -426,8 +376,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -489,12 +437,16 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.11/darwin.lock # -r requirements/zeromq.txt # pytest-salt-factories +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications requests==2.33.1 # via # -c requirements/static/pkg/py3.11/darwin.lock @@ -504,7 +456,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -512,14 +463,16 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 # via # -c requirements/static/pkg/py3.11/darwin.lock # typer +rpds-py==0.30.0 + # via + # jsonschema + # referencing s3transfer==0.18.0 # via boto3 scp==0.15.0 @@ -538,12 +491,10 @@ six==1.17.0 # via # -c requirements/static/pkg/py3.11/darwin.lock # etcd3-py - # jsonschema # junit-xml # junos-eznc # kubernetes # python-dateutil - # rfc3339-validator # transitions # vcert smmap==5.0.2 @@ -560,10 +511,6 @@ tempora==5.3.0 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -574,10 +521,6 @@ transitions==0.9.3 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/pkg/py3.11/darwin.lock @@ -591,12 +534,9 @@ typing-extensions==4.14.1 # -c requirements/static/pkg/py3.11/darwin.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics + # referencing urllib3==2.7.0 # via # -c requirements/static/pkg/py3.11/darwin.lock @@ -632,16 +572,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==1.0.2 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.11/darwin.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/darwin.txt -yamlordereddictloader==0.4.2 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.11/darwin.lock diff --git a/requirements/static/ci/py3.11/docs.lock b/requirements/static/ci/py3.11/docs.lock index b7cc76120752..090f01b81238 100644 --- a/requirements/static/ci/py3.11/docs.lock +++ b/requirements/static/ci/py3.11/docs.lock @@ -38,7 +38,7 @@ backports-tarfile==1.2.0 # jaraco-context beautifulsoup4==4.14.3 # via pydata-sphinx-theme -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt @@ -62,6 +62,10 @@ cherrypy==18.10.0 # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt # -r requirements/static/ci/docs.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.11/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.11/linux.lock @@ -103,10 +107,6 @@ gitpython==3.1.50 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.11/linux.lock @@ -115,6 +115,11 @@ idna==3.18 # yarl imagesize==1.4.1 # via sphinx +immutables==0.21 + # via + # -c requirements/static/ci/py3.11/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -163,7 +168,7 @@ markdown-it-py==4.2.0 # mdit-py-plugins # myst-docutils # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt @@ -195,42 +200,7 @@ multidict==6.7.1 # yarl myst-docutils==5.1.0 # via -r requirements/static/ci/docs.txt -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt @@ -244,20 +214,11 @@ portend==3.1.0 # via # -c requirements/static/ci/py3.11/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.11/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.11/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.11/linux.lock @@ -313,7 +274,7 @@ pyyaml==6.0.3 # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt # myst-docutils -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/zeromq.txt @@ -322,7 +283,6 @@ requests==2.33.1 # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http # sphinx rich==15.0.0 # via @@ -374,18 +334,10 @@ tempora==5.3.0 # via # -c requirements/static/ci/py3.11/linux.lock # portend -timelib==0.3.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/base.txt tornado==6.5.7 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/ci/py3.11/linux.lock @@ -400,10 +352,6 @@ typing-extensions==4.14.1 # aiohttp # aiosignal # beautifulsoup4 - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pydata-sphinx-theme # pyopenssl uc-micro-py==1.0.1 @@ -417,10 +365,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/base.txt -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/base.txt yarl==1.20.1 # via # -c requirements/static/ci/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/freebsd.lock b/requirements/static/ci/py3.11/freebsd.lock index 8a440cc6a941..56d91ba4b63b 100644 --- a/requirements/static/ci/py3.11/freebsd.lock +++ b/requirements/static/ci/py3.11/freebsd.lock @@ -42,14 +42,8 @@ backports-tarfile==1.2.0 ; python_full_version < '3.12' # via # -c requirements/static/pkg/py3.11/freebsd.lock # jaraco-context -bcrypt==4.3.0 ; python_full_version < '3.12' +bcrypt==5.0.0 # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt - # paramiko -bcrypt==5.0.0 ; python_full_version >= '3.12' - # via - # -c requirements/constraints.txt # -r requirements/static/ci/common.txt # paramiko boto==2.49.0 @@ -64,7 +58,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt @@ -99,7 +93,7 @@ cherrypy==18.10.0 # -r requirements/base.txt # -r requirements/static/ci/common.txt # -r requirements/static/pkg/freebsd.txt -clr-loader==0.3.1 ; sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.11/freebsd.lock # pythonnet @@ -110,6 +104,10 @@ colorama==0.4.6 ; sys_platform == 'win32' # -c requirements/static/pkg/py3.11/freebsd.lock # pytest # typer +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.11/freebsd.lock + # -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via # -c requirements/static/pkg/py3.11/freebsd.lock @@ -173,10 +171,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/freebsd.txt idna==3.18 @@ -187,6 +181,11 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.11/freebsd.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.11/freebsd.lock @@ -238,26 +237,14 @@ jmespath==1.1.0 # -r requirements/static/ci/common.txt # boto3 # botocore -jsonschema==3.2.0 ; python_full_version < '3.12' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt -jsonschema==4.26.0 ; python_full_version >= '3.12' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt -jsonschema-specifications==2025.9.1 ; python_full_version >= '3.12' +jsonschema==4.26.0 + # via -r requirements/static/ci/common.txt +jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.5.4 ; python_full_version < '3.12' and sys_platform != 'win32' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 ; python_full_version >= '3.12' and sys_platform != 'win32' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +junos-eznc==2.7.6 ; sys_platform != 'win32' + # via -r requirements/static/ci/common.txt jxmlease==1.0.3 ; sys_platform != 'win32' # via -r requirements/static/ci/common.txt kazoo==2.10.0 ; sys_platform != 'darwin' and sys_platform != 'win32' @@ -268,6 +255,10 @@ kubernetes==36.0.2 # via -r requirements/static/ci/common.txt libnacl==2.1.0 ; sys_platform != 'darwin' and sys_platform != 'win32' # via -r requirements/static/ci/common.txt +linode-python==1.1.1 + # via + # -c requirements/static/pkg/py3.11/freebsd.lock + # -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via # -c requirements/static/pkg/py3.11/freebsd.lock @@ -287,7 +278,7 @@ markdown-it-py==4.2.0 # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt @@ -302,7 +293,7 @@ mercurial==7.2.2 # via -r requirements/static/ci/freebsd.txt mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt @@ -325,52 +316,13 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc -netaddr==1.3.0 ; python_full_version < '3.12' and sys_platform != 'win32' +ncclient==0.7.0 ; sys_platform != 'win32' # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # opentelemetry-sdk oscrypto==1.3.0 ; sys_platform != 'win32' # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt @@ -396,20 +348,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.11/freebsd.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.11/freebsd.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.11/freebsd.lock @@ -417,8 +360,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.11/freebsd.lock @@ -447,6 +388,10 @@ pymssql==2.3.11 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.11/freebsd.lock + # -r requirements/base.txt pynacl==1.6.2 # via # -r requirements/static/ci/common.txt @@ -459,14 +404,11 @@ pyopenssl==26.2.0 # etcd3-py pyparsing==3.3.2 ; sys_platform != 'win32' # via junos-eznc -pyrsistent==0.20.0 ; python_full_version < '3.12' - # via jsonschema pyserial==3.5 ; sys_platform != 'win32' # via junos-eznc pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -475,8 +417,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -524,7 +464,7 @@ python-gnupg==0.5.6 # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.1.0 ; sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt @@ -553,13 +493,18 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 ; python_full_version < '3.13' # via # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/zeromq.txt # pytest-salt-factories -referencing==0.37.0 ; python_full_version >= '3.12' +pyzmq==27.1.0 ; python_full_version >= '3.13' + # via + # -c requirements/static/pkg/py3.11/freebsd.lock + # -r requirements/zeromq.txt + # pytest-salt-factories +referencing==0.37.0 # via # jsonschema # jsonschema-specifications @@ -572,7 +517,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -580,15 +524,13 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 # via # -c requirements/static/pkg/py3.11/freebsd.lock # typer -rpds-py==0.30.0 ; python_full_version >= '3.12' +rpds-py==0.30.0 # via # jsonschema # referencing @@ -613,12 +555,10 @@ six==1.16.0 # via # -c requirements/static/pkg/py3.11/freebsd.lock # etcd3-py - # jsonschema # junit-xml # junos-eznc # kubernetes # python-dateutil - # rfc3339-validator # transitions # vcert smmap==5.0.2 @@ -638,7 +578,6 @@ textfsm==2.1.0 timelib==0.3.0 # via # -c requirements/static/pkg/py3.11/freebsd.lock - # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt toml==0.10.2 # via -r requirements/static/ci/common.txt @@ -650,10 +589,6 @@ transitions==0.9.3 ; sys_platform != 'win32' # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/pkg/py3.11/freebsd.lock @@ -667,13 +602,13 @@ typing-extensions==4.14.1 # -c requirements/static/pkg/py3.11/freebsd.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics # referencing +tzdata==2026.2 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.11/freebsd.lock + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.11/freebsd.lock @@ -716,16 +651,10 @@ xmltodict==1.0.4 # -c requirements/static/pkg/py3.11/freebsd.lock # -r requirements/base.txt # moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.11/freebsd.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/freebsd.txt -yamlordereddictloader==0.4.2 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 ; sys_platform != 'win32' + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.11/freebsd.lock diff --git a/requirements/static/ci/py3.11/lint.lock b/requirements/static/ci/py3.11/lint.lock index 47d6fa7334ac..6a74d0a277ca 100644 --- a/requirements/static/ci/py3.11/lint.lock +++ b/requirements/static/ci/py3.11/lint.lock @@ -29,11 +29,11 @@ annotated-doc==0.0.4 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # typer -ansible==10.7.0 +ansible==12.3.0 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/linux.txt -ansible-core==2.17.14 +ansible-core==2.19.7 # via # -c requirements/static/ci/py3.11/linux.lock # ansible @@ -54,24 +54,20 @@ asn1crypto==1.5.1 # oscrypto astroid==3.1.0 # via pylint -async-timeout==5.0.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # redis attrs==23.2.0 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # aiohttp # jsonschema + # referencing backports-tarfile==1.2.0 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # jaraco-context -bcrypt==4.3.0 +bcrypt==5.0.0 # via - # -c requirements/constraints.txt # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/common.txt # paramiko @@ -91,7 +87,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock @@ -140,6 +136,11 @@ clustershell==1.9.3 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.11/linux.lock + # -c requirements/static/pkg/py3.11/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.11/linux.lock @@ -218,11 +219,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.16.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -249,6 +245,12 @@ idna==3.18 # httpx # requests # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.11/linux.lock + # -c requirements/static/pkg/py3.11/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -313,18 +315,20 @@ jmespath==1.1.0 # -r requirements/static/ci/common.txt # boto3 # botocore -jsonschema==3.2.0 +jsonschema==4.26.0 # via - # -c requirements/constraints.txt # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/common.txt +jsonschema-specifications==2025.9.1 + # via + # -c requirements/static/ci/py3.11/linux.lock + # jsonschema junit-xml==1.9 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.5.4 +junos-eznc==2.7.6 # via - # -c requirements/constraints.txt # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/common.txt jxmlease==1.0.3 @@ -367,7 +371,7 @@ markdown-it-py==4.2.0 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock @@ -413,12 +417,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/static/ci/common.txt - # junos-eznc -netaddr==1.3.0 +ncclient==0.7.0 # via # -c requirements/static/ci/py3.11/linux.lock # junos-eznc @@ -426,53 +425,11 @@ oauthlib==3.3.1 # via # -c requirements/static/ci/py3.11/linux.lock # requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via # -c requirements/static/ci/py3.11/linux.lock # certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock @@ -505,23 +462,12 @@ portend==3.1.0 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.11/linux.lock @@ -590,10 +536,6 @@ pyparsing==3.3.2 # via # -c requirements/static/ci/py3.11/linux.lock # junos-eznc -pyrsistent==0.20.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # jsonschema pyserial==3.5 # via # -c requirements/static/ci/py3.11/linux.lock @@ -651,16 +593,25 @@ pyyaml==6.0.3 # kubernetes # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/zeromq.txt -redis==7.4.0 +redis==3.5.3 + # via + # -c requirements/static/ci/py3.11/linux.lock + # redis-py-cluster +redis-py-cluster==2.1.3 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/linux.txt +referencing==0.37.0 + # via + # -c requirements/static/ci/py3.11/linux.lock + # jsonschema + # jsonschema-specifications requests==2.33.1 # via # -c requirements/static/ci/py3.11/linux.lock @@ -671,7 +622,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -689,10 +639,6 @@ responses==0.25.8 # via # -c requirements/static/ci/py3.11/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.11/linux.lock @@ -702,6 +648,11 @@ rich==15.0.0 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # typer +rpds-py==0.30.0 + # via + # -c requirements/static/ci/py3.11/linux.lock + # jsonschema + # referencing s3transfer==0.18.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -736,13 +687,11 @@ six==1.16.0 # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock # etcd3-py - # jsonschema # junit-xml # junos-eznc # kubernetes # python-consul # python-dateutil - # rfc3339-validator # transitions # vcert slack-bolt==1.18.0 @@ -781,9 +730,7 @@ textfsm==2.1.0 # -r requirements/static/ci/common.txt timelib==0.3.0 # via - # -c requirements/static/ci/py3.11/linux.lock # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -801,11 +748,6 @@ transitions==0.9.3 # via # -c requirements/static/ci/py3.11/linux.lock # junos-eznc -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt twilio==9.10.9 # via # -c requirements/static/ci/py3.11/linux.lock @@ -826,11 +768,8 @@ typing-extensions==4.14.1 # -c requirements/static/pkg/py3.11/linux.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl + # referencing urllib3==2.7.0 # via # -c requirements/static/ci/py3.11/linux.lock @@ -878,19 +817,13 @@ xmltodict==1.0.2 # via # -c requirements/static/ci/py3.11/linux.lock # moto -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.11/linux.lock - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt yamllint==1.32.0 # via # -c requirements/static/ci/py3.11/linux.lock # -r requirements/static/ci/linux.txt -yamlordereddictloader==0.4.2 +yamlloader==1.6.0 # via # -c requirements/static/ci/py3.11/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.20.1 # via diff --git a/requirements/static/ci/py3.11/linux.lock b/requirements/static/ci/py3.11/linux.lock index 82370134264e..f71f818fb075 100644 --- a/requirements/static/ci/py3.11/linux.lock +++ b/requirements/static/ci/py3.11/linux.lock @@ -23,9 +23,9 @@ annotated-doc==0.0.4 # via # -c requirements/static/pkg/py3.11/linux.lock # typer -ansible==10.7.0 +ansible==12.3.0 # via -r requirements/static/ci/linux.txt -ansible-core==2.17.14 +ansible-core==2.19.7 # via ansible anyio==4.1.0 # via httpx @@ -38,8 +38,6 @@ asn1crypto==1.5.1 # via # certvalidator # oscrypto -async-timeout==5.0.1 - # via redis attrs==23.2.0 # via # -c requirements/static/pkg/py3.11/linux.lock @@ -49,13 +47,13 @@ attrs==23.2.0 # pytest-shell-utilities # pytest-skip-markers # pytest-system-statistics + # referencing backports-tarfile==1.2.0 # via # -c requirements/static/pkg/py3.11/linux.lock # jaraco-context -bcrypt==4.3.0 +bcrypt==5.0.0 # via - # -c requirements/constraints.txt # -r requirements/static/ci/common.txt # paramiko boto==2.49.0 @@ -70,7 +68,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt @@ -106,6 +104,10 @@ cherrypy==18.10.0 # -r requirements/static/ci/common.txt clustershell==1.9.3 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.11/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.11/linux.lock @@ -168,10 +170,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.16.0 # via httpcore hglib==2.6.2 @@ -190,6 +188,11 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.11/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.11/linux.lock @@ -241,16 +244,14 @@ jmespath==1.1.0 # -r requirements/static/ci/common.txt # boto3 # botocore -jsonschema==3.2.0 - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +jsonschema==4.26.0 + # via -r requirements/static/ci/common.txt +jsonschema-specifications==2025.9.1 + # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.5.4 - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +junos-eznc==2.7.6 + # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt kazoo==2.10.0 @@ -276,7 +277,7 @@ markdown-it-py==4.2.0 # via # -c requirements/static/pkg/py3.11/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt @@ -314,52 +315,13 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -r requirements/static/ci/common.txt - # junos-eznc -netaddr==1.3.0 +ncclient==0.7.0 # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/base.txt @@ -386,20 +348,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.11/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.11/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.11/linux.lock @@ -407,8 +360,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.11/linux.lock @@ -451,14 +402,11 @@ pyopenssl==26.2.0 # etcd3-py pyparsing==3.3.2 # via junos-eznc -pyrsistent==0.20.0 - # via jsonschema pyserial==3.5 # via junos-eznc pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -467,8 +415,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -535,14 +481,20 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.11/linux.lock # -r requirements/zeromq.txt # pytest-salt-factories -redis==7.4.0 +redis==3.5.3 + # via redis-py-cluster +redis-py-cluster==2.1.3 # via -r requirements/static/ci/linux.txt +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications requests==2.33.1 # via # -c requirements/static/pkg/py3.11/linux.lock @@ -552,7 +504,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -564,14 +515,16 @@ resolvelib==1.0.1 # via ansible-core responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 # via # -c requirements/static/pkg/py3.11/linux.lock # typer +rpds-py==0.30.0 + # via + # jsonschema + # referencing s3transfer==0.18.0 # via boto3 scp==0.15.0 @@ -592,13 +545,11 @@ six==1.16.0 # via # -c requirements/static/pkg/py3.11/linux.lock # etcd3-py - # jsonschema # junit-xml # junos-eznc # kubernetes # python-consul # python-dateutil - # rfc3339-validator # transitions # vcert slack-bolt==1.18.0 @@ -621,10 +572,6 @@ tempora==5.3.0 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -635,10 +582,6 @@ transitions==0.9.3 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt twilio==9.10.9 # via -r requirements/static/ci/linux.txt typer==0.26.7 @@ -654,12 +597,9 @@ typing-extensions==4.14.1 # -c requirements/static/pkg/py3.11/linux.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics + # referencing urllib3==2.7.0 # via # -c requirements/static/pkg/py3.11/linux.lock @@ -695,16 +635,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==1.0.2 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.11/linux.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/linux.txt -yamlordereddictloader==0.4.2 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.11/linux.lock diff --git a/requirements/static/ci/py3.11/tools.lock b/requirements/static/ci/py3.11/tools.lock index 27e31170d5a3..f1ce45f8ce6e 100644 --- a/requirements/static/ci/py3.11/tools.lock +++ b/requirements/static/ci/py3.11/tools.lock @@ -29,7 +29,9 @@ jmespath==1.0.1 markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 - # via jinja2 + # via + # -r requirements/static/ci/tools.txt + # jinja2 mdurl==0.1.2 # via markdown-it-py packaging==23.1 diff --git a/requirements/static/ci/py3.11/windows.lock b/requirements/static/ci/py3.11/windows.lock index 9d018983f9f6..32b76c6e431d 100644 --- a/requirements/static/ci/py3.11/windows.lock +++ b/requirements/static/ci/py3.11/windows.lock @@ -32,14 +32,13 @@ attrs==25.4.0 # pytest-shell-utilities # pytest-skip-markers # pytest-system-statistics + # referencing backports-tarfile==1.2.0 # via # -c requirements/static/pkg/py3.11/windows.lock # jaraco-context -bcrypt==4.3.0 - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +bcrypt==5.0.0 + # via -r requirements/static/ci/common.txt boto==2.49.0 # via -r requirements/static/ci/common.txt boto3==1.43.25 @@ -52,7 +51,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt @@ -98,6 +97,10 @@ colorama==0.4.6 # -c requirements/static/pkg/py3.11/windows.lock # click # pytest +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.11/windows.lock + # -r requirements/base.txt cryptography==48.0.0 # via # -c requirements/static/pkg/py3.11/windows.lock @@ -156,10 +159,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/pkg/py3.11/windows.lock @@ -168,7 +167,12 @@ idna==3.18 # requests # trustme # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -c requirements/static/pkg/py3.11/windows.lock + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt @@ -211,10 +215,10 @@ jmespath==1.1.0 # -r requirements/static/ci/common.txt # boto3 # botocore -jsonschema==3.2.0 - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +jsonschema==4.26.0 + # via -r requirements/static/ci/common.txt +jsonschema-specifications==2025.9.1 + # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt keyring==25.7.0 @@ -236,7 +240,7 @@ markdown-it-py==4.0.0 # via # -c requirements/static/pkg/py3.11/windows.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt @@ -249,7 +253,7 @@ mdurl==0.1.2 # markdown-it-py mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt @@ -274,42 +278,7 @@ multidict==6.7.1 # yarl oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt @@ -331,20 +300,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.11/windows.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.11/windows.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.11/windows.lock @@ -352,8 +312,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.11/windows.lock @@ -381,6 +339,10 @@ pymssql==2.3.11 # via # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt +pymysql==1.2.0 + # via + # -c requirements/static/pkg/py3.11/windows.lock + # -r requirements/base.txt pynacl==1.6.2 # via -r requirements/static/ci/common.txt pyopenssl==26.2.0 @@ -388,14 +350,11 @@ pyopenssl==26.2.0 # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/base.txt # etcd3-py -pyrsistent==0.20.0 - # via jsonschema pyspnego==0.12.0 # via requests-ntlm pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -404,8 +363,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -476,11 +433,15 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.11/windows.lock # -r requirements/zeromq.txt # pytest-salt-factories +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications requests==2.33.1 # via # -c requirements/static/pkg/py3.11/windows.lock @@ -490,7 +451,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # pywinrm # requests-ntlm # requests-oauthlib @@ -501,14 +461,16 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==14.3.3 # via # -c requirements/static/pkg/py3.11/windows.lock # typer +rpds-py==0.30.0 + # via + # jsonschema + # referencing s3transfer==0.18.0 # via boto3 sed==0.3.1 @@ -527,11 +489,9 @@ six==1.17.0 # via # -c requirements/static/pkg/py3.11/windows.lock # etcd3-py - # jsonschema # junit-xml # kubernetes # python-dateutil - # rfc3339-validator smmap==5.0.2 # via # -c requirements/static/pkg/py3.11/windows.lock @@ -548,10 +508,6 @@ tempora==5.8.1 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -560,10 +516,6 @@ tornado==6.5.7 # -r requirements/base.txt trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # -r requirements/base.txt typer==0.24.1 # via # -c requirements/static/pkg/py3.11/windows.lock @@ -577,12 +529,13 @@ typing-extensions==4.15.0 # -c requirements/static/pkg/py3.11/windows.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics + # referencing +tzdata==2026.2 + # via + # -c requirements/static/pkg/py3.11/windows.lock + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.11/windows.lock @@ -624,10 +577,6 @@ xmltodict==1.0.4 # -r requirements/base.txt # moto # pywinrm -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.11/windows.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/windows.txt yarl==1.23.0 diff --git a/requirements/static/ci/py3.12/changelog.lock b/requirements/static/ci/py3.12/changelog.lock index 944979419f2a..7cd4f7fe9346 100644 --- a/requirements/static/ci/py3.12/changelog.lock +++ b/requirements/static/ci/py3.12/changelog.lock @@ -10,11 +10,11 @@ looseversion==1.3.0 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/changelog.txt -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.12/linux.lock # jinja2 -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/changelog.txt diff --git a/requirements/static/ci/py3.12/cloud.lock b/requirements/static/ci/py3.12/cloud.lock index 62f9c829099a..ceab15c46289 100644 --- a/requirements/static/ci/py3.12/cloud.lock +++ b/requirements/static/ci/py3.12/cloud.lock @@ -67,7 +67,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock @@ -113,6 +113,11 @@ clustershell==1.9.3 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.12/linux.lock + # -c requirements/static/pkg/py3.12/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.12/linux.lock @@ -197,11 +202,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.12/linux.lock @@ -211,6 +211,12 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.12/linux.lock + # -c requirements/static/pkg/py3.12/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.12/linux.lock @@ -287,7 +293,7 @@ junit-xml==1.9 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/common.txt @@ -331,7 +337,7 @@ markdown-it-py==4.2.0 # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock @@ -377,10 +383,9 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 +ncclient==0.7.0 # via # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc netaddr==0.8.0 # via -r requirements/static/ci/cloud.txt @@ -388,53 +393,11 @@ oauthlib==3.3.1 # via # -c requirements/static/ci/py3.12/linux.lock # requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via # -c requirements/static/ci/py3.12/linux.lock # certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock @@ -444,6 +407,7 @@ paramiko==5.0.0 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -467,23 +431,12 @@ portend==3.1.0 # cherrypy profitbricks==4.1.3 # via -r requirements/static/ci/cloud.txt -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.12/linux.lock @@ -492,10 +445,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via - # -c requirements/static/ci/py3.12/linux.lock - # pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/ci/py3.12/linux.lock @@ -559,7 +508,6 @@ pytest==8.4.2 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -568,10 +516,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via # -c requirements/static/ci/py3.12/linux.lock @@ -660,8 +604,8 @@ pyyaml==6.0.3 # kubernetes # pytest-salt-factories # responses - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock @@ -682,7 +626,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # profitbricks # pywinrm # requests-ntlm @@ -699,10 +642,6 @@ responses==0.25.8 # via # -c requirements/static/ci/py3.12/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.12/linux.lock @@ -755,7 +694,6 @@ six==1.16.0 # profitbricks # python-dateutil # pywinrm - # rfc3339-validator # transitions # vcert smbprotocol==1.10.1 @@ -786,9 +724,7 @@ textfsm==2.1.0 # -r requirements/static/ci/common.txt timelib==0.3.0 # via - # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -807,11 +743,6 @@ trustme==1.1.0 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/ci/py3.12/linux.lock @@ -828,10 +759,6 @@ typing-extensions==4.14.1 # -c requirements/static/pkg/py3.12/linux.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics # referencing @@ -885,15 +812,9 @@ xmltodict==1.0.2 # -c requirements/static/ci/py3.12/linux.lock # moto # pywinrm -xxhash==3.7.0 +yamlloader==1.6.0 # via # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt -yamlordereddictloader==0.4.2 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.20.1 # via diff --git a/requirements/static/ci/py3.12/darwin.lock b/requirements/static/ci/py3.12/darwin.lock index 3d66819b4266..a2b1f01a3752 100644 --- a/requirements/static/ci/py3.12/darwin.lock +++ b/requirements/static/ci/py3.12/darwin.lock @@ -55,7 +55,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.12/darwin.lock # -r requirements/base.txt @@ -89,6 +89,10 @@ cherrypy==18.10.0 # -r requirements/static/ci/common.txt clustershell==1.9.3 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.12/darwin.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.12/darwin.lock @@ -150,10 +154,6 @@ gitpython==3.1.50 # -r requirements/base.txt # -r requirements/static/ci/common.txt # -r requirements/static/ci/darwin.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/darwin.txt idna==3.18 @@ -164,7 +164,12 @@ idna==3.18 # requests # trustme # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -c requirements/static/pkg/py3.12/darwin.lock + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.12/darwin.lock # -r requirements/base.txt @@ -215,7 +220,7 @@ jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt @@ -238,7 +243,7 @@ markdown-it-py==4.2.0 # via # -c requirements/static/pkg/py3.12/darwin.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.12/darwin.lock # -r requirements/base.txt @@ -253,7 +258,7 @@ mercurial==7.2.2 # via -r requirements/static/ci/darwin.txt mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.12/darwin.lock # -r requirements/base.txt @@ -276,56 +281,20 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.7.0 + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.12/darwin.lock # -r requirements/base.txt # pytest paramiko==4.0.0 # via + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -343,20 +312,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.12/darwin.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.12/darwin.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.12/darwin.lock @@ -364,8 +324,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.12/darwin.lock @@ -405,7 +363,6 @@ pyserial==3.5 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -414,8 +371,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -477,8 +432,8 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.12/darwin.lock # -r requirements/zeromq.txt @@ -496,7 +451,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -504,8 +458,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -538,7 +490,6 @@ six==1.17.0 # junos-eznc # kubernetes # python-dateutil - # rfc3339-validator # transitions # vcert smmap==5.0.2 @@ -555,10 +506,6 @@ tempora==5.3.0 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -569,10 +516,6 @@ transitions==0.9.3 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/pkg/py3.12/darwin.lock @@ -586,10 +529,6 @@ typing-extensions==4.14.1 # -c requirements/static/pkg/py3.12/darwin.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics # referencing @@ -628,16 +567,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==1.0.2 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.12/darwin.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/darwin.txt -yamlordereddictloader==0.4.2 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.12/darwin.lock diff --git a/requirements/static/ci/py3.12/docs.lock b/requirements/static/ci/py3.12/docs.lock index 1bc210f2d773..7230dd441c3a 100644 --- a/requirements/static/ci/py3.12/docs.lock +++ b/requirements/static/ci/py3.12/docs.lock @@ -34,7 +34,7 @@ babel==2.18.0 # sphinx beautifulsoup4==4.14.3 # via pydata-sphinx-theme -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt @@ -58,6 +58,10 @@ cherrypy==18.10.0 # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt # -r requirements/static/ci/docs.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.12/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.12/linux.lock @@ -99,10 +103,6 @@ gitpython==3.1.50 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.12/linux.lock @@ -111,6 +111,11 @@ idna==3.18 # yarl imagesize==1.4.1 # via sphinx +immutables==0.21 + # via + # -c requirements/static/ci/py3.12/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.12/linux.lock @@ -159,7 +164,7 @@ markdown-it-py==4.2.0 # mdit-py-plugins # myst-docutils # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt @@ -191,42 +196,7 @@ multidict==6.7.1 # yarl myst-docutils==5.1.0 # via -r requirements/static/ci/docs.txt -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt @@ -240,20 +210,11 @@ portend==3.1.0 # via # -c requirements/static/ci/py3.12/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.12/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.12/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.12/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.12/linux.lock @@ -309,7 +270,7 @@ pyyaml==6.0.3 # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt # myst-docutils -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/zeromq.txt @@ -318,7 +279,6 @@ requests==2.33.1 # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http # sphinx rich==15.0.0 # via @@ -372,18 +332,10 @@ tempora==5.3.0 # via # -c requirements/static/ci/py3.12/linux.lock # portend -timelib==0.3.0 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/base.txt tornado==6.5.7 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/ci/py3.12/linux.lock @@ -398,10 +350,6 @@ typing-extensions==4.14.1 # aiohttp # aiosignal # beautifulsoup4 - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pydata-sphinx-theme # pyopenssl uc-micro-py==1.0.1 @@ -415,10 +363,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/base.txt -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/base.txt yarl==1.20.1 # via # -c requirements/static/ci/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/freebsd.lock b/requirements/static/ci/py3.12/freebsd.lock index 6c731897d995..7350fb26025c 100644 --- a/requirements/static/ci/py3.12/freebsd.lock +++ b/requirements/static/ci/py3.12/freebsd.lock @@ -40,7 +40,6 @@ attrs==23.2.0 # referencing bcrypt==5.0.0 # via - # -c requirements/constraints.txt # -r requirements/static/ci/common.txt # paramiko boto==2.49.0 @@ -55,7 +54,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt @@ -90,7 +89,7 @@ cherrypy==18.10.0 # -r requirements/base.txt # -r requirements/static/ci/common.txt # -r requirements/static/pkg/freebsd.txt -clr-loader==0.3.1 ; sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.12/freebsd.lock # pythonnet @@ -101,6 +100,10 @@ colorama==0.4.6 ; sys_platform == 'win32' # -c requirements/static/pkg/py3.12/freebsd.lock # pytest # typer +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.12/freebsd.lock + # -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via # -c requirements/static/pkg/py3.12/freebsd.lock @@ -164,10 +167,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/freebsd.txt idna==3.18 @@ -178,6 +177,11 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.12/freebsd.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.12/freebsd.lock @@ -229,17 +233,13 @@ jmespath==1.1.0 # boto3 # botocore jsonschema==4.26.0 - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt + # via -r requirements/static/ci/common.txt jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 ; sys_platform != 'win32' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +junos-eznc==2.7.6 ; sys_platform != 'win32' + # via -r requirements/static/ci/common.txt jxmlease==1.0.3 ; sys_platform != 'win32' # via -r requirements/static/ci/common.txt kazoo==2.10.0 ; sys_platform != 'darwin' and sys_platform != 'win32' @@ -250,6 +250,10 @@ kubernetes==36.0.2 # via -r requirements/static/ci/common.txt libnacl==2.1.0 ; sys_platform != 'darwin' and sys_platform != 'win32' # via -r requirements/static/ci/common.txt +linode-python==1.1.1 + # via + # -c requirements/static/pkg/py3.12/freebsd.lock + # -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via # -c requirements/static/pkg/py3.12/freebsd.lock @@ -269,7 +273,7 @@ markdown-it-py==4.2.0 # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt @@ -284,7 +288,7 @@ mercurial==7.2.2 # via -r requirements/static/ci/freebsd.txt mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt @@ -307,50 +311,13 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.7.0 ; sys_platform != 'win32' + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # opentelemetry-sdk oscrypto==1.3.0 ; sys_platform != 'win32' # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt @@ -358,6 +325,7 @@ packaging==24.0 paramiko==5.0.0 ; sys_platform != 'win32' # via # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -375,20 +343,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.12/freebsd.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.12/freebsd.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.12/freebsd.lock @@ -396,8 +355,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.12/freebsd.lock @@ -426,6 +383,10 @@ pymssql==2.3.11 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.12/freebsd.lock + # -r requirements/base.txt pynacl==1.6.2 # via # -r requirements/static/ci/common.txt @@ -443,7 +404,6 @@ pyserial==3.5 ; sys_platform != 'win32' pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -452,8 +412,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -501,7 +459,7 @@ python-gnupg==0.5.6 # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.1.0 ; sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt @@ -530,8 +488,13 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 ; python_full_version < '3.13' + # via + # -c requirements/static/pkg/py3.12/freebsd.lock + # -r requirements/zeromq.txt + # pytest-salt-factories +pyzmq==27.1.0 ; python_full_version >= '3.13' # via # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/zeromq.txt @@ -549,7 +512,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -557,8 +519,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -594,7 +554,6 @@ six==1.16.0 # junos-eznc # kubernetes # python-dateutil - # rfc3339-validator # transitions # vcert smmap==5.0.2 @@ -614,7 +573,6 @@ textfsm==2.1.0 timelib==0.3.0 # via # -c requirements/static/pkg/py3.12/freebsd.lock - # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt toml==0.10.2 # via -r requirements/static/ci/common.txt @@ -626,10 +584,6 @@ transitions==0.9.3 ; sys_platform != 'win32' # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/pkg/py3.12/freebsd.lock @@ -643,13 +597,13 @@ typing-extensions==4.14.1 # -c requirements/static/pkg/py3.12/freebsd.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics # referencing +tzdata==2026.2 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.12/freebsd.lock + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.12/freebsd.lock @@ -692,16 +646,10 @@ xmltodict==1.0.4 # -c requirements/static/pkg/py3.12/freebsd.lock # -r requirements/base.txt # moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.12/freebsd.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/freebsd.txt -yamlordereddictloader==0.4.2 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 ; sys_platform != 'win32' + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.12/freebsd.lock diff --git a/requirements/static/ci/py3.12/lint.lock b/requirements/static/ci/py3.12/lint.lock index 8c765055a04b..1095b1521281 100644 --- a/requirements/static/ci/py3.12/lint.lock +++ b/requirements/static/ci/py3.12/lint.lock @@ -82,7 +82,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock @@ -131,6 +131,11 @@ clustershell==1.9.3 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.12/linux.lock + # -c requirements/static/pkg/py3.12/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.12/linux.lock @@ -209,11 +214,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.16.0 # via # -c requirements/static/ci/py3.12/linux.lock @@ -240,6 +240,12 @@ idna==3.18 # httpx # requests # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.12/linux.lock + # -c requirements/static/pkg/py3.12/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.12/linux.lock @@ -315,7 +321,7 @@ junit-xml==1.9 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/common.txt @@ -359,7 +365,7 @@ markdown-it-py==4.2.0 # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock @@ -405,62 +411,19 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 +ncclient==0.7.0 # via # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc oauthlib==3.3.1 # via # -c requirements/static/ci/py3.12/linux.lock # requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via # -c requirements/static/ci/py3.12/linux.lock # certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock @@ -470,6 +433,7 @@ paramiko==5.0.0 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -492,23 +456,12 @@ portend==3.1.0 # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.12/linux.lock @@ -634,13 +587,17 @@ pyyaml==6.0.3 # kubernetes # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/zeromq.txt -redis==7.4.0 +redis==3.5.3 + # via + # -c requirements/static/ci/py3.12/linux.lock + # redis-py-cluster +redis-py-cluster==2.1.3 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/linux.txt @@ -659,7 +616,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -677,10 +633,6 @@ responses==0.25.8 # via # -c requirements/static/ci/py3.12/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.12/linux.lock @@ -734,7 +686,6 @@ six==1.16.0 # kubernetes # python-consul # python-dateutil - # rfc3339-validator # transitions # vcert slack-bolt==1.18.0 @@ -773,9 +724,7 @@ textfsm==2.1.0 # -r requirements/static/ci/common.txt timelib==0.3.0 # via - # -c requirements/static/ci/py3.12/linux.lock # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -793,11 +742,6 @@ transitions==0.9.3 # via # -c requirements/static/ci/py3.12/linux.lock # junos-eznc -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt twilio==9.10.9 # via # -c requirements/static/ci/py3.12/linux.lock @@ -818,10 +762,6 @@ typing-extensions==4.14.1 # -c requirements/static/pkg/py3.12/linux.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # referencing urllib3==2.7.0 @@ -871,19 +811,13 @@ xmltodict==1.0.2 # via # -c requirements/static/ci/py3.12/linux.lock # moto -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.12/linux.lock - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt yamllint==1.32.0 # via # -c requirements/static/ci/py3.12/linux.lock # -r requirements/static/ci/linux.txt -yamlordereddictloader==0.4.2 +yamlloader==1.6.0 # via # -c requirements/static/ci/py3.12/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.20.1 # via diff --git a/requirements/static/ci/py3.12/linux.lock b/requirements/static/ci/py3.12/linux.lock index d9b096445a38..638b3a80c918 100644 --- a/requirements/static/ci/py3.12/linux.lock +++ b/requirements/static/ci/py3.12/linux.lock @@ -64,7 +64,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt @@ -100,6 +100,10 @@ cherrypy==18.10.0 # -r requirements/static/ci/common.txt clustershell==1.9.3 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.12/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.12/linux.lock @@ -162,10 +166,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.16.0 # via httpcore hglib==2.6.2 @@ -184,6 +184,11 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.12/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.12/linux.lock @@ -240,7 +245,7 @@ jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt @@ -267,7 +272,7 @@ markdown-it-py==4.2.0 # via # -c requirements/static/pkg/py3.12/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt @@ -305,50 +310,13 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.7.0 + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/base.txt @@ -357,6 +325,7 @@ packaging==24.0 paramiko==5.0.0 # via # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -374,20 +343,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.12/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.12/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.12/linux.lock @@ -395,8 +355,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.12/linux.lock @@ -444,7 +402,6 @@ pyserial==3.5 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -453,8 +410,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -521,13 +476,15 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.12/linux.lock # -r requirements/zeromq.txt # pytest-salt-factories -redis==7.4.0 +redis==3.5.3 + # via redis-py-cluster +redis-py-cluster==2.1.3 # via -r requirements/static/ci/linux.txt referencing==0.37.0 # via @@ -542,7 +499,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -554,8 +510,6 @@ resolvelib==1.0.1 # via ansible-core responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -591,7 +545,6 @@ six==1.16.0 # kubernetes # python-consul # python-dateutil - # rfc3339-validator # transitions # vcert slack-bolt==1.18.0 @@ -614,10 +567,6 @@ tempora==5.3.0 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -628,10 +577,6 @@ transitions==0.9.3 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt twilio==9.10.9 # via -r requirements/static/ci/linux.txt typer==0.26.7 @@ -647,10 +592,6 @@ typing-extensions==4.14.1 # -c requirements/static/pkg/py3.12/linux.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics # referencing @@ -689,16 +630,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==1.0.2 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.12/linux.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/linux.txt -yamlordereddictloader==0.4.2 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.12/linux.lock diff --git a/requirements/static/ci/py3.12/tools.lock b/requirements/static/ci/py3.12/tools.lock index 7eef88776fdf..7f8a6acfdf83 100644 --- a/requirements/static/ci/py3.12/tools.lock +++ b/requirements/static/ci/py3.12/tools.lock @@ -29,7 +29,9 @@ jmespath==1.0.1 markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 - # via jinja2 + # via + # -r requirements/static/ci/tools.txt + # jinja2 mdurl==0.1.2 # via markdown-it-py packaging==23.1 diff --git a/requirements/static/ci/py3.12/windows.lock b/requirements/static/ci/py3.12/windows.lock index 0eab49a99a6b..f4efadbe9b5d 100644 --- a/requirements/static/ci/py3.12/windows.lock +++ b/requirements/static/ci/py3.12/windows.lock @@ -47,7 +47,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/base.txt @@ -93,6 +93,10 @@ colorama==0.4.6 # -c requirements/static/pkg/py3.12/windows.lock # click # pytest +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.12/windows.lock + # -r requirements/base.txt cryptography==48.0.0 # via # -c requirements/static/pkg/py3.12/windows.lock @@ -151,10 +155,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/pkg/py3.12/windows.lock @@ -163,7 +163,12 @@ idna==3.18 # requests # trustme # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -c requirements/static/pkg/py3.12/windows.lock + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/base.txt @@ -230,7 +235,7 @@ markdown-it-py==4.0.0 # via # -c requirements/static/pkg/py3.12/windows.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/base.txt @@ -243,7 +248,7 @@ mdurl==0.1.2 # markdown-it-py mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/base.txt @@ -268,42 +273,7 @@ multidict==6.7.1 # yarl oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/base.txt @@ -325,20 +295,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.12/windows.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.12/windows.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.12/windows.lock @@ -346,8 +307,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.12/windows.lock @@ -375,6 +334,10 @@ pymssql==2.3.11 # via # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/base.txt +pymysql==1.2.0 + # via + # -c requirements/static/pkg/py3.12/windows.lock + # -r requirements/base.txt pynacl==1.6.2 # via -r requirements/static/ci/common.txt pyopenssl==26.2.0 @@ -387,7 +350,6 @@ pyspnego==0.12.0 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -396,8 +358,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -468,7 +428,7 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.12/windows.lock # -r requirements/zeromq.txt @@ -486,7 +446,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # pywinrm # requests-ntlm # requests-oauthlib @@ -497,8 +456,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==14.3.3 @@ -530,7 +487,6 @@ six==1.17.0 # junit-xml # kubernetes # python-dateutil - # rfc3339-validator smmap==5.0.2 # via # -c requirements/static/pkg/py3.12/windows.lock @@ -547,10 +503,6 @@ tempora==5.8.1 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -559,10 +511,6 @@ tornado==6.5.7 # -r requirements/base.txt trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # -r requirements/base.txt typer==0.24.1 # via # -c requirements/static/pkg/py3.12/windows.lock @@ -576,13 +524,13 @@ typing-extensions==4.15.0 # -c requirements/static/pkg/py3.12/windows.lock # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-system-statistics # referencing +tzdata==2026.2 + # via + # -c requirements/static/pkg/py3.12/windows.lock + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.12/windows.lock @@ -624,10 +572,6 @@ xmltodict==1.0.4 # -r requirements/base.txt # moto # pywinrm -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.12/windows.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/windows.txt yarl==1.23.0 diff --git a/requirements/static/ci/py3.13/changelog.lock b/requirements/static/ci/py3.13/changelog.lock index f2ea22472d7c..5148024297c6 100644 --- a/requirements/static/ci/py3.13/changelog.lock +++ b/requirements/static/ci/py3.13/changelog.lock @@ -10,11 +10,11 @@ looseversion==1.3.0 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/changelog.txt -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.13/linux.lock # jinja2 -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/changelog.txt diff --git a/requirements/static/ci/py3.13/cloud.lock b/requirements/static/ci/py3.13/cloud.lock index 48d7d8584bf2..924f318b6d65 100644 --- a/requirements/static/ci/py3.13/cloud.lock +++ b/requirements/static/ci/py3.13/cloud.lock @@ -68,7 +68,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock @@ -114,6 +114,11 @@ clustershell==1.9.3 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.13/linux.lock + # -c requirements/static/pkg/py3.13/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.13/linux.lock @@ -198,11 +203,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.13/linux.lock @@ -212,6 +212,12 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.13/linux.lock + # -c requirements/static/pkg/py3.13/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.13/linux.lock @@ -288,7 +294,7 @@ junit-xml==1.9 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/common.txt @@ -332,7 +338,7 @@ markdown-it-py==4.2.0 # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock @@ -378,10 +384,9 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 +ncclient==0.7.0 # via # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc netaddr==1.3.0 # via -r requirements/static/ci/cloud.txt @@ -389,53 +394,11 @@ oauthlib==3.3.1 # via # -c requirements/static/ci/py3.13/linux.lock # requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via # -c requirements/static/ci/py3.13/linux.lock # certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock @@ -445,6 +408,7 @@ paramiko==5.0.0 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -468,23 +432,12 @@ portend==3.2.1 # cherrypy profitbricks==4.1.3 # via -r requirements/static/ci/cloud.txt -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.13/linux.lock @@ -493,10 +446,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/ci/py3.13/linux.lock @@ -560,7 +509,6 @@ pytest==8.4.2 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -569,10 +517,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via # -c requirements/static/ci/py3.13/linux.lock @@ -657,7 +601,7 @@ pyyaml==6.0.3 # kubernetes # pytest-salt-factories # responses - # yamlordereddictloader + # yamlloader pyzmq==27.1.0 # via # -c requirements/static/ci/py3.13/linux.lock @@ -679,7 +623,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # profitbricks # pywinrm # requests-ntlm @@ -696,10 +639,6 @@ responses==0.25.8 # via # -c requirements/static/ci/py3.13/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.13/linux.lock @@ -751,7 +690,6 @@ six==1.17.0 # kubernetes # profitbricks # python-dateutil - # rfc3339-validator # transitions # vcert smbprotocol==1.15.0 @@ -782,9 +720,7 @@ textfsm==2.1.0 # -r requirements/static/ci/common.txt timelib==0.3.0 # via - # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -803,11 +739,6 @@ trustme==1.2.1 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/ci/py3.13/linux.lock @@ -821,11 +752,6 @@ typer-slim==0.24.0 typing-extensions==4.15.0 # via # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pytest-system-statistics urllib3==2.7.0 # via @@ -877,15 +803,9 @@ xmltodict==1.0.2 # -c requirements/static/ci/py3.13/linux.lock # moto # pywinrm -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt -yamlordereddictloader==0.4.2 +yamlloader==1.6.0 # via # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.22.0 # via diff --git a/requirements/static/ci/py3.13/darwin.lock b/requirements/static/ci/py3.13/darwin.lock index db307c876d25..a14bb5e9d419 100644 --- a/requirements/static/ci/py3.13/darwin.lock +++ b/requirements/static/ci/py3.13/darwin.lock @@ -56,7 +56,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.13/darwin.lock # -r requirements/base.txt @@ -90,6 +90,10 @@ cherrypy==18.10.0 # -r requirements/static/ci/common.txt clustershell==1.9.3 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.13/darwin.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.13/darwin.lock @@ -151,10 +155,6 @@ gitpython==3.1.50 # -r requirements/base.txt # -r requirements/static/ci/common.txt # -r requirements/static/ci/darwin.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/darwin.txt idna==3.18 @@ -165,7 +165,12 @@ idna==3.18 # requests # trustme # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -c requirements/static/pkg/py3.13/darwin.lock + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.13/darwin.lock # -r requirements/base.txt @@ -216,7 +221,7 @@ jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt @@ -239,7 +244,7 @@ markdown-it-py==4.2.0 # via # -c requirements/static/pkg/py3.13/darwin.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.13/darwin.lock # -r requirements/base.txt @@ -254,7 +259,7 @@ mercurial==7.2.2 # via -r requirements/static/ci/darwin.txt mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.13/darwin.lock # -r requirements/base.txt @@ -277,56 +282,20 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.7.0 + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.13/darwin.lock # -r requirements/base.txt # pytest paramiko==4.0.0 # via + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -344,20 +313,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.13/darwin.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.13/darwin.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.13/darwin.lock @@ -365,8 +325,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.13/darwin.lock @@ -406,7 +364,6 @@ pyserial==3.5 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -415,8 +372,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -475,7 +430,7 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader + # yamlloader pyzmq==27.1.0 # via # -c requirements/static/pkg/py3.13/darwin.lock @@ -494,7 +449,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -502,8 +456,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -536,7 +488,6 @@ six==1.17.0 # junos-eznc # kubernetes # python-dateutil - # rfc3339-validator # transitions # vcert smmap==5.0.2 @@ -553,10 +504,6 @@ tempora==5.8.1 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -567,10 +514,6 @@ transitions==0.9.3 # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/pkg/py3.13/darwin.lock @@ -579,14 +522,8 @@ typer-slim==0.24.0 # via # -c requirements/static/pkg/py3.13/darwin.lock # jaraco-text -typing-extensions==4.15.0 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions - # pytest-system-statistics +typing-extensions==4.14.1 + # via pytest-system-statistics urllib3==2.7.0 # via # -c requirements/static/pkg/py3.13/darwin.lock @@ -622,16 +559,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==1.0.2 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.13/darwin.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/darwin.txt -yamlordereddictloader==0.4.2 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 + # via junos-eznc yarl==1.22.0 # via # -c requirements/static/pkg/py3.13/darwin.lock diff --git a/requirements/static/ci/py3.13/docs.lock b/requirements/static/ci/py3.13/docs.lock index 2c607ba12587..6b39f1032702 100644 --- a/requirements/static/ci/py3.13/docs.lock +++ b/requirements/static/ci/py3.13/docs.lock @@ -34,7 +34,7 @@ babel==2.17.0 # sphinx beautifulsoup4==4.14.3 # via pydata-sphinx-theme -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt @@ -58,6 +58,10 @@ cherrypy==18.10.0 # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt # -r requirements/static/ci/docs.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.13/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.13/linux.lock @@ -99,10 +103,6 @@ gitpython==3.1.50 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.13/linux.lock @@ -111,6 +111,11 @@ idna==3.18 # yarl imagesize==1.4.1 # via sphinx +immutables==0.21 + # via + # -c requirements/static/ci/py3.13/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.13/linux.lock @@ -159,7 +164,7 @@ markdown-it-py==4.2.0 # mdit-py-plugins # myst-docutils # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt @@ -191,42 +196,7 @@ multidict==6.7.0 # yarl myst-docutils==5.0.0 # via -r requirements/static/ci/docs.txt -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt @@ -240,20 +210,11 @@ portend==3.2.1 # via # -c requirements/static/ci/py3.13/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/ci/py3.13/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.13/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.13/linux.lock @@ -315,7 +276,6 @@ requests==2.33.1 # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http # sphinx # sphinxcontrib-spelling rich==15.0.0 @@ -370,18 +330,10 @@ tempora==5.8.1 # via # -c requirements/static/ci/py3.13/linux.lock # portend -timelib==0.3.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/base.txt tornado==6.5.7 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/ci/py3.13/linux.lock @@ -394,10 +346,6 @@ typing-extensions==4.15.0 # via # -c requirements/static/ci/py3.13/linux.lock # beautifulsoup4 - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pydata-sphinx-theme uc-micro-py==1.0.3 # via linkify-it-py @@ -410,10 +358,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/base.txt -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/base.txt yarl==1.22.0 # via # -c requirements/static/ci/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/freebsd.lock b/requirements/static/ci/py3.13/freebsd.lock index c2dacb43fbb6..152ccb6237b4 100644 --- a/requirements/static/ci/py3.13/freebsd.lock +++ b/requirements/static/ci/py3.13/freebsd.lock @@ -41,7 +41,6 @@ attrs==25.4.0 # referencing bcrypt==5.0.0 # via - # -c requirements/constraints.txt # -r requirements/static/ci/common.txt # paramiko boto==2.49.0 @@ -56,7 +55,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt @@ -91,7 +90,7 @@ cherrypy==18.10.0 # -r requirements/base.txt # -r requirements/static/ci/common.txt # -r requirements/static/pkg/freebsd.txt -clr-loader==0.3.1 ; sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.13/freebsd.lock # pythonnet @@ -102,6 +101,10 @@ colorama==0.4.6 ; sys_platform == 'win32' # -c requirements/static/pkg/py3.13/freebsd.lock # pytest # typer +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.13/freebsd.lock + # -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via # -c requirements/static/pkg/py3.13/freebsd.lock @@ -165,10 +168,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/freebsd.txt idna==3.18 @@ -179,6 +178,11 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.13/freebsd.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.13/freebsd.lock @@ -230,17 +234,13 @@ jmespath==1.1.0 # boto3 # botocore jsonschema==4.26.0 - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt + # via -r requirements/static/ci/common.txt jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 ; sys_platform != 'win32' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +junos-eznc==2.7.6 ; sys_platform != 'win32' + # via -r requirements/static/ci/common.txt jxmlease==1.0.3 ; sys_platform != 'win32' # via -r requirements/static/ci/common.txt kazoo==2.10.0 ; sys_platform != 'darwin' and sys_platform != 'win32' @@ -251,6 +251,10 @@ kubernetes==36.0.2 # via -r requirements/static/ci/common.txt libnacl==2.1.0 ; sys_platform != 'darwin' and sys_platform != 'win32' # via -r requirements/static/ci/common.txt +linode-python==1.1.1 + # via + # -c requirements/static/pkg/py3.13/freebsd.lock + # -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via # -c requirements/static/pkg/py3.13/freebsd.lock @@ -270,7 +274,7 @@ markdown-it-py==4.2.0 # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt @@ -285,7 +289,7 @@ mercurial==7.2.2 # via -r requirements/static/ci/freebsd.txt mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt @@ -308,50 +312,13 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.7.0 ; sys_platform != 'win32' + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # opentelemetry-sdk oscrypto==1.3.0 ; sys_platform != 'win32' # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt @@ -359,6 +326,7 @@ packaging==24.0 paramiko==5.0.0 ; sys_platform != 'win32' # via # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -376,20 +344,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.13/freebsd.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.13/freebsd.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.13/freebsd.lock @@ -397,8 +356,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.13/freebsd.lock @@ -427,6 +384,10 @@ pymssql==2.3.11 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.13/freebsd.lock + # -r requirements/base.txt pynacl==1.6.2 # via # -r requirements/static/ci/common.txt @@ -444,7 +405,6 @@ pyserial==3.5 ; sys_platform != 'win32' pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -453,8 +413,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -503,7 +461,7 @@ python-gnupg==0.5.6 # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.1.0 ; sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt @@ -528,7 +486,7 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader + # yamlloader pyzmq==27.1.0 # via # -c requirements/static/pkg/py3.13/freebsd.lock @@ -547,7 +505,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -555,8 +512,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -592,7 +547,6 @@ six==1.17.0 # junos-eznc # kubernetes # python-dateutil - # rfc3339-validator # transitions # vcert smmap==5.0.2 @@ -612,7 +566,6 @@ textfsm==2.1.0 timelib==0.3.0 # via # -c requirements/static/pkg/py3.13/freebsd.lock - # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt toml==0.10.2 # via -r requirements/static/ci/common.txt @@ -624,10 +577,6 @@ transitions==0.9.3 ; sys_platform != 'win32' # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/pkg/py3.13/freebsd.lock @@ -637,13 +586,11 @@ typer-slim==0.24.0 # -c requirements/static/pkg/py3.13/freebsd.lock # jaraco-text typing-extensions==4.15.0 + # via pytest-system-statistics +tzdata==2026.2 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.13/freebsd.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions - # pytest-system-statistics + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.13/freebsd.lock @@ -686,16 +633,10 @@ xmltodict==1.0.4 # -c requirements/static/pkg/py3.13/freebsd.lock # -r requirements/base.txt # moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.13/freebsd.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/freebsd.txt -yamlordereddictloader==0.4.2 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 ; sys_platform != 'win32' + # via junos-eznc yarl==1.22.0 # via # -c requirements/static/pkg/py3.13/freebsd.lock diff --git a/requirements/static/ci/py3.13/lint.lock b/requirements/static/ci/py3.13/lint.lock index e7f8dd679b5e..392d4bf50d0c 100644 --- a/requirements/static/ci/py3.13/lint.lock +++ b/requirements/static/ci/py3.13/lint.lock @@ -82,7 +82,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock @@ -131,6 +131,11 @@ clustershell==1.9.3 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.13/linux.lock + # -c requirements/static/pkg/py3.13/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.13/linux.lock @@ -209,11 +214,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.16.0 # via # -c requirements/static/ci/py3.13/linux.lock @@ -240,6 +240,12 @@ idna==3.18 # httpx # requests # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.13/linux.lock + # -c requirements/static/pkg/py3.13/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.13/linux.lock @@ -315,7 +321,7 @@ junit-xml==1.9 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/common.txt @@ -359,7 +365,7 @@ markdown-it-py==4.2.0 # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock @@ -405,62 +411,19 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 +ncclient==0.7.0 # via # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc oauthlib==3.3.1 # via # -c requirements/static/ci/py3.13/linux.lock # requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via # -c requirements/static/ci/py3.13/linux.lock # certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock @@ -470,6 +433,7 @@ paramiko==5.0.0 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -492,23 +456,12 @@ portend==3.2.1 # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.13/linux.lock @@ -630,13 +583,17 @@ pyyaml==6.0.3 # kubernetes # responses # yamllint - # yamlordereddictloader + # yamlloader pyzmq==27.1.0 # via # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/zeromq.txt -redis==7.4.0 +redis==3.5.3 + # via + # -c requirements/static/ci/py3.13/linux.lock + # redis-py-cluster +redis-py-cluster==2.1.3 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/linux.txt @@ -655,7 +612,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -673,10 +629,6 @@ responses==0.25.8 # via # -c requirements/static/ci/py3.13/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.13/linux.lock @@ -730,7 +682,6 @@ six==1.17.0 # kubernetes # python-consul # python-dateutil - # rfc3339-validator # transitions # vcert slack-bolt==1.27.0 @@ -765,9 +716,7 @@ textfsm==2.1.0 # -r requirements/static/ci/common.txt timelib==0.3.0 # via - # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -785,11 +734,6 @@ transitions==0.9.3 # via # -c requirements/static/ci/py3.13/linux.lock # junos-eznc -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt twilio==9.10.9 # via # -c requirements/static/ci/py3.13/linux.lock @@ -804,14 +748,6 @@ typer-slim==0.24.0 # -c requirements/static/ci/py3.13/linux.lock # -c requirements/static/pkg/py3.13/linux.lock # jaraco-text -typing-extensions==4.15.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions urllib3==2.7.0 # via # -c requirements/static/ci/py3.13/linux.lock @@ -859,19 +795,13 @@ xmltodict==1.0.2 # via # -c requirements/static/ci/py3.13/linux.lock # moto -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.13/linux.lock - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt yamllint==1.38.0 # via # -c requirements/static/ci/py3.13/linux.lock # -r requirements/static/ci/linux.txt -yamlordereddictloader==0.4.2 +yamlloader==1.6.0 # via # -c requirements/static/ci/py3.13/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.22.0 # via diff --git a/requirements/static/ci/py3.13/linux.lock b/requirements/static/ci/py3.13/linux.lock index 2c40c52439ba..b363c62ab31e 100644 --- a/requirements/static/ci/py3.13/linux.lock +++ b/requirements/static/ci/py3.13/linux.lock @@ -65,7 +65,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt @@ -101,6 +101,10 @@ cherrypy==18.10.0 # -r requirements/static/ci/common.txt clustershell==1.9.3 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.13/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.13/linux.lock @@ -163,10 +167,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.16.0 # via httpcore hglib==2.6.2 @@ -185,6 +185,11 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.13/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.13/linux.lock @@ -241,7 +246,7 @@ jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt @@ -268,7 +273,7 @@ markdown-it-py==4.2.0 # via # -c requirements/static/pkg/py3.13/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt @@ -306,50 +311,13 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.7.0 + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/base.txt @@ -358,6 +326,7 @@ packaging==24.0 paramiko==5.0.0 # via # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -375,20 +344,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.13/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.13/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.13/linux.lock @@ -396,8 +356,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.13/linux.lock @@ -445,7 +403,6 @@ pyserial==3.5 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -454,8 +411,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -519,13 +474,15 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader + # yamlloader pyzmq==27.1.0 # via # -c requirements/static/pkg/py3.13/linux.lock # -r requirements/zeromq.txt # pytest-salt-factories -redis==7.4.0 +redis==3.5.3 + # via redis-py-cluster +redis-py-cluster==2.1.3 # via -r requirements/static/ci/linux.txt referencing==0.37.0 # via @@ -540,7 +497,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -552,8 +508,6 @@ resolvelib==1.2.1 # via ansible-core responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -589,7 +543,6 @@ six==1.17.0 # kubernetes # python-consul # python-dateutil - # rfc3339-validator # transitions # vcert slack-bolt==1.27.0 @@ -610,10 +563,6 @@ tempora==5.8.1 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -624,10 +573,6 @@ transitions==0.9.3 # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt twilio==9.10.9 # via -r requirements/static/ci/linux.txt typer==0.26.7 @@ -639,13 +584,7 @@ typer-slim==0.24.0 # -c requirements/static/pkg/py3.13/linux.lock # jaraco-text typing-extensions==4.15.0 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions - # pytest-system-statistics + # via pytest-system-statistics urllib3==2.7.0 # via # -c requirements/static/pkg/py3.13/linux.lock @@ -681,16 +620,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==1.0.2 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.13/linux.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/linux.txt -yamlordereddictloader==0.4.2 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 + # via junos-eznc yarl==1.22.0 # via # -c requirements/static/pkg/py3.13/linux.lock diff --git a/requirements/static/ci/py3.13/tools-virustotal.lock b/requirements/static/ci/py3.13/tools-virustotal.lock index 03c7f0198741..fdbb3d903553 100644 --- a/requirements/static/ci/py3.13/tools-virustotal.lock +++ b/requirements/static/ci/py3.13/tools-virustotal.lock @@ -1,22 +1,22 @@ # This file was autogenerated by uv via the following command: # uv pip compile requirements/static/ci/tools-virustotal.txt --python-platform=linux --python-version=3.13 --constraint requirements/constraints.txt --no-emit-index-url -c=requirements/static/ci/py3.13/tools.lock -o=requirements/static/ci/py3.13/tools-virustotal.lock -certifi==2024.8.30 +certifi==2026.1.4 # via # -c requirements/static/ci/py3.13/tools.lock # requests -charset-normalizer==3.4.0 +charset-normalizer==3.4.4 # via # -c requirements/static/ci/py3.13/tools.lock # requests -idna==3.10 +idna==3.11 # via # -c requirements/static/ci/py3.13/tools.lock # requests -requests==2.32.3 +requests==2.32.5 # via # -c requirements/static/ci/py3.13/tools.lock # virustotal3 -urllib3==2.2.3 +urllib3==2.6.3 # via # -c requirements/static/ci/py3.13/tools.lock # requests diff --git a/requirements/static/ci/py3.13/tools.lock b/requirements/static/ci/py3.13/tools.lock index 7bc3fe65bb43..e5a7541aa10e 100644 --- a/requirements/static/ci/py3.13/tools.lock +++ b/requirements/static/ci/py3.13/tools.lock @@ -2,63 +2,68 @@ # uv pip compile requirements/static/ci/tools.txt --python-platform=linux --python-version=3.13 --constraint requirements/constraints.txt --no-emit-index-url -o=requirements/static/ci/py3.13/tools.lock annotated-types==0.7.0 # via pydantic -attrs==24.2.0 +attrs==25.4.0 # via # -r requirements/static/ci/tools.txt # python-tools-scripts -boto3==1.35.46 +boto3==1.42.31 # via -r requirements/static/ci/tools.txt -botocore==1.35.46 +botocore==1.42.31 # via # boto3 # s3transfer -certifi==2024.8.30 +certifi==2026.1.4 # via requests -charset-normalizer==3.4.0 +charset-normalizer==3.4.4 # via requests -filelock==3.16.1 +filelock==3.20.3 # via python-tools-scripts -idna==3.10 +idna==3.11 # via requests -jinja2==3.1.4 +jinja2==3.1.6 # via -r requirements/static/ci/tools.txt jmespath==1.0.1 # via # boto3 # botocore -markdown-it-py==3.0.0 +markdown-it-py==4.0.0 # via rich markupsafe==2.1.5 - # via jinja2 + # via + # -r requirements/static/ci/tools.txt + # jinja2 mdurl==0.1.2 # via markdown-it-py -packaging==24.1 +packaging==25.0 # via -r requirements/static/ci/tools.txt -pydantic==2.9.2 +pydantic==2.12.5 # via python-tools-scripts -pydantic-core==2.23.4 +pydantic-core==2.41.5 # via pydantic -pygments==2.18.0 +pygments==2.19.2 # via rich python-dateutil==2.9.0.post0 # via botocore python-tools-scripts==0.20.5 # via -r requirements/static/ci/tools.txt -pyyaml==6.0.2 +pyyaml==6.0.3 # via -r requirements/static/ci/tools.txt -requests==2.32.3 +requests==2.32.5 # via python-tools-scripts -rich==13.9.3 +rich==14.2.0 # via python-tools-scripts -s3transfer==0.10.3 +s3transfer==0.16.0 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil -typing-extensions==4.12.2 +typing-extensions==4.15.0 # via # pydantic # pydantic-core -urllib3==2.2.3 + # typing-inspection +typing-inspection==0.4.2 + # via pydantic +urllib3==2.6.3 # via # botocore # requests diff --git a/requirements/static/ci/py3.13/windows.lock b/requirements/static/ci/py3.13/windows.lock index fc38303617f1..9c1635bbbd1e 100644 --- a/requirements/static/ci/py3.13/windows.lock +++ b/requirements/static/ci/py3.13/windows.lock @@ -48,7 +48,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt @@ -83,7 +83,7 @@ click==8.3.1 # via # -c requirements/static/pkg/py3.13/windows.lock # typer -clr-loader==0.3.1 +clr-loader==0.2.10 # via # -c requirements/static/pkg/py3.13/windows.lock # pythonnet @@ -94,6 +94,10 @@ colorama==0.4.6 # -c requirements/static/pkg/py3.13/windows.lock # click # pytest +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.13/windows.lock + # -r requirements/base.txt cryptography==48.0.0 # via # -c requirements/static/pkg/py3.13/windows.lock @@ -152,10 +156,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/pkg/py3.13/windows.lock @@ -164,7 +164,12 @@ idna==3.18 # requests # trustme # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -c requirements/static/pkg/py3.13/windows.lock + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt @@ -231,7 +236,7 @@ markdown-it-py==4.0.0 # via # -c requirements/static/pkg/py3.13/windows.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt @@ -244,7 +249,7 @@ mdurl==0.1.2 # markdown-it-py mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt @@ -269,42 +274,7 @@ multidict==6.7.1 # yarl oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt @@ -326,20 +296,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.13/windows.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.13/windows.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.13/windows.lock @@ -347,8 +308,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.13/windows.lock @@ -376,6 +335,10 @@ pymssql==2.3.11 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt +pymysql==1.2.0 + # via + # -c requirements/static/pkg/py3.13/windows.lock + # -r requirements/base.txt pynacl==1.6.2 # via -r requirements/static/ci/common.txt pyopenssl==26.2.0 @@ -388,7 +351,6 @@ pyspnego==0.12.0 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -397,8 +359,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -443,7 +403,7 @@ python-gnupg==0.5.6 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt -pythonnet==3.1.0 +pythonnet==3.0.5 # via # -c requirements/static/pkg/py3.13/windows.lock # -r requirements/base.txt @@ -487,7 +447,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # pywinrm # requests-ntlm # requests-oauthlib @@ -498,8 +457,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==14.3.3 @@ -531,7 +488,6 @@ six==1.17.0 # junit-xml # kubernetes # python-dateutil - # rfc3339-validator smmap==5.0.2 # via # -c requirements/static/pkg/py3.13/windows.lock @@ -548,10 +504,6 @@ tempora==5.8.1 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -560,10 +512,6 @@ tornado==6.5.7 # -r requirements/base.txt trustme==1.2.1 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # -r requirements/base.txt typer==0.24.1 # via # -c requirements/static/pkg/py3.13/windows.lock @@ -573,13 +521,11 @@ typer-slim==0.24.0 # -c requirements/static/pkg/py3.13/windows.lock # jaraco-text typing-extensions==4.15.0 + # via pytest-system-statistics +tzdata==2026.2 # via # -c requirements/static/pkg/py3.13/windows.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions - # pytest-system-statistics + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.13/windows.lock @@ -621,10 +567,6 @@ xmltodict==1.0.4 # -r requirements/base.txt # moto # pywinrm -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.13/windows.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/windows.txt yarl==1.23.0 diff --git a/requirements/static/ci/py3.14/changelog.lock b/requirements/static/ci/py3.14/changelog.lock index ab46e2c36e9a..c2776a8a8864 100644 --- a/requirements/static/ci/py3.14/changelog.lock +++ b/requirements/static/ci/py3.14/changelog.lock @@ -10,11 +10,11 @@ looseversion==1.3.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/changelog.txt -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.14/linux.lock # jinja2 -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/changelog.txt diff --git a/requirements/static/ci/py3.14/cloud.lock b/requirements/static/ci/py3.14/cloud.lock index dc2ec7878ed4..f4c0ae7b20b8 100644 --- a/requirements/static/ci/py3.14/cloud.lock +++ b/requirements/static/ci/py3.14/cloud.lock @@ -68,7 +68,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock @@ -114,6 +114,11 @@ clustershell==1.9.3 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.14/linux.lock + # -c requirements/static/pkg/py3.14/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.14/linux.lock @@ -198,11 +203,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.14/linux.lock @@ -212,6 +212,12 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.14/linux.lock + # -c requirements/static/pkg/py3.14/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.14/linux.lock @@ -288,7 +294,7 @@ junit-xml==1.9 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/common.txt @@ -332,7 +338,7 @@ markdown-it-py==4.2.0 # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock @@ -378,10 +384,9 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 +ncclient==0.7.0 # via # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc netaddr==1.3.0 # via -r requirements/static/ci/cloud.txt @@ -389,53 +394,11 @@ oauthlib==3.3.1 # via # -c requirements/static/ci/py3.14/linux.lock # requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via # -c requirements/static/ci/py3.14/linux.lock # certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock @@ -445,6 +408,7 @@ paramiko==5.0.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -468,23 +432,12 @@ portend==3.2.1 # cherrypy profitbricks==4.1.3 # via -r requirements/static/ci/cloud.txt -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.14/linux.lock @@ -493,10 +446,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/ci/py3.14/linux.lock @@ -560,7 +509,6 @@ pytest==8.4.2 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -569,10 +517,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via # -c requirements/static/ci/py3.14/linux.lock @@ -657,7 +601,7 @@ pyyaml==6.0.3 # kubernetes # pytest-salt-factories # responses - # yamlordereddictloader + # yamlloader pyzmq==27.1.0 # via # -c requirements/static/ci/py3.14/linux.lock @@ -679,7 +623,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # profitbricks # pywinrm # requests-ntlm @@ -696,10 +639,6 @@ responses==0.25.8 # via # -c requirements/static/ci/py3.14/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.14/linux.lock @@ -751,7 +690,6 @@ six==1.17.0 # kubernetes # profitbricks # python-dateutil - # rfc3339-validator # transitions # vcert smbprotocol==1.15.0 @@ -782,9 +720,7 @@ textfsm==2.1.0 # -r requirements/static/ci/common.txt timelib==0.3.0 # via - # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -803,11 +739,6 @@ trustme==1.2.1 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/ci/py3.14/linux.lock @@ -821,11 +752,6 @@ typer-slim==0.24.0 typing-extensions==4.15.0 # via # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pytest-system-statistics urllib3==2.7.0 # via @@ -877,15 +803,9 @@ xmltodict==1.0.2 # -c requirements/static/ci/py3.14/linux.lock # moto # pywinrm -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt -yamlordereddictloader==0.4.2 +yamlloader==1.6.0 # via # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.22.0 # via diff --git a/requirements/static/ci/py3.14/darwin.lock b/requirements/static/ci/py3.14/darwin.lock index 456968f55a83..f18b177dca66 100644 --- a/requirements/static/ci/py3.14/darwin.lock +++ b/requirements/static/ci/py3.14/darwin.lock @@ -56,7 +56,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -90,6 +90,10 @@ cherrypy==18.10.0 # -r requirements/static/ci/common.txt clustershell==1.9.3 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.14/darwin.lock @@ -151,10 +155,6 @@ gitpython==3.1.50 # -r requirements/base.txt # -r requirements/static/ci/common.txt # -r requirements/static/ci/darwin.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/darwin.txt idna==3.18 @@ -165,7 +165,12 @@ idna==3.18 # requests # trustme # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -c requirements/static/pkg/py3.14/darwin.lock + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -216,7 +221,7 @@ jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt @@ -239,7 +244,7 @@ markdown-it-py==4.2.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -254,7 +259,7 @@ mercurial==7.2.2 # via -r requirements/static/ci/darwin.txt mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt @@ -277,56 +282,20 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.7.0 + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.14/darwin.lock # -r requirements/base.txt # pytest paramiko==4.0.0 # via + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -344,20 +313,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.14/darwin.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.14/darwin.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.14/darwin.lock @@ -365,8 +325,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.14/darwin.lock @@ -406,7 +364,6 @@ pyserial==3.5 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -415,8 +372,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -475,7 +430,7 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader + # yamlloader pyzmq==27.1.0 # via # -c requirements/static/pkg/py3.14/darwin.lock @@ -494,7 +449,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -502,8 +456,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -536,7 +488,6 @@ six==1.17.0 # junos-eznc # kubernetes # python-dateutil - # rfc3339-validator # transitions # vcert smmap==5.0.2 @@ -553,10 +504,6 @@ tempora==5.8.1 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -567,10 +514,6 @@ transitions==0.9.3 # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/pkg/py3.14/darwin.lock @@ -579,14 +522,8 @@ typer-slim==0.24.0 # via # -c requirements/static/pkg/py3.14/darwin.lock # jaraco-text -typing-extensions==4.15.0 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions - # pytest-system-statistics +typing-extensions==4.14.1 + # via pytest-system-statistics urllib3==2.7.0 # via # -c requirements/static/pkg/py3.14/darwin.lock @@ -622,16 +559,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==1.0.2 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.14/darwin.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/darwin.txt -yamlordereddictloader==0.4.2 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 + # via junos-eznc yarl==1.22.0 # via # -c requirements/static/pkg/py3.14/darwin.lock diff --git a/requirements/static/ci/py3.14/docs.lock b/requirements/static/ci/py3.14/docs.lock index 0e2b32d67b63..239dfbebd42d 100644 --- a/requirements/static/ci/py3.14/docs.lock +++ b/requirements/static/ci/py3.14/docs.lock @@ -34,7 +34,7 @@ babel==2.17.0 # sphinx beautifulsoup4==4.14.3 # via pydata-sphinx-theme -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -58,6 +58,10 @@ cherrypy==18.10.0 # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt # -r requirements/static/ci/docs.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.14/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.14/linux.lock @@ -99,10 +103,6 @@ gitpython==3.1.50 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.14/linux.lock @@ -111,6 +111,11 @@ idna==3.18 # yarl imagesize==1.4.1 # via sphinx +immutables==0.21 + # via + # -c requirements/static/ci/py3.14/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.14/linux.lock @@ -159,7 +164,7 @@ markdown-it-py==4.2.0 # mdit-py-plugins # myst-docutils # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -191,42 +196,7 @@ multidict==6.7.0 # yarl myst-docutils==5.0.0 # via -r requirements/static/ci/docs.txt -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt @@ -240,20 +210,11 @@ portend==3.2.1 # via # -c requirements/static/ci/py3.14/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/ci/py3.14/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.14/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.14/linux.lock @@ -315,7 +276,6 @@ requests==2.33.1 # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http # sphinx # sphinxcontrib-spelling rich==15.0.0 @@ -370,18 +330,10 @@ tempora==5.8.1 # via # -c requirements/static/ci/py3.14/linux.lock # portend -timelib==0.3.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/base.txt tornado==6.5.7 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/ci/py3.14/linux.lock @@ -394,10 +346,6 @@ typing-extensions==4.15.0 # via # -c requirements/static/ci/py3.14/linux.lock # beautifulsoup4 - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pydata-sphinx-theme uc-micro-py==1.0.3 # via linkify-it-py @@ -410,10 +358,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/base.txt -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/base.txt yarl==1.22.0 # via # -c requirements/static/ci/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/freebsd.lock b/requirements/static/ci/py3.14/freebsd.lock index 774d8af7136f..3378cd8c958a 100644 --- a/requirements/static/ci/py3.14/freebsd.lock +++ b/requirements/static/ci/py3.14/freebsd.lock @@ -41,7 +41,6 @@ attrs==25.4.0 # referencing bcrypt==5.0.0 # via - # -c requirements/constraints.txt # -r requirements/static/ci/common.txt # paramiko boto==2.49.0 @@ -56,7 +55,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -91,7 +90,7 @@ cherrypy==18.10.0 # -r requirements/base.txt # -r requirements/static/ci/common.txt # -r requirements/static/pkg/freebsd.txt -clr-loader==0.3.1 ; sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.14/freebsd.lock # pythonnet @@ -102,6 +101,10 @@ colorama==0.4.6 ; sys_platform == 'win32' # -c requirements/static/pkg/py3.14/freebsd.lock # pytest # typer +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -165,10 +168,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/freebsd.txt idna==3.18 @@ -179,6 +178,11 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -230,17 +234,13 @@ jmespath==1.1.0 # boto3 # botocore jsonschema==4.26.0 - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt + # via -r requirements/static/ci/common.txt jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 ; sys_platform != 'win32' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt +junos-eznc==2.7.6 ; sys_platform != 'win32' + # via -r requirements/static/ci/common.txt jxmlease==1.0.3 ; sys_platform != 'win32' # via -r requirements/static/ci/common.txt kazoo==2.10.0 ; sys_platform != 'darwin' and sys_platform != 'win32' @@ -251,6 +251,10 @@ kubernetes==36.0.2 # via -r requirements/static/ci/common.txt libnacl==2.1.0 ; sys_platform != 'darwin' and sys_platform != 'win32' # via -r requirements/static/ci/common.txt +linode-python==1.1.1 + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -270,7 +274,7 @@ markdown-it-py==4.2.0 # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -285,7 +289,7 @@ mercurial==7.2.2 # via -r requirements/static/ci/freebsd.txt mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -308,50 +312,13 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.7.0 ; sys_platform != 'win32' + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # opentelemetry-sdk oscrypto==1.3.0 ; sys_platform != 'win32' # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -359,6 +326,7 @@ packaging==24.0 paramiko==5.0.0 ; sys_platform != 'win32' # via # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -376,20 +344,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.14/freebsd.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.14/freebsd.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -397,8 +356,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -427,6 +384,10 @@ pymssql==2.3.11 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.14/freebsd.lock + # -r requirements/base.txt pynacl==1.6.2 # via # -r requirements/static/ci/common.txt @@ -444,7 +405,6 @@ pyserial==3.5 ; sys_platform != 'win32' pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -453,8 +413,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -503,7 +461,7 @@ python-gnupg==0.5.6 # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.1.0 ; sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt @@ -528,7 +486,7 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader + # yamlloader pyzmq==27.1.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -547,7 +505,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -555,8 +512,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -592,7 +547,6 @@ six==1.17.0 # junos-eznc # kubernetes # python-dateutil - # rfc3339-validator # transitions # vcert smmap==5.0.2 @@ -612,7 +566,6 @@ textfsm==2.1.0 timelib==0.3.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock - # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt toml==0.10.2 # via -r requirements/static/ci/common.txt @@ -624,10 +577,6 @@ transitions==0.9.3 ; sys_platform != 'win32' # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # -r requirements/base.txt typer==0.26.7 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -637,13 +586,11 @@ typer-slim==0.24.0 # -c requirements/static/pkg/py3.14/freebsd.lock # jaraco-text typing-extensions==4.15.0 + # via pytest-system-statistics +tzdata==2026.2 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.14/freebsd.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions - # pytest-system-statistics + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock @@ -686,16 +633,10 @@ xmltodict==1.0.4 # -c requirements/static/pkg/py3.14/freebsd.lock # -r requirements/base.txt # moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.14/freebsd.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/freebsd.txt -yamlordereddictloader==0.4.2 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 ; sys_platform != 'win32' + # via junos-eznc yarl==1.22.0 # via # -c requirements/static/pkg/py3.14/freebsd.lock diff --git a/requirements/static/ci/py3.14/lint.lock b/requirements/static/ci/py3.14/lint.lock index 10e25c06244e..b1b47fef2c7b 100644 --- a/requirements/static/ci/py3.14/lint.lock +++ b/requirements/static/ci/py3.14/lint.lock @@ -82,7 +82,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock @@ -131,6 +131,11 @@ clustershell==1.9.3 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.14/linux.lock + # -c requirements/static/pkg/py3.14/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.14/linux.lock @@ -209,11 +214,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.16.0 # via # -c requirements/static/ci/py3.14/linux.lock @@ -241,6 +241,12 @@ idna==3.18 # httpx # requests # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.14/linux.lock + # -c requirements/static/pkg/py3.14/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/ci/py3.14/linux.lock @@ -316,7 +322,7 @@ junit-xml==1.9 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/common.txt @@ -360,7 +366,7 @@ markdown-it-py==4.2.0 # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock @@ -406,62 +412,19 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 +ncclient==0.7.0 # via # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc oauthlib==3.3.1 # via # -c requirements/static/ci/py3.14/linux.lock # requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via # -c requirements/static/ci/py3.14/linux.lock # certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock @@ -471,6 +434,7 @@ paramiko==5.0.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -493,23 +457,12 @@ portend==3.2.1 # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/ci/py3.14/linux.lock @@ -631,13 +584,17 @@ pyyaml==6.0.3 # kubernetes # responses # yamllint - # yamlordereddictloader + # yamlloader pyzmq==27.1.0 # via # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/zeromq.txt -redis==7.4.0 +redis==3.5.3 + # via + # -c requirements/static/ci/py3.14/linux.lock + # redis-py-cluster +redis-py-cluster==2.1.3 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/linux.txt @@ -656,7 +613,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -674,10 +630,6 @@ responses==0.25.8 # via # -c requirements/static/ci/py3.14/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.14/linux.lock @@ -731,7 +683,6 @@ six==1.17.0 # kubernetes # python-consul # python-dateutil - # rfc3339-validator # transitions # vcert slack-bolt==1.27.0 @@ -766,9 +717,7 @@ textfsm==2.1.0 # -r requirements/static/ci/common.txt timelib==0.3.0 # via - # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -786,11 +735,6 @@ transitions==0.9.3 # via # -c requirements/static/ci/py3.14/linux.lock # junos-eznc -truststore==0.10.4 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt twilio==9.10.9 # via # -c requirements/static/ci/py3.14/linux.lock @@ -805,14 +749,6 @@ typer-slim==0.24.0 # -c requirements/static/ci/py3.14/linux.lock # -c requirements/static/pkg/py3.14/linux.lock # jaraco-text -typing-extensions==4.15.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions urllib3==2.7.0 # via # -c requirements/static/ci/py3.14/linux.lock @@ -860,19 +796,13 @@ xmltodict==1.0.2 # via # -c requirements/static/ci/py3.14/linux.lock # moto -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.14/linux.lock - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt yamllint==1.38.0 # via # -c requirements/static/ci/py3.14/linux.lock # -r requirements/static/ci/linux.txt -yamlordereddictloader==0.4.2 +yamlloader==1.6.0 # via # -c requirements/static/ci/py3.14/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.22.0 # via diff --git a/requirements/static/ci/py3.14/linux.lock b/requirements/static/ci/py3.14/linux.lock index 60cfda77f804..a4be3e9624af 100644 --- a/requirements/static/ci/py3.14/linux.lock +++ b/requirements/static/ci/py3.14/linux.lock @@ -65,7 +65,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt @@ -101,6 +101,10 @@ cherrypy==18.10.0 # -r requirements/static/ci/common.txt clustershell==1.9.3 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.14/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.14/linux.lock @@ -163,10 +167,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.16.0 # via httpcore hglib==2.6.2 @@ -187,6 +187,11 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.14/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.14/linux.lock @@ -243,7 +248,7 @@ jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via -r requirements/static/ci/common.txt jxmlease==1.0.3 # via -r requirements/static/ci/common.txt @@ -270,7 +275,7 @@ markdown-it-py==4.2.0 # via # -c requirements/static/pkg/py3.14/linux.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt @@ -308,50 +313,13 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -ncclient==0.7.1 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +ncclient==0.7.0 + # via junos-eznc oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/base.txt @@ -360,6 +328,7 @@ packaging==24.0 paramiko==5.0.0 # via # -r requirements/static/ci/common.txt + # junos-eznc # ncclient # scp passlib==1.7.4 @@ -377,20 +346,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.14/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.14/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.14/linux.lock @@ -398,8 +358,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.14/linux.lock @@ -447,7 +405,6 @@ pyserial==3.5 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -456,8 +413,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -521,13 +476,15 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader + # yamlloader pyzmq==27.1.0 # via # -c requirements/static/pkg/py3.14/linux.lock # -r requirements/zeromq.txt # pytest-salt-factories -redis==7.4.0 +redis==3.5.3 + # via redis-py-cluster +redis-py-cluster==2.1.3 # via -r requirements/static/ci/linux.txt referencing==0.37.0 # via @@ -542,7 +499,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -554,8 +510,6 @@ resolvelib==1.2.1 # via ansible-core responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -591,7 +545,6 @@ six==1.17.0 # kubernetes # python-consul # python-dateutil - # rfc3339-validator # transitions # vcert slack-bolt==1.27.0 @@ -612,10 +565,6 @@ tempora==5.8.1 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -626,10 +575,6 @@ transitions==0.9.3 # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt twilio==9.10.9 # via -r requirements/static/ci/linux.txt typer==0.26.7 @@ -641,13 +586,7 @@ typer-slim==0.24.0 # -c requirements/static/pkg/py3.14/linux.lock # jaraco-text typing-extensions==4.15.0 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions - # pytest-system-statistics + # via pytest-system-statistics urllib3==2.7.0 # via # -c requirements/static/pkg/py3.14/linux.lock @@ -683,16 +622,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==1.0.2 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.14/linux.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/linux.txt -yamlordereddictloader==0.4.2 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 + # via junos-eznc yarl==1.22.0 # via # -c requirements/static/pkg/py3.14/linux.lock diff --git a/requirements/static/ci/py3.14/tools.lock b/requirements/static/ci/py3.14/tools.lock index 2955981fac01..3b6fba298394 100644 --- a/requirements/static/ci/py3.14/tools.lock +++ b/requirements/static/ci/py3.14/tools.lock @@ -29,7 +29,9 @@ jmespath==1.0.1 markdown-it-py==4.0.0 # via rich markupsafe==2.1.5 - # via jinja2 + # via + # -r requirements/static/ci/tools.txt + # jinja2 mdurl==0.1.2 # via markdown-it-py packaging==25.0 diff --git a/requirements/static/ci/py3.14/windows.lock b/requirements/static/ci/py3.14/windows.lock index bb7f7cbd9259..20fcea458d03 100644 --- a/requirements/static/ci/py3.14/windows.lock +++ b/requirements/static/ci/py3.14/windows.lock @@ -48,7 +48,7 @@ botocore==1.43.25 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -83,7 +83,7 @@ click==8.3.1 # via # -c requirements/static/pkg/py3.14/windows.lock # typer -clr-loader==0.3.1 +clr-loader==0.2.10 # via # -c requirements/static/pkg/py3.14/windows.lock # pythonnet @@ -94,6 +94,10 @@ colorama==0.4.6 # -c requirements/static/pkg/py3.14/windows.lock # click # pytest +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.14/windows.lock + # -r requirements/base.txt cryptography==48.0.0 # via # -c requirements/static/pkg/py3.14/windows.lock @@ -152,10 +156,6 @@ gitpython==3.1.50 # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/pkg/py3.14/windows.lock @@ -164,7 +164,12 @@ idna==3.18 # requests # trustme # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -c requirements/static/pkg/py3.14/windows.lock + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -231,7 +236,7 @@ markdown-it-py==4.0.0 # via # -c requirements/static/pkg/py3.14/windows.lock # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -244,7 +249,7 @@ mdurl==0.1.2 # markdown-it-py mock==5.2.0 # via -r requirements/pytest.txt -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -269,42 +274,7 @@ multidict==6.7.1 # yarl oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.42.1 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -326,20 +296,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.14/windows.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.14/windows.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via # -c requirements/static/pkg/py3.14/windows.lock @@ -347,8 +308,6 @@ psutil==7.2.2 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.14/windows.lock @@ -376,6 +335,10 @@ pymssql==2.3.11 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt +pymysql==1.2.0 + # via + # -c requirements/static/pkg/py3.14/windows.lock + # -r requirements/base.txt pynacl==1.6.2 # via -r requirements/static/ci/common.txt pyopenssl==26.2.0 @@ -388,7 +351,6 @@ pyspnego==0.12.0 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -397,8 +359,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -443,7 +403,7 @@ python-gnupg==0.5.6 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt -pythonnet==3.1.0 +pythonnet==3.0.5 # via # -c requirements/static/pkg/py3.14/windows.lock # -r requirements/base.txt @@ -487,7 +447,6 @@ requests==2.33.1 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # pywinrm # requests-ntlm # requests-oauthlib @@ -498,8 +457,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==14.3.3 @@ -531,7 +488,6 @@ six==1.17.0 # junit-xml # kubernetes # python-dateutil - # rfc3339-validator smmap==5.0.2 # via # -c requirements/static/pkg/py3.14/windows.lock @@ -548,10 +504,6 @@ tempora==5.8.1 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tornado==6.5.7 @@ -560,10 +512,6 @@ tornado==6.5.7 # -r requirements/base.txt trustme==1.2.1 # via -r requirements/pytest.txt -truststore==0.10.4 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # -r requirements/base.txt typer==0.24.1 # via # -c requirements/static/pkg/py3.14/windows.lock @@ -573,13 +521,11 @@ typer-slim==0.24.0 # -c requirements/static/pkg/py3.14/windows.lock # jaraco-text typing-extensions==4.15.0 + # via pytest-system-statistics +tzdata==2026.2 # via # -c requirements/static/pkg/py3.14/windows.lock - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions - # pytest-system-statistics + # -r requirements/base.txt urllib3==2.7.0 # via # -c requirements/static/pkg/py3.14/windows.lock @@ -621,10 +567,6 @@ xmltodict==1.0.4 # -r requirements/base.txt # moto # pywinrm -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.14/windows.lock - # -r requirements/base.txt yamllint==1.38.0 # via -r requirements/static/ci/windows.txt yarl==1.23.0 diff --git a/requirements/static/ci/py3.9/changelog.lock b/requirements/static/ci/py3.9/changelog.lock index a7857cf156a5..06d01c7c13fb 100644 --- a/requirements/static/ci/py3.9/changelog.lock +++ b/requirements/static/ci/py3.9/changelog.lock @@ -8,7 +8,7 @@ importlib-metadata==8.7.0 # via # -c requirements/static/ci/py3.9/linux.lock # towncrier -importlib-resources==5.0.7 +importlib-resources==6.5.2 # via towncrier jinja2==3.1.6 # via @@ -18,7 +18,7 @@ looseversion==1.3.0 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/changelog.txt -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.9/linux.lock # jinja2 @@ -36,3 +36,4 @@ zipp==3.23.1 # via # -c requirements/static/ci/py3.9/linux.lock # importlib-metadata + # importlib-resources diff --git a/requirements/static/ci/py3.9/cloud.lock b/requirements/static/ci/py3.9/cloud.lock index 08019716e18e..3d17766b3a68 100644 --- a/requirements/static/ci/py3.9/cloud.lock +++ b/requirements/static/ci/py3.9/cloud.lock @@ -79,7 +79,7 @@ cachetools==5.5.2 # via # -c requirements/static/ci/py3.9/linux.lock # google-auth -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock @@ -131,6 +131,11 @@ clustershell==1.9.3 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.9/linux.lock + # -c requirements/static/pkg/py3.9/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.9/linux.lock @@ -223,11 +228,6 @@ google-auth==2.35.0 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.9/linux.lock @@ -237,6 +237,12 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.9/linux.lock + # -c requirements/static/pkg/py3.9/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==8.7.0 # via # -c requirements/static/ci/py3.9/linux.lock @@ -244,7 +250,6 @@ importlib-metadata==8.7.0 # -r requirements/base.txt # -r requirements/static/pkg/linux.txt # keyring - # opentelemetry-api iniconfig==2.0.0 # via # -c requirements/static/ci/py3.9/linux.lock @@ -317,7 +322,7 @@ junit-xml==1.9 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt @@ -365,7 +370,7 @@ markdown-it-py==2.2.0 # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock @@ -411,14 +416,13 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -napalm==5.0.0 +napalm==5.1.0 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt -ncclient==0.7.1 +ncclient==0.7.0 # via # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc # napalm netaddr==1.3.0 @@ -443,48 +447,6 @@ oauthlib==3.3.1 # via # -c requirements/static/ci/py3.9/linux.lock # requests-oauthlib -opentelemetry-api==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via # -c requirements/static/ci/py3.9/linux.lock @@ -499,6 +461,7 @@ paramiko==5.0.0 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt + # junos-eznc # napalm # ncclient # netmiko @@ -524,23 +487,12 @@ portend==3.1.0 # cherrypy profitbricks==4.1.3 # via -r requirements/static/ci/cloud.txt -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 # via # -c requirements/static/ci/py3.9/linux.lock @@ -549,10 +501,6 @@ psutil==5.9.8 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via - # -c requirements/static/ci/py3.9/linux.lock - # pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/ci/py3.9/linux.lock @@ -628,7 +576,6 @@ pytest==8.4.2 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -637,10 +584,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via # -c requirements/static/ci/py3.9/linux.lock @@ -732,8 +675,8 @@ pyyaml==6.0.3 # netmiko # pytest-salt-factories # responses - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock @@ -755,7 +698,6 @@ requests==2.32.5 # kubernetes # moto # napalm - # opentelemetry-exporter-otlp-proto-http # profitbricks # pywinrm # requests-ntlm @@ -772,10 +714,6 @@ responses==0.25.8 # via # -c requirements/static/ci/py3.9/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.9/linux.lock @@ -807,7 +745,6 @@ scp==0.15.0 # via # -c requirements/static/ci/py3.9/linux.lock # junos-eznc - # napalm # netmiko secretstorage==3.3.3 # via @@ -839,7 +776,6 @@ six==1.16.0 # profitbricks # python-dateutil # pywinrm - # rfc3339-validator # transitions # vcert smbprotocol==1.10.1 @@ -873,9 +809,7 @@ textfsm==2.1.0 # ntc-templates timelib==0.3.0 # via - # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -926,10 +860,6 @@ typing-extensions==4.14.1 # gitpython # multidict # napalm - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-shell-utilities # pytest-system-statistics @@ -985,15 +915,9 @@ xmltodict==1.0.2 # -c requirements/static/ci/py3.9/linux.lock # moto # pywinrm -xxhash==3.7.0 +yamlloader==1.6.0 # via # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt -yamlordereddictloader==0.4.2 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.20.1 # via diff --git a/requirements/static/ci/py3.9/darwin.lock b/requirements/static/ci/py3.9/darwin.lock index 68f332d0073b..4efbfbd71a60 100644 --- a/requirements/static/ci/py3.9/darwin.lock +++ b/requirements/static/ci/py3.9/darwin.lock @@ -62,7 +62,7 @@ botocore==1.42.33 # s3transfer cachetools==5.5.2 # via google-auth -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.9/darwin.lock # -r requirements/base.txt @@ -101,6 +101,10 @@ click==8.1.8 # typer clustershell==1.9.3 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.9/darwin.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.9/darwin.lock @@ -166,10 +170,6 @@ gitpython==3.1.50 # -r requirements/static/ci/darwin.txt google-auth==2.35.0 # via -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/darwin.txt idna==3.18 @@ -180,12 +180,16 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.9/darwin.lock + # -r requirements/base.txt + # contextvars importlib-metadata==8.7.1 # via # -c requirements/static/pkg/py3.9/darwin.lock # -r requirements/base.txt # keyring - # opentelemetry-api iniconfig==2.0.0 # via pytest invoke==2.2.1 @@ -235,7 +239,7 @@ jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via # -r requirements/static/ci/common.txt # napalm @@ -263,7 +267,7 @@ markdown-it-py==2.2.0 # -c requirements/static/pkg/py3.9/darwin.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.9/darwin.lock # -r requirements/base.txt @@ -301,11 +305,10 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -napalm==5.0.0 +napalm==5.1.0 # via -r requirements/static/ci/common.txt -ncclient==0.7.1 +ncclient==0.7.0 # via - # -r requirements/static/ci/common.txt # junos-eznc # napalm netaddr==1.3.0 @@ -320,41 +323,6 @@ ntc-templates==8.1.0 # via netmiko oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.41.1 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # -r requirements/base.txt -opentelemetry-proto==1.41.1 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator packaging==26.2 @@ -364,6 +332,7 @@ packaging==26.2 # pytest paramiko==4.0.0 # via + # junos-eznc # napalm # ncclient # netmiko @@ -383,20 +352,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.9/darwin.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.9/darwin.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 # via # -c requirements/static/pkg/py3.9/darwin.lock @@ -404,8 +364,6 @@ psutil==5.9.8 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.9/darwin.lock @@ -430,7 +388,7 @@ pyeapi==1.0.4 # via napalm pyfakefs==5.3.1 # via -r requirements/pytest.txt -pygit2==1.15.1 +pygit2==1.13.1 # via -r requirements/static/ci/darwin.txt pygments==2.20.0 # via @@ -455,7 +413,6 @@ pyserial==3.5 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -464,8 +421,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -530,8 +485,8 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.9/darwin.lock # -r requirements/zeromq.txt @@ -550,7 +505,6 @@ requests==2.32.5 # kubernetes # moto # napalm - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -558,8 +512,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -580,7 +532,6 @@ s3transfer==0.16.0 scp==0.15.0 # via # junos-eznc - # napalm # netmiko semantic-version==2.10.0 # via etcd3-py @@ -600,7 +551,6 @@ six==1.17.0 # junos-eznc # kubernetes # python-dateutil - # rfc3339-validator # transitions # vcert smmap==5.0.2 @@ -621,10 +571,6 @@ textfsm==2.1.0 # napalm # netmiko # ntc-templates -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tomli==2.2.1 @@ -659,10 +605,6 @@ typing-extensions==4.14.1 # gitpython # multidict # napalm - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-shell-utilities # pytest-system-statistics @@ -703,16 +645,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==1.0.2 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.9/darwin.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/darwin.txt -yamlordereddictloader==0.4.2 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.9/darwin.lock diff --git a/requirements/static/ci/py3.9/docs.lock b/requirements/static/ci/py3.9/docs.lock index c3e563abb9f5..3db837d84e43 100644 --- a/requirements/static/ci/py3.9/docs.lock +++ b/requirements/static/ci/py3.9/docs.lock @@ -42,7 +42,7 @@ backports-tarfile==1.2.0 # jaraco-context beautifulsoup4==4.14.3 # via pydata-sphinx-theme -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt @@ -70,6 +70,10 @@ click==8.1.8 # via # -c requirements/static/ci/py3.9/linux.lock # typer +contextvars==2.4 + # via + # -c requirements/static/ci/py3.9/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.9/linux.lock @@ -111,10 +115,6 @@ gitpython==3.1.50 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/ci/py3.9/linux.lock @@ -123,11 +123,15 @@ idna==3.18 # yarl imagesize==1.4.1 # via sphinx +immutables==0.21 + # via + # -c requirements/static/ci/py3.9/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==8.7.0 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt - # opentelemetry-api # sphinx jaraco-collections==4.1.0 # via @@ -174,7 +178,7 @@ markdown-it-py==2.2.0 # mdit-py-plugins # myst-docutils # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt @@ -206,41 +210,6 @@ multidict==6.7.1 # yarl myst-docutils==1.0.0 # via -r requirements/static/ci/docs.txt -opentelemetry-api==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # opentelemetry-sdk packaging==26.2 # via # -c requirements/static/ci/py3.9/linux.lock @@ -255,20 +224,11 @@ portend==3.1.0 # via # -c requirements/static/ci/py3.9/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.9/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.9/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.9/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 # via # -c requirements/static/ci/py3.9/linux.lock @@ -324,7 +284,7 @@ pyyaml==6.0.3 # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt # myst-docutils -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/zeromq.txt @@ -333,7 +293,6 @@ requests==2.32.5 # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http # sphinx rich==15.0.0 # via @@ -386,10 +345,6 @@ tempora==5.3.0 # via # -c requirements/static/ci/py3.9/linux.lock # portend -timelib==0.3.0 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/base.txt tornado==6.5.7 # via # -c requirements/static/ci/py3.9/linux.lock @@ -410,10 +365,6 @@ typing-extensions==4.14.1 # cryptography # gitpython # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pydata-sphinx-theme # pyopenssl # virtualenv @@ -428,10 +379,6 @@ virtualenv==21.4.2 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/base.txt -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/base.txt yarl==1.20.1 # via # -c requirements/static/ci/py3.9/linux.lock diff --git a/requirements/static/ci/py3.9/freebsd.lock b/requirements/static/ci/py3.9/freebsd.lock index 8cf858971cba..f11844b0624c 100644 --- a/requirements/static/ci/py3.9/freebsd.lock +++ b/requirements/static/ci/py3.9/freebsd.lock @@ -58,14 +58,8 @@ backports-tarfile==1.2.0 ; python_full_version < '3.12' # via # -c requirements/static/pkg/py3.9/freebsd.lock # jaraco-context -bcrypt==4.3.0 ; python_full_version == '3.11.*' +bcrypt==5.0.0 # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt - # paramiko -bcrypt==5.0.0 ; python_full_version != '3.11.*' - # via - # -c requirements/constraints.txt # -r requirements/static/ci/common.txt # paramiko boto==2.49.0 @@ -92,7 +86,7 @@ botocore==1.43.25 ; python_full_version >= '3.10' # s3transfer cachetools==5.5.2 ; python_full_version < '3.10' # via google-auth -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt @@ -132,11 +126,7 @@ click==8.1.8 ; python_full_version < '3.10' # via # -c requirements/static/pkg/py3.9/freebsd.lock # typer -clr-loader==0.2.10 ; python_full_version < '3.10' and sys_platform == 'win32' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # pythonnet -clr-loader==0.3.1 ; python_full_version >= '3.10' and sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.9/freebsd.lock # pythonnet @@ -148,6 +138,10 @@ colorama==0.4.6 ; sys_platform == 'win32' # click # pytest # typer +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.9/freebsd.lock + # -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via # -c requirements/static/pkg/py3.9/freebsd.lock @@ -235,10 +229,6 @@ gitpython==3.1.50 # -r requirements/static/ci/common.txt google-auth==2.35.0 ; python_full_version < '3.10' # via -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # opentelemetry-exporter-otlp-proto-http hglib==2.6.2 # via -r requirements/static/ci/freebsd.txt idna==3.18 @@ -249,13 +239,17 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.9/freebsd.lock + # -r requirements/base.txt + # contextvars importlib-metadata==8.7.0 ; python_full_version < '3.10' # via # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt # keyring - # opentelemetry-api importlib-metadata==9.0.0 ; python_full_version >= '3.10' # via # -c requirements/static/pkg/py3.9/freebsd.lock @@ -323,25 +317,14 @@ jmespath==1.1.0 # -r requirements/static/ci/common.txt # boto3 # botocore -jsonschema==3.2.0 ; python_full_version == '3.11.*' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt -jsonschema==4.25.1 ; python_full_version != '3.11.*' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt -jsonschema-specifications==2025.9.1 ; python_full_version != '3.11.*' +jsonschema==4.25.1 + # via -r requirements/static/ci/common.txt +jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.5.4 ; python_full_version == '3.11.*' and sys_platform != 'win32' - # via - # -c requirements/constraints.txt - # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 ; python_full_version != '3.11.*' and sys_platform != 'win32' +junos-eznc==2.7.6 ; sys_platform != 'win32' # via - # -c requirements/constraints.txt # -r requirements/static/ci/common.txt # napalm jxmlease==1.0.3 ; sys_platform != 'win32' @@ -356,6 +339,10 @@ kubernetes==36.0.2 ; python_full_version >= '3.10' # via -r requirements/static/ci/common.txt libnacl==2.1.0 ; sys_platform != 'darwin' and sys_platform != 'win32' # via -r requirements/static/ci/common.txt +linode-python==1.1.1 + # via + # -c requirements/static/pkg/py3.9/freebsd.lock + # -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via # -c requirements/static/pkg/py3.9/freebsd.lock @@ -382,7 +369,7 @@ markdown-it-py==4.2.0 ; python_full_version >= '3.11' # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt @@ -432,16 +419,14 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -napalm==5.0.0 ; python_full_version < '3.10' and sys_platform != 'win32' +napalm==5.1.0 ; python_full_version < '3.10' and sys_platform != 'win32' # via -r requirements/static/ci/common.txt -ncclient==0.7.1 ; sys_platform != 'win32' +ncclient==0.7.0 ; sys_platform != 'win32' # via - # -r requirements/static/ci/common.txt # junos-eznc # napalm -netaddr==1.3.0 ; (python_full_version < '3.10' and sys_platform != 'win32') or (python_full_version == '3.11.*' and sys_platform != 'win32') +netaddr==1.3.0 ; python_full_version < '3.10' and sys_platform != 'win32' # via - # junos-eznc # napalm # pyeapi netmiko==4.6.0 ; python_full_version < '3.10' and sys_platform != 'win32' @@ -452,84 +437,9 @@ ntc-templates==8.1.0 ; python_full_version < '3.10' and sys_platform != 'win32' # via netmiko oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.41.1 ; python_full_version < '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-api==1.42.1 ; python_full_version >= '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 ; python_full_version < '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-common==1.42.1 ; python_full_version >= '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 ; python_full_version < '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt -opentelemetry-exporter-otlp-proto-http==1.42.1 ; python_full_version >= '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 ; python_full_version < '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 ; python_full_version >= '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt -opentelemetry-proto==1.41.1 ; python_full_version < '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-proto==1.42.1 ; python_full_version >= '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 ; python_full_version < '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-sdk==1.42.1 ; python_full_version >= '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 ; python_full_version < '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # opentelemetry-sdk -opentelemetry-semantic-conventions==0.63b1 ; python_full_version >= '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # opentelemetry-sdk oscrypto==1.3.0 ; sys_platform != 'win32' # via certvalidator -packaging==24.0 ; python_full_version >= '3.11' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt - # pytest -packaging==26.2 ; python_full_version < '3.11' +packaging==26.2 # via # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt @@ -557,20 +467,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.9/freebsd.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.9/freebsd.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 ; python_full_version < '3.10' # via # -c requirements/static/pkg/py3.9/freebsd.lock @@ -585,8 +486,6 @@ psutil==7.2.2 ; python_full_version >= '3.10' # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.9/freebsd.lock @@ -629,6 +528,10 @@ pymssql==2.3.11 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.9/freebsd.lock + # -r requirements/base.txt pynacl==1.6.2 # via # -r requirements/static/ci/common.txt @@ -641,8 +544,6 @@ pyopenssl==26.2.0 # etcd3-py pyparsing==3.3.2 ; sys_platform != 'win32' # via junos-eznc -pyrsistent==0.20.0 ; python_full_version == '3.11.*' - # via jsonschema pyserial==3.5 ; sys_platform != 'win32' # via # junos-eznc @@ -650,7 +551,6 @@ pyserial==3.5 ; sys_platform != 'win32' pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -659,8 +559,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -713,11 +611,7 @@ python-gnupg==0.5.6 # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.0.5 ; python_full_version < '3.10' and sys_platform == 'win32' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt -pythonnet==3.1.0 ; python_full_version >= '3.10' and sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt @@ -749,13 +643,18 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 ; python_full_version < '3.13' # via # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/zeromq.txt # pytest-salt-factories -referencing==0.36.2 ; python_full_version != '3.11.*' +pyzmq==27.1.0 ; python_full_version >= '3.13' + # via + # -c requirements/static/pkg/py3.9/freebsd.lock + # -r requirements/zeromq.txt + # pytest-salt-factories +referencing==0.36.2 # via # jsonschema # jsonschema-specifications @@ -768,7 +667,6 @@ requests==2.31.0 ; python_full_version == '3.10.*' # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -782,7 +680,6 @@ requests==2.32.5 ; python_full_version < '3.10' # kubernetes # moto # napalm - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -795,7 +692,6 @@ requests==2.33.1 ; python_full_version >= '3.11' # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # requests-oauthlib # responses # vcert @@ -803,8 +699,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -812,7 +706,7 @@ rich==15.0.0 # -c requirements/static/pkg/py3.9/freebsd.lock # netmiko # typer -rpds-py==0.27.1 ; python_full_version != '3.11.*' +rpds-py==0.27.1 # via # jsonschema # referencing @@ -827,7 +721,6 @@ s3transfer==0.18.0 ; python_full_version >= '3.10' scp==0.15.0 ; sys_platform != 'win32' # via # junos-eznc - # napalm # netmiko secretstorage==3.3.3 ; python_full_version < '3.10' and sys_platform == 'linux' # via keyring @@ -848,12 +741,10 @@ six==1.16.0 # via # -c requirements/static/pkg/py3.9/freebsd.lock # etcd3-py - # jsonschema # junit-xml # junos-eznc # kubernetes # python-dateutil - # rfc3339-validator # transitions # vcert smmap==5.0.2 @@ -877,7 +768,6 @@ textfsm==2.1.0 timelib==0.3.0 # via # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt toml==0.10.2 # via -r requirements/static/ci/common.txt @@ -891,10 +781,6 @@ transitions==0.9.3 ; sys_platform != 'win32' # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt -truststore==0.10.4 ; python_full_version >= '3.10' - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt ttp==0.10.0 ; python_full_version < '3.10' and sys_platform != 'win32' # via # napalm @@ -926,15 +812,15 @@ typing-extensions==4.14.1 # gitpython # multidict # napalm - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-shell-utilities # pytest-system-statistics # referencing # virtualenv +tzdata==2026.2 ; sys_platform == 'win32' + # via + # -c requirements/static/pkg/py3.9/freebsd.lock + # -r requirements/base.txt urllib3==1.26.20 ; python_full_version < '3.10' # via # -c requirements/static/pkg/py3.9/freebsd.lock @@ -987,16 +873,10 @@ xmltodict==1.0.4 # -c requirements/static/pkg/py3.9/freebsd.lock # -r requirements/base.txt # moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.9/freebsd.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/freebsd.txt -yamlordereddictloader==0.4.2 ; sys_platform != 'win32' - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 ; sys_platform != 'win32' + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.9/freebsd.lock diff --git a/requirements/static/ci/py3.9/lint.lock b/requirements/static/ci/py3.9/lint.lock index 1c226e79525e..be852319efc8 100644 --- a/requirements/static/ci/py3.9/lint.lock +++ b/requirements/static/ci/py3.9/lint.lock @@ -85,7 +85,7 @@ cachetools==5.5.2 # via # -c requirements/static/ci/py3.9/linux.lock # google-auth -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock @@ -140,6 +140,11 @@ clustershell==1.9.3 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/ci/py3.9/linux.lock + # -c requirements/static/pkg/py3.9/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/ci/py3.9/linux.lock @@ -225,11 +230,6 @@ google-auth==2.35.0 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.14.0 # via # -c requirements/static/ci/py3.9/linux.lock @@ -256,6 +256,12 @@ idna==3.18 # httpx # requests # yarl +immutables==0.21 + # via + # -c requirements/static/ci/py3.9/linux.lock + # -c requirements/static/pkg/py3.9/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==8.7.0 # via # -c requirements/static/ci/py3.9/linux.lock @@ -263,7 +269,6 @@ importlib-metadata==8.7.0 # -r requirements/base.txt # -r requirements/static/pkg/linux.txt # keyring - # opentelemetry-api invoke==2.2.1 # via # -c requirements/static/ci/py3.9/linux.lock @@ -334,7 +339,7 @@ junit-xml==1.9 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt @@ -382,7 +387,7 @@ markdown-it-py==2.2.0 # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock @@ -428,14 +433,13 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -napalm==5.0.0 +napalm==5.1.0 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt -ncclient==0.7.1 +ncclient==0.7.0 # via # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc # napalm netaddr==1.3.0 @@ -459,48 +463,6 @@ oauthlib==3.3.1 # via # -c requirements/static/ci/py3.9/linux.lock # requests-oauthlib -opentelemetry-api==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via # -c requirements/static/ci/py3.9/linux.lock @@ -514,6 +476,7 @@ paramiko==5.0.0 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/common.txt + # junos-eznc # napalm # ncclient # netmiko @@ -538,23 +501,12 @@ portend==3.1.0 # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 # via # -c requirements/static/ci/py3.9/linux.lock @@ -589,7 +541,7 @@ pyeapi==1.0.4 # via # -c requirements/static/ci/py3.9/linux.lock # napalm -pygit2==1.15.1 +pygit2==1.13.1 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/linux.txt @@ -694,13 +646,17 @@ pyyaml==6.0.3 # netmiko # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/zeromq.txt redis==3.5.3 + # via + # -c requirements/static/ci/py3.9/linux.lock + # redis-py-cluster +redis-py-cluster==2.1.3 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/linux.txt @@ -720,7 +676,6 @@ requests==2.32.5 # kubernetes # moto # napalm - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -734,10 +689,6 @@ responses==0.25.8 # via # -c requirements/static/ci/py3.9/linux.lock # moto -rfc3339-validator==0.1.4 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/static/ci/common.txt rfc3987==1.3.8 # via # -c requirements/static/ci/py3.9/linux.lock @@ -771,7 +722,6 @@ scp==0.15.0 # via # -c requirements/static/ci/py3.9/linux.lock # junos-eznc - # napalm # netmiko secretstorage==3.3.3 # via @@ -802,7 +752,6 @@ six==1.16.0 # kubernetes # python-consul # python-dateutil - # rfc3339-validator # transitions # vcert slack-bolt==1.18.0 @@ -846,9 +795,7 @@ textfsm==2.1.0 # ntc-templates timelib==0.3.0 # via - # -c requirements/static/ci/py3.9/linux.lock # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt # -r requirements/static/pkg/linux.txt toml==0.10.2 # via @@ -903,10 +850,6 @@ typing-extensions==4.14.1 # gitpython # multidict # napalm - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyjwt # pylint # pyopenssl @@ -959,19 +902,13 @@ xmltodict==1.0.2 # via # -c requirements/static/ci/py3.9/linux.lock # moto -xxhash==3.7.0 - # via - # -c requirements/static/ci/py3.9/linux.lock - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt yamllint==1.32.0 # via # -c requirements/static/ci/py3.9/linux.lock # -r requirements/static/ci/linux.txt -yamlordereddictloader==0.4.2 +yamlloader==1.6.0 # via # -c requirements/static/ci/py3.9/linux.lock - # -r requirements/static/ci/common.txt # junos-eznc yarl==1.20.1 # via diff --git a/requirements/static/ci/py3.9/linux.lock b/requirements/static/ci/py3.9/linux.lock index 9a6e36c10c21..fd505e2b90da 100644 --- a/requirements/static/ci/py3.9/linux.lock +++ b/requirements/static/ci/py3.9/linux.lock @@ -67,7 +67,7 @@ botocore==1.42.33 # s3transfer cachetools==5.5.2 # via google-auth -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/base.txt @@ -108,6 +108,10 @@ click==8.1.8 # typer clustershell==1.9.3 # via -r requirements/static/ci/common.txt +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.9/linux.lock + # -r requirements/base.txt croniter==6.2.2 # via # -c requirements/static/pkg/py3.9/linux.lock @@ -175,10 +179,6 @@ gitpython==3.1.50 # -r requirements/static/ci/common.txt google-auth==2.35.0 # via -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-http h11==0.14.0 # via httpcore hglib==2.6.2 @@ -197,12 +197,16 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.9/linux.lock + # -r requirements/base.txt + # contextvars importlib-metadata==8.7.0 # via # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/base.txt # keyring - # opentelemetry-api iniconfig==2.0.0 # via pytest invoke==2.2.1 @@ -256,7 +260,7 @@ jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via -r requirements/static/ci/common.txt -junos-eznc==2.7.1 +junos-eznc==2.7.6 # via # -r requirements/static/ci/common.txt # napalm @@ -288,7 +292,7 @@ markdown-it-py==2.2.0 # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/base.txt @@ -326,11 +330,10 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -napalm==5.0.0 +napalm==5.1.0 # via -r requirements/static/ci/common.txt -ncclient==0.7.1 +ncclient==0.7.0 # via - # -r requirements/static/ci/common.txt # junos-eznc # napalm netaddr==1.3.0 @@ -345,41 +348,6 @@ ntc-templates==8.1.0 # via netmiko oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.41.1 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt -opentelemetry-proto==1.41.1 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-sdk oscrypto==1.3.0 # via certvalidator packaging==26.2 @@ -390,6 +358,7 @@ packaging==26.2 paramiko==5.0.0 # via # -r requirements/static/ci/common.txt + # junos-eznc # napalm # ncclient # netmiko @@ -409,20 +378,11 @@ portend==3.1.0 # via # -c requirements/static/pkg/py3.9/linux.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # opentelemetry-exporter-prometheus propcache==0.3.2 # via # -c requirements/static/pkg/py3.9/linux.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 # via # -c requirements/static/pkg/py3.9/linux.lock @@ -430,8 +390,6 @@ psutil==5.9.8 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.9/linux.lock @@ -456,7 +414,7 @@ pyeapi==1.0.4 # via napalm pyfakefs==5.3.1 # via -r requirements/pytest.txt -pygit2==1.15.1 +pygit2==1.13.1 # via -r requirements/static/ci/linux.txt pygments==2.20.0 # via @@ -489,7 +447,6 @@ pyserial==3.5 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -498,8 +455,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -568,13 +523,15 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint - # yamlordereddictloader -pyzmq==27.1.0 + # yamlloader +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.9/linux.lock # -r requirements/zeromq.txt # pytest-salt-factories redis==3.5.3 + # via redis-py-cluster +redis-py-cluster==2.1.3 # via -r requirements/static/ci/linux.txt referencing==0.36.2 # via @@ -590,7 +547,6 @@ requests==2.32.5 # kubernetes # moto # napalm - # opentelemetry-exporter-otlp-proto-http # python-consul # requests-oauthlib # responses @@ -600,8 +556,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==15.0.0 @@ -622,7 +576,6 @@ s3transfer==0.16.0 scp==0.15.0 # via # junos-eznc - # napalm # netmiko secretstorage==3.3.3 # via keyring @@ -645,7 +598,6 @@ six==1.16.0 # kubernetes # python-consul # python-dateutil - # rfc3339-validator # transitions # vcert slack-bolt==1.18.0 @@ -675,10 +627,6 @@ textfsm==2.1.0 # napalm # netmiko # ntc-templates -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tomli==2.2.1 @@ -715,10 +663,6 @@ typing-extensions==4.14.1 # gitpython # multidict # napalm - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyjwt # pyopenssl # pytest-shell-utilities @@ -760,16 +704,10 @@ xmldiff==2.7.0 # via -r requirements/static/ci/common.txt xmltodict==1.0.2 # via moto -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.9/linux.lock - # -r requirements/base.txt yamllint==1.32.0 # via -r requirements/static/ci/linux.txt -yamlordereddictloader==0.4.2 - # via - # -r requirements/static/ci/common.txt - # junos-eznc +yamlloader==1.6.0 + # via junos-eznc yarl==1.20.1 # via # -c requirements/static/pkg/py3.9/linux.lock diff --git a/requirements/static/ci/py3.9/tools.lock b/requirements/static/ci/py3.9/tools.lock index d1ada87465e9..bb3b1902eca2 100644 --- a/requirements/static/ci/py3.9/tools.lock +++ b/requirements/static/ci/py3.9/tools.lock @@ -31,7 +31,9 @@ markdown-it-py==2.2.0 # -c requirements/constraints.txt # rich markupsafe==2.1.5 - # via jinja2 + # via + # -r requirements/static/ci/tools.txt + # jinja2 mdurl==0.1.2 # via markdown-it-py packaging==23.1 diff --git a/requirements/static/ci/py3.9/windows.lock b/requirements/static/ci/py3.9/windows.lock index 5f60993ace44..ecacdc042ee8 100644 --- a/requirements/static/ci/py3.9/windows.lock +++ b/requirements/static/ci/py3.9/windows.lock @@ -54,7 +54,7 @@ botocore==1.42.33 # s3transfer cachetools==5.5.2 # via google-auth -certifi==2026.6.17 +certifi==2026.5.20 # via # -c requirements/static/pkg/py3.9/windows.lock # -r requirements/base.txt @@ -100,6 +100,10 @@ colorama==0.4.6 # -c requirements/static/pkg/py3.9/windows.lock # click # pytest +contextvars==2.4 + # via + # -c requirements/static/pkg/py3.9/windows.lock + # -r requirements/base.txt cryptography==46.0.7 # via # -c requirements/static/pkg/py3.9/windows.lock @@ -162,10 +166,6 @@ gitpython==3.1.50 # -r requirements/static/ci/common.txt google-auth==2.35.0 # via -r requirements/static/ci/common.txt -googleapis-common-protos==1.75.0 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -c requirements/static/pkg/py3.9/windows.lock @@ -174,12 +174,16 @@ idna==3.18 # requests # trustme # yarl +immutables==0.21 + # via + # -c requirements/static/pkg/py3.9/windows.lock + # -r requirements/base.txt + # contextvars importlib-metadata==8.7.1 # via # -c requirements/static/pkg/py3.9/windows.lock # -r requirements/base.txt # keyring - # opentelemetry-api iniconfig==2.0.0 # via pytest jaraco-classes==3.4.0 @@ -246,7 +250,7 @@ markdown-it-py==2.2.0 # -c requirements/static/pkg/py3.9/windows.lock # -r requirements/static/ci/common.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -c requirements/static/pkg/py3.9/windows.lock # -r requirements/base.txt @@ -284,41 +288,6 @@ multidict==6.7.1 # yarl oauthlib==3.3.1 # via requests-oauthlib -opentelemetry-api==1.41.1 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # -r requirements/base.txt -opentelemetry-proto==1.41.1 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # opentelemetry-sdk packaging==26.2 # via # -c requirements/static/pkg/py3.9/windows.lock @@ -341,20 +310,11 @@ portend==3.2.1 # via # -c requirements/static/pkg/py3.9/windows.lock # cherrypy -prometheus-client==0.25.0 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # opentelemetry-exporter-prometheus propcache==0.4.1 # via # -c requirements/static/pkg/py3.9/windows.lock # aiohttp # yarl -protobuf==6.33.6 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 # via # -c requirements/static/pkg/py3.9/windows.lock @@ -362,8 +322,6 @@ psutil==5.9.8 # pytest-salt-factories # pytest-shell-utilities # pytest-system-statistics -py-cpuinfo==9.0.0 - # via pytest-benchmark pyasn1==0.6.3 # via # -c requirements/static/pkg/py3.9/windows.lock @@ -397,6 +355,10 @@ pymssql==2.3.11 # via # -c requirements/static/pkg/py3.9/windows.lock # -r requirements/base.txt +pymysql==1.2.0 + # via + # -c requirements/static/pkg/py3.9/windows.lock + # -r requirements/base.txt pynacl==1.6.2 # via -r requirements/static/ci/common.txt pyopenssl==26.2.0 @@ -409,7 +371,6 @@ pyspnego==0.12.0 pytest==8.4.2 # via # -r requirements/pytest.txt - # pytest-benchmark # pytest-custom-exit-code # pytest-helpers-namespace # pytest-salt-factories @@ -418,8 +379,6 @@ pytest==8.4.2 # pytest-subtests # pytest-system-statistics # pytest-timeout -pytest-benchmark==5.2.3 - # via -r requirements/pytest.txt pytest-custom-exit-code==0.3.0 # via -r requirements/pytest.txt pytest-helpers-namespace==2021.12.29 @@ -492,7 +451,7 @@ pyyaml==6.0.3 # pytest-salt-factories # responses # yamllint -pyzmq==27.1.0 +pyzmq==25.1.2 # via # -c requirements/static/pkg/py3.9/windows.lock # -r requirements/zeromq.txt @@ -510,7 +469,6 @@ requests==2.32.5 # etcd3-py # kubernetes # moto - # opentelemetry-exporter-otlp-proto-http # pywinrm # requests-ntlm # requests-oauthlib @@ -521,8 +479,6 @@ requests-oauthlib==2.0.0 # via kubernetes responses==0.25.8 # via moto -rfc3339-validator==0.1.4 - # via -r requirements/static/ci/common.txt rfc3987==1.3.8 # via -r requirements/static/ci/common.txt rich==14.3.3 @@ -556,7 +512,6 @@ six==1.17.0 # junit-xml # kubernetes # python-dateutil - # rfc3339-validator smmap==5.0.2 # via # -c requirements/static/pkg/py3.9/windows.lock @@ -573,10 +528,6 @@ tempora==5.8.1 # portend textfsm==2.1.0 # via -r requirements/static/ci/common.txt -timelib==0.3.0 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # -r requirements/base.txt toml==0.10.2 # via -r requirements/static/ci/common.txt tomli==2.2.1 @@ -602,15 +553,15 @@ typing-extensions==4.15.0 # cryptography # gitpython # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # pytest-shell-utilities # pytest-system-statistics # referencing # virtualenv +tzdata==2026.2 + # via + # -c requirements/static/pkg/py3.9/windows.lock + # -r requirements/base.txt urllib3==1.26.20 # via # -c requirements/static/pkg/py3.9/windows.lock @@ -652,10 +603,6 @@ xmltodict==1.0.4 # -r requirements/base.txt # moto # pywinrm -xxhash==3.7.0 - # via - # -c requirements/static/pkg/py3.9/windows.lock - # -r requirements/base.txt yamllint==1.37.1 # via -r requirements/static/ci/windows.txt yarl==1.22.0 diff --git a/requirements/static/ci/tools.txt b/requirements/static/ci/tools.txt index 3d63fed6d856..ed5b9bf098f9 100644 --- a/requirements/static/ci/tools.txt +++ b/requirements/static/ci/tools.txt @@ -3,4 +3,5 @@ python-tools-scripts >= 0.20.5 boto3 pyyaml jinja2 +MarkupSafe<4.0.0 packaging diff --git a/requirements/static/pkg/darwin.txt b/requirements/static/pkg/darwin.txt index 7bd529c4dabc..3810e3cf4f73 100644 --- a/requirements/static/pkg/darwin.txt +++ b/requirements/static/pkg/darwin.txt @@ -3,3 +3,4 @@ # If they are macOS specific, place "; sys_platform == 'darwin'" in front of the requirement. timelib>=0.2.5; python_version < '3.11' timelib>=0.3.0; python_version >= '3.11' +linode-python>=1.1.1 diff --git a/requirements/static/pkg/freebsd.txt b/requirements/static/pkg/freebsd.txt index 93cb3f61ea14..c55e9469a82b 100644 --- a/requirements/static/pkg/freebsd.txt +++ b/requirements/static/pkg/freebsd.txt @@ -13,6 +13,7 @@ python-gnupg>=0.5.6 setproctitle>=1.3.7 timelib>=0.2.5; python_version < '3.11' timelib>=0.3.0; python_version >= '3.11' +linode-python>=1.1.1 distro>=1.9.0 importlib-metadata>=8.7.0,<9.0.0; python_version < '3.10' importlib-metadata>=9.0.0; python_version >= '3.10' diff --git a/requirements/static/pkg/py3.10/darwin.lock b/requirements/static/pkg/py3.10/darwin.lock index 4479cf6f193d..2be9a9ef3c84 100644 --- a/requirements/static/pkg/py3.10/darwin.lock +++ b/requirements/static/pkg/py3.10/darwin.lock @@ -16,7 +16,7 @@ attrs==23.2.0 # via aiohttp backports-tarfile==1.2.0 # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -32,6 +32,8 @@ cheroot==11.1.2 # cherrypy cherrypy==18.10.0 # via -r requirements/base.txt +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==48.0.0 @@ -56,14 +58,16 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via -r requirements/base.txt jaraco-collections==4.1.0 # via cherrypy @@ -85,13 +89,15 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/darwin.txt looseversion==1.3.0 # via -r requirements/base.txt markdown-it-py==3.0.0 # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -111,30 +117,6 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 @@ -143,16 +125,10 @@ platformdirs==4.5.1 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -181,13 +157,12 @@ pytz==2024.1 # via tempora pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.31.0 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -205,13 +180,9 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/darwin.txt + # via -r requirements/static/pkg/darwin.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 @@ -222,10 +193,6 @@ typing-extensions==4.14.1 # aiosignal # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # virtualenv urllib3==2.7.0 @@ -234,8 +201,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.10/freebsd.lock b/requirements/static/pkg/py3.10/freebsd.lock index 3d3b734bc23a..9c4d422a412c 100644 --- a/requirements/static/pkg/py3.10/freebsd.lock +++ b/requirements/static/pkg/py3.10/freebsd.lock @@ -16,7 +16,7 @@ attrs==23.2.0 # via aiohttp backports-tarfile==1.2.0 ; python_full_version < '3.12' # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -37,10 +37,12 @@ cherrypy==18.10.0 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -clr-loader==0.3.1 ; sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via pythonnet colorama==0.4.6 ; sys_platform == 'win32' # via typer +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via -r requirements/base.txt cryptography==48.0.0 @@ -68,13 +70,15 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -r requirements/base.txt @@ -99,6 +103,8 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 ; sys_platform == 'win32' @@ -111,7 +117,7 @@ markdown-it-py==4.2.0 ; python_full_version >= '3.11' # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -131,33 +137,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 ; python_full_version >= '3.11' - # via -r requirements/base.txt -packaging==26.2 ; python_full_version < '3.11' +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -165,16 +145,10 @@ platformdirs==4.5.1 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -192,6 +166,8 @@ pygments==2.20.0 # via rich pymssql==2.3.11 ; sys_platform == 'win32' # via -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via -r requirements/base.txt pyopenssl==26.2.0 # via # -r requirements/base.txt @@ -207,7 +183,7 @@ python-gnupg==0.5.6 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.1.0 ; sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via -r requirements/base.txt pytz==2024.1 # via tempora @@ -217,18 +193,18 @@ pywin32==312 ; sys_platform == 'win32' # wmi pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 ; python_full_version < '3.13' + # via -r requirements/zeromq.txt +pyzmq==27.1.0 ; python_full_version >= '3.13' # via -r requirements/zeromq.txt requests==2.31.0 ; python_full_version < '3.11' # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http requests==2.33.1 ; python_full_version >= '3.11' # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -248,29 +224,23 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/freebsd.txt + # via -r requirements/static/pkg/freebsd.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.14.1 +typing-extensions==4.14.1 ; python_full_version < '3.13' # via # aiohttp # aiosignal # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # virtualenv +tzdata==2026.2 ; sys_platform == 'win32' + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt @@ -281,8 +251,6 @@ wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.10/linux.lock b/requirements/static/pkg/py3.10/linux.lock index 39ea0e2d798f..c6a576b6e941 100644 --- a/requirements/static/pkg/py3.10/linux.lock +++ b/requirements/static/pkg/py3.10/linux.lock @@ -16,7 +16,7 @@ attrs==23.2.0 # via aiohttp backports-tarfile==1.2.0 # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -35,6 +35,8 @@ cherrypy==18.10.0 # via # -r requirements/base.txt # -r requirements/static/pkg/linux.txt +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==48.0.0 @@ -60,13 +62,15 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -r requirements/base.txt @@ -97,7 +101,7 @@ markdown-it-py==3.0.0 # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -118,30 +122,6 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 @@ -150,16 +130,10 @@ platformdirs==4.5.1 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -194,13 +168,12 @@ pytz==2024.1 # via tempora pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.31.0 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -220,13 +193,9 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt + # via -r requirements/static/pkg/linux.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 @@ -237,10 +206,6 @@ typing-extensions==4.14.1 # aiosignal # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # virtualenv urllib3==2.7.0 @@ -249,8 +214,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.10/windows.lock b/requirements/static/pkg/py3.10/windows.lock index 35b3750e9c15..13184fb216b6 100644 --- a/requirements/static/pkg/py3.10/windows.lock +++ b/requirements/static/pkg/py3.10/windows.lock @@ -16,7 +16,7 @@ attrs==25.4.0 # via aiohttp backports-tarfile==1.2.0 # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -39,6 +39,8 @@ clr-loader==0.2.10 # via pythonnet colorama==0.4.6 # via click +contextvars==2.4 + # via -r requirements/base.txt cryptography==48.0.0 # via # -r requirements/base.txt @@ -61,14 +63,16 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via -r requirements/base.txt jaraco-collections==5.2.1 # via cherrypy @@ -90,6 +94,8 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/windows.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 @@ -98,13 +104,13 @@ markdown-it-py==3.0.0 # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -118,30 +124,6 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk packaging==26.2 # via -r requirements/base.txt platformdirs==4.9.2 @@ -150,16 +132,10 @@ platformdirs==4.9.2 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -176,6 +152,8 @@ pygments==2.19.2 # via rich pymssql==2.3.11 # via -r requirements/base.txt +pymysql==1.2.0 + # via -r requirements/base.txt pyopenssl==26.2.0 # via -r requirements/base.txt python-dateutil==2.9.0.post0 @@ -194,13 +172,12 @@ pywin32==312 # wmi pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.31.0 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==14.3.3 # via typer setproctitle==1.3.7 @@ -218,13 +195,9 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/windows.txt + # via -r requirements/static/pkg/windows.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.24.1 # via typer-slim typer-slim==0.24.0 @@ -235,12 +208,10 @@ typing-extensions==4.15.0 # aiosignal # cryptography # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # virtualenv +tzdata==2026.2 + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt @@ -251,8 +222,6 @@ wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.23.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.11/darwin.lock b/requirements/static/pkg/py3.11/darwin.lock index 86f130d813d1..ad656bf61d8e 100644 --- a/requirements/static/pkg/py3.11/darwin.lock +++ b/requirements/static/pkg/py3.11/darwin.lock @@ -14,7 +14,7 @@ attrs==23.2.0 # via aiohttp backports-tarfile==1.2.0 # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -30,6 +30,8 @@ cheroot==11.1.2 # cherrypy cherrypy==18.10.0 # via -r requirements/base.txt +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==48.0.0 @@ -54,14 +56,16 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via -r requirements/base.txt jaraco-collections==4.1.0 # via cherrypy @@ -83,17 +87,19 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/darwin.txt looseversion==1.3.0 # via -r requirements/base.txt markdown-it-py==4.2.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -107,31 +113,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -139,16 +121,10 @@ platformdirs==4.5.1 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -177,13 +153,12 @@ pytz==2024.1 # via tempora pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -201,13 +176,9 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/darwin.txt + # via -r requirements/static/pkg/darwin.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 @@ -216,10 +187,6 @@ typing-extensions==4.14.1 # via # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl urllib3==2.7.0 # via @@ -227,8 +194,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.11/freebsd.lock b/requirements/static/pkg/py3.11/freebsd.lock index ef09d0095252..32055b35f145 100644 --- a/requirements/static/pkg/py3.11/freebsd.lock +++ b/requirements/static/pkg/py3.11/freebsd.lock @@ -14,7 +14,7 @@ attrs==23.2.0 # via aiohttp backports-tarfile==1.2.0 ; python_full_version < '3.12' # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -35,10 +35,12 @@ cherrypy==18.10.0 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -clr-loader==0.3.1 ; sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via pythonnet colorama==0.4.6 ; sys_platform == 'win32' # via typer +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via -r requirements/base.txt cryptography==48.0.0 @@ -66,13 +68,15 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -r requirements/base.txt @@ -97,6 +101,8 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 ; sys_platform == 'win32' @@ -105,13 +111,13 @@ markdown-it-py==4.2.0 # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -125,31 +131,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -157,16 +139,10 @@ platformdirs==4.5.1 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -184,6 +160,8 @@ pygments==2.20.0 # via rich pymssql==2.3.11 ; sys_platform == 'win32' # via -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via -r requirements/base.txt pyopenssl==26.2.0 # via # -r requirements/base.txt @@ -199,7 +177,7 @@ python-gnupg==0.5.6 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.1.0 ; sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via -r requirements/base.txt pytz==2024.1 # via tempora @@ -209,13 +187,14 @@ pywin32==312 ; sys_platform == 'win32' # wmi pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 ; python_full_version < '3.13' + # via -r requirements/zeromq.txt +pyzmq==27.1.0 ; python_full_version >= '3.13' # via -r requirements/zeromq.txt requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -235,26 +214,20 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/freebsd.txt + # via -r requirements/static/pkg/freebsd.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.14.1 +typing-extensions==4.14.1 ; python_full_version < '3.13' # via # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl +tzdata==2026.2 ; sys_platform == 'win32' + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt @@ -265,8 +238,6 @@ wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.11/linux.lock b/requirements/static/pkg/py3.11/linux.lock index fb69489af79f..b001d52aa140 100644 --- a/requirements/static/pkg/py3.11/linux.lock +++ b/requirements/static/pkg/py3.11/linux.lock @@ -14,7 +14,7 @@ attrs==23.2.0 # via aiohttp backports-tarfile==1.2.0 # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -33,6 +33,8 @@ cherrypy==18.10.0 # via # -r requirements/base.txt # -r requirements/static/pkg/linux.txt +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==48.0.0 @@ -58,13 +60,15 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -r requirements/base.txt @@ -93,7 +97,7 @@ looseversion==1.3.0 # via -r requirements/base.txt markdown-it-py==4.2.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -114,31 +118,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -146,16 +126,10 @@ platformdirs==4.5.1 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -190,13 +164,12 @@ pytz==2024.1 # via tempora pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -216,13 +189,9 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt + # via -r requirements/static/pkg/linux.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 @@ -231,10 +200,6 @@ typing-extensions==4.14.1 # via # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl urllib3==2.7.0 # via @@ -242,8 +207,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.11/windows.lock b/requirements/static/pkg/py3.11/windows.lock index a30daae3839e..4e4f771fbda9 100644 --- a/requirements/static/pkg/py3.11/windows.lock +++ b/requirements/static/pkg/py3.11/windows.lock @@ -14,7 +14,7 @@ attrs==25.4.0 # via aiohttp backports-tarfile==1.2.0 # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -37,6 +37,8 @@ clr-loader==0.2.10 # via pythonnet colorama==0.4.6 # via click +contextvars==2.4 + # via -r requirements/base.txt cryptography==48.0.0 # via # -r requirements/base.txt @@ -59,14 +61,16 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via -r requirements/base.txt jaraco-collections==5.2.1 # via cherrypy @@ -88,19 +92,21 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/windows.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 # via -r requirements/base.txt markdown-it-py==4.0.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -114,31 +120,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.9.2 # via @@ -146,16 +128,10 @@ platformdirs==4.9.2 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -172,6 +148,8 @@ pygments==2.19.2 # via rich pymssql==2.3.11 # via -r requirements/base.txt +pymysql==1.2.0 + # via -r requirements/base.txt pyopenssl==26.2.0 # via -r requirements/base.txt python-dateutil==2.9.0.post0 @@ -190,13 +168,12 @@ pywin32==312 # wmi pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==14.3.3 # via typer setproctitle==1.3.7 @@ -214,13 +191,9 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/windows.txt + # via -r requirements/static/pkg/windows.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.24.1 # via typer-slim typer-slim==0.24.0 @@ -229,11 +202,9 @@ typing-extensions==4.15.0 # via # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl +tzdata==2026.2 + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt @@ -244,8 +215,6 @@ wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.23.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.12/darwin.lock b/requirements/static/pkg/py3.12/darwin.lock index 3a00102788b9..9ac0d7ebd326 100644 --- a/requirements/static/pkg/py3.12/darwin.lock +++ b/requirements/static/pkg/py3.12/darwin.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==23.2.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -28,6 +28,8 @@ cheroot==11.1.2 # cherrypy cherrypy==18.10.0 # via -r requirements/base.txt +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==48.0.0 @@ -52,14 +54,16 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via -r requirements/base.txt jaraco-collections==4.1.0 # via cherrypy @@ -81,17 +85,19 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/darwin.txt looseversion==1.3.0 # via -r requirements/base.txt markdown-it-py==4.2.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -105,31 +111,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -137,16 +119,10 @@ platformdirs==4.5.1 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -175,13 +151,12 @@ pytz==2024.1 # via tempora pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -199,13 +174,9 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/darwin.txt + # via -r requirements/static/pkg/darwin.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 @@ -214,10 +185,6 @@ typing-extensions==4.14.1 # via # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl urllib3==2.7.0 # via @@ -225,8 +192,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.12/freebsd.lock b/requirements/static/pkg/py3.12/freebsd.lock index 88371681a31d..9e735c8ff93e 100644 --- a/requirements/static/pkg/py3.12/freebsd.lock +++ b/requirements/static/pkg/py3.12/freebsd.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==23.2.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -33,10 +33,12 @@ cherrypy==18.10.0 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -clr-loader==0.3.1 ; sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via pythonnet colorama==0.4.6 ; sys_platform == 'win32' # via typer +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via -r requirements/base.txt cryptography==48.0.0 @@ -64,13 +66,15 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -r requirements/base.txt @@ -95,6 +99,8 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 ; sys_platform == 'win32' @@ -103,13 +109,13 @@ markdown-it-py==4.2.0 # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -123,31 +129,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -155,16 +137,10 @@ platformdirs==4.5.1 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -182,6 +158,8 @@ pygments==2.20.0 # via rich pymssql==2.3.11 ; sys_platform == 'win32' # via -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via -r requirements/base.txt pyopenssl==26.2.0 # via # -r requirements/base.txt @@ -197,7 +175,7 @@ python-gnupg==0.5.6 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.1.0 ; sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via -r requirements/base.txt pytz==2024.1 # via tempora @@ -207,13 +185,14 @@ pywin32==312 ; sys_platform == 'win32' # wmi pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 ; python_full_version < '3.13' + # via -r requirements/zeromq.txt +pyzmq==27.1.0 ; python_full_version >= '3.13' # via -r requirements/zeromq.txt requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -233,26 +212,20 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/freebsd.txt + # via -r requirements/static/pkg/freebsd.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.14.1 +typing-extensions==4.14.1 ; python_full_version < '3.13' # via # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl +tzdata==2026.2 ; sys_platform == 'win32' + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt @@ -263,8 +236,6 @@ wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.12/linux.lock b/requirements/static/pkg/py3.12/linux.lock index 6743a846dfdb..fd0f710a9b6a 100644 --- a/requirements/static/pkg/py3.12/linux.lock +++ b/requirements/static/pkg/py3.12/linux.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==23.2.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -31,6 +31,8 @@ cherrypy==18.10.0 # via # -r requirements/base.txt # -r requirements/static/pkg/linux.txt +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==48.0.0 @@ -56,13 +58,15 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -r requirements/base.txt @@ -91,7 +95,7 @@ looseversion==1.3.0 # via -r requirements/base.txt markdown-it-py==4.2.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -112,31 +116,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -144,16 +124,10 @@ platformdirs==4.5.1 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -188,13 +162,12 @@ pytz==2024.1 # via tempora pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -214,13 +187,9 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt + # via -r requirements/static/pkg/linux.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 @@ -229,10 +198,6 @@ typing-extensions==4.14.1 # via # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl urllib3==2.7.0 # via @@ -240,8 +205,6 @@ urllib3==2.7.0 # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.12/windows.lock b/requirements/static/pkg/py3.12/windows.lock index 59246082ac7a..e1885a77c630 100644 --- a/requirements/static/pkg/py3.12/windows.lock +++ b/requirements/static/pkg/py3.12/windows.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==25.4.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -35,6 +35,8 @@ clr-loader==0.2.10 # via pythonnet colorama==0.4.6 # via click +contextvars==2.4 + # via -r requirements/base.txt cryptography==48.0.0 # via # -r requirements/base.txt @@ -57,14 +59,16 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via -r requirements/base.txt jaraco-collections==5.2.1 # via cherrypy @@ -86,19 +90,21 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/windows.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 # via -r requirements/base.txt markdown-it-py==4.0.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -112,31 +118,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.9.2 # via @@ -144,16 +126,10 @@ platformdirs==4.9.2 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -170,6 +146,8 @@ pygments==2.19.2 # via rich pymssql==2.3.11 # via -r requirements/base.txt +pymysql==1.2.0 + # via -r requirements/base.txt pyopenssl==26.2.0 # via -r requirements/base.txt python-dateutil==2.9.0.post0 @@ -188,13 +166,12 @@ pywin32==312 # wmi pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==14.3.3 # via typer setproctitle==1.3.7 @@ -212,13 +189,9 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/windows.txt + # via -r requirements/static/pkg/windows.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.24.1 # via typer-slim typer-slim==0.24.0 @@ -227,11 +200,9 @@ typing-extensions==4.15.0 # via # aiohttp # aiosignal - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl +tzdata==2026.2 + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt @@ -242,8 +213,6 @@ wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.23.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.13/darwin.lock b/requirements/static/pkg/py3.13/darwin.lock index 7060a46f2aab..d76f4fedeea4 100644 --- a/requirements/static/pkg/py3.13/darwin.lock +++ b/requirements/static/pkg/py3.13/darwin.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==25.4.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -28,6 +28,8 @@ cheroot==11.1.2 # cherrypy cherrypy==18.10.0 # via -r requirements/base.txt +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==48.0.0 @@ -52,14 +54,16 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via -r requirements/base.txt jaraco-collections==5.2.1 # via cherrypy @@ -81,17 +85,19 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/darwin.txt looseversion==1.3.0 # via -r requirements/base.txt markdown-it-py==4.2.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -105,31 +111,7 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -137,16 +119,10 @@ platformdirs==4.5.1 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -180,7 +156,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -198,31 +173,19 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/darwin.txt + # via -r requirements/static/pkg/darwin.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.15.0 - # via - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions urllib3==2.7.0 # via # -r requirements/base.txt # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.13/freebsd.lock b/requirements/static/pkg/py3.13/freebsd.lock index 104075fc453a..bcdcda6867ea 100644 --- a/requirements/static/pkg/py3.13/freebsd.lock +++ b/requirements/static/pkg/py3.13/freebsd.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==25.4.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -33,10 +33,12 @@ cherrypy==18.10.0 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -clr-loader==0.3.1 ; sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via pythonnet colorama==0.4.6 ; sys_platform == 'win32' # via typer +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via -r requirements/base.txt cryptography==48.0.0 @@ -64,13 +66,15 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -r requirements/base.txt @@ -95,6 +99,8 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 ; sys_platform == 'win32' @@ -103,13 +109,13 @@ markdown-it-py==4.2.0 # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -123,31 +129,7 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -155,16 +137,10 @@ platformdirs==4.5.1 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -182,6 +158,8 @@ pygments==2.20.0 # via rich pymssql==2.3.11 ; sys_platform == 'win32' # via -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via -r requirements/base.txt pyopenssl==26.2.0 # via # -r requirements/base.txt @@ -198,7 +176,7 @@ python-gnupg==0.5.6 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.1.0 ; sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via -r requirements/base.txt pywin32==312 ; sys_platform == 'win32' # via @@ -212,7 +190,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -232,23 +209,15 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/freebsd.txt + # via -r requirements/static/pkg/freebsd.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.15.0 - # via - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions +tzdata==2026.2 ; sys_platform == 'win32' + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt @@ -259,8 +228,6 @@ wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.13/linux.lock b/requirements/static/pkg/py3.13/linux.lock index 5b64c1f082a8..2392946d4e9f 100644 --- a/requirements/static/pkg/py3.13/linux.lock +++ b/requirements/static/pkg/py3.13/linux.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==25.4.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -31,6 +31,8 @@ cherrypy==18.10.0 # via # -r requirements/base.txt # -r requirements/static/pkg/linux.txt +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==48.0.0 @@ -56,13 +58,15 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -r requirements/base.txt @@ -91,7 +95,7 @@ looseversion==1.3.0 # via -r requirements/base.txt markdown-it-py==4.2.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -112,31 +116,7 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -144,16 +124,10 @@ platformdirs==4.5.1 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -193,7 +167,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -213,31 +186,19 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt + # via -r requirements/static/pkg/linux.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.15.0 - # via - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions urllib3==2.7.0 # via # -r requirements/base.txt # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.13/windows.lock b/requirements/static/pkg/py3.13/windows.lock index 2e1d63908170..0fb79fa2c3d9 100644 --- a/requirements/static/pkg/py3.13/windows.lock +++ b/requirements/static/pkg/py3.13/windows.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==25.4.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -31,10 +31,12 @@ cherrypy==18.10.0 # via -r requirements/base.txt click==8.3.1 # via typer -clr-loader==0.3.1 +clr-loader==0.2.10 # via pythonnet colorama==0.4.6 # via click +contextvars==2.4 + # via -r requirements/base.txt cryptography==48.0.0 # via # -r requirements/base.txt @@ -57,14 +59,16 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via -r requirements/base.txt jaraco-collections==5.2.1 # via cherrypy @@ -86,19 +90,21 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/windows.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 # via -r requirements/base.txt markdown-it-py==4.0.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -112,31 +118,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.9.2 # via @@ -144,16 +126,10 @@ platformdirs==4.9.2 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -170,6 +146,8 @@ pygments==2.19.2 # via rich pymssql==2.3.11 # via -r requirements/base.txt +pymysql==1.2.0 + # via -r requirements/base.txt pyopenssl==26.2.0 # via -r requirements/base.txt python-dateutil==2.9.0.post0 @@ -180,7 +158,7 @@ python-discovery==1.4.0 # via virtualenv python-gnupg==0.5.6 # via -r requirements/base.txt -pythonnet==3.1.0 +pythonnet==3.0.5 # via -r requirements/base.txt pywin32==312 # via @@ -194,7 +172,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==14.3.3 # via typer setproctitle==1.3.7 @@ -212,23 +189,15 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/windows.txt + # via -r requirements/static/pkg/windows.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.24.1 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.15.0 - # via - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions +tzdata==2026.2 + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt @@ -239,8 +208,6 @@ wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.23.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.14/darwin.lock b/requirements/static/pkg/py3.14/darwin.lock index 0a1c3d6289f6..8b33854eca06 100644 --- a/requirements/static/pkg/py3.14/darwin.lock +++ b/requirements/static/pkg/py3.14/darwin.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==25.4.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -28,6 +28,8 @@ cheroot==11.1.2 # cherrypy cherrypy==18.10.0 # via -r requirements/base.txt +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==48.0.0 @@ -52,14 +54,16 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via -r requirements/base.txt jaraco-collections==5.2.1 # via cherrypy @@ -81,17 +85,19 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/darwin.txt looseversion==1.3.0 # via -r requirements/base.txt markdown-it-py==4.2.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -105,31 +111,7 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -137,16 +119,10 @@ platformdirs==4.5.1 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -180,7 +156,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -198,31 +173,19 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/darwin.txt + # via -r requirements/static/pkg/darwin.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.15.0 - # via - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions urllib3==2.7.0 # via # -r requirements/base.txt # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.14/freebsd.lock b/requirements/static/pkg/py3.14/freebsd.lock index 0c1ee8f14874..9ec41363228c 100644 --- a/requirements/static/pkg/py3.14/freebsd.lock +++ b/requirements/static/pkg/py3.14/freebsd.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==25.4.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -33,10 +33,12 @@ cherrypy==18.10.0 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -clr-loader==0.3.1 ; sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via pythonnet colorama==0.4.6 ; sys_platform == 'win32' # via typer +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via -r requirements/base.txt cryptography==48.0.0 @@ -64,13 +66,15 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -r requirements/base.txt @@ -95,6 +99,8 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 ; sys_platform == 'win32' @@ -103,13 +109,13 @@ markdown-it-py==4.2.0 # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -123,31 +129,7 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -155,16 +137,10 @@ platformdirs==4.5.1 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -182,6 +158,8 @@ pygments==2.20.0 # via rich pymssql==2.3.11 ; sys_platform == 'win32' # via -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via -r requirements/base.txt pyopenssl==26.2.0 # via # -r requirements/base.txt @@ -198,7 +176,7 @@ python-gnupg==0.5.6 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.1.0 ; sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via -r requirements/base.txt pywin32==312 ; sys_platform == 'win32' # via @@ -212,7 +190,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -232,23 +209,15 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/freebsd.txt + # via -r requirements/static/pkg/freebsd.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.15.0 - # via - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions +tzdata==2026.2 ; sys_platform == 'win32' + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt @@ -259,8 +228,6 @@ wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.14/linux.lock b/requirements/static/pkg/py3.14/linux.lock index 4e7fb464a22f..140f296157fb 100644 --- a/requirements/static/pkg/py3.14/linux.lock +++ b/requirements/static/pkg/py3.14/linux.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==25.4.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -31,6 +31,8 @@ cherrypy==18.10.0 # via # -r requirements/base.txt # -r requirements/static/pkg/linux.txt +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==48.0.0 @@ -56,13 +58,15 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==9.0.0 # via # -r requirements/base.txt @@ -91,7 +95,7 @@ looseversion==1.3.0 # via -r requirements/base.txt markdown-it-py==4.2.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -112,31 +116,7 @@ multidict==6.7.0 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.5.1 # via @@ -144,16 +124,10 @@ platformdirs==4.5.1 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -193,7 +167,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -213,31 +186,19 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt + # via -r requirements/static/pkg/linux.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.26.7 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.15.0 - # via - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions urllib3==2.7.0 # via # -r requirements/base.txt # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.14/windows.lock b/requirements/static/pkg/py3.14/windows.lock index e23e41101f30..9dd12d4a635d 100644 --- a/requirements/static/pkg/py3.14/windows.lock +++ b/requirements/static/pkg/py3.14/windows.lock @@ -12,7 +12,7 @@ apache-libcloud==3.9.1 # via -r requirements/base.txt attrs==25.4.0 # via aiohttp -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -31,10 +31,12 @@ cherrypy==18.10.0 # via -r requirements/base.txt click==8.3.1 # via typer -clr-loader==0.3.1 +clr-loader==0.2.10 # via pythonnet colorama==0.4.6 # via click +contextvars==2.4 + # via -r requirements/base.txt cryptography==48.0.0 # via # -r requirements/base.txt @@ -57,14 +59,16 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars +importlib-metadata==9.0.0 # via -r requirements/base.txt jaraco-collections==5.2.1 # via cherrypy @@ -86,19 +90,21 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/windows.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 # via -r requirements/base.txt markdown-it-py==4.0.0 # via rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -more-itertools==11.1.0 +more-itertools==10.8.0 # via # -r requirements/base.txt # cheroot @@ -112,31 +118,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.42.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.42.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 - # via -r requirements/base.txt -opentelemetry-proto==1.42.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.42.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.63b1 - # via opentelemetry-sdk -packaging==24.0 +packaging==26.2 # via -r requirements/base.txt platformdirs==4.9.2 # via @@ -144,16 +126,10 @@ platformdirs==4.9.2 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==7.2.2 # via -r requirements/base.txt pyasn1==0.6.3 @@ -170,6 +146,8 @@ pygments==2.19.2 # via rich pymssql==2.3.11 # via -r requirements/base.txt +pymysql==1.2.0 + # via -r requirements/base.txt pyopenssl==26.2.0 # via -r requirements/base.txt python-dateutil==2.9.0.post0 @@ -180,7 +158,7 @@ python-discovery==1.4.0 # via virtualenv python-gnupg==0.5.6 # via -r requirements/base.txt -pythonnet==3.1.0 +pythonnet==3.0.5 # via -r requirements/base.txt pywin32==312 # via @@ -194,7 +172,6 @@ requests==2.33.1 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==14.3.3 # via typer setproctitle==1.3.7 @@ -212,23 +189,15 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/windows.txt + # via -r requirements/static/pkg/windows.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 - # via -r requirements/base.txt typer==0.24.1 # via typer-slim typer-slim==0.24.0 # via jaraco-text -typing-extensions==4.15.0 - # via - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions +tzdata==2026.2 + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt @@ -239,8 +208,6 @@ wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.23.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/py3.9/darwin.lock b/requirements/static/pkg/py3.9/darwin.lock index f3c21d374091..00be0ba32bd8 100644 --- a/requirements/static/pkg/py3.9/darwin.lock +++ b/requirements/static/pkg/py3.9/darwin.lock @@ -16,7 +16,7 @@ attrs==23.2.0 # via aiohttp backports-tarfile==1.2.0 # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -34,6 +34,8 @@ cherrypy==18.10.0 # via -r requirements/base.txt click==8.1.8 # via typer +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==46.0.7 @@ -58,17 +60,17 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 # via # -r requirements/base.txt - # opentelemetry-api + # contextvars +importlib-metadata==8.7.1 + # via -r requirements/base.txt jaraco-collections==4.1.0 # via cherrypy jaraco-context==6.1.1 @@ -89,13 +91,15 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/darwin.txt looseversion==1.3.0 # via -r requirements/base.txt markdown-it-py==2.2.0 # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -115,30 +119,6 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.41.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 - # via -r requirements/base.txt -opentelemetry-proto==1.41.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 - # via opentelemetry-sdk packaging==26.2 # via -r requirements/base.txt platformdirs==4.4.0 @@ -147,16 +127,10 @@ platformdirs==4.4.0 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 # via -r requirements/base.txt pyasn1==0.6.3 @@ -185,13 +159,12 @@ pytz==2024.1 # via tempora pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.32.5 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -209,9 +182,7 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/darwin.txt + # via -r requirements/static/pkg/darwin.txt tornado==6.5.7 # via -r requirements/base.txt typer==0.23.2 @@ -224,10 +195,6 @@ typing-extensions==4.14.1 # cryptography # gitpython # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # virtualenv urllib3==1.26.20 @@ -236,8 +203,6 @@ urllib3==1.26.20 # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.9/freebsd.lock b/requirements/static/pkg/py3.9/freebsd.lock index 7af9fe55a183..62b107e22b32 100644 --- a/requirements/static/pkg/py3.9/freebsd.lock +++ b/requirements/static/pkg/py3.9/freebsd.lock @@ -20,7 +20,7 @@ attrs==23.2.0 # via aiohttp backports-tarfile==1.2.0 ; python_full_version < '3.12' # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -43,14 +43,14 @@ cherrypy==18.10.0 # -r requirements/static/pkg/freebsd.txt click==8.1.8 ; python_full_version < '3.10' # via typer -clr-loader==0.2.10 ; python_full_version < '3.10' and sys_platform == 'win32' - # via pythonnet -clr-loader==0.3.1 ; python_full_version >= '3.10' and sys_platform == 'win32' +clr-loader==0.2.10 ; sys_platform == 'win32' # via pythonnet colorama==0.4.6 ; sys_platform == 'win32' # via # click # typer +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 ; sys_platform != 'win32' # via -r requirements/base.txt cryptography==46.0.7 ; python_full_version < '3.10' @@ -88,18 +88,19 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==8.7.0 ; python_full_version < '3.10' # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt - # opentelemetry-api importlib-metadata==9.0.0 ; python_full_version >= '3.10' # via # -r requirements/base.txt @@ -134,6 +135,8 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/freebsd.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 ; sys_platform == 'win32' @@ -146,7 +149,7 @@ markdown-it-py==4.2.0 ; python_full_version >= '3.11' # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -173,57 +176,7 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.41.1 ; python_full_version < '3.10' - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-api==1.42.1 ; python_full_version >= '3.10' - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 ; python_full_version < '3.10' - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-common==1.42.1 ; python_full_version >= '3.10' - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 ; python_full_version < '3.10' - # via -r requirements/base.txt -opentelemetry-exporter-otlp-proto-http==1.42.1 ; python_full_version >= '3.10' - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 ; python_full_version < '3.10' - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.63b1 ; python_full_version >= '3.10' - # via -r requirements/base.txt -opentelemetry-proto==1.41.1 ; python_full_version < '3.10' - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-proto==1.42.1 ; python_full_version >= '3.10' - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 ; python_full_version < '3.10' - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-sdk==1.42.1 ; python_full_version >= '3.10' - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 ; python_full_version < '3.10' - # via opentelemetry-sdk -opentelemetry-semantic-conventions==0.63b1 ; python_full_version >= '3.10' - # via opentelemetry-sdk -packaging==24.0 ; python_full_version >= '3.11' - # via -r requirements/base.txt -packaging==26.2 ; python_full_version < '3.11' +packaging==26.2 # via -r requirements/base.txt platformdirs==4.4.0 # via @@ -231,16 +184,10 @@ platformdirs==4.4.0 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 ; python_full_version < '3.10' # via -r requirements/base.txt psutil==7.2.2 ; python_full_version >= '3.10' @@ -265,6 +212,8 @@ pygments==2.20.0 # via rich pymssql==2.3.11 ; sys_platform == 'win32' # via -r requirements/base.txt +pymysql==1.2.0 ; sys_platform == 'win32' + # via -r requirements/base.txt pyopenssl==26.2.0 # via # -r requirements/base.txt @@ -280,9 +229,7 @@ python-gnupg==0.5.6 # via # -r requirements/base.txt # -r requirements/static/pkg/freebsd.txt -pythonnet==3.0.5 ; python_full_version < '3.10' and sys_platform == 'win32' - # via -r requirements/base.txt -pythonnet==3.1.0 ; python_full_version >= '3.10' and sys_platform == 'win32' +pythonnet==3.0.5 ; sys_platform == 'win32' # via -r requirements/base.txt pytz==2024.1 # via tempora @@ -293,23 +240,22 @@ pywin32==312 ; sys_platform == 'win32' # wmi pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 ; python_full_version < '3.13' + # via -r requirements/zeromq.txt +pyzmq==27.1.0 ; python_full_version >= '3.13' # via -r requirements/zeromq.txt requests==2.31.0 ; python_full_version == '3.10.*' # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http requests==2.32.5 ; python_full_version < '3.10' # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http requests==2.33.1 ; python_full_version >= '3.11' # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -329,13 +275,9 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/freebsd.txt + # via -r requirements/static/pkg/freebsd.txt tornado==6.5.7 # via -r requirements/base.txt -truststore==0.10.4 ; python_full_version >= '3.10' - # via -r requirements/base.txt typer==0.23.2 ; python_full_version < '3.10' # via typer-slim typer==0.26.7 ; python_full_version >= '3.10' @@ -344,19 +286,17 @@ typer-slim==0.23.2 ; python_full_version < '3.10' # via jaraco-text typer-slim==0.24.0 ; python_full_version >= '3.10' # via jaraco-text -typing-extensions==4.14.1 +typing-extensions==4.14.1 ; python_full_version < '3.13' # via # aiohttp # aiosignal # cryptography # gitpython # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # virtualenv +tzdata==2026.2 ; sys_platform == 'win32' + # via -r requirements/base.txt urllib3==1.26.20 ; python_full_version < '3.10' # via # -r requirements/base.txt @@ -371,8 +311,6 @@ wmi==1.5.1 ; sys_platform == 'win32' # via -r requirements/base.txt xmltodict==1.0.4 ; sys_platform == 'win32' # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.9/linux.lock b/requirements/static/pkg/py3.9/linux.lock index 31f1874e42b5..fb5b40bbe92d 100644 --- a/requirements/static/pkg/py3.9/linux.lock +++ b/requirements/static/pkg/py3.9/linux.lock @@ -16,7 +16,7 @@ attrs==23.2.0 # via aiohttp backports-tarfile==1.2.0 # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -37,6 +37,8 @@ cherrypy==18.10.0 # -r requirements/static/pkg/linux.txt click==8.1.8 # via typer +contextvars==2.4 + # via -r requirements/base.txt croniter==6.2.2 # via -r requirements/base.txt cryptography==46.0.7 @@ -62,18 +64,19 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl +immutables==0.21 + # via + # -r requirements/base.txt + # contextvars importlib-metadata==8.7.0 # via # -r requirements/base.txt # -r requirements/static/pkg/linux.txt - # opentelemetry-api jaraco-collections==4.1.0 # via cherrypy jaraco-context==6.1.1 @@ -100,7 +103,7 @@ markdown-it-py==2.2.0 # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -121,30 +124,6 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.41.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 - # via -r requirements/base.txt -opentelemetry-proto==1.41.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 - # via opentelemetry-sdk packaging==26.2 # via -r requirements/base.txt platformdirs==4.4.0 @@ -153,16 +132,10 @@ platformdirs==4.4.0 # virtualenv portend==3.1.0 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.3.2 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 # via -r requirements/base.txt pyasn1==0.6.3 @@ -197,13 +170,12 @@ pytz==2024.1 # via tempora pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.32.5 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==15.0.0 # via typer setproctitle==1.3.7 @@ -223,9 +195,7 @@ smmap==5.0.2 tempora==5.3.0 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/linux.txt + # via -r requirements/static/pkg/linux.txt tornado==6.5.7 # via -r requirements/base.txt typer==0.23.2 @@ -238,10 +208,6 @@ typing-extensions==4.14.1 # cryptography # gitpython # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # virtualenv urllib3==1.26.20 @@ -250,8 +216,6 @@ urllib3==1.26.20 # requests virtualenv==21.4.2 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.20.1 # via aiohttp zc-lockfile==3.0.post1 diff --git a/requirements/static/pkg/py3.9/windows.lock b/requirements/static/pkg/py3.9/windows.lock index 271cb95ea0af..8d780e06e688 100644 --- a/requirements/static/pkg/py3.9/windows.lock +++ b/requirements/static/pkg/py3.9/windows.lock @@ -16,7 +16,7 @@ attrs==25.4.0 # via aiohttp backports-tarfile==1.2.0 # via jaraco-context -certifi==2026.6.17 +certifi==2026.5.20 # via # -r requirements/base.txt # requests @@ -39,6 +39,8 @@ clr-loader==0.2.10 # via pythonnet colorama==0.4.6 # via click +contextvars==2.4 + # via -r requirements/base.txt cryptography==46.0.7 # via # -r requirements/base.txt @@ -61,17 +63,17 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.50 # via -r requirements/base.txt -googleapis-common-protos==1.75.0 - # via opentelemetry-exporter-otlp-proto-http idna==3.18 # via # -r requirements/base.txt # requests # yarl -importlib-metadata==8.7.1 +immutables==0.21 # via # -r requirements/base.txt - # opentelemetry-api + # contextvars +importlib-metadata==8.7.1 + # via -r requirements/base.txt jaraco-collections==5.2.1 # via cherrypy jaraco-context==6.1.1 @@ -92,6 +94,8 @@ jinja2==3.1.6 # via -r requirements/base.txt jmespath==1.1.0 # via -r requirements/base.txt +linode-python==1.1.1 + # via -r requirements/static/pkg/windows.txt looseversion==1.3.0 # via -r requirements/base.txt lxml==6.1.1 @@ -100,7 +104,7 @@ markdown-it-py==2.2.0 # via # -c requirements/constraints.txt # rich -markupsafe==3.0.3 +markupsafe==2.1.5 # via # -r requirements/base.txt # jinja2 @@ -120,30 +124,6 @@ multidict==6.7.1 # -r requirements/base.txt # aiohttp # yarl -opentelemetry-api==1.41.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.41.1 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.41.1 - # via -r requirements/base.txt -opentelemetry-exporter-prometheus==0.62b1 - # via -r requirements/base.txt -opentelemetry-proto==1.41.1 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.41.1 - # via - # -r requirements/base.txt - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-exporter-prometheus -opentelemetry-semantic-conventions==0.62b1 - # via opentelemetry-sdk packaging==26.2 # via -r requirements/base.txt platformdirs==4.4.0 @@ -152,16 +132,10 @@ platformdirs==4.4.0 # virtualenv portend==3.2.1 # via cherrypy -prometheus-client==0.25.0 - # via opentelemetry-exporter-prometheus propcache==0.4.1 # via # aiohttp # yarl -protobuf==6.33.6 - # via - # googleapis-common-protos - # opentelemetry-proto psutil==5.9.8 # via -r requirements/base.txt pyasn1==0.6.3 @@ -178,6 +152,8 @@ pygments==2.19.2 # via rich pymssql==2.3.11 # via -r requirements/base.txt +pymysql==1.2.0 + # via -r requirements/base.txt pyopenssl==26.2.0 # via -r requirements/base.txt python-dateutil==2.9.0.post0 @@ -197,13 +173,12 @@ pywin32==312 # wmi pyyaml==6.0.3 # via -r requirements/base.txt -pyzmq==27.1.0 +pyzmq==25.1.2 # via -r requirements/zeromq.txt requests==2.32.5 # via # -r requirements/base.txt # apache-libcloud - # opentelemetry-exporter-otlp-proto-http rich==14.3.3 # via typer setproctitle==1.3.7 @@ -221,9 +196,7 @@ smmap==5.0.2 tempora==5.8.1 # via portend timelib==0.3.0 - # via - # -r requirements/base.txt - # -r requirements/static/pkg/windows.txt + # via -r requirements/static/pkg/windows.txt tornado==6.5.7 # via -r requirements/base.txt typer==0.23.2 @@ -236,12 +209,10 @@ typing-extensions==4.15.0 # cryptography # gitpython # multidict - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pyopenssl # virtualenv +tzdata==2026.2 + # via -r requirements/base.txt urllib3==1.26.20 # via # -r requirements/base.txt @@ -252,8 +223,6 @@ wmi==1.5.1 # via -r requirements/base.txt xmltodict==1.0.4 # via -r requirements/base.txt -xxhash==3.7.0 - # via -r requirements/base.txt yarl==1.22.0 # via aiohttp zc-lockfile==4.0 diff --git a/requirements/static/pkg/windows.txt b/requirements/static/pkg/windows.txt index 9a1e58eb9490..c9643f170dc5 100644 --- a/requirements/static/pkg/windows.txt +++ b/requirements/static/pkg/windows.txt @@ -3,3 +3,4 @@ # If they are windows specific, place "; sys_platform == 'win32'" in front of the requirement. timelib>=0.2.5; python_version < '3.11' timelib>=0.3.0; python_version >= '3.11' +linode-python>=1.1.1 diff --git a/requirements/zeromq.txt b/requirements/zeromq.txt index 1595a4813a4b..597e3b19fec1 100644 --- a/requirements/zeromq.txt +++ b/requirements/zeromq.txt @@ -1,2 +1,2 @@ -pyzmq>=27.1.0 ; python_version < '3.13' -pyzmq>=27.1.0 ; python_version >= '3.13' +pyzmq>=25.1.2,<26; python_version < "3.13" +pyzmq>=27.1.0; python_version >= "3.13" diff --git a/salt/__init__.py b/salt/__init__.py index 85cc4dc7f653..cf9e821216eb 100644 --- a/salt/__init__.py +++ b/salt/__init__.py @@ -159,19 +159,6 @@ def exec_module(self, module): category=DeprecationWarning, ) -# Filter deprecated datetime calls in third-party libraries (like dateutil) -# All core Salt code has been migrated to use salt.utils.timeutil wrappers. -warnings.filterwarnings( - "ignore", - message="datetime.datetime.utcfromtimestamp\\(\\) is deprecated and scheduled for removal.*", - category=DeprecationWarning, -) -warnings.filterwarnings( - "ignore", - message="datetime.datetime.utcnow\\(\\) is deprecated and scheduled for removal.*", - category=DeprecationWarning, -) - # Third-party libraries that salt's loader pulls in eagerly (boto modules # via salt.utils.boto*, paramiko via salt-ssh, etc.) emit SyntaxWarning / # CryptographyDeprecationWarning at *compile* time on Python 3.10. They diff --git a/salt/_compat.py b/salt/_compat.py index 85d729c7c5e2..2cead97d0b26 100644 --- a/salt/_compat.py +++ b/salt/_compat.py @@ -14,4 +14,18 @@ else: import salt.ext.ipaddress as ipaddress -import importlib.metadata as importlib_metadata +# importlib_metadata before version 3.3.0 does not include the functionality we need. +try: + import importlib_metadata + + importlib_metadata_version = [ + int(part) + for part in importlib_metadata.version("importlib_metadata").split(".") + if part.isdigit() + ] + if tuple(importlib_metadata_version) < (3, 3, 0): + # Use the vendored importlib_metadata + import salt.ext.importlib_metadata as importlib_metadata +except ImportError: + # Use the vendored importlib_metadata + import salt.ext.importlib_metadata as importlib_metadata diff --git a/salt/_logging/__init__.py b/salt/_logging/__init__.py index a30dc0e2c571..ea97b6ce9a84 100644 --- a/salt/_logging/__init__.py +++ b/salt/_logging/__init__.py @@ -16,7 +16,6 @@ DFLT_LOG_FMT_CONSOLE, DFLT_LOG_FMT_JID, DFLT_LOG_FMT_LOGFILE, - DFLT_LOG_FMT_MINION_ID, LOG_COLORS, LOG_LEVELS, LOG_VALUES_TO_LEVELS, diff --git a/salt/_logging/impl.py b/salt/_logging/impl.py index 0d1c0a3dacc6..10bb7e244945 100644 --- a/salt/_logging/impl.py +++ b/salt/_logging/impl.py @@ -1,8 +1,8 @@ """ -salt._logging.impl -~~~~~~~~~~~~~~~~~~ + salt._logging.impl + ~~~~~~~~~~~~~~~~~~ -Salt's logging implementation classes/functionality + Salt's logging implementation classes/functionality """ import atexit @@ -100,7 +100,6 @@ # Default logging formatting options DFLT_LOG_FMT_JID = "[JID: %(jid)s]" -DFLT_LOG_FMT_MINION_ID = "[%(minion_id)s]" DFLT_LOG_DATEFMT = "%H:%M:%S" DFLT_LOG_DATEFMT_LOGFILE = "%Y-%m-%d %H:%M:%S" DFLT_LOG_FMT_CONSOLE = "[%(levelname)-8s] %(message)s" @@ -261,35 +260,21 @@ def _log( if extra is None: extra = {} - current_jid = salt.utils.ctx.get_request_context().get("data", {}).get("jid") + current_jid = ( + salt.utils.ctx.get_request_context().get("data", {}).get("jid", None) + ) log_fmt_jid = ( salt.utils.ctx.get_request_context() .get("opts", {}) .get("log_fmt_jid", None) ) - current_minion_id = ( - salt.utils.ctx.get_request_context().get("data", {}).get("id") - ) - - log_fmt_minion_id = ( - salt.utils.ctx.get_request_context() - .get("opts", {}) - .get("log_fmt_minion_id") - ) - if current_jid is not None: extra["jid"] = current_jid if log_fmt_jid is not None: extra["log_fmt_jid"] = log_fmt_jid - if current_minion_id is not None: - extra["minion_id"] = current_minion_id - - if log_fmt_minion_id is not None: - extra["log_fmt_minion_id"] = log_fmt_minion_id - # If both exc_info and exc_info_on_loglevel are both passed, let's fail if exc_info and exc_info_on_loglevel: raise LoggingRuntimeError( @@ -364,11 +349,6 @@ def makeRecord( log_fmt_jid = extra.pop("log_fmt_jid") jid = log_fmt_jid % {"jid": jid} - minion_id = extra.pop("minion_id", "") - if minion_id: - log_fmt_minion_id = extra.pop("log_fmt_minion_id") - minion_id = log_fmt_minion_id % {"minion_id": minion_id} - if not extra: # If nothing else is in extra, make it None extra = None @@ -418,7 +398,6 @@ def makeRecord( logrecord.exc_info_on_loglevel = exc_info_on_loglevel logrecord.jid = jid - logrecord.minion_id = minion_id return logrecord diff --git a/salt/auth/__init__.py b/salt/auth/__init__.py index e471c17f22c9..a574f6f5b319 100644 --- a/salt/auth/__init__.py +++ b/salt/auth/__init__.py @@ -13,24 +13,23 @@ # 6. Interface to verify tokens import getpass -import hashlib import logging -import os import random import time from collections.abc import Iterable, Mapping -import salt.cache import salt.channel.client +import salt.config import salt.exceptions import salt.loader +import salt.payload import salt.utils.args +import salt.utils.dictupdate import salt.utils.files import salt.utils.minions import salt.utils.network import salt.utils.user import salt.utils.versions -from salt.utils.decorators import cached_property log = logging.getLogger(__name__) @@ -61,15 +60,7 @@ def __init__(self, opts, ckminions=None): self.max_fail = 1.0 self.auth = salt.loader.auth(opts) self.tokens = salt.loader.eauth_tokens(opts) - self._ckminions = ckminions - tokens_cluster_id = opts["eauth_tokens.cluster_id"] or opts["cluster_id"] - self.cache = salt.cache.factory( - opts, driver=opts["eauth_tokens.cache_driver"], cluster_id=tokens_cluster_id - ) - - @cached_property - def ckminions(self): - return self._ckminions or salt.utils.minions.CkMinions(self.opts) + self.ckminions = ckminions or salt.utils.minions.CkMinions(opts) def destroy(self): """ @@ -273,236 +264,81 @@ def mk_token(self, load): if groups: tdata["groups"] = groups - if self.opts["eauth_tokens.cache_driver"] == "rediscluster": - salt.utils.versions.warn_until( - 3010, - "The 'rediscluster' token backend has been deprecated, and will be removed " - "in the Calcium release. Please use the 'redis_cache' cache backend instead.", - ) - return self.tokens["{}.mk_token".format(self.opts["eauth_tokens"])]( - self.opts, tdata - ) - else: - hash_type = getattr(hashlib, self.opts.get("hash_type", "md5")) - new_token = str(hash_type(os.urandom(512)).hexdigest()) - tdata["token"] = new_token - try: - # ``Cache.store``'s ``expires`` is a *relative* duration in - # seconds, not an absolute epoch. Passing ``tdata["expire"]`` - # here -- which is ``time.time() + token_expire`` -- caused - # the envelope ``_expires`` to be set to ``now + (now + - # token_expire)`` (~ year 4090), and combined with the - # broken ``Cache.clean_expired`` fallback resulted in tokens - # being deleted within a single master loop interval. - # Issue #69307. - self.cache.store("tokens", new_token, tdata, expires=token_expire) - except salt.exceptions.SaltCacheError as err: - log.error( - "Cannot mk_token from tokens cache using %s: %s", - self.opts["eauth_tokens.cache_driver"], - err, - ) - return {} - - return tdata + return self.tokens["{}.mk_token".format(self.opts["eauth_tokens"])]( + self.opts, tdata + ) def get_tok(self, tok): """ Return the name associated with the token, or False if the token is not valid """ - if self.opts["eauth_tokens.cache_driver"] == "rediscluster": - salt.utils.versions.warn_until( - 3010, - "The 'rediscluster' token backend has been deprecated, and will be removed " - "in the Calcium release. Please use the 'redis_cache' cache backend instead.", + try: + tdata = self.tokens["{}.get_token".format(self.opts["eauth_tokens"])]( + self.opts, tok ) + except salt.exceptions.SaltDeserializationError as exc: + # The on-disk / in-store token blob is corrupt and cannot + # be parsed. Removing it is the right call -- a corrupt + # token can never authenticate anyway, and leaving it + # around makes every subsequent ``get_tok`` for the same + # id keep failing. ``%r`` on the exception gives the + # operator the class and message inline (e.g. msgpack + # format error, truncated file) without spamming a full + # traceback into a hot-path WARNING; the full traceback is + # available via the companion ``log.debug`` for deeper + # investigation. + log.warning( + "Token %r could not be deserialized (%r); removing it from the store.", + tok, + exc, + ) + log.debug("Token deserialization traceback:", exc_info=True) + self.rm_token(tok) + return {} + except OSError as exc: + # Transient backend error (Redis connection blip, NFS hang, + # hung disk). The token itself is fine; do NOT delete it -- + # that would log every authenticated user out on every + # backend hiccup. Return an empty dict so the caller treats + # this request as not-authenticated; the next request will + # retry against the backend and succeed once it recovers. + # Same logging pattern as above -- exception class + message + # at WARNING, full traceback at DEBUG so a flapping deploy + # stays diagnoseable without GB/hour of stack frames. + log.warning( + "Token store transient error reading %r (%r); treating as " + "not-authenticated for this request without removing the " + "token from the store.", + tok, + exc, + ) + log.debug("Token store transient-error traceback:", exc_info=True) + return {} - tdata = {} - try: - tdata = self.tokens["{}.get_token".format(self.opts["eauth_tokens"])]( - self.opts, tok - ) - except salt.exceptions.SaltDeserializationError as exc: - # The on-disk / in-store token blob is corrupt and cannot - # be parsed. Removing it is the right call -- a corrupt - # token can never authenticate anyway, and leaving it - # around makes every subsequent ``get_tok`` for the same - # id keep failing. ``%r`` on the exception gives the - # operator the class and message inline (e.g. msgpack - # format error, truncated file) without spamming a full - # traceback into a hot-path WARNING; the full traceback is - # available via the companion ``log.debug`` for deeper - # investigation. - log.warning( - "Token %r could not be deserialized (%r); removing it from the store.", - tok, - exc, - ) - log.debug("Token deserialization traceback:", exc_info=True) - rm_tok = True - except OSError as exc: - # Transient backend error (Redis connection blip, NFS hang, - # hung disk). The token itself is fine; do NOT delete it -- - # that would log every authenticated user out on every - # backend hiccup. Return an empty dict so the caller treats - # this request as not-authenticated; the next request will - # retry against the backend and succeed once it recovers. - # Same logging pattern as above -- exception class + message - # at WARNING, full traceback at DEBUG so a flapping deploy - # stays diagnoseable without GB/hour of stack frames. - log.warning( - "Token store transient error reading %r (%r); treating as " - "not-authenticated for this request without removing the " - "token from the store.", - tok, - exc, - ) - log.debug("Token store transient-error traceback:", exc_info=True) - return {} - else: - if not tdata: - return {} - rm_tok = False - - if tdata.get("expire", 0) < time.time(): - # If expire isn't present in the token it's invalid and needs - # to be removed. Also, if it's present and has expired - in - # other words, the expiration is before right now, it should - # be removed. - rm_tok = True - - if rm_tok: - self.rm_token(tok) - return {} - - return tdata - else: - try: - tdata = self.cache.fetch("tokens", tok) - - if tdata.get("expire", 0) < time.time(): - raise salt.exceptions.TokenExpiredError - - return tdata - except ( - salt.exceptions.SaltDeserializationError, - salt.exceptions.TokenExpiredError, - ) as exc: - # The on-disk / in-store token blob is corrupt (or expired) - # and cannot be used. Removing it is the right call -- a - # corrupt token can never authenticate anyway, and leaving - # it around makes every subsequent ``get_tok`` for the same - # id keep failing. ``%r`` on the exception gives the - # operator the class and message inline without spamming a - # full traceback into a hot-path WARNING; the full - # traceback is available via the companion ``log.debug`` - # for deeper investigation. - log.warning( - "Token %r could not be loaded (%r); removing it from the store.", - tok, - exc, - ) - log.debug("Token load traceback:", exc_info=True) - self.rm_token(tok) - except OSError as exc: - # Transient backend error (Redis connection blip, NFS hang, - # hung disk). The token itself is fine; do NOT delete it -- - # that would log every authenticated user out on every - # backend hiccup. Return an empty dict so the caller treats - # this request as not-authenticated; the next request will - # retry against the backend and succeed once it recovers. - log.warning( - "Token store transient error reading %r (%r); treating as " - "not-authenticated for this request without removing the " - "token from the store.", - tok, - exc, - ) - log.debug("Token store transient-error traceback:", exc_info=True) - return {} - except salt.exceptions.SaltCacheError as err: - log.error( - "Cannot get token %s from tokens cache using %s: %s", - tok, - self.opts["eauth_tokens.cache_driver"], - err, - ) + if not tdata: return {} + if tdata.get("expire", 0) < time.time(): + # Expired token: drop it from the store. ``expire`` defaults + # to 0 if missing, so a malformed-but-deserializable token + # without an ``expire`` key falls into this branch too. + self.rm_token(tok) + return {} + return tdata def list_tokens(self): """ List all tokens in eauth_tokens storage. """ - if self.opts["eauth_tokens.cache_driver"] == "rediscluster": - salt.utils.versions.warn_until( - 3010, - "The 'rediscluster' token backend has been deprecated, and will be removed " - "in the Calcium release. Please use the 'redis_cache' cache backend instead.", - ) - - return self.tokens["{}.list_tokens".format(self.opts["eauth_tokens"])]( - self.opts - ) - else: - try: - return self.cache.list("tokens") - except salt.exceptions.SaltCacheError as err: - log.error( - "Cannot list tokens from tokens cache using %s: %s", - self.opts["eauth_tokens.cache_driver"], - err, - ) - return [] + return self.tokens["{}.list_tokens".format(self.opts["eauth_tokens"])]( + self.opts + ) def rm_token(self, tok): """ Remove the given token from token storage. """ - if self.opts["eauth_tokens.cache_driver"] == "rediscluster": - salt.utils.versions.warn_until( - 3010, - "The 'rediscluster' token backend has been deprecated, and will be removed " - "in the Calcium release. Please use the 'redis_cache' cache backend instead.", - ) - - self.tokens["{}.rm_token".format(self.opts["eauth_tokens"])](self.opts, tok) - else: - try: - return self.cache.flush("tokens", tok) - except salt.exceptions.SaltCacheError as err: - log.error( - "Cannot rm token %s from tokens cache using %s: %s", - tok, - self.opts["eauth_tokens.cache_driver"], - err, - ) - return {} - - def clean_expired_tokens(self): - """ - Clean expired tokens - """ - if self.opts["eauth_tokens.cache_driver"] == "rediscluster": - salt.utils.versions.warn_until( - 3010, - "The 'rediscluster' token backend has been deprecated, and will be removed " - "in the Calcium release. Please use the 'redis_cache' cache backend instead.", - ) - log.debug( - "cleaning expired tokens using token driver: {}".format( - self.opts["eauth_tokens"] - ) - ) - for token in self.list_tokens(): - token_data = self.get_tok(token) - if ( - "expire" not in token_data - or token_data.get("expire", 0) < time.time() - ): - self.rm_token(token) - else: - self.cache.clean_expired("tokens") + self.tokens["{}.rm_token".format(self.opts["eauth_tokens"])](self.opts, tok) def authenticate_token(self, load): """ @@ -829,15 +665,6 @@ def get_token(self, token): tdata = self._send_token_request(load) return tdata - def rm_token(self, token): - """ - Delete a token from the master - """ - load = {} - load["token"] = token - load["cmd"] = "rm_token" - self._send_token_request(load) - class AuthUser: """ diff --git a/salt/auth/django.py b/salt/auth/django.py new file mode 100644 index 000000000000..d11fa438f308 --- /dev/null +++ b/salt/auth/django.py @@ -0,0 +1,218 @@ +""" +Provide authentication using Django Web Framework + +:depends: - Django Web Framework + +Django authentication depends on the presence of the django framework in the +``PYTHONPATH``, the Django project's ``settings.py`` file being in the +``PYTHONPATH`` and accessible via the ``DJANGO_SETTINGS_MODULE`` environment +variable. + +Django auth can be defined like any other eauth module: + +.. code-block:: yaml + + external_auth: + django: + fred: + - .* + - '@runner' + +This will authenticate Fred via Django and allow him to run any execution +module and all runners. + +The authorization details can optionally be located inside the Django database. +The relevant entry in the ``models.py`` file would look like this: + +.. code-block:: python + + class SaltExternalAuthModel(models.Model): + user_fk = models.ForeignKey(User, on_delete=models.CASCADE) + minion_or_fn_matcher = models.CharField(max_length=255) + minion_fn = models.CharField(max_length=255) + +The :conf_master:`external_auth` clause in the master config would then look +like this: + +.. code-block:: yaml + + external_auth: + django: + ^model: + +When a user attempts to authenticate via Django, Salt will import the package +indicated via the keyword ``^model``. That model must have the fields +indicated above, though the model DOES NOT have to be named +'SaltExternalAuthModel'. +""" + +import logging +import os +import sys + +# pylint: disable=import-error +try: + import django + from django.db import connection # pylint: disable=no-name-in-module + + HAS_DJANGO = True +except Exception as exc: # pylint: disable=broad-except + # If Django is installed and is not detected, uncomment + # the following line to display additional information + # log.warning('Could not load Django auth module. Found exception: %s', exc) + HAS_DJANGO = False +# pylint: enable=import-error + +DJANGO_AUTH_CLASS = None + +log = logging.getLogger(__name__) + +__virtualname__ = "django" + + +def __virtual__(): + if HAS_DJANGO: + return __virtualname__ + return False + + +def is_connection_usable(): + try: + connection.connection.ping() + except Exception: # pylint: disable=broad-except + return False + else: + return True + + +def __django_auth_setup(): + """ + Prepare the connection to the Django authentication framework + """ + if django.VERSION >= (1, 7): + django.setup() + + global DJANGO_AUTH_CLASS + + if DJANGO_AUTH_CLASS is not None: + return + + # Versions 1.7 and later of Django don't pull models until + # they are needed. When using framework facilities outside the + # web application container we need to run django.setup() to + # get the model definitions cached. + if "^model" in __opts__["external_auth"]["django"]: + django_model_fullname = __opts__["external_auth"]["django"]["^model"] + django_model_name = django_model_fullname.split(".")[-1] + django_module_name = ".".join(django_model_fullname.split(".")[0:-1]) + + # pylint: disable=possibly-unused-variable + django_auth_module = __import__( + django_module_name, globals(), locals(), "SaltExternalAuthModel" + ) + # pylint: enable=possibly-unused-variable + DJANGO_AUTH_CLASS_str = f"django_auth_module.{django_model_name}" + DJANGO_AUTH_CLASS = eval(DJANGO_AUTH_CLASS_str) # pylint: disable=W0123 + + +def auth(username, password): + """ + Simple Django auth + """ + django_auth_path = __opts__["django_auth_path"] + if django_auth_path not in sys.path: + sys.path.append(django_auth_path) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", __opts__["django_auth_settings"]) + + __django_auth_setup() + + if not is_connection_usable(): + connection.close() + + import django.contrib.auth # pylint: disable=import-error,3rd-party-module-not-gated,no-name-in-module + + user = django.contrib.auth.authenticate(username=username, password=password) + if user is not None: + if user.is_active: + log.debug("Django authentication successful") + return True + else: + log.debug( + "Django authentication: the password is valid but the account is disabled." + ) + else: + log.debug("Django authentication failed.") + + return False + + +def acl(username): + """ + + :param username: Username to filter for + :return: Dictionary that can be slotted into the ``__opts__`` structure for + eauth that designates the user associated ACL + + Database records such as: + + =========== ==================== ========= + username minion_or_fn_matcher minion_fn + =========== ==================== ========= + fred test.ping + fred server1 network.interfaces + fred server1 raid.list + fred server2 .* + guru .* + smartadmin server1 .* + =========== ==================== ========= + + Should result in an eauth config such as: + + .. code-block:: yaml + + fred: + - test.ping + - server1: + - network.interfaces + - raid.list + - server2: + - .* + guru: + - .* + smartadmin: + - server1: + - .* + + """ + __django_auth_setup() + + if username is None: + db_records = DJANGO_AUTH_CLASS.objects.all() + else: + db_records = DJANGO_AUTH_CLASS.objects.filter(user_fk__username=username) + auth_dict = {} + + for a in db_records: + if a.user_fk.username not in auth_dict: + auth_dict[a.user_fk.username] = [] + + if not a.minion_or_fn_matcher and a.minion_fn: + auth_dict[a.user_fk.username].append(a.minion_fn) + elif a.minion_or_fn_matcher and not a.minion_fn: + auth_dict[a.user_fk.username].append(a.minion_or_fn_matcher) + else: + found = False + for d in auth_dict[a.user_fk.username]: + if isinstance(d, dict): + if a.minion_or_fn_matcher in d: + auth_dict[a.user_fk.username][a.minion_or_fn_matcher].append( + a.minion_fn + ) + found = True + if not found: + auth_dict[a.user_fk.username].append( + {a.minion_or_fn_matcher: [a.minion_fn]} + ) + + log.debug("django auth_dict is %s", auth_dict) + return auth_dict diff --git a/salt/auth/keystone.py b/salt/auth/keystone.py new file mode 100644 index 000000000000..def2e8170e5b --- /dev/null +++ b/salt/auth/keystone.py @@ -0,0 +1,42 @@ +""" +Provide authentication using OpenStack Keystone + +:depends: - keystoneclient Python module +""" + +try: + from keystoneclient.exceptions import AuthorizationFailure, Unauthorized + from keystoneclient.v2_0 import client +except ImportError: + pass + + +def get_auth_url(): + """ + Try and get the URL from the config, else return localhost + """ + try: + return __opts__["keystone.auth_url"] + except KeyError: + return "http://localhost:35357/v2.0" + + +def auth(username, password): + """ + Try and authenticate + """ + try: + keystone = client.Client( + username=username, password=password, auth_url=get_auth_url() + ) + return keystone.authenticate() + except (AuthorizationFailure, Unauthorized): + return False + + +if __name__ == "__main__": + __opts__ = {} + if auth("test", "test"): + print("Authenticated") + else: + print("Failed to authenticate") diff --git a/salt/auth/mysql.py b/salt/auth/mysql.py new file mode 100644 index 000000000000..215e7a42d2bb --- /dev/null +++ b/salt/auth/mysql.py @@ -0,0 +1,124 @@ +""" +Provide authentication using MySQL. + +When using MySQL as an authentication backend, you will need to create or +use an existing table that has a username and a password column. + +To get started, create a simple table that holds just a username and +a password. The password field will hold a SHA256 checksum. + +.. code-block:: sql + + CREATE TABLE `users` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(25) DEFAULT NULL, + `password` varchar(70) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1; + +To create a user within MySQL, execute the following statement. + +.. code-block:: sql + + INSERT INTO users VALUES (NULL, 'diana', SHA2('secret', 256)) + +.. code-block:: yaml + + mysql_auth: + hostname: localhost + database: SaltStack + username: root + password: letmein + auth_sql: 'SELECT username FROM users WHERE username = "{0}" AND password = SHA2("{1}", 256)' + +The `auth_sql` contains the SQL that will validate a user to ensure they are +correctly authenticated. This is where you can specify other SQL queries to +authenticate users. + +Enable MySQL authentication. + +.. code-block:: yaml + + external_auth: + mysql: + damian: + - test.* + +:depends: - MySQL-python Python module +""" + +import logging + +log = logging.getLogger(__name__) + +try: + # Trying to import MySQLdb + import MySQLdb + import MySQLdb.converters + import MySQLdb.cursors + from MySQLdb.connections import OperationalError +except ImportError: + try: + # MySQLdb import failed, try to import PyMySQL + import pymysql + + pymysql.install_as_MySQLdb() + import MySQLdb + import MySQLdb.converters + import MySQLdb.cursors + from MySQLdb.err import OperationalError + except ImportError: + MySQLdb = None + + +def __virtual__(): + """ + Confirm that a python mysql client is installed. + """ + return bool(MySQLdb), "No python mysql client installed." if MySQLdb is None else "" + + +def __get_connection_info(): + """ + Grab MySQL Connection Details + """ + conn_info = {} + + try: + conn_info["hostname"] = __opts__["mysql_auth"]["hostname"] + conn_info["username"] = __opts__["mysql_auth"]["username"] + conn_info["password"] = __opts__["mysql_auth"]["password"] + conn_info["database"] = __opts__["mysql_auth"]["database"] + + conn_info["auth_sql"] = __opts__["mysql_auth"]["auth_sql"] + except KeyError as e: + log.error("%s does not exist", e) + return None + + return conn_info + + +def auth(username, password): + """ + Authenticate using a MySQL user table + """ + _info = __get_connection_info() + + if _info is None: + return False + + try: + conn = MySQLdb.connect( + _info["hostname"], _info["username"], _info["password"], _info["database"] + ) + except OperationalError as e: + log.error(e) + return False + + cur = conn.cursor() + cur.execute(_info["auth_sql"].format(username, password)) + + if cur.rowcount == 1: + return True + + return False diff --git a/salt/auth/pam.py b/salt/auth/pam.py index b163e379f1e1..5decdba8c2dd 100644 --- a/salt/auth/pam.py +++ b/salt/auth/pam.py @@ -71,8 +71,6 @@ ) from ctypes.util import find_library -import salt.utils.package - HAS_USER = True try: import salt.utils.user @@ -341,39 +339,12 @@ def authenticate(username, password): ``password``: the password in plain text """ - - def __find_pyexe(): - """ - Provides the path to the Python interpreter to use. - - Priority: - - 1. ``auth.pam.python`` config override, when set. - 2. ``sys.executable`` when Salt is running from a relenv/onedir - bundle. The system ``/usr/bin/python3`` on such a host does not - have salt or ``python-pam`` available and will exit non-zero, - causing every PAM auth attempt to return 401 (see #69303). - 3. ``/usr/bin/python3`` if it exists. This branch matters for - non-bundled installs (e.g. pip-installed Salt running in a venv - whose interpreter lacks the system PAM bindings) where the - historical behavior of shelling out to the system Python is - still the right call. - 4. ``sys.executable`` as a last resort. - """ - if __opts__.get("auth.pam.python"): - return __opts__.get("auth.pam.python") - if salt.utils.package.bundled(): - return sys.executable - if os.path.exists("/usr/bin/python3"): - return "/usr/bin/python3" - return sys.executable - env = os.environ.copy() env["SALT_PAM_USERNAME"] = username env["SALT_PAM_PASSWORD"] = password env["SALT_PAM_SERVICE"] = __opts__.get("auth.pam.service", "login") env["SALT_PAM_ENCODING"] = __salt_system_encoding__ - pyexe = pathlib.Path(__find_pyexe()).resolve() + pyexe = pathlib.Path(__opts__.get("auth.pam.python", "/usr/bin/python3")).resolve() pyfile = pathlib.Path(__file__).resolve() if not pyexe.exists(): log.error("Error 'auth.pam.python' config value does not exist: %s", pyexe) diff --git a/salt/auth/pki.py b/salt/auth/pki.py new file mode 100644 index 000000000000..ad9c0ad6dd8b --- /dev/null +++ b/salt/auth/pki.py @@ -0,0 +1,148 @@ +# Majority of code shamelessly stolen from +# http://www.v13.gr/blog/?p=303 +""" +Authenticate via a PKI certificate. + +.. note:: + + This module is Experimental and should be used with caution + +Provides an authenticate function that will allow the caller to authenticate +a user via their public cert against a pre-defined Certificate Authority. + +TODO: Add a 'ca_dir' option to configure a directory of CA files, a la Apache. + +:depends: - pyOpenSSL module +""" +import logging + +import salt.utils.files +import salt.utils.versions + +# pylint: disable=import-error +try: + try: + from M2Crypto import X509 + + HAS_M2 = True + except ImportError: + HAS_M2 = False + try: + from Cryptodome.Util import asn1 + except ImportError: + from Crypto.Util import asn1 # nosec + import OpenSSL # pylint: disable=W8410 + HAS_DEPS = True +except ImportError: + HAS_DEPS = False +# pylint: enable=import-error + + +log = logging.getLogger(__name__) + + +def __virtual__(): + """ + Requires newer pycrypto and pyOpenSSL + """ + if HAS_DEPS: + return True + return False + + +def auth(username, password, **kwargs): + """ + Returns True if the given user cert (password is the cert contents) + was issued by the CA and if cert's Common Name is equal to username. + + Returns False otherwise. + + ``username``: we need it to run the auth function from CLI/API; + it should be in master config auth/acl + ``password``: contents of user certificate (pem-encoded user public key); + why "password"? For CLI, it's the only available name + + Configure the CA cert in the master config file: + + .. code-block:: yaml + + external_auth: + pki: + ca_file: /etc/pki/tls/ca_certs/trusted-ca.crt + your_user: + - .* + """ + salt.utils.versions.warn_until( + 3008, + "This module has been deprecated as it is known to be insecure.", + ) + pem = password + cacert_file = __salt__["config.get"]("external_auth:pki:ca_file") + + log.debug("Attempting to authenticate via pki.") + log.debug("Using CA file: %s", cacert_file) + log.debug("Certificate contents: %s", pem) + + if HAS_M2: + cert = X509.load_cert_string(pem, X509.FORMAT_PEM) + cacert = X509.load_cert(cacert_file, X509.FORMAT_PEM) + if cert.verify(cacert.get_pubkey()): + log.info("Successfully authenticated certificate: %s", pem) + return True + log.info("Failed to authenticate certificate: %s", pem) + return False + + c = OpenSSL.crypto # pylint: disable=used-before-assignment + cert = c.load_certificate(c.FILETYPE_PEM, pem) + + with salt.utils.files.fopen(cacert_file) as f: + cacert = c.load_certificate(c.FILETYPE_PEM, f.read()) + + # Get the signing algorithm + algo = cert.get_signature_algorithm() + + # Get the ASN1 format of the certificate + cert_asn1 = c.dump_certificate(c.FILETYPE_ASN1, cert) + + # Decode the certificate + der = asn1.DerSequence() # pylint: disable=used-before-assignment + der.decode(cert_asn1) + + # The certificate has three parts: + # - certificate + # - signature algorithm + # - signature + # http://usefulfor.com/nothing/2009/06/10/x509-certificate-basics/ + der_cert = der[0] + # der_algo = der[1] + der_sig = der[2] + + # The signature is a BIT STRING (Type 3) + # Decode that as well + der_sig_in = asn1.DerObject() + der_sig_in.decode(der_sig) + + # Get the payload + sig0 = der_sig_in.payload + + # Do the following to see a validation error for tests + # der_cert=der_cert[:20]+'1'+der_cert[21:] + + # First byte is the number of unused bits. This should be 0 + # http://msdn.microsoft.com/en-us/library/windows/desktop/bb540792(v=vs.85).aspx + if sig0[0] != "\x00": + raise Exception("Number of unused bits is strange") + # Now get the signature itself + sig = sig0[1:] + + # And verify the certificate + try: + c.verify(cacert, sig, der_cert, algo) + assert ( + dict(cert.get_subject().get_components())["CN"] == username + ), "Certificate's CN should match the username" + log.info("Successfully authenticated certificate: %s", pem) + return True + except (OpenSSL.crypto.Error, AssertionError): + log.info("Failed to authenticate certificate: %s", pem) + return False diff --git a/salt/auth/yubico.py b/salt/auth/yubico.py new file mode 100644 index 000000000000..7682d4441941 --- /dev/null +++ b/salt/auth/yubico.py @@ -0,0 +1,95 @@ +""" +Provide authentication using YubiKey. + +.. versionadded:: 2015.5.0 + +:depends: yubico-client Python module + +To get your YubiKey API key you will need to visit the website below. + +https://upgrade.yubico.com/getapikey/ + +The resulting page will show the generated Client ID (aka AuthID or API ID) +and the generated API key (Secret Key). Make a note of both and use these +two values in your /etc/salt/master configuration. + + /etc/salt/master + + .. code-block:: yaml + + yubico_users: + damian: + id: 12345 + key: ABCDEFGHIJKLMNOPQRSTUVWXYZ + + + .. code-block:: yaml + + external_auth: + yubico: + damian: + - test.* + + +Please wait five to ten minutes after generating the key before testing so that +the API key will be updated on all the YubiCloud servers. + +""" + +import logging + +log = logging.getLogger(__name__) + +try: + from yubico_client import Yubico, yubico_exceptions + + HAS_YUBICO = True +except ImportError: + HAS_YUBICO = False + + +def __get_yubico_users(username): + """ + Grab the YubiKey Client ID & Secret Key + """ + user = {} + + try: + if __opts__["yubico_users"].get(username, None): + (user["id"], user["key"]) = list( + __opts__["yubico_users"][username].values() + ) + else: + return None + except KeyError: + return None + + return user + + +def auth(username, password): + """ + Authenticate against yubico server + """ + _cred = __get_yubico_users(username) + + client = Yubico(_cred["id"], _cred["key"]) + + try: + return client.verify(password) + except yubico_exceptions.StatusCodeError as e: + log.info("Unable to verify YubiKey `%s`", e) + return False + + +def groups(username, *args, **kwargs): + return False + + +if __name__ == "__main__": + __opts__ = {"yubico_users": {"damian": {"id": "12345", "key": "ABC123"}}} + + if auth("damian", "OPT"): + print("Authenticated") + else: + print("Failed to authenticate") diff --git a/salt/beacons/adb.py b/salt/beacons/adb.py new file mode 100644 index 000000000000..e5ebba1771c5 --- /dev/null +++ b/salt/beacons/adb.py @@ -0,0 +1,166 @@ +""" +Beacon to emit adb device state changes for Android devices + +.. versionadded:: 2016.3.0 +""" + +import logging + +import salt.utils.beacons +import salt.utils.path + +log = logging.getLogger(__name__) + +__virtualname__ = "adb" + +last_state = {} +last_state_extra = {"value": False, "no_devices": False} + + +def __virtual__(): + which_result = salt.utils.path.which("adb") + if which_result is None: + err_msg = "adb is missing." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + else: + return __virtualname__ + + +def validate(config): + """ + Validate the beacon configuration + """ + # Configuration for adb beacon should be a dictionary with states array + if not isinstance(config, list): + log.info("Configuration for adb beacon must be a list.") + return False, "Configuration for adb beacon must be a list." + + config = salt.utils.beacons.list_to_dict(config) + + if "states" not in config: + log.info("Configuration for adb beacon must include a states array.") + return False, "Configuration for adb beacon must include a states array." + else: + if not isinstance(config["states"], list): + log.info("Configuration for adb beacon must include a states array.") + return False, "Configuration for adb beacon must include a states array." + else: + states = [ + "offline", + "bootloader", + "device", + "host", + "recovery", + "no permissions", + "sideload", + "unauthorized", + "unknown", + "missing", + ] + if any(s not in states for s in config["states"]): + log.info( + "Need a one of the following adb states: %s", ", ".join(states) + ) + return ( + False, + "Need a one of the following adb states: {}".format( + ", ".join(states) + ), + ) + return True, "Valid beacon configuration" + + +def beacon(config): + """ + Emit the status of all devices returned by adb + + Specify the device states that should emit an event, + there will be an event for each device with the + event type and device specified. + + .. code-block:: yaml + + beacons: + adb: + - states: + - offline + - unauthorized + - missing + - no_devices_event: True + - battery_low: 25 + + """ + + log.trace("adb beacon starting") + ret = [] + + config = salt.utils.beacons.list_to_dict(config) + + out = __salt__["cmd.run"]("adb devices", runas=config.get("user", None)) + + lines = out.split("\n")[1:] + last_state_devices = list(last_state.keys()) + found_devices = [] + + for line in lines: + try: + device, state = line.split("\t") + found_devices.append(device) + if device not in last_state_devices or ( + "state" in last_state[device] and last_state[device]["state"] != state + ): + if state in config["states"]: + ret.append({"device": device, "state": state, "tag": state}) + last_state[device] = {"state": state} + + if "battery_low" in config: + val = last_state.get(device, {}) + cmd = "adb -s {} shell cat /sys/class/power_supply/*/capacity".format( + device + ) + battery_levels = __salt__["cmd.run"]( + cmd, runas=config.get("user", None) + ).split("\n") + + for l in battery_levels: + battery_level = int(l) + if 0 < battery_level < 100: + if "battery" not in val or battery_level != val["battery"]: + if ( + "battery" not in val + or val["battery"] > config["battery_low"] + ) and battery_level <= config["battery_low"]: + ret.append( + { + "device": device, + "battery_level": battery_level, + "tag": "battery_low", + } + ) + + if device not in last_state: + last_state[device] = {} + + last_state[device].update({"battery": battery_level}) + + except ValueError: + continue + + # Find missing devices and remove them / send an event + for device in last_state_devices: + if device not in found_devices: + if "missing" in config["states"]: + ret.append({"device": device, "state": "missing", "tag": "missing"}) + + del last_state[device] + + # Maybe send an event if we don't have any devices + if "no_devices_event" in config and config["no_devices_event"] is True: + if not found_devices and not last_state_extra["no_devices"]: + ret.append({"tag": "no_devices"}) + + # Did we have no devices listed this time around? + last_state_extra["no_devices"] = not found_devices + + return ret diff --git a/salt/beacons/aix_account.py b/salt/beacons/aix_account.py new file mode 100644 index 000000000000..07aad263846a --- /dev/null +++ b/salt/beacons/aix_account.py @@ -0,0 +1,63 @@ +""" +Beacon to fire event when we notice a AIX user is locked due to many failed login attempts. + +.. versionadded:: 2018.3.0 + +:depends: none +""" + +import logging + +log = logging.getLogger(__name__) + +__virtualname__ = "aix_account" + + +def __virtual__(): + """ + Only load if kernel is AIX + """ + if __grains__["kernel"] == "AIX": + return __virtualname__ + + err_msg = "Only available on AIX systems." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def validate(config): + """ + Validate the beacon configuration + """ + # Configuration for aix_account beacon should be a dictionary + if not isinstance(config, dict): + return False, "Configuration for aix_account beacon must be a dict." + if "user" not in config: + return ( + False, + "Configuration for aix_account beacon must include a user or ALL for all users.", + ) + return True, "Valid beacon configuration" + + +def beacon(config): + """ + Checks for locked accounts due to too many invalid login attempts, 3 or higher. + + .. code-block:: yaml + + beacons: + aix_account: + user: ALL + interval: 120 + + """ + + ret = [] + + user = config["user"] + + locked_accounts = __salt__["shadow.login_failures"](user) + ret.append({"accounts": locked_accounts}) + + return ret diff --git a/salt/beacons/avahi_announce.py b/salt/beacons/avahi_announce.py new file mode 100644 index 000000000000..216a71325e15 --- /dev/null +++ b/salt/beacons/avahi_announce.py @@ -0,0 +1,264 @@ +""" +Beacon to announce via avahi (zeroconf) + +.. versionadded:: 2016.11.0 + +Dependencies +============ + +- python-avahi +- dbus-python + +""" + +import logging +import time + +import salt.utils.beacons +import salt.utils.stringutils + +try: + import avahi + + HAS_PYAVAHI = True +except ImportError: + HAS_PYAVAHI = False + +try: + import dbus + from dbus import DBusException + + BUS = dbus.SystemBus() + SERVER = dbus.Interface( + BUS.get_object(avahi.DBUS_NAME, avahi.DBUS_PATH_SERVER), + avahi.DBUS_INTERFACE_SERVER, + ) + GROUP = dbus.Interface( + BUS.get_object(avahi.DBUS_NAME, SERVER.EntryGroupNew()), + avahi.DBUS_INTERFACE_ENTRY_GROUP, + ) + HAS_DBUS = True +except (ImportError, NameError): + HAS_DBUS = False +except DBusException: + HAS_DBUS = False + +log = logging.getLogger(__name__) + +__virtualname__ = "avahi_announce" + +LAST_GRAINS = {} + + +def __virtual__(): + if HAS_PYAVAHI: + if HAS_DBUS: + return __virtualname__ + err_msg = "The 'python-dbus' dependency is missing." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + err_msg = "The 'python-avahi' dependency is missing." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def validate(config): + """ + Validate the beacon configuration + """ + + _config = salt.utils.beacons.list_to_dict(config) + + if not isinstance(config, list): + return False, "Configuration for avahi_announce beacon must be a list." + + elif not all(x in _config for x in ("servicetype", "port", "txt")): + return ( + False, + "Configuration for avahi_announce beacon must contain servicetype, port and txt items.", + ) + return True, "Valid beacon configuration." + + +def _enforce_txt_record_maxlen(key, value): + """ + Enforces the TXT record maximum length of 255 characters. + TXT record length includes key, value, and '='. + + :param str key: Key of the TXT record + :param str value: Value of the TXT record + + :rtype: str + :return: The value of the TXT record. It may be truncated if it exceeds + the maximum permitted length. In case of truncation, '...' is + appended to indicate that the entire value is not present. + """ + # Add 1 for '=' separator between key and value + if len(key) + len(value) + 1 > 255: + # 255 - 3 ('...') - 1 ('=') = 251 + return value[: 251 - len(key)] + "..." + return value + + +def beacon(config): + """ + Broadcast values via zeroconf + + If the announced values are static, it is advised to set run_once: True + (do not poll) on the beacon configuration. + + The following are required configuration settings: + + - ``servicetype`` - The service type to announce + - ``port`` - The port of the service to announce + - ``txt`` - The TXT record of the service being announced as a dict. Grains + can be used to define TXT values using one of following two formats: + + - ``grains.`` + - ``grains.[i]`` where i is an integer representing the + index of the grain to use. If the grain is not a list, the index is + ignored. + + The following are optional configuration settings: + + - ``servicename`` - Set the name of the service. Will use the hostname from + the minion's ``host`` grain if this value is not set. + - ``reset_on_change`` - If ``True`` and there is a change in TXT records + detected, it will stop announcing the service and then restart announcing + the service. This interruption in service announcement may be desirable + if the client relies on changes in the browse records to update its cache + of TXT records. Defaults to ``False``. + - ``reset_wait`` - The number of seconds to wait after announcement stops + announcing and before it restarts announcing in the case where there is a + change in TXT records detected and ``reset_on_change`` is ``True``. + Defaults to ``0``. + - ``copy_grains`` - If ``True``, Salt will copy the grains passed into the + beacon when it backs them up to check for changes on the next iteration. + Normally, instead of copy, it would use straight value assignment. This + will allow detection of changes to grains where the grains are modified + in-place instead of completely replaced. In-place grains changes are not + currently done in the main Salt code but may be done due to a custom + plug-in. Defaults to ``False``. + + Example Config + + .. code-block:: yaml + + beacons: + avahi_announce: + - run_once: True + - servicetype: _demo._tcp + - port: 1234 + - txt: + ProdName: grains.productname + SerialNo: grains.serialnumber + Comments: 'this is a test' + """ + ret = [] + changes = {} + txt = {} + + global LAST_GRAINS + + config = salt.utils.beacons.list_to_dict(config) + + if "servicename" in config: + servicename = config["servicename"] + else: + servicename = __grains__["host"] + # Check for hostname change + if LAST_GRAINS and LAST_GRAINS["host"] != servicename: + changes["servicename"] = servicename + + if LAST_GRAINS and config.get("reset_on_change", False): + # Check for IP address change in the case when we reset on change + if LAST_GRAINS.get("ipv4", []) != __grains__.get("ipv4", []): + changes["ipv4"] = __grains__.get("ipv4", []) + if LAST_GRAINS.get("ipv6", []) != __grains__.get("ipv6", []): + changes["ipv6"] = __grains__.get("ipv6", []) + + for item in config["txt"]: + changes_key = "txt." + salt.utils.stringutils.to_unicode(item) + if config["txt"][item].startswith("grains."): + grain = config["txt"][item][7:] + grain_index = None + square_bracket = grain.find("[") + if square_bracket != -1 and grain[-1] == "]": + grain_index = int(grain[square_bracket + 1 : -1]) + grain = grain[:square_bracket] + + grain_value = __grains__.get(grain, "") + if isinstance(grain_value, list): + if grain_index is not None: + grain_value = grain_value[grain_index] + else: + grain_value = ",".join(grain_value) + txt[item] = _enforce_txt_record_maxlen(item, grain_value) + if LAST_GRAINS and ( + LAST_GRAINS.get(grain, "") != __grains__.get(grain, "") + ): + changes[changes_key] = txt[item] + else: + txt[item] = _enforce_txt_record_maxlen(item, config["txt"][item]) + + if not LAST_GRAINS: + changes[changes_key] = txt[item] + + if changes: + if not LAST_GRAINS: + changes["servicename"] = servicename + changes["servicetype"] = config["servicetype"] + changes["port"] = config["port"] + changes["ipv4"] = __grains__.get("ipv4", []) + changes["ipv6"] = __grains__.get("ipv6", []) + GROUP.AddService( + avahi.IF_UNSPEC, + avahi.PROTO_UNSPEC, + dbus.UInt32(0), + servicename, + config["servicetype"], + "", + "", + dbus.UInt16(config["port"]), + avahi.dict_to_txt_array(txt), + ) + GROUP.Commit() + elif config.get("reset_on_change", False) or "servicename" in changes: + # A change in 'servicename' requires a reset because we can only + # directly update TXT records + GROUP.Reset() + reset_wait = config.get("reset_wait", 0) + if reset_wait > 0: + time.sleep(reset_wait) + GROUP.AddService( + avahi.IF_UNSPEC, + avahi.PROTO_UNSPEC, + dbus.UInt32(0), + servicename, + config["servicetype"], + "", + "", + dbus.UInt16(config["port"]), + avahi.dict_to_txt_array(txt), + ) + GROUP.Commit() + else: + GROUP.UpdateServiceTxt( + avahi.IF_UNSPEC, + avahi.PROTO_UNSPEC, + dbus.UInt32(0), + servicename, + config["servicetype"], + "", + avahi.dict_to_txt_array(txt), + ) + + ret.append({"tag": "result", "changes": changes}) + + if config.get("copy_grains", False): + LAST_GRAINS = __grains__.copy() + else: + LAST_GRAINS = __grains__ + + return ret diff --git a/salt/beacons/bonjour_announce.py b/salt/beacons/bonjour_announce.py new file mode 100644 index 000000000000..c129128f8495 --- /dev/null +++ b/salt/beacons/bonjour_announce.py @@ -0,0 +1,246 @@ +""" +Beacon to announce via Bonjour (zeroconf) +""" + +import atexit +import logging +import select +import time + +import salt.utils.beacons +import salt.utils.stringutils + +try: + import pybonjour + + HAS_PYBONJOUR = True +except ImportError: + HAS_PYBONJOUR = False + +log = logging.getLogger(__name__) + +__virtualname__ = "bonjour_announce" + +LAST_GRAINS = {} +SD_REF = None + + +def __virtual__(): + if HAS_PYBONJOUR: + return __virtualname__ + err_msg = "pybonjour library is missing." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def _close_sd_ref(): + """ + Close the SD_REF object if it isn't NULL + For use with atexit.register + """ + global SD_REF + if SD_REF: + SD_REF.close() + SD_REF = None + + +def _register_callback( + sdRef, flags, errorCode, name, regtype, domain +): # pylint: disable=unused-argument + if errorCode != pybonjour.kDNSServiceErr_NoError: + log.error("Bonjour registration failed with error code %s", errorCode) + + +def validate(config): + """ + Validate the beacon configuration + """ + _config = salt.utils.beacons.list_to_dict(config) + + if not isinstance(config, list): + return False, "Configuration for bonjour_announce beacon must be a list." + + elif not all(x in _config for x in ("servicetype", "port", "txt")): + return ( + False, + "Configuration for bonjour_announce beacon must contain servicetype, port and txt items.", + ) + return True, "Valid beacon configuration." + + +def _enforce_txt_record_maxlen(key, value): + """ + Enforces the TXT record maximum length of 255 characters. + TXT record length includes key, value, and '='. + + :param str key: Key of the TXT record + :param str value: Value of the TXT record + + :rtype: str + :return: The value of the TXT record. It may be truncated if it exceeds + the maximum permitted length. In case of truncation, '...' is + appended to indicate that the entire value is not present. + """ + # Add 1 for '=' separator between key and value + if len(key) + len(value) + 1 > 255: + # 255 - 3 ('...') - 1 ('=') = 251 + return value[: 251 - len(key)] + "..." + return value + + +def beacon(config): + """ + Broadcast values via zeroconf + + If the announced values are static, it is advised to set run_once: True + (do not poll) on the beacon configuration. + + The following are required configuration settings: + + - ``servicetype`` - The service type to announce + - ``port`` - The port of the service to announce + - ``txt`` - The TXT record of the service being announced as a dict. Grains + can be used to define TXT values using one of following two formats: + + - ``grains.`` + - ``grains.[i]`` where i is an integer representing the + index of the grain to use. If the grain is not a list, the index is + ignored. + + The following are optional configuration settings: + + - ``servicename`` - Set the name of the service. Will use the hostname from + the minion's ``host`` grain if this value is not set. + - ``reset_on_change`` - If ``True`` and there is a change in TXT records + detected, it will stop announcing the service and then restart announcing + the service. This interruption in service announcement may be desirable + if the client relies on changes in the browse records to update its cache + of TXT records. Defaults to ``False``. + - ``reset_wait`` - The number of seconds to wait after announcement stops + announcing and before it restarts announcing in the case where there is a + change in TXT records detected and ``reset_on_change`` is ``True``. + Defaults to ``0``. + - ``copy_grains`` - If ``True``, Salt will copy the grains passed into the + beacon when it backs them up to check for changes on the next iteration. + Normally, instead of copy, it would use straight value assignment. This + will allow detection of changes to grains where the grains are modified + in-place instead of completely replaced. In-place grains changes are not + currently done in the main Salt code but may be done due to a custom + plug-in. Defaults to ``False``. + + Example Config + + .. code-block:: yaml + + beacons: + bonjour_announce: + - run_once: True + - servicetype: _demo._tcp + - port: 1234 + - txt: + ProdName: grains.productname + SerialNo: grains.serialnumber + Comments: 'this is a test' + """ + ret = [] + changes = {} + txt = {} + + global LAST_GRAINS + global SD_REF + + config = salt.utils.beacons.list_to_dict(config) + + if "servicename" in config: + servicename = config["servicename"] + else: + servicename = __grains__["host"] + # Check for hostname change + if LAST_GRAINS and LAST_GRAINS["host"] != servicename: + changes["servicename"] = servicename + + if LAST_GRAINS and config.get("reset_on_change", False): + # Check for IP address change in the case when we reset on change + if LAST_GRAINS.get("ipv4", []) != __grains__.get("ipv4", []): + changes["ipv4"] = __grains__.get("ipv4", []) + if LAST_GRAINS.get("ipv6", []) != __grains__.get("ipv6", []): + changes["ipv6"] = __grains__.get("ipv6", []) + + for item in config["txt"]: + changes_key = "txt." + salt.utils.stringutils.to_unicode(item) + if config["txt"][item].startswith("grains."): + grain = config["txt"][item][7:] + grain_index = None + square_bracket = grain.find("[") + if square_bracket != -1 and grain[-1] == "]": + grain_index = int(grain[square_bracket + 1 : -1]) + grain = grain[:square_bracket] + + grain_value = __grains__.get(grain, "") + if isinstance(grain_value, list): + if grain_index is not None: + grain_value = grain_value[grain_index] + else: + grain_value = ",".join(grain_value) + txt[item] = _enforce_txt_record_maxlen(item, grain_value) + if LAST_GRAINS and ( + LAST_GRAINS.get(grain, "") != __grains__.get(grain, "") + ): + changes[changes_key] = txt[item] + else: + txt[item] = _enforce_txt_record_maxlen(item, config["txt"][item]) + + if not LAST_GRAINS: + changes[changes_key] = txt[item] + + if changes: + txt_record = pybonjour.TXTRecord(items=txt) + if not LAST_GRAINS: + changes["servicename"] = servicename + changes["servicetype"] = config["servicetype"] + changes["port"] = config["port"] + changes["ipv4"] = __grains__.get("ipv4", []) + changes["ipv6"] = __grains__.get("ipv6", []) + SD_REF = pybonjour.DNSServiceRegister( + name=servicename, + regtype=config["servicetype"], + port=config["port"], + txtRecord=txt_record, + callBack=_register_callback, + ) + atexit.register(_close_sd_ref) + ready = select.select([SD_REF], [], []) + if SD_REF in ready[0]: + pybonjour.DNSServiceProcessResult(SD_REF) + elif config.get("reset_on_change", False) or "servicename" in changes: + # A change in 'servicename' requires a reset because we can only + # directly update TXT records + SD_REF.close() + SD_REF = None + reset_wait = config.get("reset_wait", 0) + if reset_wait > 0: + time.sleep(reset_wait) + SD_REF = pybonjour.DNSServiceRegister( + name=servicename, + regtype=config["servicetype"], + port=config["port"], + txtRecord=txt_record, + callBack=_register_callback, + ) + ready = select.select([SD_REF], [], []) + if SD_REF in ready[0]: + pybonjour.DNSServiceProcessResult(SD_REF) + else: + txt_record_raw = str(txt_record).encode("utf-8") + pybonjour.DNSServiceUpdateRecord( + SD_REF, RecordRef=None, flags=0, rdata=txt_record_raw + ) + + ret.append({"tag": "result", "changes": changes}) + + if config.get("copy_grains", False): + LAST_GRAINS = __grains__.copy() + else: + LAST_GRAINS = __grains__ + + return ret diff --git a/salt/beacons/btmp.py b/salt/beacons/btmp.py new file mode 100644 index 000000000000..a5d10f997fce --- /dev/null +++ b/salt/beacons/btmp.py @@ -0,0 +1,310 @@ +""" +Beacon to fire events at failed login of users + +.. versionadded:: 2015.5.0 + +Example Configuration +===================== + +.. code-block:: yaml + + # Fire events on all failed logins + beacons: + btmp: [] + + # Matching on user name, using a default time range + beacons: + btmp: + - users: + gareth: + - defaults: + time_range: + start: '8am' + end: '4pm' + + # Matching on user name, overriding the default time range + beacons: + btmp: + - users: + gareth: + time_range: + start: '8am' + end: '4pm' + - defaults: + time_range: + start: '8am' + end: '4pm' + + # Matching on group name, overriding the default time range + beacons: + btmp: + - groups: + users: + time_range: + start: '8am' + end: '4pm' + - defaults: + time_range: + start: '8am' + end: '4pm' + + +Use Case: Posting Failed Login Events to Slack +============================================== + +This can be done using the following reactor SLS: + +.. code-block:: jinja + + report-wtmp: + runner.salt.cmd: + - args: + - fun: slack.post_message + - channel: mychannel # Slack channel + - from_name: someuser # Slack user + - message: "Failed login from `{{ data.get('user', '') or 'unknown user' }}` on `{{ data['id'] }}`" + +Match the event like so in the master config file: + +.. code-block:: yaml + + reactor: + + - 'salt/beacon/*/btmp/': + - salt://reactor/btmp.sls + +.. note:: + This approach uses the :py:mod:`slack execution module + ` directly on the master, and therefore requires + that the master has a slack API key in its configuration: + + .. code-block:: yaml + + slack: + api_key: xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX + + See the :py:mod:`slack execution module ` + documentation for more information. While you can use an individual user's + API key to post to Slack, a bot user is likely better suited for this. The + :py:mod:`slack engine ` documentation has information + on how to set up a bot user. +""" + +import datetime +import logging +import os +import struct + +import salt.utils.beacons +import salt.utils.files +import salt.utils.stringutils + +__virtualname__ = "btmp" +BTMP = "/var/log/btmp" +FMT = b"hi32s4s32s256shhiii4i20x" +FIELDS = [ + "type", + "PID", + "line", + "inittab", + "user", + "hostname", + "exit_status", + "session", + "time", + "addr", +] +SIZE = struct.calcsize(FMT) +LOC_KEY = "btmp.loc" + +log = logging.getLogger(__name__) + +try: + import dateutil.parser as dateutil_parser + + _TIME_SUPPORTED = True +except ImportError: + _TIME_SUPPORTED = False + + +def __virtual__(): + if os.path.isfile(BTMP): + return __virtualname__ + err_msg = f"{BTMP} does not exist." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def _validate_time_range(trange, status, msg): + """ + Check time range + """ + # If trange is empty, just return the current status & msg + if not trange: + return status, msg + + if not isinstance(trange, dict): + status = False + msg = "The time_range parameter for btmp beacon must be a dictionary." + + if not all(k in trange for k in ("start", "end")): + status = False + msg = ( + "The time_range parameter for btmp beacon must contain start & end options." + ) + + return status, msg + + +def _gather_group_members(group, groups, users): + """ + Gather group members + """ + _group = __salt__["group.info"](group) + + if not _group: + log.warning("Group %s does not exist, ignoring.", group) + return + + for member in _group["members"]: + if member not in users: + users[member] = groups[group] + + +def _check_time_range(time_range, now): + """ + Check time range + """ + if _TIME_SUPPORTED: + _start = dateutil_parser.parse(time_range["start"]) + _end = dateutil_parser.parse(time_range["end"]) + + return bool(_start <= now <= _end) + else: + log.error("Dateutil is required.") + return False + + +def _get_loc(): + """ + return the active file location + """ + if LOC_KEY in __context__: + return __context__[LOC_KEY] + + +def validate(config): + """ + Validate the beacon configuration + """ + vstatus = True + vmsg = "Valid beacon configuration" + + # Configuration for load beacon should be a list of dicts + if not isinstance(config, list): + vstatus = False + vmsg = "Configuration for btmp beacon must be a list." + else: + config = salt.utils.beacons.list_to_dict(config) + + if "users" in config: + if not isinstance(config["users"], dict): + vstatus = False + vmsg = "User configuration for btmp beacon must be a dictionary." + else: + for user in config["users"]: + _time_range = config["users"][user].get("time_range", {}) + vstatus, vmsg = _validate_time_range(_time_range, vstatus, vmsg) + + if not vstatus: + return vstatus, vmsg + + if "groups" in config: + if not isinstance(config["groups"], dict): + vstatus = False + vmsg = "Group configuration for btmp beacon must be a dictionary." + else: + for group in config["groups"]: + _time_range = config["groups"][group].get("time_range", {}) + vstatus, vmsg = _validate_time_range(_time_range, vstatus, vmsg) + if not vstatus: + return vstatus, vmsg + + if "defaults" in config: + if not isinstance(config["defaults"], dict): + vstatus = False + vmsg = "Defaults configuration for btmp beacon must be a dictionary." + else: + _time_range = config["defaults"].get("time_range", {}) + vstatus, vmsg = _validate_time_range(_time_range, vstatus, vmsg) + if not vstatus: + return vstatus, vmsg + + return vstatus, vmsg + + +def beacon(config): + """ + Read the last btmp file and return information on the failed logins + """ + ret = [] + + users = {} + groups = {} + defaults = None + + for config_item in config: + if "users" in config_item: + users = config_item["users"] + + if "groups" in config_item: + groups = config_item["groups"] + + if "defaults" in config_item: + defaults = config_item["defaults"] + + with salt.utils.files.fopen(BTMP, "rb") as fp_: + loc = __context__.get(LOC_KEY, 0) + if loc == 0: + fp_.seek(0, 2) + __context__[LOC_KEY] = fp_.tell() + return ret + else: + fp_.seek(loc) + while True: + now = datetime.datetime.now() + raw = fp_.read(SIZE) + if len(raw) != SIZE: + return ret + __context__[LOC_KEY] = fp_.tell() + pack = struct.unpack(FMT, raw) + event = {} + for ind, field in enumerate(FIELDS): + event[field] = pack[ind] + if isinstance(event[field], (str, bytes)): + if isinstance(event[field], bytes): + event[field] = salt.utils.stringutils.to_unicode(event[field]) + event[field] = event[field].strip("\x00") + + for group in groups: + _gather_group_members(group, groups, users) + + if users: + if event["user"] in users: + _user = users[event["user"]] + if isinstance(_user, dict) and "time_range" in _user: + if _check_time_range(_user["time_range"], now): + ret.append(event) + else: + if defaults and "time_range" in defaults: + if _check_time_range(defaults["time_range"], now): + ret.append(event) + else: + ret.append(event) + else: + if defaults and "time_range" in defaults: + if _check_time_range(defaults["time_range"], now): + ret.append(event) + else: + ret.append(event) + return ret diff --git a/salt/beacons/cert_info.py b/salt/beacons/cert_info.py index cffc8465d857..9959574c6d8b 100644 --- a/salt/beacons/cert_info.py +++ b/salt/beacons/cert_info.py @@ -21,11 +21,6 @@ except ImportError: HAS_OPENSSL = False -# pyOpenSSL >= 25 removed X509.get_extension / X509.get_extension_count -# (and X509Extension as a whole). This beacon iterates extensions via the -# removed API, so refuse to load when those attributes are gone. -HAS_X509_EXTENSION_API = HAS_OPENSSL and hasattr(crypto.X509(), "get_extension") - log = logging.getLogger(__name__) DEFAULT_NOTIFY_DAYS = 45 @@ -78,10 +73,6 @@ def __virtual__(): err_msg = "OpenSSL library is missing." log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) return False, err_msg - if not HAS_X509_EXTENSION_API: - err_msg = "pyOpenSSL >= 25 removed the X509Extension API used by this beacon." - log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) - return False, err_msg return __virtualname__ diff --git a/salt/beacons/glxinfo.py b/salt/beacons/glxinfo.py new file mode 100644 index 000000000000..a4aa40138040 --- /dev/null +++ b/salt/beacons/glxinfo.py @@ -0,0 +1,81 @@ +""" +Beacon to emit when a display is available to a linux machine + +.. versionadded:: 2016.3.0 +""" + +import logging + +import salt.utils.beacons +import salt.utils.path + +log = logging.getLogger(__name__) + +__virtualname__ = "glxinfo" + +last_state = {} + + +def __virtual__(): + + which_result = salt.utils.path.which("glxinfo") + if which_result is None: + err_msg = "glxinfo is missing." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + else: + return __virtualname__ + + +def validate(config): + """ + Validate the beacon configuration + """ + # Configuration for glxinfo beacon should be a dictionary + if not isinstance(config, list): + return False, "Configuration for glxinfo beacon must be a list." + + config = salt.utils.beacons.list_to_dict(config) + + if "user" not in config: + return ( + False, + "Configuration for glxinfo beacon must include a user as glxinfo is not available to root.", + ) + return True, "Valid beacon configuration" + + +def beacon(config): + """ + Emit the status of a connected display to the minion + + Mainly this is used to detect when the display fails to connect + for whatever reason. + + .. code-block:: yaml + + beacons: + glxinfo: + - user: frank + - screen_event: True + + """ + + log.trace("glxinfo beacon starting") + ret = [] + + config = salt.utils.beacons.list_to_dict(config) + + retcode = __salt__["cmd.retcode"]( + "DISPLAY=:0 glxinfo", runas=config["user"], python_shell=True + ) + + if "screen_event" in config and config["screen_event"]: + last_value = last_state.get("screen_available", False) + screen_available = retcode == 0 + if last_value != screen_available or "screen_available" not in last_state: + ret.append({"tag": "screen_event", "screen_available": screen_available}) + + last_state["screen_available"] = screen_available + + return ret diff --git a/salt/beacons/haproxy.py b/salt/beacons/haproxy.py new file mode 100644 index 000000000000..a3aa13de5977 --- /dev/null +++ b/salt/beacons/haproxy.py @@ -0,0 +1,102 @@ +""" +Watch current connections of haproxy server backends. +Fire an event when over a specified threshold. + +.. versionadded:: 2016.11.0 +""" + +import logging + +import salt.utils.beacons + +log = logging.getLogger(__name__) + +__virtualname__ = "haproxy" + + +def __virtual__(): + """ + Only load the module if haproxyctl module is installed + """ + if "haproxy.get_sessions" in __salt__: + return __virtualname__ + else: + err_msg = "haproxy.get_sessions is missing." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def validate(config): + """ + Validate the beacon configuration + """ + if not isinstance(config, list): + return False, "Configuration for haproxy beacon must be a list." + else: + config = salt.utils.beacons.list_to_dict(config) + + if "backends" not in config: + return False, "Configuration for haproxy beacon requires backends." + else: + if not isinstance(config["backends"], dict): + return False, "Backends for haproxy beacon must be a dictionary." + else: + for backend in config["backends"]: + log.debug("config %s", config["backends"][backend]) + if "servers" not in config["backends"][backend]: + return ( + False, + "Backends for haproxy beacon require servers.", + ) + else: + _servers = config["backends"][backend]["servers"] + if not isinstance(_servers, list): + return ( + False, + "Servers for haproxy beacon must be a list.", + ) + return True, "Valid beacon configuration" + + +def beacon(config): + """ + Check if current number of sessions of a server for a specific haproxy backend + is over a defined threshold. + + .. code-block:: yaml + + beacons: + haproxy: + - backends: + www-backend: + threshold: 45 + servers: + - web1 + - web2 + - interval: 120 + """ + ret = [] + + config = salt.utils.beacons.list_to_dict(config) + + for backend in config.get("backends", ()): + backend_config = config["backends"][backend] + threshold = backend_config["threshold"] + for server in backend_config["servers"]: + scur = __salt__["haproxy.get_sessions"](server, backend) + if scur: + if int(scur) > int(threshold): + _server = { + "server": server, + "scur": scur, + "threshold": threshold, + } + log.debug( + "Emit because %s > %s for %s in %s", + scur, + threshold, + server, + backend, + ) + ret.append(_server) + return ret diff --git a/salt/beacons/junos_rre_keys.py b/salt/beacons/junos_rre_keys.py new file mode 100644 index 000000000000..ff776367f5ea --- /dev/null +++ b/salt/beacons/junos_rre_keys.py @@ -0,0 +1,37 @@ +""" +Junos redundant routing engine beacon. + +.. note:: + + This beacon only works on the Juniper native minion. + +Copies salt-minion keys to the backup RE when present + +Configure with + +.. code-block:: yaml + + beacon: + beacons: + junos_rre_keys: + - interval: 43200 + +`interval` above is in seconds, 43200 is recommended (every 12 hours) +""" + +__virtualname__ = "junos_rre_keys" + + +def beacon(config): + ret = [] + + engine_status = __salt__["junos.routing_engine"]() + + if not engine_status["success"]: + return [] + + for e in engine_status["backup"]: + result = __salt__["junos.dir_copy"]("/var/local/salt/etc", e) + ret.append({"result": result, "success": True}) + + return ret diff --git a/salt/beacons/log_beacon.py b/salt/beacons/log_beacon.py index e551865c43ef..52227cd09cb3 100644 --- a/salt/beacons/log_beacon.py +++ b/salt/beacons/log_beacon.py @@ -35,6 +35,7 @@ def __virtual__(): if not salt.utils.platform.is_windows() and HAS_REGEX: return __virtualname__ err_msg = "Not available for Windows systems or when regex library is missing." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) return False, err_msg @@ -116,7 +117,7 @@ def beacon(config): fp_.seek(loc) txt = fp_.read() - log.trace("txt %s", txt) + log.info("txt %s", txt) d = {} for tag in config.get("tags", {}): diff --git a/salt/beacons/napalm_beacon.py b/salt/beacons/napalm_beacon.py new file mode 100644 index 000000000000..e1c97415bf8c --- /dev/null +++ b/salt/beacons/napalm_beacon.py @@ -0,0 +1,354 @@ +""" +Watch NAPALM functions and fire events on specific triggers +=========================================================== + +.. versionadded:: 2018.3.0 + + +.. note:: + + The ``NAPALM`` beacon only works only when running under + a regular Minion or a Proxy Minion, managed via NAPALM_. + Check the documentation for the + :mod:`NAPALM proxy module `. + + .. _NAPALM: http://napalm.readthedocs.io/en/latest/index.html + +The configuration accepts a list of Salt functions to be +invoked, and the corresponding output hierarchy that should +be matched against. To invoke a function with certain +arguments, they can be specified using the ``_args`` key, or +``_kwargs`` for more specific key-value arguments. + +The match structure follows the output hierarchy of the NAPALM +functions, under the ``out`` key. + +For example, the following is normal structure returned by the +:mod:`ntp.stats ` execution function: + +.. code-block:: json + + { + "comment": "", + "result": true, + "out": [ + { + "referenceid": ".GPSs.", + "remote": "172.17.17.1", + "synchronized": true, + "reachability": 377, + "offset": 0.461, + "when": "860", + "delay": 143.606, + "hostpoll": 1024, + "stratum": 1, + "jitter": 0.027, + "type": "-" + }, + { + "referenceid": ".INIT.", + "remote": "172.17.17.2", + "synchronized": false, + "reachability": 0, + "offset": 0.0, + "when": "-", + "delay": 0.0, + "hostpoll": 1024, + "stratum": 16, + "jitter": 4000.0, + "type": "-" + } + ] + } + +In order to fire events when the synchronization is lost with +one of the NTP peers, e.g., ``172.17.17.2``, we can match it explicitly as: + +.. code-block:: yaml + + ntp.stats: + remote: 172.17.17.2 + synchronized: false + +There is one single nesting level, as the output of ``ntp.stats`` is +just a list of dictionaries, and this beacon will compare each dictionary +from the list with the structure examplified above. + +.. note:: + + When we want to match on any element at a certain level, we can + configure ``*`` to match anything. + +Considering a more complex structure consisting on multiple nested levels, +e.g., the output of the :mod:`bgp.neighbors ` +execution function, to check when any neighbor from the ``global`` +routing table is down, the match structure would have the format: + +.. code-block:: yaml + + bgp.neighbors: + global: + '*': + up: false + +The match structure above will match any BGP neighbor, with +any network (``*`` matches any AS number), under the ``global`` VRF. +In other words, this beacon will push an event on the Salt bus +when there's a BGP neighbor down. + +The right operand can also accept mathematical operations +(i.e., ``<``, ``<=``, ``!=``, ``>``, ``>=`` etc.) when comparing +numerical values. + +Configuration Example: + +.. code-block:: yaml + + beacons: + napalm: + - net.interfaces: + # fire events when any interfaces is down + '*': + is_up: false + - net.interfaces: + # fire events only when the xe-0/0/0 interface is down + 'xe-0/0/0': + is_up: false + - ntp.stats: + # fire when there's any NTP peer unsynchornized + synchronized: false + - ntp.stats: + # fire only when the synchronization + # with with the 172.17.17.2 NTP server is lost + _args: + - 172.17.17.2 + synchronized: false + - ntp.stats: + # fire only when there's a NTP peer with + # synchronization stratum > 5 + stratum: '> 5' + +Event structure example: + +.. code-block:: json + + { + "_stamp": "2017-09-05T09:51:09.377202", + "args": [], + "data": { + "comment": "", + "out": [ + { + "delay": 0.0, + "hostpoll": 1024, + "jitter": 4000.0, + "offset": 0.0, + "reachability": 0, + "referenceid": ".INIT.", + "remote": "172.17.17.1", + "stratum": 16, + "synchronized": false, + "type": "-", + "when": "-" + } + ], + "result": true + }, + "fun": "ntp.stats", + "id": "edge01.bjm01", + "kwargs": {}, + "match": { + "stratum": "> 5" + } + } + +The event examplified above has been fired when the device +identified by the Minion id ``edge01.bjm01`` has been synchronized +with a NTP server at a stratum level greater than 5. +""" + +import logging +import re + +import salt.utils.beacons +import salt.utils.napalm + +log = logging.getLogger(__name__) +_numeric_regex = re.compile(r"^(<|>|<=|>=|==|!=)\s*(\d+(\.\d+){0,1})$") +# the numeric regex will match the right operand, e.g '>= 20', '< 100', '!= 20', '< 1000.12' etc. +_numeric_operand = { + "<": "__lt__", + ">": "__gt__", + ">=": "__ge__", + "<=": "__le__", + "==": "__eq__", + "!=": "__ne__", +} # mathematical operand - private method map + + +__virtualname__ = "napalm" + + +def __virtual__(): + """ + This beacon can only work when running under a regular or a proxy minion, managed through napalm. + """ + if salt.utils.napalm.virtual(__opts__, __virtualname__, __file__): + return __virtualname__ + else: + err_msg = "NAPALM is not installed." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def _compare(cur_cmp, cur_struct): + """ + Compares two objects and return a boolean value + when there's a match. + """ + if isinstance(cur_cmp, dict) and isinstance(cur_struct, dict): + log.debug("Comparing dict to dict") + for cmp_key, cmp_value in cur_cmp.items(): + if cmp_key == "*": + # matches any key from the source dictionary + if isinstance(cmp_value, dict): + found = False + for _, cur_struct_val in cur_struct.items(): + found |= _compare(cmp_value, cur_struct_val) + return found + else: + found = False + if isinstance(cur_struct, (list, tuple)): + for cur_ele in cur_struct: + found |= _compare(cmp_value, cur_ele) + elif isinstance(cur_struct, dict): + for _, cur_ele in cur_struct.items(): + found |= _compare(cmp_value, cur_ele) + return found + else: + if isinstance(cmp_value, dict): + if cmp_key not in cur_struct: + return False + return _compare(cmp_value, cur_struct[cmp_key]) + if isinstance(cmp_value, list): + found = False + for _, cur_struct_val in cur_struct.items(): + found |= _compare(cmp_value, cur_struct_val) + return found + else: + return _compare(cmp_value, cur_struct[cmp_key]) + elif isinstance(cur_cmp, (list, tuple)) and isinstance(cur_struct, (list, tuple)): + log.debug("Comparing list to list") + found = False + for cur_cmp_ele in cur_cmp: + for cur_struct_ele in cur_struct: + found |= _compare(cur_cmp_ele, cur_struct_ele) + return found + elif isinstance(cur_cmp, dict) and isinstance(cur_struct, (list, tuple)): + log.debug("Comparing dict to list (of dicts?)") + found = False + for cur_struct_ele in cur_struct: + found |= _compare(cur_cmp, cur_struct_ele) + return found + elif isinstance(cur_cmp, bool) and isinstance(cur_struct, bool): + log.debug("Comparing booleans: %s ? %s", cur_cmp, cur_struct) + return cur_cmp == cur_struct + elif isinstance(cur_cmp, ((str,), str)) and isinstance(cur_struct, ((str,), str)): + log.debug("Comparing strings (and regex?): %s ? %s", cur_cmp, cur_struct) + # Trying literal match + matched = re.match(cur_cmp, cur_struct, re.I) + if matched: + return True + return False + elif isinstance(cur_cmp, ((int,), float)) and isinstance( + cur_struct, ((int,), float) + ): + log.debug("Comparing numeric values: %d ? %d", cur_cmp, cur_struct) + # numeric compare + return cur_cmp == cur_struct + elif isinstance(cur_struct, ((int,), float)) and isinstance(cur_cmp, ((str,), str)): + # Comparing the numerical value against a presumably mathematical value + log.debug( + "Comparing a numeric value (%d) with a string (%s)", cur_struct, cur_cmp + ) + numeric_compare = _numeric_regex.match(cur_cmp) + # determine if the value to compare against is a mathematical operand + if numeric_compare: + compare_value = numeric_compare.group(2) + return getattr( + float(cur_struct), _numeric_operand[numeric_compare.group(1)] + )(float(compare_value)) + return False + return False + + +def validate(config): + """ + Validate the beacon configuration. + """ + # Must be a list of dicts. + if not isinstance(config, list): + return False, "Configuration for napalm beacon must be a list." + for mod in config: + fun, fun_cfg = next(iter(mod.items())) + if not isinstance(fun_cfg, dict): + return ( + False, + "The match structure for the {} execution function output must be a" + " dictionary".format(fun), + ) + if fun not in __salt__: + return False, f"Execution function {fun} is not availabe!" + return True, "Valid configuration for the napal beacon!" + + +def beacon(config): + """ + Watch napalm function and fire events. + """ + whitelist = [] + config = salt.utils.beacons.remove_hidden_options(config, whitelist) + + log.debug("Executing napalm beacon with config:") + log.debug(config) + ret = [] + for mod in config: + if not mod: + continue + event = {} + fun, fun_cfg = next(iter(mod.items())) + args = fun_cfg.pop("_args", []) + kwargs = fun_cfg.pop("_kwargs", {}) + log.debug("Executing %s with %s and %s", fun, args, kwargs) + fun_ret = __salt__[fun](*args, **kwargs) + log.debug("Got the reply from the minion:") + log.debug(fun_ret) + if not fun_ret.get("result", False): + log.error("Error whilst executing %s", fun) + log.error(fun_ret) + continue + fun_ret_out = fun_ret["out"] + log.debug("Comparing to:") + log.debug(fun_cfg) + try: + fun_cmp_result = _compare(fun_cfg, fun_ret_out) + except Exception as err: # pylint: disable=broad-except + log.error(err, exc_info=True) + # catch any exception and continue + # to not jeopardise the execution of the next function in the list + continue + log.debug("Result of comparison: %s", fun_cmp_result) + if fun_cmp_result: + log.info("Matched %s with %s", fun, fun_cfg) + event["tag"] = "{os}/{fun}".format(os=__grains__["os"], fun=fun) + event["fun"] = fun + event["args"] = args + event["kwargs"] = kwargs + event["data"] = fun_ret + event["match"] = fun_cfg + log.debug("Queueing event:") + log.debug(event) + ret.append(event) + log.debug("NAPALM beacon generated the events:") + log.debug(ret) + return ret diff --git a/salt/beacons/sensehat.py b/salt/beacons/sensehat.py new file mode 100644 index 000000000000..a6b4c912a187 --- /dev/null +++ b/salt/beacons/sensehat.py @@ -0,0 +1,100 @@ +""" +Monitor temperature, humidity and pressure using the SenseHat of a Raspberry Pi +=============================================================================== + +.. versionadded:: 2017.7.0 + +:maintainer: Benedikt Werner <1benediktwerner@gmail.com> +:maturity: new +:depends: sense_hat Python module +""" + +import logging +import re + +import salt.utils.beacons + +log = logging.getLogger(__name__) + +__virtualname__ = "sensehat" + + +def __virtual__(): + if "sensehat.get_pressure" in __salt__: + return __virtualname__ + else: + err_msg = "sensehat.get_pressure is missing." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def validate(config): + """ + Validate the beacon configuration + """ + # Configuration for sensehat beacon should be a list + if not isinstance(config, list): + return False, "Configuration for sensehat beacon must be a list." + else: + config = salt.utils.beacons.list_to_dict(config) + + if "sensors" not in config: + return False, "Configuration for sensehat beacon requires sensors." + return True, "Valid beacon configuration" + + +def beacon(config): + """ + Monitor the temperature, humidity and pressure using the SenseHat sensors. + + You can either specify a threshold for each value and only emit a beacon + if it is exceeded or define a range and emit a beacon when the value is + out of range. + + Units: + * humidity: percent + * temperature: degrees Celsius + * temperature_from_pressure: degrees Celsius + * pressure: Millibars + + .. code-block:: yaml + + beacons: + sensehat: + - sensors: + humidity: 70% + temperature: [20, 40] + temperature_from_pressure: 40 + pressure: 1500 + """ + ret = [] + min_default = {"humidity": "0", "pressure": "0", "temperature": "-273.15"} + + config = salt.utils.beacons.list_to_dict(config) + + for sensor in config.get("sensors", {}): + sensor_function = f"sensehat.get_{sensor}" + if sensor_function not in __salt__: + log.error("No sensor for meassuring %s. Skipping.", sensor) + continue + + sensor_config = config["sensors"][sensor] + if isinstance(sensor_config, list): + sensor_min = str(sensor_config[0]) + sensor_max = str(sensor_config[1]) + else: + sensor_min = min_default.get(sensor, "0") + sensor_max = str(sensor_config) + + if isinstance(sensor_min, str) and "%" in sensor_min: + sensor_min = re.sub("%", "", sensor_min) + if isinstance(sensor_max, str) and "%" in sensor_max: + sensor_max = re.sub("%", "", sensor_max) + sensor_min = float(sensor_min) + sensor_max = float(sensor_max) + + current_value = __salt__[sensor_function]() + if not sensor_min <= current_value <= sensor_max: + ret.append({"tag": f"sensehat/{sensor}", sensor: current_value}) + + return ret diff --git a/salt/beacons/service.py b/salt/beacons/service.py index d5c50664c3a2..6f7519e8f953 100644 --- a/salt/beacons/service.py +++ b/salt/beacons/service.py @@ -76,12 +76,6 @@ def beacon(config): event when the minion is reload. Applicable only when `onchangeonly` is True. The default is True. - `is_running_state`: This boolean parameter controls when the beacon will - fire based on the service's running state. If `is_running_state` is set to - `True`, the beacon will fire only when the service is running. If - `is_running_state` is set to `False`, the beacon will fire only when the - service is not running. - `uncleanshutdown`: If `uncleanshutdown` is present it should point to the location of a pid file for the service. Most services will not clean up this pid file if they are shutdown uncleanly (e.g. via `kill -9`) or if they @@ -147,15 +141,6 @@ def beacon(config): ret_dict[service]["uncleanshutdown"] = ( True if os.path.exists(filename) else False ) - - # If is_running_state does not match the current running state, skip it - if ( - "is_running_state" in service_config - and isinstance(service_config["is_running_state"], bool) - and service_config["is_running_state"] != ret_dict[service]["running"] - ): - continue - if "onchangeonly" in service_config and service_config["onchangeonly"] is True: if service not in LAST_STATUS: LAST_STATUS[service] = ret_dict[service] diff --git a/salt/beacons/smartos_imgadm.py b/salt/beacons/smartos_imgadm.py new file mode 100644 index 000000000000..665168c0e3f5 --- /dev/null +++ b/salt/beacons/smartos_imgadm.py @@ -0,0 +1,108 @@ +""" +Beacon that fires events on image import/delete. + +.. code-block:: yaml + + ## minimal + # - check for new images every 1 second (salt default) + # - does not send events at startup + beacons: + imgadm: [] + + ## standard + # - check for new images every 60 seconds + # - send import events at startup for all images + beacons: + imgadm: + - interval: 60 + - startup_import_event: True +""" + +import logging + +import salt.utils.beacons + +__virtualname__ = "imgadm" + +IMGADM_STATE = { + "first_run": True, + "images": [], +} + +log = logging.getLogger(__name__) + + +def __virtual__(): + """ + Provides imgadm beacon on SmartOS + """ + if "imgadm.list" in __salt__: + return True + else: + err_msg = "Only available on SmartOS compute nodes." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def validate(config): + """ + Validate the beacon configuration + """ + vcfg_ret = True + vcfg_msg = "Valid beacon configuration" + + if not isinstance(config, list): + vcfg_ret = False + vcfg_msg = "Configuration for imgadm beacon must be a list!" + + return vcfg_ret, vcfg_msg + + +def beacon(config): + """ + Poll imgadm and compare available images + """ + ret = [] + + # NOTE: lookup current images + current_images = __salt__["imgadm.list"](verbose=True) + + # NOTE: apply configuration + if IMGADM_STATE["first_run"]: + log.info("Applying configuration for imgadm beacon") + + config = salt.utils.beacons.list_to_dict(config) + + if "startup_import_event" not in config or not config["startup_import_event"]: + IMGADM_STATE["images"] = current_images + + # NOTE: import events + for uuid in current_images: + event = {} + if uuid not in IMGADM_STATE["images"]: + event["tag"] = f"imported/{uuid}" + for label in current_images[uuid]: + event[label] = current_images[uuid][label] + + if event: + ret.append(event) + + # NOTE: delete events + for uuid in IMGADM_STATE["images"]: + event = {} + if uuid not in current_images: + event["tag"] = f"deleted/{uuid}" + for label in IMGADM_STATE["images"][uuid]: + event[label] = IMGADM_STATE["images"][uuid][label] + + if event: + ret.append(event) + + # NOTE: update stored state + IMGADM_STATE["images"] = current_images + + # NOTE: disable first_run + if IMGADM_STATE["first_run"]: + IMGADM_STATE["first_run"] = False + + return ret diff --git a/salt/beacons/smartos_vmadm.py b/salt/beacons/smartos_vmadm.py new file mode 100644 index 000000000000..7cdb8806a187 --- /dev/null +++ b/salt/beacons/smartos_vmadm.py @@ -0,0 +1,135 @@ +""" +Beacon that fires events on vm state changes + +.. code-block:: yaml + + ## minimal + # - check for vm changes every 1 second (salt default) + # - does not send events at startup + beacons: + vmadm: [] + + ## standard + # - check for vm changes every 60 seconds + # - send create event at startup for all vms + beacons: + vmadm: + - interval: 60 + - startup_create_event: True +""" + +import logging + +import salt.utils.beacons + +__virtualname__ = "vmadm" + +VMADM_STATE = { + "first_run": True, + "vms": [], +} + +log = logging.getLogger(__name__) + + +def __virtual__(): + """ + Provides vmadm beacon on SmartOS + """ + if "vmadm.list" in __salt__: + return True + else: + err_msg = "Only available on SmartOS compute nodes." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def validate(config): + """ + Validate the beacon configuration + """ + vcfg_ret = True + vcfg_msg = "Valid beacon configuration" + + if not isinstance(config, list): + vcfg_ret = False + vcfg_msg = "Configuration for vmadm beacon must be a list!" + + return vcfg_ret, vcfg_msg + + +def beacon(config): + """ + Poll vmadm for changes + """ + ret = [] + + # NOTE: lookup current images + current_vms = __salt__["vmadm.list"]( + keyed=True, + order="uuid,state,alias,hostname,dns_domain", + ) + + # NOTE: apply configuration + if VMADM_STATE["first_run"]: + log.info("Applying configuration for vmadm beacon") + + config = salt.utils.beacons.list_to_dict(config) + + if "startup_create_event" not in config or not config["startup_create_event"]: + VMADM_STATE["vms"] = current_vms + + # NOTE: create events + for uuid in current_vms: + event = {} + if uuid not in VMADM_STATE["vms"]: + event["tag"] = f"created/{uuid}" + for label in current_vms[uuid]: + if label == "state": + continue + event[label] = current_vms[uuid][label] + + if event: + ret.append(event) + + # NOTE: deleted events + for uuid in VMADM_STATE["vms"]: + event = {} + if uuid not in current_vms: + event["tag"] = f"deleted/{uuid}" + for label in VMADM_STATE["vms"][uuid]: + if label == "state": + continue + event[label] = VMADM_STATE["vms"][uuid][label] + + if event: + ret.append(event) + + # NOTE: state change events + for uuid in current_vms: + event = {} + if ( + VMADM_STATE["first_run"] + or uuid not in VMADM_STATE["vms"] + or current_vms[uuid].get("state", "unknown") + != VMADM_STATE["vms"][uuid].get("state", "unknown") + ): + event["tag"] = "{}/{}".format( + current_vms[uuid].get("state", "unknown"), uuid + ) + for label in current_vms[uuid]: + if label == "state": + continue + event[label] = current_vms[uuid][label] + + if event: + ret.append(event) + + # NOTE: update stored state + VMADM_STATE["vms"] = current_vms + + # NOTE: disable first_run + if VMADM_STATE["first_run"]: + VMADM_STATE["first_run"] = False + + return ret diff --git a/salt/beacons/status.py b/salt/beacons/status.py index cfb17715f3da..8c1210e7dbc7 100644 --- a/salt/beacons/status.py +++ b/salt/beacons/status.py @@ -88,12 +88,12 @@ """ +import datetime import logging import salt.exceptions import salt.utils.beacons import salt.utils.platform -import salt.utils.timeutil log = logging.getLogger(__name__) @@ -118,7 +118,7 @@ def beacon(config): Return status for requested information """ log.debug(config) - ctime = salt.utils.timeutil.utcnow().isoformat() + ctime = datetime.datetime.utcnow().isoformat() whitelist = [] config = salt.utils.beacons.remove_hidden_options(config, whitelist) diff --git a/salt/beacons/telegram_bot_msg.py b/salt/beacons/telegram_bot_msg.py new file mode 100644 index 000000000000..b4328052ded6 --- /dev/null +++ b/salt/beacons/telegram_bot_msg.py @@ -0,0 +1,129 @@ +""" +Beacon to emit Telegram messages + +Requires the python-telegram-bot library + +""" + +import asyncio +import inspect +import logging + +import salt.utils.beacons + +try: + import telegram + + logging.getLogger("telegram").setLevel(logging.CRITICAL) + HAS_TELEGRAM = True +except ImportError: + HAS_TELEGRAM = False + +log = logging.getLogger(__name__) + + +__virtualname__ = "telegram_bot_msg" + + +async def _async_get_updates(token, **kwargs): + """ + Asynchronous helper to get updates from Telegram Bot + """ + async with telegram.Bot(token) as bot: + return await bot.get_updates(**kwargs) + + +def _get_updates(token, **kwargs): + """ + Synchronous wrapper for getting updates, handles both v13 and v20+ + """ + if HAS_TELEGRAM and inspect.iscoroutinefunction(telegram.Bot.get_updates): + return asyncio.run(_async_get_updates(token, **kwargs)) + else: + bot = telegram.Bot(token) + return bot.get_updates(**kwargs) + + +def __virtual__(): + if HAS_TELEGRAM: + return __virtualname__ + else: + err_msg = "telegram library is missing." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def validate(config): + """ + Validate the beacon configuration + """ + if not isinstance(config, list): + return False, "Configuration for telegram_bot_msg beacon must be a list." + + config = salt.utils.beacons.list_to_dict(config) + + if not all( + config.get(required_config) for required_config in ["token", "accept_from"] + ): + return ( + False, + "Not all required configuration for telegram_bot_msg are set.", + ) + + if not isinstance(config.get("accept_from"), list): + return ( + False, + "Configuration for telegram_bot_msg, " + "accept_from must be a list of usernames.", + ) + + return True, "Valid beacon configuration." + + +def beacon(config): + """ + Emit a dict with a key "msgs" whose value is a list of messages + sent to the configured bot by one of the allowed usernames. + + .. code-block:: yaml + + beacons: + telegram_bot_msg: + - token: "" + - accept_from: + - "" + - interval: 10 + + """ + + config = salt.utils.beacons.list_to_dict(config) + + log.debug("telegram_bot_msg beacon starting") + ret = [] + output = {} + output["msgs"] = [] + + updates = _get_updates(config["token"], limit=100, timeout=0) + + log.debug("Num updates: %d", len(updates)) + if not updates: + log.debug("Telegram Bot beacon has no new messages") + return ret + + latest_update_id = 0 + for update in updates: + message = update.message + + if update.update_id > latest_update_id: + latest_update_id = update.update_id + + if message.chat.username in config["accept_from"]: + output["msgs"].append(message.to_dict()) + + # mark in the server that previous messages are processed + _get_updates(config["token"], offset=latest_update_id + 1) + + log.debug("Emitting %d messages.", len(output["msgs"])) + if output["msgs"]: + ret.append(output) + return ret diff --git a/salt/beacons/twilio_txt_msg.py b/salt/beacons/twilio_txt_msg.py new file mode 100644 index 000000000000..1f2bc64f3982 --- /dev/null +++ b/salt/beacons/twilio_txt_msg.py @@ -0,0 +1,103 @@ +""" +Beacon to emit Twilio text messages +""" + +import logging + +import salt.utils.beacons + +try: + import twilio + + # Grab version, ensure elements are ints + twilio_version = tuple(int(x) for x in twilio.__version_info__) + if twilio_version > (5,): + from twilio.rest import Client as TwilioRestClient + else: + from twilio.rest import TwilioRestClient # pylint: disable=no-name-in-module + HAS_TWILIO = True +except ImportError: + HAS_TWILIO = False + +log = logging.getLogger(__name__) + +__virtualname__ = "twilio_txt_msg" + + +def __virtual__(): + if HAS_TWILIO: + return __virtualname__ + else: + err_msg = "twilio library is missing." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def validate(config): + """ + Validate the beacon configuration + """ + # Configuration for twilio_txt_msg beacon should be a list of dicts + if not isinstance(config, list): + return False, "Configuration for twilio_txt_msg beacon must be a list." + else: + config = salt.utils.beacons.list_to_dict(config) + + if not all(x in config for x in ("account_sid", "auth_token", "twilio_number")): + return ( + False, + "Configuration for twilio_txt_msg beacon " + "must contain account_sid, auth_token " + "and twilio_number items.", + ) + return True, "Valid beacon configuration" + + +def beacon(config): + """ + Emit a dict name "texts" whose value is a list + of texts. + + .. code-block:: yaml + + beacons: + twilio_txt_msg: + - account_sid: "" + - auth_token: "" + - twilio_number: "+15555555555" + - interval: 10 + + """ + log.trace("twilio_txt_msg beacon starting") + + config = salt.utils.beacons.list_to_dict(config) + + ret = [] + if not all([config["account_sid"], config["auth_token"], config["twilio_number"]]): + return ret + output = {} + output["texts"] = [] + client = TwilioRestClient(config["account_sid"], config["auth_token"]) + messages = client.messages.list(to=config["twilio_number"]) + log.trace("Num messages: %d", len(messages)) + if not messages: + log.trace("Twilio beacon has no texts") + return ret + + for message in messages: + item = {} + item["id"] = str(message.sid) + item["body"] = str(message.body) + item["from"] = str(message.from_) + item["sent"] = str(message.date_sent) + item["images"] = [] + + if int(message.num_media): + media = client.media(message.sid).list() + if media: + for pic in media: + item["images"].append(str(pic.uri)) + output["texts"].append(item) + message.delete() + ret.append(output) + return ret diff --git a/salt/beacons/wtmp.py b/salt/beacons/wtmp.py new file mode 100644 index 000000000000..a9f1b09281a9 --- /dev/null +++ b/salt/beacons/wtmp.py @@ -0,0 +1,367 @@ +""" +Beacon to fire events at login of users as registered in the wtmp file + +.. versionadded:: 2015.5.0 + + +Example Configuration +===================== + +.. code-block:: yaml + + # Fire events on all logins + beacons: + wtmp: [] + + # Matching on user name, using a default time range + beacons: + wtmp: + - users: + gareth: + - defaults: + time_range: + start: '8am' + end: '4pm' + + # Matching on user name, overriding the default time range + beacons: + wtmp: + - users: + gareth: + time_range: + start: '7am' + end: '3pm' + - defaults: + time_range: + start: '8am' + end: '4pm' + + # Matching on group name, overriding the default time range + beacons: + wtmp: + - groups: + users: + time_range: + start: '7am' + end: '3pm' + - defaults: + time_range: + start: '8am' + end: '4pm' + + +How to Tell What An Event Means +=============================== + +In the events that this beacon fires, a type of ``7`` denotes a login, while a +type of ``8`` denotes a logout. These values correspond to the ``ut_type`` +value from a wtmp/utmp event (see the ``wtmp`` manpage for more information). +In the extremely unlikely case that your platform uses different values, they +can be overridden using a ``ut_type`` key in the beacon configuration: + +.. code-block:: yaml + + beacons: + wtmp: + - ut_type: + login: 9 + logout: 10 + +This beacon's events include an ``action`` key which will be either ``login`` +or ``logout`` depending on the event type. + +.. versionchanged:: 2019.2.0 + ``action`` key added to beacon event, and ``ut_type`` config parameter + added. + + +Use Case: Posting Login/Logout Events to Slack +============================================== + +This can be done using the following reactor SLS: + +.. code-block:: jinja + + report-wtmp: + runner.salt.cmd: + - args: + - fun: slack.post_message + - channel: mychannel # Slack channel + - from_name: someuser # Slack user + - message: "{{ data.get('action', 'Unknown event') | capitalize }} from `{{ data.get('user', '') or 'unknown user' }}` on `{{ data['id'] }}`" + +Match the event like so in the master config file: + +.. code-block:: yaml + + reactor: + + - 'salt/beacon/*/wtmp/': + - salt://reactor/wtmp.sls + +.. note:: + This approach uses the :py:mod:`slack execution module + ` directly on the master, and therefore requires + that the master has a slack API key in its configuration: + + .. code-block:: yaml + + slack: + api_key: xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX + + See the :py:mod:`slack execution module ` + documentation for more information. While you can use an individual user's + API key to post to Slack, a bot user is likely better suited for this. The + :py:mod:`slack engine ` documentation has information + on how to set up a bot user. +""" + +import datetime +import logging +import os +import struct + +import salt.utils.beacons +import salt.utils.files +import salt.utils.stringutils + +__virtualname__ = "wtmp" +WTMP = "/var/log/wtmp" +FMT = b"hi32s4s32s256shhiii4i20x" +FIELDS = [ + "type", + "PID", + "line", + "inittab", + "user", + "hostname", + "exit_status", + "session", + "time", + "addr", +] +SIZE = struct.calcsize(FMT) +LOC_KEY = "wtmp.loc" +TTY_KEY_PREFIX = "wtmp.tty." +LOGIN_TYPE = 7 +LOGOUT_TYPE = 8 + +log = logging.getLogger(__name__) + +try: + import dateutil.parser as dateutil_parser + + _TIME_SUPPORTED = True +except ImportError: + _TIME_SUPPORTED = False + + +def __virtual__(): + if os.path.isfile(WTMP): + return __virtualname__ + err_msg = f"{WTMP} does not exist." + log.error("Unable to load %s beacon: %s", __virtualname__, err_msg) + return False, err_msg + + +def _validate_time_range(trange, status, msg): + """ + Check time range + """ + # If trange is empty, just return the current status & msg + if not trange: + return status, msg + + if not isinstance(trange, dict): + status = False + msg = "The time_range parameter for wtmp beacon must be a dictionary." + + if not all(k in trange for k in ("start", "end")): + status = False + msg = ( + "The time_range parameter for wtmp beacon must contain start & end options." + ) + + return status, msg + + +def _gather_group_members(group, groups, users): + """ + Gather group members + """ + _group = __salt__["group.info"](group) + + if not _group: + log.warning("Group %s does not exist, ignoring.", group) + return + + for member in _group["members"]: + if member not in users: + users[member] = groups[group] + + +def _check_time_range(time_range, now): + """ + Check time range + """ + if _TIME_SUPPORTED: + _start = dateutil_parser.parse(time_range["start"]) + _end = dateutil_parser.parse(time_range["end"]) + + return bool(_start <= now <= _end) + else: + log.error("Dateutil is required.") + return False + + +def _get_loc(): + """ + return the active file location + """ + if LOC_KEY in __context__: + return __context__[LOC_KEY] + + +def validate(config): + """ + Validate the beacon configuration + """ + vstatus = True + vmsg = "Valid beacon configuration" + + # Configuration for wtmp beacon should be a list of dicts + if not isinstance(config, list): + vstatus = False + vmsg = "Configuration for wtmp beacon must be a list." + else: + config = salt.utils.beacons.list_to_dict(config) + + if "users" in config: + if not isinstance(config["users"], dict): + vstatus = False + vmsg = "User configuration for wtmp beacon must be a dictionary." + else: + for user in config["users"]: + _time_range = config["users"][user].get("time_range", {}) + vstatus, vmsg = _validate_time_range(_time_range, vstatus, vmsg) + + if not vstatus: + return vstatus, vmsg + + if "groups" in config: + if not isinstance(config["groups"], dict): + vstatus = False + vmsg = "Group configuration for wtmp beacon must be a dictionary." + else: + for group in config["groups"]: + _time_range = config["groups"][group].get("time_range", {}) + vstatus, vmsg = _validate_time_range(_time_range, vstatus, vmsg) + if not vstatus: + return vstatus, vmsg + + if "defaults" in config: + if not isinstance(config["defaults"], dict): + vstatus = False + vmsg = "Defaults configuration for wtmp beacon must be a dictionary." + else: + _time_range = config["defaults"].get("time_range", {}) + vstatus, vmsg = _validate_time_range(_time_range, vstatus, vmsg) + if not vstatus: + return vstatus, vmsg + + return vstatus, vmsg + + +def beacon(config): + """ + Read the last wtmp file and return information on the logins + """ + ret = [] + + users = {} + groups = {} + defaults = None + + login_type = LOGIN_TYPE + logout_type = LOGOUT_TYPE + + for config_item in config: + if "users" in config_item: + users = config_item["users"] + + if "groups" in config_item: + groups = config_item["groups"] + + if "defaults" in config_item: + defaults = config_item["defaults"] + + if config_item == "ut_type": + try: + login_type = config_item["ut_type"]["login"] + except KeyError: + pass + try: + logout_type = config_item["ut_type"]["logout"] + except KeyError: + pass + + with salt.utils.files.fopen(WTMP, "rb") as fp_: + loc = __context__.get(LOC_KEY, 0) + if loc == 0: + fp_.seek(0, 2) + __context__[LOC_KEY] = fp_.tell() + return ret + else: + fp_.seek(loc) + while True: + now = datetime.datetime.now() + raw = fp_.read(SIZE) + if len(raw) != SIZE: + return ret + __context__[LOC_KEY] = fp_.tell() + pack = struct.unpack(FMT, raw) + event = {} + for ind, field in enumerate(FIELDS): + event[field] = pack[ind] + if isinstance(event[field], (str, bytes)): + if isinstance(event[field], bytes): + event[field] = salt.utils.stringutils.to_unicode(event[field]) + event[field] = event[field].strip("\x00") + + if event["type"] == login_type: + event["action"] = "login" + # Store the tty to identify the logout event + __context__["{}{}".format(TTY_KEY_PREFIX, event["line"])] = event[ + "user" + ] + elif event["type"] == logout_type: + event["action"] = "logout" + try: + event["user"] = __context__.pop( + "{}{}".format(TTY_KEY_PREFIX, event["line"]) + ) + except KeyError: + pass + + for group in groups: + _gather_group_members(group, groups, users) + + if users: + if event["user"] in users: + _user = users[event["user"]] + if isinstance(_user, dict) and "time_range" in _user: + if _check_time_range(_user["time_range"], now): + ret.append(event) + else: + if defaults and "time_range" in defaults: + if _check_time_range(defaults["time_range"], now): + ret.append(event) + else: + ret.append(event) + else: + if defaults and "time_range" in defaults: + if _check_time_range(defaults["time_range"], now): + ret.append(event) + else: + ret.append(event) + return ret diff --git a/salt/cache/__init__.py b/salt/cache/__init__.py index 480cbfbdcb6c..e80b42b3268d 100644 --- a/salt/cache/__init__.py +++ b/salt/cache/__init__.py @@ -4,7 +4,6 @@ .. versionadded:: 2016.11.0 """ -import datetime import logging import time from collections import OrderedDict @@ -12,8 +11,6 @@ import salt.config import salt.loader import salt.syspaths -from salt.exceptions import SaltCacheError -from salt.utils.decorators import cached_property log = logging.getLogger(__name__) @@ -61,28 +58,28 @@ class Cache: def __init__(self, opts, cachedir=None, **kwargs): self.opts = opts - - if kwargs.get("driver"): - self.driver = kwargs["driver"] + if cachedir is None: + self.cachedir = opts.get("cachedir", salt.syspaths.CACHE_DIR) else: - self.driver = opts.get("cache", salt.config.DEFAULT_MASTER_OPTS["cache"]) - - self.cachedir = kwargs["cachedir"] = cachedir or opts.get( - "cachedir", salt.syspaths.CACHE_DIR - ) + self.cachedir = cachedir + self.driver = opts.get("cache", salt.config.DEFAULT_MASTER_OPTS["cache"]) self._modules = None self._kwargs = kwargs + self._kwargs["cachedir"] = self.cachedir - @cached_property - def modules(self): - return salt.loader.cache(self.opts) + def __lazy_init(self): + self._modules = salt.loader.cache(self.opts) + fun = f"{self.driver}.init_kwargs" + if fun in self.modules: + self._kwargs = self.modules[fun](self._kwargs) + else: + self._kwargs = {} - @cached_property - def kwargs(self): - try: - return self.modules[f"{self.driver}.init_kwargs"](self._kwargs) - except KeyError: - return {} + @property + def modules(self): + if self._modules is None: + self.__lazy_init() + return self._modules def destroy(self): if hasattr(self, "_modules") and self._modules is not None: @@ -128,7 +125,7 @@ def cache(self, bank, key, fun, loop_fun=None, **kwargs): return data - def store(self, bank, key, data, expires=None): + def store(self, bank, key, data): """ Store data using the specified module @@ -145,28 +142,12 @@ def store(self, bank, key, data, expires=None): The data which will be stored in the cache. This data should be in a format which can be serialized by msgpack. - :param expires: - how many seconds from now the data should be considered stale. - - :raises SaltCacheError: + :raises SaltCacheError: Raises an exception if cache driver detected an error accessing data in the cache backend (auth, permissions, etc). """ fun = f"{self.driver}.store" - try: - return self.modules[fun](bank, key, data, expires=expires, **self.kwargs) - except TypeError: - # if the backing store doesnt natively support expiry, we handle it as a fallback - if expires: - expires_at = datetime.datetime.now().astimezone() + datetime.timedelta( - seconds=expires - ) - expires_at = int(expires_at.timestamp()) - return self.modules[fun]( - bank, key, {"data": data, "_expires": expires_at}, **self.kwargs - ) - else: - return self.modules[fun](bank, key, data, **self.kwargs) + return self.modules[fun](bank, key, data, **self._kwargs) def fetch(self, bank, key): """ @@ -190,17 +171,7 @@ def fetch(self, bank, key): in the cache backend (auth, permissions, etc). """ fun = f"{self.driver}.fetch" - ret = self.modules[fun](bank, key, **self.kwargs) - - # handle fallback if necessary - if isinstance(ret, dict) and set(ret.keys()) == {"data", "_expires"}: - now = datetime.datetime.now().astimezone().timestamp() - if ret["_expires"] > now: - return ret["data"] - else: - return {} - else: - return ret + return self.modules[fun](bank, key, **self._kwargs) def updated(self, bank, key): """ @@ -224,7 +195,7 @@ def updated(self, bank, key): in the cache backend (auth, permissions, etc). """ fun = f"{self.driver}.updated" - return self.modules[fun](bank, key, **self.kwargs) + return self.modules[fun](bank, key, **self._kwargs) def flush(self, bank, key=None): """ @@ -245,7 +216,7 @@ def flush(self, bank, key=None): in the cache backend (auth, permissions, etc). """ fun = f"{self.driver}.flush" - return self.modules[fun](bank, key=key, **self.kwargs) + return self.modules[fun](bank, key=key, **self._kwargs) def list(self, bank): """ @@ -264,37 +235,7 @@ def list(self, bank): in the cache backend (auth, permissions, etc). """ fun = f"{self.driver}.list" - return self.modules[fun](bank, **self.kwargs) - - def list_all(self, bank, include_data=False): - """ - Lists all entries with their data from the specified bank. - This is more efficient than calling list() + fetch() for each entry. - - :param bank: - The name of the location inside the cache which will hold the key - and its associated data. - - :param include_data: - Whether to include the full data for each entry. For some drivers - (like localfs_key), setting this to False avoids expensive disk reads. - - :return: - A dict of {key: data} for all entries in the bank. Returns an empty - dict if the bank doesn't exist or the driver doesn't support list_all. - - :raises SaltCacheError: - Raises an exception if cache driver detected an error accessing data - in the cache backend (auth, permissions, etc). - """ - fun = f"{self.driver}.list_all" - if fun in self.modules: - return self.modules[fun](bank, include_data=include_data, **self.kwargs) - else: - # Fallback for drivers that don't implement list_all - raise AttributeError( - f"Cache driver '{self.driver}' does not implement list_all" - ) + return self.modules[fun](bank, **self._kwargs) def contains(self, bank, key=None): """ @@ -319,51 +260,7 @@ def contains(self, bank, key=None): in the cache backend (auth, permissions, etc). """ fun = f"{self.driver}.contains" - return self.modules[fun](bank, key, **self.kwargs) - - def clean_expired(self, bank, *args, **kwargs): - """ - Clean expired keys - - :param bank: - The name of the location inside the cache which will hold the key - and its associated data. - - :raises SaltCacheError: - Raises an exception if cache driver detected an error accessing data - in the cache backend (auth, permissions, etc). - """ - # If the cache driver has a clean_expired() func, call it to clean up - # expired keys. - clean_expired = f"{self.driver}.clean_expired" - if clean_expired in self.modules: - self.modules[clean_expired](bank, *args, **{**self.kwargs, **kwargs}) - return - - # Fallback for drivers without native clean_expired. Use the - # ``_expires`` envelope written by ``Cache.store`` when ``expires`` - # is supplied; entries without that envelope have no cache-level - # expiry and are left alone. (Previously this path treated the - # driver's ``updated()`` value -- the file mtime for ``localfs`` -- - # as an absolute expiry epoch, which deleted every entry whose - # mtime was in the past, i.e. every entry. Issue #69307.) - list_ = f"{self.driver}.list" - fetch = f"{self.driver}.fetch" - flush = f"{self.driver}.flush" - now = time.time() - for key in self.modules[list_](bank, **self.kwargs): - try: - raw = self.modules[fetch](bank, key, **self.kwargs) - except SaltCacheError: - # Best-effort: don't let one unreadable key abort the sweep. - log.debug("clean_expired: unable to read %s/%s; skipping", bank, key) - continue - if ( - isinstance(raw, dict) - and set(raw.keys()) == {"data", "_expires"} - and raw["_expires"] <= now - ): - self.modules[flush](bank, key, **self.kwargs) + return self.modules[fun](bank, key, **self._kwargs) class MemCache(Cache): @@ -416,30 +313,21 @@ def fetch(self, bank, key): if self.debug: self.call += 1 now = time.time() - expires = None record = self.storage.pop((bank, key), None) # Have a cached value for the key - if record is not None: - if len(record) == 2: - (created_at, data) = record - elif len(record) == 3: - (created_at, expires, data) = record - else: - raise SaltCacheError("Unexpected record structure") - - if (created_at + (expires or self.expire)) >= now: - if self.debug: - self.hit += 1 - log.debug( - "MemCache stats (call/hit/rate): %s/%s/%s", - self.call, - self.hit, - float(self.hit) / self.call, - ) - # update atime and return - record[0] = now - self.storage[(bank, key)] = record - return data + if record is not None and record[0] + self.expire >= now: + if self.debug: + self.hit += 1 + log.debug( + "MemCache stats (call/hit/rate): %s/%s/%s", + self.call, + self.hit, + float(self.hit) / self.call, + ) + # update atime and return + record[0] = now + self.storage[(bank, key)] = record + return record[1] # Have no value for the key or value is expired data = super().fetch(bank, key) @@ -448,18 +336,18 @@ def fetch(self, bank, key): MemCache.__cleanup(self.expire) if len(self.storage) >= self.max: self.storage.popitem(last=False) - self.storage[(bank, key)] = [now, self.expire, data] + self.storage[(bank, key)] = [now, data] return data - def store(self, bank, key, data, expires=None): + def store(self, bank, key, data): self.storage.pop((bank, key), None) - super().store(bank, key, data, expires=expires) + super().store(bank, key, data) if len(self.storage) >= self.max: if self.cleanup: MemCache.__cleanup(self.expire) if len(self.storage) >= self.max: self.storage.popitem(last=False) - self.storage[(bank, key)] = [time.time(), expires, data] + self.storage[(bank, key)] = [time.time(), data] def flush(self, bank, key=None): if key is None: diff --git a/salt/cache/localfs.py b/salt/cache/localfs.py index 855598e7952e..96a9a13aeb41 100644 --- a/salt/cache/localfs.py +++ b/salt/cache/localfs.py @@ -76,7 +76,6 @@ def fetch(bank, key, cachedir): inkey = False key_file = salt.utils.path.join(cachedir, os.path.normpath(bank), f"{key}.p") if not os.path.isfile(key_file): - log.debug('Cache file "%s" does not exist', key_file) # The bank includes the full filename, and the key is inside the file key_file = salt.utils.path.join(cachedir, os.path.normpath(bank) + ".p") inkey = True @@ -149,7 +148,7 @@ def list_(bank, cachedir): ret = [] for item in items: if item.endswith(".p"): - ret.append(item[:-2]) + ret.append(item.rstrip(item[-2:])) else: ret.append(item) return ret diff --git a/salt/cache/localfs_key.py b/salt/cache/localfs_key.py deleted file mode 100644 index 8476f4412305..000000000000 --- a/salt/cache/localfs_key.py +++ /dev/null @@ -1,481 +0,0 @@ -""" -Backward compatible shim layer for pki interaction - -.. versionadded:: 3008.0 - -The ``localfs_key`` is a shim driver meant to allow the salt.cache -subsystem to interact with the existing master pki folder/file structure -without any migration from previous versions of salt. It is not meant for -general purpose use and should not be used outside of the master auth system. - -The main difference from before is the 'state' of the key, ie accepted/rejected -is now stored in the data itself, as opposed to the cache equivalent of a bank -previously. - -store and fetch handle ETL from new style, where data itself contains key -state, to old style, where folder and/or bank contain state. -flush/list/contains/updated are left as nearly equivalent to localfs, without -the .p file extension to work with legacy keys via banks. -""" - -import errno -import logging -import os -import os.path - -try: - import pwd -except ImportError: - pwd = None -import shutil -import stat -import tempfile -from pathlib import Path - -import salt.utils.atomicfile -import salt.utils.files -import salt.utils.stringutils -from salt.exceptions import SaltCacheError -from salt.utils.verify import clean_path, valid_id - -log = logging.getLogger(__name__) - -__func_alias__ = {"list_": "list"} - - -BASE_MAPPING = { - "minions_pre": "pending", - "minions_rejected": "rejected", - "minions": "accepted", - "minions_denied": "denied", -} - -# master_keys keys that if fetched, even with cluster_id set, will still refer -# to pki_dir instead of cluster_pki_dir -NON_CLUSTERED_MASTER_KEYS = [] - - -# we explicitly override cache dir to point to pki here -def init_kwargs(kwargs): - """ - setup kwargs for cache functions - """ - if __opts__["__role"] != "minion": - global NON_CLUSTERED_MASTER_KEYS - NON_CLUSTERED_MASTER_KEYS = [ - "master.pem", - "master.pub", - f"{__opts__['master_sign_key_name']}.pem", - f"{__opts__['master_sign_key_name']}.pub", - f"{__opts__['id'].removesuffix('_master')}.pub", - f"{__opts__['id'].removesuffix('_master')}.pem", - __opts__.get( - "master_pubkey_signature", f"{__opts__['id']}_pubkey_signature" - ), - ] - - if "pki_dir" in kwargs: - pki_dir = kwargs["pki_dir"] - elif __opts__.get("cluster_id"): - pki_dir = __opts__["cluster_pki_dir"] - else: - pki_dir = __opts__["pki_dir"] - - user = kwargs.get("user", __opts__.get("user")) - - return {"cachedir": pki_dir, "user": user} - - -def store(bank, key, data, cachedir, user, **kwargs): - """ - Store key state information. storing a accepted/pending/rejected state - means clearing it from the other 2. denied is handled separately - """ - base = None - if bank in ["keys", "denied_keys"] and not valid_id(__opts__, key): - raise SaltCacheError(f"key {key} is not a valid minion_id") - - if bank not in ["keys", "denied_keys", "master_keys"]: - raise SaltCacheError(f"Unrecognized bank: {bank}") - - if __opts__["permissive_pki_access"]: - umask = 0o0700 - else: - umask = 0o0750 - - if bank == "keys": - if data["state"] == "rejected": - base = "minions_rejected" - elif data["state"] == "pending": - base = "minions_pre" - elif data["state"] == "accepted": - base = "minions" - else: - raise SaltCacheError("Unrecognized data/bank: {}".format(data["state"])) - data = data["pub"] - elif bank == "denied_keys": - # denied keys is a list post migration, but is a single key in legacy - data = data[0] - base = "minions_denied" - elif bank == "master_keys": - # private keys are separate from permissive_pki_access - umask = 0o277 - base = "" - # even in clustered mode, master and signing keys live in the - # non-clustered pki dir - if key in NON_CLUSTERED_MASTER_KEYS: - cachedir = __opts__["pki_dir"] - - savefn = Path(cachedir) / base / key - base_dir = savefn.parent - - if not clean_path(cachedir, str(savefn), subdir=True): - raise SaltCacheError(f"key {key} is not a valid key path.") - - try: - os.makedirs(base_dir) - except OSError as exc: - if exc.errno != errno.EEXIST: - raise SaltCacheError( - f"The cache directory, {base_dir}, could not be created: {exc}" - ) - - # delete current state before re-serializing new state - flush(bank, key, cachedir, **kwargs) - - tmpfh, tmpfname = tempfile.mkstemp(dir=base_dir) - os.close(tmpfh) - - if user and not salt.utils.platform.is_windows(): - try: - uid = pwd.getpwnam(user).pw_uid - os.chown(tmpfname, uid, -1) - except (KeyError, ImportError, OSError, NameError): - # The specified user was not found, allow the backup systems to - # report the error - pass - - try: - with salt.utils.files.set_umask(umask): - with salt.utils.files.fopen(tmpfname, "w+b") as fh_: - fh_.write(salt.utils.stringutils.to_bytes(data)) - - if bank == "master_keys": - os.chmod(tmpfname, 0o400) - - # On Windows, os.rename will fail if the destination file exists. - salt.utils.atomicfile.atomic_rename(tmpfname, savefn) - except OSError as exc: - raise SaltCacheError( - f"There was an error writing the cache file, base={base}: {exc}" - ) - - -def fetch(bank, key, cachedir, **kwargs): - """ - Fetch and construct state data for a given minion based on the bank and id - """ - if bank in ["keys", "denied_keys"] and not valid_id(__opts__, key): - raise SaltCacheError(f"key {key} is not a valid minion_id") - - if bank not in ["keys", "denied_keys", "master_keys"]: - raise SaltCacheError(f"Unrecognized bank: {bank}") - - if not clean_path(cachedir, key, subdir=True): - raise SaltCacheError(f"key {key} is not a valid key path.") - - if key == ".key_cache": - raise SaltCacheError("trying to read key_cache, there is a bug at call-site") - try: - if bank == "keys": - for state, bank in [ - ("rejected", "minions_rejected"), - ("pending", "minions_pre"), - ("accepted", "minions"), - ]: - keyfile = Path(cachedir, bank, key) - - if not clean_path(cachedir, str(keyfile), subdir=True): - raise SaltCacheError(f"key {key} is not a valid key path.") - - if keyfile.is_file() and not keyfile.is_symlink(): - with salt.utils.files.fopen(keyfile, "r") as fh_: - return {"state": state, "pub": fh_.read()} - return None - elif bank == "denied_keys": - # there can be many denied keys per minion post refactor, but only 1 - # with the filesystem, so return a list of 1 - pubfn_denied = os.path.join(cachedir, "minions_denied", key) - - if not clean_path(cachedir, pubfn_denied, subdir=True): - raise SaltCacheError(f"key {key} is not a valid key path.") - - if os.path.isfile(pubfn_denied): - with salt.utils.files.fopen(pubfn_denied, "r") as fh_: - return [fh_.read()] - elif bank == "master_keys": - if key in NON_CLUSTERED_MASTER_KEYS: - cachedir = __opts__["pki_dir"] - - keyfile = Path(cachedir, key) - - if not clean_path(cachedir, str(keyfile), subdir=True): - raise SaltCacheError(f"key {key} is not a valid key path.") - - if keyfile.is_file() and not keyfile.is_symlink(): - with salt.utils.files.fopen(keyfile, "r") as fh_: - return fh_.read() - else: - raise SaltCacheError(f'unrecognized bank "{bank}"') - except OSError as exc: - raise SaltCacheError( - 'There was an error reading the cache bank "{}", key "{}": {}'.format( - bank, key, exc - ) - ) - - -def updated(bank, key, cachedir, **kwargs): - """ - Return the epoch of the mtime for this cache file - """ - if not valid_id(__opts__, key): - raise SaltCacheError(f"key {key} is not a valid minion_id") - - if bank == "keys": - bases = [base for base in BASE_MAPPING if base != "minions_denied"] - elif bank == "denied_keys": - bases = ["minions_denied"] - elif bank == "master_keys": - if key in NON_CLUSTERED_MASTER_KEYS: - cachedir = __opts__["pki_dir"] - bases = [""] - else: - raise SaltCacheError(f"Unrecognized bank: {bank}") - - for dir in bases: - keyfile = Path(cachedir, dir, key) - - if not clean_path(cachedir, str(keyfile), subdir=True): - raise SaltCacheError(f"key {key} is not a valid key path.") - - if keyfile.is_file() and not keyfile.is_symlink(): - try: - return int(os.path.getmtime(keyfile)) - except OSError as exc: - raise SaltCacheError( - 'There was an error reading the mtime for "{}": {}'.format( - keyfile, exc - ) - ) - log.debug('pki file "%s" does not exist in accepted/rejected/pending', key) - return - - -def flush(bank, key=None, cachedir=None, **kwargs): - """ - Remove the key from the cache bank with all the key content. - flush can take a legacy bank or a keys/denied_keys modern bank - """ - if bank in ["keys", "denied_keys"] and not valid_id(__opts__, key): - raise SaltCacheError(f"key {key} is not a valid minion_id") - - if cachedir is None: - raise SaltCacheError("cachedir missing") - - if bank == "keys": - bases = [base for base in BASE_MAPPING if base != "minions_denied"] - elif bank == "denied_keys": - bases = ["minions_denied"] - elif bank == "master_keys": - if key in NON_CLUSTERED_MASTER_KEYS: - cachedir = __opts__["pki_dir"] - bases = [""] - else: - raise SaltCacheError(f"Unrecognized bank: {bank}") - - flushed = False - - for base in bases: - try: - if key is None: - target = os.path.join(cachedir, base) - if not os.path.isdir(target): - return False - shutil.rmtree(target) - else: - target = os.path.join(cachedir, base, key) - - if not clean_path(cachedir, target, subdir=True): - raise SaltCacheError(f"key {key} is not a valid key path.") - - if not os.path.isfile(target): - continue - - # necessary on windows, otherwise PermissionError: [WinError 5] Access is denied - os.chmod(target, stat.S_IWRITE) - - os.remove(target) - flushed = True - except OSError as exc: - if exc.errno != errno.ENOENT: - raise SaltCacheError(f'There was an error removing "{target}": {exc}') - - return flushed - - -def list_(bank, cachedir, **kwargs): - """ - Return an iterable object containing all entries stored in the specified bank. - Uses internal mmap index for O(1) performance when available. - """ - if bank == "keys": - bases = [base for base in BASE_MAPPING if base != "minions_denied"] - elif bank == "denied_keys": - bases = ["minions_denied"] - elif bank == "master_keys": - bases = [""] - else: - raise SaltCacheError(f"Unrecognized bank: {bank}") - - ret = [] - for base in bases: - base = os.path.join(cachedir, os.path.normpath(base)) - if not os.path.isdir(base): - continue - try: - items = os.listdir(base) - except OSError as exc: - raise SaltCacheError( - f'There was an error accessing directory "{base}": {exc}' - ) - for item in items: - # salt foolishly dumps a file here for key cache, ignore it - if item == ".key_cache": - continue - - keyfile = Path(cachedir, base, item) - - if ( - bank in ["keys", "denied_keys"] and not valid_id(__opts__, item) - ) or not clean_path(cachedir, str(keyfile), subdir=True): - log.error("saw invalid id %s, discarding", item) - continue - - if keyfile.is_file() and not keyfile.is_symlink(): - ret.append(item) - return ret - - -def list_all(bank, cachedir, include_data=False, **kwargs): - """ - Return all entries with their data from the specified bank. - This is much faster than calling list() + fetch() for each item. - Returns a dict of {key: data}. - - If include_data is False (default), only the state is returned for 'keys' bank, - avoiding expensive file reads. - """ - if bank not in ["keys", "denied_keys"]: - raise SaltCacheError(f"Unrecognized bank: {bank}") - - ret = {} - - if bank == "keys": - # Map directory names to states - state_mapping = { - "minions": "accepted", - "minions_pre": "pending", - "minions_rejected": "rejected", - } - - for dir_name, state in state_mapping.items(): - dir_path = os.path.join(cachedir, dir_name) - if not os.path.isdir(dir_path): - continue - - try: - with os.scandir(dir_path) as it: - for entry in it: - if not entry.is_file() or entry.is_symlink(): - continue - if entry.name.startswith("."): - continue - if not valid_id(__opts__, entry.name): - continue - if not clean_path(cachedir, entry.path, subdir=True): - continue - - if include_data: - - # Read the public key - try: - with salt.utils.files.fopen(entry.path, "r") as fh_: - pub_key = fh_.read() - ret[entry.name] = {"state": state, "pub": pub_key} - except OSError as exc: - log.error( - "Error reading key file %s: %s", entry.path, exc - ) - else: - # Just return the state, no disk read - ret[entry.name] = {"state": state} - except OSError as exc: - log.error("Error scanning directory %s: %s", dir_path, exc) - - elif bank == "denied_keys": - # Denied keys work differently - multiple keys per minion ID - dir_path = os.path.join(cachedir, "minions_denied") - if os.path.isdir(dir_path): - try: - with os.scandir(dir_path) as it: - for entry in it: - if not entry.is_file() or entry.is_symlink(): - continue - if not valid_id(__opts__, entry.name): - continue - if not clean_path(cachedir, entry.path, subdir=True): - continue - - try: - with salt.utils.files.fopen(entry.path, "r") as fh_: - ret[entry.name] = fh_.read() - except OSError as exc: - log.error( - "Error reading denied key %s: %s", entry.path, exc - ) - except OSError as exc: - log.error("Error scanning denied keys directory: %s", exc) - - return ret - - -def contains(bank, key, cachedir, **kwargs): - """ - Checks if the specified bank contains the specified key. - Uses internal mmap index for O(1) performance when available. - """ - if bank in ["keys", "denied_keys"] and not valid_id(__opts__, key): - raise SaltCacheError(f"key {key} is not a valid minion_id") - - if bank == "keys": - bases = [base for base in BASE_MAPPING if base != "minions_denied"] - elif bank == "denied_keys": - bases = ["minions_denied"] - elif bank == "master_keys": - if key in NON_CLUSTERED_MASTER_KEYS: - cachedir = __opts__["pki_dir"] - bases = [""] - else: - raise SaltCacheError(f"Unrecognized bank: {bank}") - - for base in bases: - keyfile = Path(cachedir, base, key) - - if not clean_path(cachedir, str(keyfile), subdir=True): - raise SaltCacheError(f"key {key} is not a valid key path.") - - if keyfile.is_file() and not keyfile.is_symlink(): - return True - - return False diff --git a/salt/cache/mmap_cache.py b/salt/cache/mmap_cache.py deleted file mode 100644 index c0be3ef7b15c..000000000000 --- a/salt/cache/mmap_cache.py +++ /dev/null @@ -1,334 +0,0 @@ -""" -Cache data in memory-mapped files (index + heap architecture). - -.. versionadded:: 3009.0 - -The ``mmap_cache`` module is a drop-in replacement for the ``localfs`` cache -backend. It stores cache data in a pair of memory-mapped files per bank: - -* **index file** — a fixed-size open-addressing hash table that maps keys to - heap pointers. -* **heap file** — a flat binary append-log that holds the serialized values. - -This layout gives O(1) reads and O(1) appends, which makes it well-suited for -high-frequency workloads such as Raft log persistence. - -Configuration (all optional, can be set in ``/etc/salt/master``): - -.. code-block:: yaml - - cache: mmap_cache - - # Number of index slots per bank (default: 1 000 000) - mmap_cache_size: 1000000 - - # Bytes per index slot; must be >= 1 + mmap_cache_key_size + 20 - mmap_cache_slot_size: 96 - - # Maximum key length in bytes - mmap_cache_key_size: 64 - -The ``bank`` concept maps directly to a sub-directory of ``cachedir``. One -``MmapCache`` instance (index + heap pair) is created per ``(cachedir, bank)`` -and kept alive in a module-level registry for the lifetime of the process. -""" - -import logging -import os - -import msgpack - -import salt.utils.mmap_cache -import salt.utils.path -from salt.exceptions import SaltCacheError - -# Use raw msgpack directly rather than salt.payload for serialisation. -# salt.payload.loads wraps msgpack with gc.disable/enable, an ext_hook -# closure, and a full decode_embedded_strs walk — making it ~12x slower than -# msgpack.unpackb on typical cache payloads. The cache layer only stores -# plain Python dicts; it never needs datetime/Constant extension types. -_PACK_OPTS = {"use_bin_type": True} -_UNPACK_OPTS = {"raw": False} - -log = logging.getLogger(__name__) - -__func_alias__ = {"list_": "list", "flush_": "flush"} - -# Module-level registry: (cachedir, bank) -> (tuning_tuple, MmapCache). -# When ``__opts__`` mmap tuning changes (e.g. tests patch opts after an early -# cache miss vs hit), evict and rebuild so we never reuse an ``MmapCache`` -# sized for stale configuration over the same bank directory. -_caches = {} - -# Default tuning knobs (overridable via opts) -_DEFAULT_SIZE = 1_000_000 -_DEFAULT_SLOT_SIZE = 96 -_DEFAULT_KEY_SIZE = 64 - - -def _mmap_tuning_tuple(): - """Return the effective mmap index tuning from ``__opts__``.""" - return ( - __opts__.get("mmap_cache_size", _DEFAULT_SIZE), - __opts__.get("mmap_cache_slot_size", _DEFAULT_SLOT_SIZE), - __opts__.get("mmap_cache_key_size", _DEFAULT_KEY_SIZE), - ) - - -def _unlink_mmap_bank_files(index_path, *, strict_remove=False): - """ - Remove mmap cache files for one bank (index + heap segments + roster + lock). - - ``strict_remove=False`` (default): log and continue on ``OSError`` — used when - evicting a stale registry entry after ``__opts__`` tuning changes. - - ``strict_remove=True``: propagate ``OSError`` — used by ``flush_``. - - Returns ``True`` if at least one file was present and removed. - """ - - removed = False - - def _rm(path): - nonlocal removed - if not os.path.exists(path): - return - try: - os.remove(path) - removed = True - except OSError as exc: - if strict_remove: - raise SaltCacheError( - f'Error removing cache file "{path}": {exc}' - ) from exc - log.warning("Could not remove mmap cache file %s", path, exc_info=True) - - seg = 0 - while True: - heap_p = index_path + ".heap" if seg == 0 else index_path + ".heap." + str(seg) - if not os.path.exists(heap_p): - break - _rm(heap_p) - seg += 1 - - for tail in (".roster", ".lock"): - _rm(index_path + tail) - _rm(index_path) - return removed - - -def __cachedir(kwargs=None): - if kwargs and "cachedir" in kwargs: - return kwargs["cachedir"] - return __opts__.get("cachedir", salt.syspaths.CACHE_DIR) - - -def init_kwargs(kwargs): - """ - Return the canonical keyword arguments for this cache driver. - """ - return {"cachedir": __cachedir(kwargs)} - - -def get_storage_id(kwargs): - """ - Return a unique identifier for this cache driver instance. - """ - return ("mmap_cache", __cachedir(kwargs)) - - -def _get_cache(bank, cachedir): - """ - Return (or lazily create) the ``MmapCache`` instance for *bank* under - *cachedir*. - """ - key = (cachedir, bank) - tuning = _mmap_tuning_tuple() - entry = _caches.get(key) - if entry is not None: - old_tuning, cache_obj = entry - if old_tuning == tuning: - return cache_obj - index_path = cache_obj.path - try: - cache_obj.close() - except (BufferError, OSError): - pass - del _caches[key] - _unlink_mmap_bank_files(index_path) - - bank_dir = salt.utils.path.join(cachedir, os.path.normpath(bank)) - os.makedirs(bank_dir, exist_ok=True) - index_path = os.path.join(bank_dir, ".mmap_cache.idx") - - size, slot_size, key_size = tuning - # Heap segment cap controls "how big can a single .heap-N file get - # before the next append rolls a new segment". Pulled from opts - # rather than hardcoded so operators can tune for filesystems or - # backup tools that struggle past a given size. Not part of the - # ``tuning`` tuple because changing it does not invalidate - # already-written segments — it only affects future appends. - max_segment_bytes = __opts__.get( - "mmap_cache_max_segment_bytes", - salt.utils.mmap_cache.DEFAULT_MAX_SEGMENT_BYTES, - ) - cache_obj = salt.utils.mmap_cache.MmapCache( - path=index_path, - size=size, - slot_size=slot_size, - key_size=key_size, - max_segment_bytes=max_segment_bytes, - ) - _caches[key] = (tuning, cache_obj) - return cache_obj - - -def store(bank, key, data, cachedir, **kwargs): - """ - Serialise *data* with msgpack and store it under *bank*/*key*. - """ - try: - raw = msgpack.packb(data, **_PACK_OPTS) - except Exception as exc: # pylint: disable=broad-except - raise SaltCacheError( - f"Failed to serialise cache data for bank={bank!r} key={key!r}: {exc}" - ) - - cache = _get_cache(bank, cachedir) - if not cache.put(key, raw): - raise SaltCacheError( - f"Failed to write mmap cache entry bank={bank!r} key={key!r}" - ) - - -def fetch(bank, key, cachedir, **kwargs): - """ - Return the deserialised value for *bank*/*key*, or ``{}`` if not found. - """ - cache = _get_cache(bank, cachedir) - raw = cache.get(key, default=None) - - if raw is None: - return {} - - # set-mode entries (value=True) indicate presence without data - if raw is True: - return {} - - if isinstance(raw, str): - raw = raw.encode() - - try: - return msgpack.unpackb(raw, **_UNPACK_OPTS) - except Exception as exc: # pylint: disable=broad-except - raise SaltCacheError( - f"Failed to deserialise cache data for bank={bank!r} key={key!r}: {exc}" - ) - - -def updated(bank, key, cachedir, **kwargs): - """ - Return the Unix timestamp (int seconds) of the last write for *bank*/*key*, - or ``None`` if the key does not exist. - - This reads only the index — no heap access required. - """ - cache = _get_cache(bank, cachedir) - mtime = cache.get_mtime(key) - if mtime is None: - return None - return int(mtime) - - -def flush_(bank, key=None, cachedir=None, **kwargs): - """ - Remove *key* from *bank*, or clear the entire *bank* if *key* is ``None``. - - Clearing a bank removes the mmap files from the registry and deletes them - from disk, mirroring ``localfs`` behaviour where ``shutil.rmtree`` removes - the bank directory. - """ - if cachedir is None: - cachedir = __cachedir() - - if key is None: - # Flush entire bank: evict from registry, remove files from disk. - cache_key = (cachedir, bank) - entry = _caches.pop(cache_key, None) - if entry is not None: - entry[1].close() - - bank_dir = salt.utils.path.join(cachedir, os.path.normpath(bank)) - if not os.path.isdir(bank_dir): - return False - - # Remove just the mmap files, leave the directory structure intact - # so that sub-banks are not inadvertently destroyed. - removed = False - index_base = os.path.join(bank_dir, ".mmap_cache.idx") - return _unlink_mmap_bank_files(index_base, strict_remove=True) - - cache = _get_cache(bank, cachedir) - deleted = cache.delete(key) - return deleted - - -def list_(bank, cachedir, **kwargs): - """ - Return a list of all keys stored in *bank*. - """ - cache = _get_cache(bank, cachedir) - return cache.list_keys() - - -def list_all(bank, cachedir, include_data=False, **kwargs): - """ - Return ``{key: data}`` for every entry in *bank* in a single pass. - - Walks the mmap roster once (O(occupied)) and msgpack-decodes each - heap entry inline, avoiding the per-key hash probe that - ``list_(bank) + fetch(bank, k)`` would do. - - With ``include_data=False`` the data slot is ``{}`` so callers can - use this purely to enumerate keys without paying msgpack - deserialisation; with ``include_data=True`` (default behaviour for - contract parity with other backends) each value is the - ``msgpack.unpackb`` round-trip of what ``store`` wrote. - """ - cache = _get_cache(bank, cachedir) - if not include_data: - return {k: {} for k in cache.list_keys()} - ret = {} - for k, raw in cache.list_items(): - if raw is True: - ret[k] = {} - continue - if isinstance(raw, str): - raw = raw.encode() - if not isinstance(raw, (bytes, bytearray)) or not raw: - ret[k] = {} - continue - try: - ret[k] = msgpack.unpackb(bytes(raw), **_UNPACK_OPTS) - except Exception as exc: # pylint: disable=broad-except - log.warning( - "mmap_cache list_all: skipping undeserialisable entry " - "bank=%r key=%r: %s", - bank, - k, - exc, - ) - return ret - - -def contains(bank, key, cachedir, **kwargs): - """ - Return ``True`` if *bank* contains *key* (or, if *key* is ``None``, - whether the bank itself exists at all). - """ - if key is None: - bank_dir = salt.utils.path.join(cachedir, os.path.normpath(bank)) - return os.path.isdir(bank_dir) - - cache = _get_cache(bank, cachedir) - return cache.contains(key) diff --git a/salt/cache/mmap_key.py b/salt/cache/mmap_key.py deleted file mode 100644 index 848c410eed30..000000000000 --- a/salt/cache/mmap_key.py +++ /dev/null @@ -1,437 +0,0 @@ -""" -mmap-native PKI key cache backend. - -.. versionadded:: 3009.0 - -Replaces ``localfs_key`` as the ``keys.cache_driver`` when higher performance -is needed. Unlike ``localfs_key``, this backend stores everything — minion -IDs, key state, and public key material — in a pair of memory-mapped files -per bank. There is no filesystem fallback and no dual code path. - -On-heap record layout for the ``keys`` bank:: - - [STATE: 1 byte][PUB: variable bytes] - -State byte values:: - - 0x01 accepted - 0x02 pending - 0x03 rejected - -All other banks (``denied_keys``, ``master_keys``) store raw bytes in the -heap with no state prefix. - -The ``master_keys`` bank stores private key material (PEM files). A separate -``MmapCache`` instance is used for ``master_keys`` so that its permissions can -be locked down independently. - -Configuration (all optional, can be set in ``/etc/salt/master``): - -.. code-block:: yaml - - keys.cache_driver: mmap_key - - # Slots in the minion key index (default: 1 000 000) - mmap_key_size: 1000000 - - # Bytes per index slot (default: 96) - mmap_key_slot_size: 96 - - # Maximum minion ID length in bytes (default: 64) - mmap_key_id_size: 64 -""" - -import logging -import os - -import salt.utils.files -import salt.utils.mmap_cache -import salt.utils.path -import salt.utils.stringutils -from salt.exceptions import SaltCacheError -from salt.utils.verify import valid_id - -log = logging.getLogger(__name__) - -__func_alias__ = {"list_": "list", "flush_": "flush"} - -# State byte encoding for the keys bank heap prefix -_STATE_ACCEPTED = 0x01 -_STATE_PENDING = 0x02 -_STATE_REJECTED = 0x03 - -_STATE_TO_BYTE = { - "accepted": _STATE_ACCEPTED, - "pending": _STATE_PENDING, - "rejected": _STATE_REJECTED, -} -_BYTE_TO_STATE = {v: k for k, v in _STATE_TO_BYTE.items()} - -# Separate index files per bank -_BANK_INDEX_NAME = { - "keys": ".mmap_keys.idx", - "denied_keys": ".mmap_denied.idx", - "master_keys": ".mmap_master.idx", -} - -_DEFAULT_SIZE = 1_000_000 -_DEFAULT_SLOT_SIZE = 96 -_DEFAULT_ID_SIZE = 64 - -# Module-level registry: (cachedir, bank) -> MmapCache -_caches: dict = {} - - -def init_kwargs(kwargs): - """ - Return canonical kwargs; mirrors ``localfs_key.init_kwargs``. - """ - if "pki_dir" in kwargs: - cachedir = kwargs["pki_dir"] - elif __opts__.get("cluster_id"): - cachedir = __opts__["cluster_pki_dir"] - else: - cachedir = __opts__["pki_dir"] - user = kwargs.get("user", __opts__.get("user")) - return {"cachedir": cachedir, "user": user} - - -def get_storage_id(kwargs): - """ - Return a unique identifier for this cache driver instance. - """ - return ("mmap_key", kwargs.get("cachedir", __opts__.get("pki_dir", ""))) - - -def _get_cache(bank, cachedir): - """ - Return (or create) the ``MmapCache`` instance for *bank* under *cachedir*. - """ - key = (cachedir, bank) - if key not in _caches: - if bank not in _BANK_INDEX_NAME: - raise SaltCacheError(f"mmap_key: unrecognised bank {bank!r}") - os.makedirs(cachedir, exist_ok=True) - index_path = os.path.join(cachedir, _BANK_INDEX_NAME[bank]) - size = __opts__.get("mmap_key_size", _DEFAULT_SIZE) - slot_size = __opts__.get("mmap_key_slot_size", _DEFAULT_SLOT_SIZE) - id_size = __opts__.get("mmap_key_id_size", _DEFAULT_ID_SIZE) - # Heap segment cap — how big a single .heap-N file may grow - # before the next append rolls a new segment. Read from opts - # so operators can tune below 1 GiB on filesystems or backup - # tools that don't like multi-GiB single files. Falls back to - # ``mmap_cache_max_segment_bytes`` so an operator who already - # set it for the generic backend doesn't need to set it twice. - max_segment_bytes = __opts__.get( - "mmap_key_max_segment_bytes", - __opts__.get( - "mmap_cache_max_segment_bytes", - salt.utils.mmap_cache.DEFAULT_MAX_SEGMENT_BYTES, - ), - ) - _caches[key] = salt.utils.mmap_cache.MmapCache( - path=index_path, - size=size, - slot_size=slot_size, - key_size=id_size, - max_segment_bytes=max_segment_bytes, - ) - return _caches[key] - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _encode_key_entry(state, pub): - """Pack state byte + pub key bytes for the ``keys`` bank heap.""" - state_byte = _STATE_TO_BYTE.get(state) - if state_byte is None: - raise SaltCacheError(f"mmap_key: unknown key state {state!r}") - return bytes([state_byte]) + salt.utils.stringutils.to_bytes(pub) - - -def _decode_key_entry(raw): - """ - Unpack a ``keys`` bank heap entry. - - Returns ``{"state": str, "pub": str}`` or ``None`` on corrupt data. - """ - if not raw or len(raw) < 2: - return None - state_byte = raw[0] if isinstance(raw[0], int) else ord(raw[0]) - state = _BYTE_TO_STATE.get(state_byte) - if state is None: - return None - pub = raw[1:].decode("utf-8", errors="replace") - return {"state": state, "pub": pub} - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def _check_id(bank, key): - """Raise SaltCacheError when *key* is not a valid minion_id for key banks.""" - if bank in ("keys", "denied_keys") and not valid_id(__opts__, key): - raise SaltCacheError(f"mmap_key: {key!r} is not a valid minion_id") - - -def store(bank, key, data, cachedir, **kwargs): - """ - Store *data* for *bank*/*key*. - - ``keys`` bank expects ``{"state": str, "pub": str}``. - ``denied_keys`` bank expects a list; the first element is stored. - ``master_keys`` bank expects a raw string or bytes. - """ - _check_id(bank, key) - cache = _get_cache(bank, cachedir) - - if bank == "keys": - if not isinstance(data, dict) or "state" not in data or "pub" not in data: - raise SaltCacheError( - f"mmap_key: keys bank requires {{state, pub}} dict, got {type(data)}" - ) - raw = _encode_key_entry(data["state"], data["pub"]) - - elif bank == "denied_keys": - # data is a list; store the first (and typically only) entry - if isinstance(data, list): - raw = salt.utils.stringutils.to_bytes(data[0] if data else "") - else: - raw = salt.utils.stringutils.to_bytes(data) - - elif bank == "master_keys": - raw = salt.utils.stringutils.to_bytes(data) - - else: - raise SaltCacheError(f"mmap_key: unrecognised bank {bank!r}") - - if not cache.put(key, raw): - raise SaltCacheError(f"mmap_key: failed to write bank={bank!r} key={key!r}") - - -def fetch(bank, key, cachedir, **kwargs): - """ - Return the stored value for *bank*/*key*. - - ``keys`` bank returns ``{"state": str, "pub": str}`` or ``None``. - ``denied_keys`` returns a list of one pub key string, or ``{}``. - ``master_keys`` returns the raw PEM string, or ``{}``. - """ - _check_id(bank, key) - cache = _get_cache(bank, cachedir) - raw = cache.get(key, default=None) - - if raw is None or raw is True: - return {} if bank != "keys" else None - - if isinstance(raw, str): - raw = raw.encode("utf-8") - - if bank == "keys": - entry = _decode_key_entry(raw) - return entry # may be None on corrupt data - - elif bank == "denied_keys": - return [raw.decode("utf-8", errors="replace").rstrip("\x00")] - - elif bank == "master_keys": - return raw.decode("utf-8", errors="replace").rstrip("\x00") - - return {} - - -def updated(bank, key, cachedir, **kwargs): - """ - Return the Unix timestamp (int) of the last write for *bank*/*key*, - or ``None`` if not found. - """ - _check_id(bank, key) - cache = _get_cache(bank, cachedir) - mtime = cache.get_mtime(key) - return int(mtime) if mtime is not None else None - - -def flush_(bank, key=None, cachedir=None, **kwargs): - """ - Remove *key* from *bank*, or wipe the entire *bank* if *key* is ``None``. - """ - if cachedir is None: - cachedir = __opts__.get("pki_dir", "") - - if key is not None: - _check_id(bank, key) - - cache = _get_cache(bank, cachedir) - - if key is None: - # Wipe the whole bank: close and delete the mmap files. - cache_key = (cachedir, bank) - c = _caches.pop(cache_key, None) - if c is not None: - c.close() - index_name = _BANK_INDEX_NAME.get(bank) - if index_name: - base = os.path.join(cachedir, index_name) - # Collect all files to remove: fixed suffixes plus any numbered - # heap segments (.heap.1, .heap.2, …). - paths_to_remove = [base + s for s in ("", ".heap", ".lock", ".roster")] - seg_id = 1 - while True: - seg = f"{base}.heap.{seg_id}" - if not os.path.exists(seg): - break - paths_to_remove.append(seg) - seg_id += 1 - for p in paths_to_remove: - try: - if os.path.exists(p): - os.remove(p) - except OSError as exc: - raise SaltCacheError(f"mmap_key: error removing {p!r}: {exc}") - return True - - return cache.delete(key) - - -def list_(bank, cachedir, **kwargs): - """ - Return all keys in *bank*. - """ - cache = _get_cache(bank, cachedir) - return cache.list_keys() - - -def list_all(bank, cachedir, include_data=False, **kwargs): - """ - Return ``{minion_id: data}`` for every entry in *bank* in a single pass. - - Faster than ``list_(bank) + fetch(bank, k)`` per minion: walks the - mmap roster once (O(occupied)) and decodes each heap entry inline, - rather than re-probing the index for every key. - - For the ``keys`` bank the value shape matches ``localfs_key.list_all``: - - * ``include_data=False`` (default) — ``{"state": str}`` per minion; - cheaper to deserialise but still requires reading the heap entry - because state is the first byte of the packed value. - * ``include_data=True`` — ``{"state": str, "pub": str}``. - - For ``denied_keys`` the value is always ``[pub_str]`` (denied - payloads are small enough that the ``include_data`` distinction - doesn't pay back). - - ``master_keys`` is intentionally unsupported — callers that need - master-side keys should iterate ``list_`` and ``fetch`` explicitly. - """ - if bank not in ("keys", "denied_keys"): - raise SaltCacheError(f"mmap_key: list_all unsupported for bank {bank!r}") - - cache = _get_cache(bank, cachedir) - ret = {} - for k, raw in cache.list_items(): - if isinstance(raw, str): - raw = raw.encode("utf-8") - if not isinstance(raw, (bytes, bytearray)) or not raw: - continue - if bank == "keys": - entry = _decode_key_entry(bytes(raw)) - if entry is None: - log.warning("mmap_key list_all: skipping invalid keys entry %r", k) - continue - ret[k] = entry if include_data else {"state": entry["state"]} - else: # denied_keys - ret[k] = [bytes(raw).decode("utf-8", errors="replace").rstrip("\x00")] - return ret - - -def contains(bank, key, cachedir, **kwargs): - """ - Return ``True`` if *bank* contains *key*. - """ - if key is not None: - _check_id(bank, key) - if key is None: - # Bank-level existence check: does the index file exist? - index_name = _BANK_INDEX_NAME.get(bank) - if not index_name: - return False - return os.path.exists(os.path.join(cachedir, index_name)) - - cache = _get_cache(bank, cachedir) - return cache.contains(key) - - -def rebuild_from_localfs(opts): - """ - One-time migration: scan the legacy pki directory layout and load all - existing keys into the mmap backend. - - Safe to call repeatedly — already-present keys are overwritten in-place. - Returns ``(accepted, pending, rejected, denied)`` counts. - """ - if opts.get("cluster_id"): - pki_dir = opts["cluster_pki_dir"] - else: - pki_dir = opts.get("pki_dir", "") - - cachedir = pki_dir # mmap_key stores alongside pki files - - state_dirs = { - "minions": "accepted", - "minions_pre": "pending", - "minions_rejected": "rejected", - } - - counts = {"accepted": 0, "pending": 0, "rejected": 0, "denied": 0} - - for dir_name, state in state_dirs.items(): - dir_path = os.path.join(pki_dir, dir_name) - if not os.path.isdir(dir_path): - continue - try: - with os.scandir(dir_path) as it: - for entry in it: - if not entry.is_file() or entry.is_symlink(): - continue - if entry.name.startswith("."): - continue - try: - with salt.utils.files.fopen(entry.path, "r") as fh_: - pub = fh_.read() - store( - "keys", entry.name, {"state": state, "pub": pub}, cachedir - ) - counts[state] += 1 - except (OSError, SaltCacheError) as exc: - log.warning( - "mmap_key migrate: skipping %s: %s", entry.path, exc - ) - except OSError as exc: - log.error("mmap_key migrate: cannot scan %s: %s", dir_path, exc) - - denied_path = os.path.join(pki_dir, "minions_denied") - if os.path.isdir(denied_path): - try: - with os.scandir(denied_path) as it: - for entry in it: - if not entry.is_file() or entry.is_symlink(): - continue - try: - with salt.utils.files.fopen(entry.path, "r") as fh_: - pub = fh_.read() - store("denied_keys", entry.name, [pub], cachedir) - counts["denied"] += 1 - except (OSError, SaltCacheError) as exc: - log.warning( - "mmap_key migrate denied: skipping %s: %s", entry.path, exc - ) - except OSError as exc: - log.error("mmap_key migrate denied: cannot scan %s: %s", denied_path, exc) - - log.info("mmap_key migrate complete: %s", counts) - return counts diff --git a/salt/cache/mysql_cache.py b/salt/cache/mysql_cache.py index 2cc73ff92f65..b811f181ce9e 100644 --- a/salt/cache/mysql_cache.py +++ b/salt/cache/mysql_cache.py @@ -36,7 +36,7 @@ # This may be enabled to create a fresh connection on every call mysql.fresh_connection: false -Related docs can be found in the `PyMySQL documentation`_. +Related docs can be found in the `python-mysql documentation`_. To use the mysql as a minion data cache backend, set the master ``cache`` config value to ``mysql``: @@ -47,7 +47,7 @@ .. _`MySQL documentation`: https://github.com/coreos/mysql -.. _`PyMySQL documentation`: https://pymysql.readthedocs.io/en/latest/ +.. _`python-mysql documentation`: http://python-mysql.readthedocs.io/en/latest/ """ diff --git a/salt/cache/redis_cache.py b/salt/cache/redis_cache.py index 082f510796eb..bdef741d0252 100644 --- a/salt/cache/redis_cache.py +++ b/salt/cache/redis_cache.py @@ -12,56 +12,60 @@ .. code-block:: bash - salt \* pip.install redis>=6.1.0 + salt \* pip.install redis As Redis provides a simple mechanism for very fast key-value store, in order to provide the necessary features for the Salt caching subsystem, the following conventions are used: +- A Redis key consists of the bank name and the cache key separated by ``/``, e.g.: + ``$KEY_minions/alpha/stuff`` where ``minions/alpha`` is the bank name + and ``stuff`` is the key name. - As the caching subsystem is organised as a tree, we need to store the caching path and identify the bank and its offspring. At the same time, Redis is linear and we need to avoid doing ``keys `` which is very inefficient as it goes through all the keys on the remote Redis server. - Instead a SORTED SET of all banks is stored in a single value. This can act as - an index for finding sub-banks. By default this key is prefixed with ``$BANKS_``. -- Each bank is stored as a hash. It is a simple mechanism and allows for fast access - to the data and of the keys. By default this key is prefixed with ``$KEYS_``. -- An additional hash is used to store the last update time of each cache key. By - default this key is prefixed with ``$TSTAMP_``. + Instead, each bank hierarchy has a Redis SET associated which stores the list + of sub-banks. By default, these keys begin with ``$BANK_``. +- In addition, each key name is stored in a separate SET of all the keys within + a bank. By default, these SETs begin with ``$BANKEYS_``. -For example, to store the key ``my-key`` with value ``my-value`` under the bank -``root-bank/sub-bank/leaf-bank``, the following datastructures will be created. +For example, to store the key ``my-key`` under the bank ``root-bank/sub-bank/leaf-bank``, +the following hierarchy will be built: .. code-block:: text - 127.0.0.1:6379> ZSCAN $BANKS_ 0 - 1) "0" - 2) 1) "$KEYS_root-bank/sub-bank/leaf-bank/" - 2) "0" - 127.0.0.1:6379> HGETALL $KEYS_root-bank/sub-bank/leaf-bank/ + 127.0.0.1:6379> SMEMBERS $BANK_root-bank + 1) "sub-bank" + 127.0.0.1:6379> SMEMBERS $BANK_root-bank/sub-bank + 1) "leaf-bank" + 127.0.0.1:6379> SMEMBERS $BANKEYS_root-bank/sub-bank/leaf-bank 1) "my-key" - 2) "my-value" - 127.0.0.1:6379> HGETALL $TSTAMP_root-bank/sub-bank/leaf-bank/ - 1) "my-key" - 2) "1773671718" + 127.0.0.1:6379> GET $KEY_root-bank/sub-bank/leaf-bank/my-key + "my-value" -There are three types of keys stored: +There are four types of keys stored: -- ``$BANKS_*`` is a Redis SORTED SET containing the list of all banks -- ``$KEYS_*`` is a Redis SET containing key, value pairs of the current bank. +- ``$BANK_*`` is a Redis SET containing the list of banks under the current bank. +- ``$BANKEYS_*`` is a Redis SET containing the list of keys under the current bank. +- ``$KEY_*`` keeps the value of the key. - ``$TSTAMP_*`` stores the last updated timestamp of the key. These prefixes and the separator can be adjusted using the configuration options: -banks_prefix: ``$BANK`` - The prefix used for the name of the Redis key storing the sorted set of banks. +bank_prefix: ``$BANK`` + The prefix used for the name of the Redis key storing the list of sub-banks. + +bank_keys_prefix: ``$BANKEYS`` + The prefix used for the name of the Redis key storing the list of keys under a certain bank. -keys_prefix: ``$KEY`` - The prefix used for Redis keys storing bank key/value pairs. +key_prefix: ``$KEY`` + The prefix of the Redis keys having the value of the keys to be cached under + a certain bank. timestamp_prefix: ``$TSTAMP`` - The prefix used for Redis keys storing timestamps of bank keys. + The prefix for the last modified timestamp for keys. .. versionadded:: 3005 @@ -122,10 +126,11 @@ cache.redis.port: 6379 cache.redis.db: '0' cache.redis.password: my pass - cache.redis.banks_prefix: #BANKS - cache.redis.keys_prefix: #KEYS - cache.redis.timestamp_prefix: #TSTAMP - cache.redis.separator: '_' + cache.redis.bank_prefix: #BANK + cache.redis.bank_keys_prefix: #BANKEYS + cache.redis.key_prefix: #KEY + cache.redis.timestamp_prefix: #TICKS + cache.redis.separator: '@' Cluster Configuration Example: @@ -140,10 +145,10 @@ port: 6379 cache.redis.db: '0' cache.redis.password: my pass - cache.redis.banks_prefix: #BANKS - cache.redis.keys_prefix: #KEYS - cache.redis.timestamp_prefix: #TSTAMP - cache.redis.separator: '_' + cache.redis.bank_prefix: #BANK + cache.redis.bank_keys_prefix: #BANKEYS + cache.redis.key_prefix: #KEY + cache.redis.separator: '@' """ import logging @@ -164,6 +169,13 @@ except ImportError: HAS_REDIS = False +try: + from rediscluster import RedisCluster # pylint: disable=no-name-in-module + + HAS_REDIS_CLUSTER = True +except ImportError: + HAS_REDIS_CLUSTER = False + # ----------------------------------------------------------------------------- # module properties @@ -174,11 +186,14 @@ log = logging.getLogger(__file__) -_BANKS_PREFIX = "$BANKS" -_KEYS_PREFIX = "$KEYS" +_BANK_PREFIX = "$BANK" +_KEY_PREFIX = "$KEY" _TIMESTAMP_PREFIX = "$TSTAMP" +_BANK_KEYS_PREFIX = "$BANKEYS" _SEPARATOR = "_" +REDIS_SERVER = None + # ----------------------------------------------------------------------------- # property functions # ----------------------------------------------------------------------------- @@ -191,7 +206,9 @@ def __virtual__(): The redis redis cluster library must be installed if cluster_mode is True """ if not HAS_REDIS: - return (False, "Please install the redis package.") + return (False, "Please install the python-redis package.") + if not HAS_REDIS_CLUSTER and _get_redis_cache_opts()["cluster_mode"]: + return (False, "Please install the redis-py-cluster package.") return __virtualname__ @@ -211,8 +228,7 @@ def _get_redis_cache_opts(): """ Return the Redis server connection details from the __opts__. """ - sep = __opts__.get("cache.redis.separator", _SEPARATOR) - opts = { + return { "host": __opts__.get("cache.redis.host", "localhost"), "port": __opts__.get("cache.redis.port", 6379), "unix_socket_path": __opts__.get("cache.redis.unix_socket_path", None), @@ -223,133 +239,164 @@ def _get_redis_cache_opts(): "skip_full_coverage_check": __opts__.get( "cache.redis.cluster.skip_full_coverage_check", False ), - "banks_prefix": __opts__.get("cache.redis.banks_prefix", _BANKS_PREFIX) + sep, - "keys_prefix": __opts__.get("cache.redis.keys_prefix", _KEYS_PREFIX) + sep, - "timestamp_prefix": __opts__.get( - "cache.redis.timestamp_prefix", _TIMESTAMP_PREFIX - ) - + sep, } - prefix_confs = [opts["banks_prefix"], opts["keys_prefix"], opts["timestamp_prefix"]] - if any("/" in conf for conf in prefix_confs): - mesg = "Slash '/' cannot be used in redis cache prefix configuration." - log.error(mesg) - raise SaltCacheError(mesg) - return opts -def _get_redis_server(): +def _get_redis_server(opts=None): """ Return the Redis server instance. Caching the object instance. """ - redis_server = __context__.get("cache.redis", {}).get("client") - if redis_server is not None: - return redis_server - opts = _get_redis_cache_opts() + global REDIS_SERVER + if REDIS_SERVER: + return REDIS_SERVER + if not opts: + opts = _get_redis_cache_opts() + if opts["cluster_mode"]: - redis_server = redis.RedisCluster( + REDIS_SERVER = RedisCluster( startup_nodes=opts["startup_nodes"], skip_full_coverage_check=opts["skip_full_coverage_check"], ) else: - redis_server = redis.Redis( + REDIS_SERVER = redis.StrictRedis( opts["host"], opts["port"], unix_socket_path=opts["unix_socket_path"], db=opts["db"], password=opts["password"], ) - __context__["cache.redis"] = { - "client": redis_server, - "banks_prefix": opts["banks_prefix"], - "keys_prefix": opts["keys_prefix"], - "timestamp_prefix": opts["timestamp_prefix"], - } - return __context__["cache.redis"]["client"] + return REDIS_SERVER -def _banks_set_key(): +def _get_redis_keys_opts(): """ - Return the Redis key that stores all banks. + Build the key opts based on the user options. """ - return __context__["cache.redis"]["banks_prefix"] + return { + "bank_prefix": __opts__.get("cache.redis.bank_prefix", _BANK_PREFIX), + "bank_keys_prefix": __opts__.get( + "cache.redis.bank_keys_prefix", _BANK_KEYS_PREFIX + ), + "key_prefix": __opts__.get("cache.redis.key_prefix", _KEY_PREFIX), + "separator": __opts__.get("cache.redis.separator", _SEPARATOR), + "timestamp_prefix": __opts__.get( + "cache.redis.timestamp_prefix", _TIMESTAMP_PREFIX + ), + } -def _normalize_bank(bank): +def _get_bank_redis_key(bank): """ - Return the normalized bank key and bank timestamp names. + Return the Redis key for the bank given the name. """ - bankname = "{}/".format(bank.rstrip("/")) - return ( - "{}{}".format(__context__["cache.redis"]["keys_prefix"], bankname), - "{}{}".format(__context__["cache.redis"]["timestamp_prefix"], bankname), + opts = _get_redis_keys_opts() + return "{prefix}{separator}{bank}".format( + prefix=opts["bank_prefix"], separator=opts["separator"], bank=bank ) -def _timestamp_from_bank_key(bank_key): - """ - Convert a bank key into a timestamp key. - """ - return "{}{}".format( - __context__["cache.redis"]["timestamp_prefix"], - bank_key.removeprefix(__context__["cache.redis"]["keys_prefix"]), +def _get_timestamp_key(bank, key): + opts = _get_redis_keys_opts() + return "{}{}{}/{}".format( + opts["timestamp_prefix"], opts["separator"], {bank}, {key} ) + # Use this line when we can use modern python + # return f"{opts['timestamp_prefix']}{opts['separator']}{bank}/{key}" -def _flush_key(bank, key): +def _get_key_redis_key(bank, key): """ - Remove the key from the cache. - - If this is the last key in the bank then also remove the bank from the list of banks. + Return the Redis key given the bank name and the key name. """ - redis_server = _get_redis_server() - bank_key, timestamp_key = _normalize_bank(bank) - redis_pipe = redis_server.pipeline() - redis_pipe.hdel(bank_key, key) - redis_pipe.hdel(timestamp_key, key) - redis_pipe.exists(bank_key) - batch_results = redis_pipe.execute() - # I wish this could be made atomic, but it relies on the previous result. Scripts could make it - # atomic, but that's a lot of overhead.. - if not batch_results[-1]: - redis_server.zrem(_banks_set_key(), bank_key) - - -def _flush_bank(bank): - """ - Clear out an entire bank and subbanks. - """ - redis_server = _get_redis_server() - bank_key, timestamp_key = _normalize_bank(bank) - subbanks = _get_subbanks(redis_server, bank_key) - if not subbanks: - return - redis_pipe = redis_server.pipeline() - redis_pipe.zrem(_banks_set_key(), *subbanks) - redis_pipe.unlink(*subbanks) - redis_pipe.unlink(*[_timestamp_from_bank_key(bank_key) for bank_key in subbanks]) - redis_pipe.execute() + opts = _get_redis_keys_opts() + return "{prefix}{separator}{bank}/{key}".format( + prefix=opts["key_prefix"], + separator=opts["separator"], + bank=bank, + key=salt.utils.stringutils.to_str(key), + ) -def _get_subbanks(redis_server, bank_key): +def _get_bank_keys_redis_key(bank): """ - Scan the BANKS key for the sub banks of the given bank. - - The function will also return the current bank if it exists. + Return the Redis key for the SET of keys under a certain bank, given the bank name. """ - startrange = f"[{bank_key}" - endrange = "({}0".format(bank_key.rstrip("/")) - return list( - _decode(redis_server.zrange(_banks_set_key(), startrange, endrange, bylex=True)) + opts = _get_redis_keys_opts() + return "{prefix}{separator}{bank}".format( + prefix=opts["bank_keys_prefix"], separator=opts["separator"], bank=bank ) -def _decode(iterable): - """ - Decode the iterable. - """ - yield from (item.decode("utf8") for item in iterable) +def _build_bank_hier(bank, redis_pipe): + """ + Build the bank hierarchy from the root of the tree. + + For each level in the bank path: + + - ensure a ``.`` placeholder exists in ``$BANK_`` so that an + empty bank still has a recognisable record; + - for every non-root level, register this segment as a child of its + parent in both ``$BANK_`` (consumed by the flush + tree-traversal in ``_get_banks_to_remove``) and + ``$BANKEYS_`` (consumed by ``list_()``). Without this, + ``list_("minions")`` reads an empty set even though the data is + stored correctly under ``minions/``, breaking + ``CkMinions.connected_ids()`` and therefore + ``salt-run manage.present`` / ``manage.up`` for any deployment + that uses the redis cache backend. + + Uses the Redis pipeline so there is only one round-trip with the + server. + """ + parts = bank.split("/") + for index, segment in enumerate(parts): + bank_path = "/".join(parts[: index + 1]) + bank_set = _get_bank_redis_key(bank_path) + log.debug("Adding %s to %s", bank, bank_set) + redis_pipe.sadd(bank_set, ".") + if index > 0: + parent_path = "/".join(parts[:index]) + # Register the child in BOTH the parent's $BANK_ set (so + # ``_get_banks_to_remove`` finds it for flush traversal) + # AND the parent's $BANKEYS_ set (so ``list_()`` returns + # it). Both reads exist in the codebase and both must see + # the child for the cache to behave like the localfs + # backend. + redis_pipe.sadd(_get_bank_redis_key(parent_path), segment) + redis_pipe.sadd(_get_bank_keys_redis_key(parent_path), segment) + + +def _get_banks_to_remove(redis_server, bank, path=""): + """ + A simple tree traversal algorithm that builds the list of banks to remove, + starting from an arbitrary node in the tree. + """ + current_path = bank if not path else f"{path}/{bank}" + bank_paths_to_remove = [current_path] + # as you got here, you'll be removed + + bank_key = _get_bank_redis_key(current_path) + child_banks = redis_server.smembers(bank_key) + if not child_banks: + return bank_paths_to_remove # this bank does not have any child banks so we stop here + for child_bank in child_banks: + # ``smembers`` returns ``bytes`` because the cache client is not + # configured with ``decode_responses=True``; decode here so that + # the recursive path concatenation does not embed ``b'foo'`` in + # the resulting Redis key name. + if isinstance(child_bank, bytes): + child_bank = child_bank.decode() + # Skip the ``.`` placeholder written by ``_build_bank_hier`` -- + # it marks "this bank exists" and is not itself a sub-bank. + if child_bank == ".": + continue + bank_paths_to_remove.extend( + _get_banks_to_remove(redis_server, child_bank, path=current_path) + ) + # go one more level deeper + # and also remove the children of this child bank (if any) + return bank_paths_to_remove # ----------------------------------------------------------------------------- @@ -362,18 +409,25 @@ def store(bank, key, data): Store the data in a Redis key. """ redis_server = _get_redis_server() - bank_key, timestamp_key = _normalize_bank(bank) redis_pipe = redis_server.pipeline() + redis_key = _get_key_redis_key(bank, key) + redis_bank_keys = _get_bank_keys_redis_key(bank) try: - redis_pipe.zadd(_banks_set_key(), {bank_key: 0}) - redis_pipe.hset(bank_key, key, salt.payload.dumps(data)) - redis_pipe.hset(timestamp_key, key, salt.payload.dumps(int(time.time()))) + _build_bank_hier(bank, redis_pipe) + value = salt.payload.dumps(data) + redis_pipe.set(redis_key, value) + log.debug("Setting the value for %s under %s (%s)", key, bank, redis_key) + redis_pipe.sadd(redis_bank_keys, key) + # localfs cache truncates the timestamp to int only. We'll do the same. + redis_pipe.set( + _get_timestamp_key(bank=bank, key=key), + salt.payload.dumps(int(time.time())), + ) + log.debug("Adding %s to %s", key, redis_bank_keys) redis_pipe.execute() except (RedisConnectionError, RedisResponseError) as rerr: - mesg = "Cannot set the Redis cache key {rbank}.{rkey}: {rerr}".format( - rbank=bank_key, - rkey=key, - rerr=rerr, + mesg = "Cannot set the Redis cache key {rkey}: {rerr}".format( + rkey=redis_key, rerr=rerr ) log.error(mesg) raise SaltCacheError(mesg) @@ -384,43 +438,129 @@ def fetch(bank, key): Fetch data from the Redis cache. """ redis_server = _get_redis_server() - bank_key, _ = _normalize_bank(bank) + redis_key = _get_key_redis_key(bank, key) + redis_value = None try: - redis_value = redis_server.hget(bank_key, key) + redis_value = redis_server.get(redis_key) except (RedisConnectionError, RedisResponseError) as rerr: - mesg = "Cannot fetch the Redis cache key {rbank}.{rkey}: {rerr}".format( - rbank=bank_key, - rkey=key, - rerr=rerr, + mesg = "Cannot fetch the Redis cache key {rkey}: {rerr}".format( + rkey=redis_key, rerr=rerr ) log.error(mesg) raise SaltCacheError(mesg) - return {} if redis_value is None else salt.payload.loads(redis_value) + if redis_value is None: + return {} + return salt.payload.loads(redis_value) def flush(bank, key=None): """ Remove the key from the cache bank with all the key content. If no key is specified, remove the entire bank with all keys and sub-banks inside. + This function is using the Redis pipelining for best performance. + However, when removing a whole bank, + in order to re-create the tree, there are a couple of requests made. In total: + + - one for node in the hierarchy sub-tree, starting from the bank node + - one pipelined request to get the keys under all banks in the sub-tree + - one pipeline request to remove the corresponding keys + + This is not quite optimal, as if we need to flush a bank having + a very long list of sub-banks, the number of requests to build the sub-tree may grow quite big. + + An improvement for this would be loading a custom Lua script in the Redis instance of the user + (using the ``register_script`` feature) and call it whenever we flush. + This script would only need to build this sub-tree causing problems. It can be added later and the behaviour + should not change as the user needs to explicitly allow Salt inject scripts in their Redis instance. """ - try: - if key is None: - _flush_bank(bank) - else: - _flush_key(bank, key) - except (RedisConnectionError, RedisResponseError) as rerr: - bank_key, _ = _normalize_bank(bank) - if key is None: - mesg = "Cannot flush Redis cache bank {rbank}: {rerr}".format( - rbank=bank_key, - rerr=rerr, + redis_server = _get_redis_server() + redis_pipe = redis_server.pipeline() + if key is None: + # will remove all bank keys + bank_paths_to_remove = _get_banks_to_remove(redis_server, bank) + # tree traversal to get all bank hierarchy + for bank_to_remove in bank_paths_to_remove: + bank_keys_redis_key = _get_bank_keys_redis_key(bank_to_remove) + # Redis key of the SET that stores the bank keys + redis_pipe.smembers(bank_keys_redis_key) # fetch these keys + log.debug( + "Fetching the keys of the %s bank (%s)", + bank_to_remove, + bank_keys_redis_key, ) - else: - mesg = "Cannot flush Redis cache key {rbank}.{rkey}: {rerr}".format( - rbank=bank_key, - rkey=key, - rerr=rerr, + try: + log.debug("Executing the pipe...") + subtree_keys = ( + redis_pipe.execute() + ) # here are the keys under these banks to be removed + # this retunrs a list of sets, e.g.: + # [set([]), set(['my-key']), set(['my-other-key', 'yet-another-key'])] + # one set corresponding to a bank + except (RedisConnectionError, RedisResponseError) as rerr: + mesg = "Cannot retrieve the keys under these cache banks: {rbanks}: {rerr}".format( + rbanks=", ".join(bank_paths_to_remove), rerr=rerr ) + log.error(mesg) + raise SaltCacheError(mesg) + total_banks = len(bank_paths_to_remove) + # bank_paths_to_remove and subtree_keys have the same length (see above) + for index in range(total_banks): + bank_keys = subtree_keys[index] # all the keys under this bank + bank_path = bank_paths_to_remove[index] + for key in bank_keys: + redis_key = _get_key_redis_key(bank_path, key) + redis_pipe.delete(redis_key) # kill 'em all! + timestamp_key = _get_timestamp_key(bank=bank_path, key=key.decode()) + redis_pipe.delete(timestamp_key) + log.debug( + "Removing the key %s under the %s bank (%s)", + key, + bank_path, + redis_key, + ) + bank_keys_redis_key = _get_bank_keys_redis_key(bank_path) + redis_pipe.delete(bank_keys_redis_key) + log.debug( + "Removing the bank-keys key for the %s bank (%s)", + bank_path, + bank_keys_redis_key, + ) + # delete the Redis key where are stored + # the list of keys under this bank + bank_key = _get_bank_redis_key(bank_path) + redis_pipe.delete(bank_key) + log.debug("Removing the %s bank (%s)", bank_path, bank_key) + # delete the bank key itself + # Drop this bank's own reference from its parent's index sets, + # otherwise ``list_(parent)`` would still report the bank as + # present after a full flush. ``_build_bank_hier`` writes into + # both the parent's $BANK_ and $BANKEYS_ sets (see that + # function's docstring); both must be cleaned up here. + if "/" in bank: + parent_path, segment = bank.rsplit("/", 1) + redis_pipe.srem(_get_bank_redis_key(parent_path), segment) + redis_pipe.srem(_get_bank_keys_redis_key(parent_path), segment) + else: + redis_key = _get_key_redis_key(bank, key) + redis_pipe.delete(redis_key) # delete the key cached + timestamp_key = _get_timestamp_key(bank=bank, key=key) + redis_pipe.delete(timestamp_key) + log.debug("Removing the key %s under the %s bank (%s)", key, bank, redis_key) + bank_keys_redis_key = _get_bank_keys_redis_key(bank) + redis_pipe.srem(bank_keys_redis_key, key) + log.debug( + "De-referencing the key %s from the bank-keys of the %s bank (%s)", + key, + bank, + bank_keys_redis_key, + ) + # but also its reference from $BANKEYS list + try: + redis_pipe.execute() # Fluuuush + except (RedisConnectionError, RedisResponseError) as rerr: + mesg = "Cannot flush the Redis cache bank {rbank}: {rerr}".format( + rbank=bank, rerr=rerr + ) log.error(mesg) raise SaltCacheError(mesg) return True @@ -431,59 +571,38 @@ def list_(bank): Lists entries stored in the specified bank. """ redis_server = _get_redis_server() - bank_key, _ = _normalize_bank(bank) + bank_redis_key = _get_bank_keys_redis_key(bank) try: - subbanks = _get_subbanks(redis_server, bank_key) + banks = redis_server.smembers(bank_redis_key) except (RedisConnectionError, RedisResponseError) as rerr: - mesg = "Cannot list the Redis cache key subbanks {rbank}: {rerr}".format( - rbank=bank_key, - rerr=rerr, + mesg = "Cannot list the Redis cache key {rkey}: {rerr}".format( + rkey=bank_redis_key, rerr=rerr ) log.error(mesg) raise SaltCacheError(mesg) - # Unfortunately we get all subsub+ banks from the _get_subbanks() function call. A simple method - # of filter just the direct descendents is to count the number of slashes. If there is +1 slashes - # it is a direct descendent. Also strip out the full path and extra gunk for the final listing. - slashcount = bank_key.count("/") + 1 - listing = [ - sub.removeprefix(bank_key).rstrip("/") - for sub in subbanks - if sub.count("/") == slashcount - ] - try: - listing.extend(_decode(redis_server.hkeys(bank_key))) - except (RedisConnectionError, RedisResponseError) as rerr: - mesg = "Cannot list the Redis cache key {rbank}: {rerr}".format( - rbank=bank_key, - rerr=rerr, - ) - log.error(mesg) - raise SaltCacheError(mesg) - return listing + if not banks: + return [] + return [bank.decode() for bank in banks if bank != b"."] -def contains(bank, key=None): +def contains(bank, key): """ Checks if the specified bank contains the specified key. """ redis_server = _get_redis_server() - bank_key, _ = _normalize_bank(bank) + bank_redis_key = _get_bank_keys_redis_key(bank) try: if key is None: - return bool(redis_server.exists(bank_key)) - return bool(redis_server.hexists(bank_key, key)) - except (RedisConnectionError, RedisResponseError) as rerr: - if key is None: - mesg = "Cannot check contains of Redis cache bank {rbank}: {rerr}".format( - rbank=bank_key, - rerr=rerr, + return ( + salt.utils.stringutils.to_str(redis_server.type(bank_redis_key)) + != "none" ) else: - mesg = "Cannot check contains of Redis cache key {rbank}.{rkey}: {rerr}".format( - rbank=bank_key, - rkey=key, - rerr=rerr, - ) + return redis_server.sismember(bank_redis_key, key) + except (RedisConnectionError, RedisResponseError) as rerr: + mesg = "Cannot retrieve the Redis cache key {rkey}: {rerr}".format( + rkey=bank_redis_key, rerr=rerr + ) log.error(mesg) raise SaltCacheError(mesg) @@ -494,15 +613,8 @@ def updated(bank, key): None if key is not found. """ redis_server = _get_redis_server() - _, timestamp_key = _normalize_bank(bank) - try: - cache_time = redis_server.hget(timestamp_key, key) - except (RedisConnectionError, RedisResponseError) as rerr: - mesg = "Cannot get timestamp of Redis cache key {rstamp}.{rkey}: {rerr}".format( - rstamp=timestamp_key, - rkey=key, - rerr=rerr, - ) - log.error(mesg) - raise SaltCacheError(mesg) - return None if cache_time is None else salt.payload.loads(cache_time) + timestamp_key = _get_timestamp_key(bank=bank, key=key) + value = redis_server.get(timestamp_key) + if value is not None: + value = salt.payload.loads(value) + return value diff --git a/salt/channel/client.py b/salt/channel/client.py index 804fa4cd54d0..963cfbcfa35d 100644 --- a/salt/channel/client.py +++ b/salt/channel/client.py @@ -20,10 +20,11 @@ import salt.transport.frame import salt.utils.event import salt.utils.files +import salt.utils.minions import salt.utils.stringutils -import salt.utils.tracing import salt.utils.verify -from salt.utils.asynchronous import SyncWrapper, aioloop +import salt.utils.versions +from salt.utils.asynchronous import SyncWrapper log = logging.getLogger(__name__) @@ -47,6 +48,36 @@ def factory(opts, **kwargs): ) +class PushChannel: + """ + Factory class to create Sync channel for push side of push/pull IPC + """ + + @staticmethod + def factory(opts, **kwargs): + return SyncWrapper( + AsyncPushChannel.factory, + (opts,), + kwargs, + loop_kwarg="io_loop", + ) + + +class PullChannel: + """ + Factory class to create Sync channel for pull side of push/pull IPC + """ + + @staticmethod + def factory(opts, **kwargs): + return SyncWrapper( + AsyncPullChannel.factory, + (opts,), + kwargs, + loop_kwarg="io_loop", + ) + + class AsyncReqChannel: """ Factory class to create a asynchronous communication channels to the @@ -156,7 +187,6 @@ def _package_load(self, load, nonce=None, session_crypticle=None): load["ts"] = int(time.time()) load["tok"] = self.auth.gen_token(b"salt") load["id"] = self.opts["id"] - salt.utils.tracing.inject(load) if self.opts.get("minion_sign_messages"): # ReqServerChannel strips ``nonce`` and ``tok`` from the # load before it reaches AESFuncs._return for verification @@ -182,8 +212,6 @@ def _package_load(self, load, nonce=None, session_crypticle=None): if session_crypticle is None: session_crypticle = self.auth.session_crypticle load = session_crypticle.dumps(load) - elif isinstance(load, dict): - salt.utils.tracing.inject(load) ret = { "enc": self.crypt, @@ -196,11 +224,12 @@ def _package_load(self, load, nonce=None, session_crypticle=None): ret["sig_algo"] = self.opts["signing_algorithm"] return ret - async def _send_with_retry(self, load, tries, timeout): + @tornado.gen.coroutine + def _send_with_retry(self, load, tries, timeout): _try = 1 while True: try: - ret = await self.transport.send( + ret = yield self.transport.send( load, timeout=timeout, ) @@ -212,9 +241,10 @@ async def _send_with_retry(self, load, tries, timeout): else: _try += 1 continue - return ret + raise tornado.gen.Return(ret) - async def crypted_transfer_decode_dictentry( + @tornado.gen.coroutine + def crypted_transfer_decode_dictentry( self, load, dictkey=None, @@ -226,23 +256,23 @@ async def crypted_transfer_decode_dictentry( if tries is None: tries = self.tries if not self.auth.authenticated: - await self.auth.authenticate() + yield self.auth.authenticate() # Serialize concurrent transfers on this channel to keep each # (send, decrypt-reply) pair atomic w.r.t. any other coroutine # driving this same channel. See issue #69753. - async with self._req_lock: + with (yield self._req_lock.acquire()): nonce = uuid.uuid4().hex - ret = await self._send_with_retry( + ret = yield self._send_with_retry( self._package_load(load, nonce), tries, timeout, ) key = self.auth.get_keys() - if not isinstance(ret, dict) or "key" not in ret: + if "key" not in ret: # Reauth in the case our key is deleted on the master side. - await self.auth.authenticate() - ret = await self._send_with_retry( + yield self.auth.authenticate() + ret = yield self._send_with_retry( self._package_load(load, nonce), tries, timeout, @@ -270,7 +300,7 @@ async def crypted_transfer_decode_dictentry( # Validate the master's signature. if not self.verify_signature(signed_msg["data"], signed_msg["sig"]): # Try to reauth on error - await self.auth.authenticate() + yield self.auth.authenticate() if not self.verify_signature(signed_msg["data"], signed_msg["sig"]): raise salt.crypt.AuthenticationError( "Pillar payload signature failed to validate." @@ -284,14 +314,15 @@ async def crypted_transfer_decode_dictentry( # Validate the nonce. if data["nonce"] != nonce: raise salt.crypt.AuthenticationError("Pillar nonce verification failed.") - return data["pillar"] + raise tornado.gen.Return(data["pillar"]) def verify_signature(self, data, sig): - return salt.crypt.PublicKey.from_file(self.master_pubkey_path).verify( + return salt.crypt.PublicKey(self.master_pubkey_path).verify( data, sig, self.opts["signing_algorithm"] ) - async def _crypted_transfer(self, load, timeout, raw=False): + @tornado.gen.coroutine + def _crypted_transfer(self, load, timeout, raw=False): """ Send a load across the wire, with encryption @@ -305,14 +336,15 @@ async def _crypted_transfer(self, load, timeout, raw=False): :param int timeout: The number of seconds on a response before failing """ - async def _do_transfer(): + @tornado.gen.coroutine + def _do_transfer(): # Pin the session_crypticle reference so a concurrent re-auth # cannot swap the key between the ``dumps`` on the send path # and the ``loads`` on the receive path. See issue #69753. session_crypticle = self.auth.session_crypticle # Yield control to the caller. When send() completes, resume by populating data with the Future.result nonce = uuid.uuid4().hex - data = await self.transport.send( + data = yield self.transport.send( self._package_load(load, nonce, session_crypticle=session_crypticle), timeout=timeout, ) @@ -324,38 +356,41 @@ async def _do_transfer(): data = session_crypticle.loads(data, raw, nonce=nonce) if not raw or self.ttype == "tcp": # XXX Why is this needed for tcp data = salt.transport.frame.decode_embedded_strs(data) - return data + raise tornado.gen.Return(data) if not self.auth.authenticated: # Return control back to the caller, resume when authentication succeeds - await self.auth.authenticate() + yield self.auth.authenticate() # Serialize concurrent transfers on this channel to keep each # (send, decrypt-reply) pair atomic w.r.t. any other coroutine # driving this same channel. See issue #69753. - async with self._req_lock: + with (yield self._req_lock.acquire()): try: # We did not get data back the first time. Retry. - ret = await _do_transfer() + ret = yield _do_transfer() except salt.crypt.AuthenticationError: # If auth error, return control back to the caller, continue when authentication succeeds - await self.auth.authenticate() - ret = await _do_transfer() - return ret + yield self.auth.authenticate() + ret = yield _do_transfer() + raise tornado.gen.Return(ret) - async def _uncrypted_transfer(self, load, timeout): + @tornado.gen.coroutine + def _uncrypted_transfer(self, load, timeout): """ Send a load across the wire in cleartext :param dict load: A load to send across the wire :param int timeout: The number of seconds on a response before failing """ - ret = await self.transport.send(self._package_load(load), timeout=timeout) - return ret + ret = yield self.transport.send(self._package_load(load), timeout=timeout) + + raise tornado.gen.Return(ret) async def connect(self): await self.transport.connect() - async def send(self, load, tries=None, timeout=None, raw=False): + @tornado.gen.coroutine + def send(self, load, tries=None, timeout=None, raw=False): """ Send a request, return a future which will complete when we send the message @@ -363,39 +398,28 @@ async def send(self, load, tries=None, timeout=None, raw=False): :param int tries: The number of times to make before failure :param int timeout: The number of seconds on a response before failing """ - cmd = load.get("cmd") if isinstance(load, dict) else None - span_name = f"salt.req.send.{cmd}" if cmd else "salt.req.send" - with salt.utils.tracing.start_span( - span_name, - attributes={ - "salt.req.cmd": cmd or "", - "salt.transport": self.transport.ttype, - }, - ): - if timeout is None: - timeout = self.timeout - if tries is None: - tries = self.tries - _try = 1 - while True: - try: - if self.crypt == "clear": - log.trace("ReqChannel send clear load=%r", load) - ret = await self._uncrypted_transfer(load, timeout=timeout) - else: - log.trace("ReqChannel send crypt load=%r", load) - ret = await self._crypted_transfer( - load, timeout=timeout, raw=raw - ) - break - except Exception as exc: # pylint: disable=broad-except - log.trace("Failed to send msg %r", exc) - if _try >= tries: - raise - else: - _try += 1 - continue - return ret + if timeout is None: + timeout = self.timeout + if tries is None: + tries = self.tries + _try = 1 + while True: + try: + if self.crypt == "clear": + log.trace("ReqChannel send clear load=%r", load) + ret = yield self._uncrypted_transfer(load, timeout=timeout) + else: + log.trace("ReqChannel send crypt load=%r", load) + ret = yield self._crypted_transfer(load, timeout=timeout, raw=raw) + break + except Exception as exc: # pylint: disable=broad-except + log.trace("Failed to send msg %r", exc) + if _try >= tries: + raise + else: + _try += 1 + continue + raise tornado.gen.Return(ret) def close(self): """ @@ -466,7 +490,7 @@ def factory(cls, opts, **kwargs): def __init__(self, opts, transport, auth, io_loop=None): self.opts = opts - self.io_loop = aioloop(io_loop) + self.io_loop = io_loop self.auth = auth try: # This loads or generates the minion's public key. @@ -485,13 +509,14 @@ def __init__(self, opts, transport, auth, io_loop=None): def crypt(self): return "aes" if self.auth else "clear" - async def connect(self): + @tornado.gen.coroutine + def connect(self): """ Return a future which completes when connected to the remote publisher """ try: if not self.auth.authenticated: - await self.auth.authenticate() + yield self.auth.authenticate() # if this is changed from the default, we assume it was intentional if int(self.opts.get("publish_port", 4506)) != 4506: publish_port = self.opts.get("publish_port") @@ -500,7 +525,7 @@ async def connect(self): publish_port = self.auth.creds["publish_port"] # TODO: The zeromq transport does not use connect_callback and # disconnect_callback. - await self.transport.connect( + yield self.transport.connect( publish_port, self.connect_callback, self.disconnect_callback ) # TODO: better exception handling... @@ -547,7 +572,8 @@ def _package_load(self, load): "version": 3, } - async def send_id(self, tok, force_auth): + @tornado.gen.coroutine + def send_id(self, tok, force_auth): """ Send the minion id to the master so that the master may better track the connection state of the minion. @@ -556,11 +582,13 @@ async def send_id(self, tok, force_auth): """ load = {"id": self.opts["id"], "tok": tok} - async def _do_transfer(): + @tornado.gen.coroutine + def _do_transfer(): msg = self._package_load(self.auth.crypticle.dumps(load)) package = salt.transport.frame.frame_msg(msg, header=None) - await self.transport.send(package) - return True + yield self.transport.send(package) + + raise tornado.gen.Return(True) if force_auth or not self.auth.authenticated: count = 0 @@ -569,24 +597,27 @@ async def _do_transfer(): or self.opts["tcp_authentication_retries"] < 0 ): try: - await self.auth.authenticate() + yield self.auth.authenticate() break except salt.exceptions.SaltClientError as exc: log.debug(exc) count += 1 try: - return await _do_transfer() + ret = yield _do_transfer() + raise tornado.gen.Return(ret) except salt.crypt.AuthenticationError: - await self.auth.authenticate() - return await _do_transfer() + yield self.auth.authenticate() + ret = yield _do_transfer() + raise tornado.gen.Return(ret) - async def connect_callback(self, result): + @tornado.gen.coroutine + def connect_callback(self, result): if self._closing: return try: # Force re-auth on reconnect since the master # may have been restarted - await self.send_id(self.token, self._reconnected) + yield self.send_id(self.token, self._reconnected) self.connected = True if self.event: self.event.fire_event( @@ -615,7 +646,7 @@ async def connect_callback(self, result): } with AsyncReqChannel.factory(self.opts) as channel: try: - await channel.send(load, timeout=60) + yield channel.send(load, timeout=60) except salt.exceptions.SaltReqTimeoutError: log.info( "fire_master failed: master could not be contacted. Request timed" @@ -654,7 +685,8 @@ def _verify_master_signature(self, payload): "Message signature failed to validate." ) - async def _decode_payload(self, payload): + @tornado.gen.coroutine + def _decode_payload(self, payload): # we need to decrypt it log.trace("Decoding payload: %s", payload) reauth = False @@ -666,30 +698,70 @@ async def _decode_payload(self, payload): reauth = True if reauth: try: - await self.auth.authenticate() + yield self.auth.authenticate() payload["load"] = self.auth.crypticle.loads(payload["load"]) except salt.crypt.AuthenticationError: log.error( "Payload decryption failed even after re-authenticating with master %s", self.opts["master_ip"], ) - return None + raise tornado.gen.Return(None) if isinstance(payload["load"], (bytes, str)): log.error( "Discarding load from master %s because it could not be decrypted", self.opts["master_ip"], ) - return None - return payload + raise tornado.gen.Return(None) + raise tornado.gen.Return(payload) def __enter__(self): return self def __exit__(self, *args): - self.io_loop.call_soon(self.close) + self.io_loop.spawn_callback(self.close) async def __aenter__(self): return self async def __aexit__(self, *_): await self.close() + + +class AsyncPushChannel: + """ + Factory class to create IPC Push channels + """ + + @staticmethod + def factory(opts, **kwargs): + """ + If we have additional IPC transports other than UxD and TCP, add them here + """ + # FIXME for now, just UXD + # Obviously, this makes the factory approach pointless, but we'll extend later + salt.utils.versions.warn_until( + 3009, + "AsyncPushChannel is deprecated. Use zeromq or tcp transport instead.", + ) + import salt.transport.ipc + + return salt.transport.ipc.IPCMessageClient(opts, **kwargs) + + +class AsyncPullChannel: + """ + Factory class to create IPC pull channels + """ + + @staticmethod + def factory(opts, **kwargs): + """ + If we have additional IPC transports other than UXD and TCP, add them here + """ + salt.utils.versions.warn_until( + 3009, + "AsyncPullChannel is deprecated. Use zeromq or tcp transport instead.", + ) + import salt.transport.ipc + + return salt.transport.ipc.IPCMessageServer(opts, **kwargs) diff --git a/salt/channel/server.py b/salt/channel/server.py index f42e85210de4..1ec46d63d78e 100644 --- a/salt/channel/server.py +++ b/salt/channel/server.py @@ -5,125 +5,34 @@ """ import asyncio +import binascii import collections -import errno import hashlib -import hmac import logging import os import pathlib -import random -import string +import shutil import time -import zlib -import tornado.ioloop +import tornado.gen -import salt.cache -import salt.cluster.consensus.rpc import salt.crypt import salt.master import salt.payload -import salt.transport import salt.transport.frame -import salt.transport.tcp import salt.utils.channel import salt.utils.event -import salt.utils.metrics +import salt.utils.files import salt.utils.minions import salt.utils.platform import salt.utils.stringutils -import salt.utils.tracing -from salt.exceptions import SaltDeserializationError +import salt.utils.verify +from salt.exceptions import SaltDeserializationError, UnsupportedAlgorithm from salt.utils.cache import CacheCli log = logging.getLogger(__name__) -def _get_crypticle(opts, key_string, key_size=192, serial=0): - """ - Get appropriate Crypticle class based on configuration. - - Returns TLSAwareCrypticle if TLS optimization is enabled, otherwise - returns standard Crypticle. - - Args: - opts: Configuration dictionary - key_string: AES key string - key_size: Key size in bits (default: 192) - serial: Serial number (default: 0) - - Returns: - Crypticle or TLSAwareCrypticle instance - """ - if opts.get("disable_aes_with_tls", False): - return salt.crypt.TLSAwareCrypticle(opts, key_string, key_size, serial) - else: - return salt.crypt.Crypticle(opts, key_string, key_size, serial) - - -def _cluster_is_ready(opts): - """ - Return ``True`` if this master may serve minion/CLI requests. - - For non-cluster masters this is always ``True``. For cluster members it - returns ``True`` only after the Raft ``MembershipStateMachine`` has - committed a CONFIG entry listing this node as a voter and - ``SMaster.secrets["cluster_ready"]["event"]`` has been set. - """ - if not opts.get("cluster_id"): - return True - import salt.master # pylint: disable=import-outside-toplevel - - entry = salt.master.SMaster.secrets.get("cluster_ready") - if entry is None: - return False - return entry["event"].is_set() - - -def _transport_has_builtin_router(transport): - """ - Return ``True`` when *transport*'s ``pre_fork`` already starts a process - that accepts external connections and dispatches them to pool workers. - - ZeroMQ's ``pre_fork`` adds ``zmq_device_pooled`` for that purpose, so - :class:`PoolRoutingChannel` does not need to spawn its own router. - Other transports (TCP, WebSockets) bind the external socket but rely on - a separate process to serve it — :class:`PoolRoutingChannel.pre_fork` - spawns ``_run_pool_router`` for that case. - """ - module = getattr(transport.__class__, "__module__", "") or "" - return module.startswith("salt.transport.zeromq") - - -def cluster_pub_matches_fingerprint(opts, cluster_pub): - """ - Verify a received cluster public key against a pinned fingerprint. - - When ``opts["cluster_pub_fingerprint"]`` is set, the joining master - requires the ``cluster_pub`` it receives in a - ``cluster/peer/discover-reply`` to hash to that value (SHA-256 hex - digest of the PEM bytes, case-insensitive). When the option is unset - this function returns ``True`` unconditionally, which is the - trust-on-first-contact behavior documented for deployments that share - ``cluster_pki_dir`` over a filesystem. - - ``cluster_pub`` may be a ``str`` (PEM text) or ``bytes``. - - Returns ``True`` on match (or when no fingerprint is pinned) and - ``False`` on mismatch. - """ - pinned = opts.get("cluster_pub_fingerprint") - if not pinned: - return True - if isinstance(cluster_pub, str): - pub_bytes = cluster_pub.encode() - else: - pub_bytes = cluster_pub - digest = hashlib.sha256(pub_bytes).hexdigest() - return hmac.compare_digest(digest.lower(), str(pinned).lower()) - - class ReqServerChannel: """ ReqServerChannel handles request/reply messages from ReqChannels. @@ -131,51 +40,9 @@ class ReqServerChannel: @classmethod def factory(cls, opts, **kwargs): - """ - Return the appropriate server channel for the configured transport. - - Two mutually exclusive code paths exist, selected here at startup: - - 1. **Pooled** (``worker_pools_enabled=True``, the default): - Returns a :class:`PoolRoutingChannel` that sits in front of the - external transport. Incoming requests are routed to per-pool IPC - RequestServers and dispatched to MWorkers. Clear-text ``_auth`` - uses that IPC path when connected; before IPC clients exist it is - handled inline (same semantics as the non-pooled channel). - - 2. **Non-pooled** (``worker_pools_enabled=False``, legacy): - Returns a plain :class:`ReqServerChannel` whose - :meth:`handle_message` intercepts ``_auth`` inline (before the - payload ever reaches a worker) and handles it directly via - :meth:`_auth`. All other commands are forwarded to the single - worker pool via ``payload_handler``. - - These paths are mutually exclusive at runtime; ``_auth`` is not run - twice for a single request. - """ if "master_uri" not in opts and "master_uri" in kwargs: opts["master_uri"] = kwargs["master_uri"] - - # Handle worker pool routing if enabled. - # PoolRoutingChannel is now the default implementation when - # worker_pools_enabled=True. We only wrap if we are NOT already a - # pool-specific server (to avoid recursion). - if opts.get("worker_pools_enabled", True) and not opts.get("pool_name"): - from salt.config.worker_pools import get_worker_pools_config - - worker_pools = get_worker_pools_config(opts) - if worker_pools: - # Wrap the standard transport in the routing channel - external_opts = opts.copy() - external_opts["worker_pools_enabled"] = False - import salt.transport.base - - transport = salt.transport.base.request_server(external_opts, **kwargs) - return PoolRoutingChannel(opts, transport, worker_pools) - - import salt.transport.base - - transport = salt.transport.base.request_server(opts, **kwargs) + transport = salt.transport.request_server(opts, **kwargs) return cls(opts, transport) @classmethod @@ -191,7 +58,9 @@ def compare_keys(cls, key1, key2): def __init__(self, opts, transport): self.opts = opts self.transport = transport - self.cache = salt.cache.Cache(opts, driver=self.opts["keys.cache_driver"]) + # The event and master_key attributes will be populated after fork. + # self.event = None + # self.master_key = None self.event = salt.utils.event.get_master_event( self.opts, self.opts["sock_dir"], listen=False ) @@ -239,19 +108,15 @@ def session_key(self, minion): ) return self.sessions[minion][1] - def pre_fork(self, process_manager, *args, **kwargs): + def pre_fork(self, process_manager): """ Do anything necessary pre-fork. Since this is on the master side this will primarily be bind and listen (or the equivalent for your network library) """ - import salt.master - - if "secrets" not in kwargs: - kwargs["secrets"] = salt.master.SMaster.secrets if hasattr(self.transport, "pre_fork"): - self.transport.pre_fork(process_manager, *args, **kwargs) + self.transport.pre_fork(process_manager) - def post_fork(self, payload_handler, io_loop, **kwargs): + def post_fork(self, payload_handler, io_loop): """ Do anything you need post-fork. This should handle all incoming payloads and call payload_handler. You will also be passed io_loop, for all of your @@ -266,7 +131,7 @@ def post_fork(self, payload_handler, io_loop, **kwargs): ) os.nice(self.opts["pub_server_niceness"]) self.io_loop = io_loop - self.crypticle = _get_crypticle(self.opts, self.aes_key) + self.crypticle = salt.crypt.Crypticle(self.opts, self.aes_key) # other things needed for _auth # Create the event manager self.event = salt.utils.event.get_master_event( @@ -283,38 +148,17 @@ def post_fork(self, payload_handler, io_loop, **kwargs): self.master_key = salt.crypt.MasterKeys(self.opts) self.payload_handler = payload_handler if hasattr(self.transport, "post_fork"): - self.transport.post_fork(self.handle_message, io_loop, **kwargs) - - async def handle_message(self, payload): - """ - Handle an incoming request payload (non-pooled / legacy path only). - - This method is only active when ``worker_pools_enabled=False``. In - that configuration this channel owns the external transport socket and - processes every request inline. - - ``_auth`` handling - ------------------ - When the payload command is ``_auth`` this method calls - :meth:`_auth` directly and returns the result without forwarding the - payload to any worker. This is the **only** place ``_auth`` executes - in the non-pooled path. + self.transport.post_fork(self.handle_message, io_loop) - All other commands are forwarded to a worker via ``payload_handler`` - (i.e. :meth:`~salt.master.MWorker._handle_payload`). - - See :meth:`factory` for the full description of the two mutually - exclusive request paths and why ``_auth`` is always executed exactly - once. - """ - nonce = None + @tornado.gen.coroutine + def handle_message(self, payload): if ( not isinstance(payload, dict) or "enc" not in payload or "load" not in payload ): - log.warning("bad load received on socket") - return "bad load" + log.warn("bad load received on socket") + raise tornado.gen.Return("bad load") try: version = int(payload.get("version", 0)) except ValueError: @@ -323,19 +167,13 @@ async def handle_message(self, payload): # Enforce minimum authentication protocol version to prevent downgrade attacks minimum_version = self.opts.get("minimum_auth_version", 0) if minimum_version > 0 and version < minimum_version: - load = payload.get("load") - if isinstance(load, dict): - minion_id = load.get("id", "unknown minion") - else: - minion_id = "unknown minion" log.warning( - "Rejected authentication attempt from minion '%s' using " - "protocol version %d (minimum required: %d)", - minion_id, + "Rejected authentication attempt using protocol version %d " + "(minimum required: %d)", version, minimum_version, ) - return "bad load" + raise tornado.gen.Return("bad load") try: payload = self._decode_payload(payload, version) @@ -350,29 +188,37 @@ async def handle_message(self, payload): ) else: log.error("Bad load from minion: %s: %s", exc_type, exc) - return "bad load" + raise tornado.gen.Return("bad load") # TODO helper functions to normalize payload? if not isinstance(payload, dict) or not isinstance(payload.get("load"), dict): log.error( - "payload and load must be a dict. Payload was: %s", + "payload and load must be a dict. Payload was: %s and load was %s", payload, + payload.get("load"), ) - return "payload and load must be a dict" + raise tornado.gen.Return("payload and load must be a dict") try: id_ = payload["load"].get("id", "") if "\0" in id_: log.error("Payload contains an id with a null byte: %s", payload) - return "bad load: id contains a null byte" + raise tornado.gen.Return("bad load: id contains a null byte") except TypeError: log.error("Payload contains non-string id: %s", payload) - return f"bad load: id {id_} is not a string" + raise tornado.gen.Return(f"bad load: id {id_} is not a string") sign_messages = False if version > 1: sign_messages = True + # intercept the "_auth" commands, since the main daemon shouldn't know + # anything about our key auth + if payload["enc"] == "clear" and payload.get("load", {}).get("cmd") == "_auth": + raise tornado.gen.Return( + self._auth(payload["load"], sign_messages, version) + ) + if payload["enc"] == "aes": nonce = None if version > 1: @@ -389,7 +235,7 @@ async def handle_message(self, payload): ttl, self.opts["request_server_ttl"], ) - return "bad load" + raise tornado.gen.Return("bad load") if payload["id"] != payload["load"]["id"]: log.warning( @@ -397,77 +243,44 @@ async def handle_message(self, payload): payload["load"]["id"], payload["id"], ) - return "bad load" + raise tornado.gen.Return("bad load") if not salt.utils.verify.valid_id(self.opts, payload["load"]["id"]): log.warning( "Request contains invalid minion id '%s'", payload["load"]["id"] ) - return "bad load" + raise tornado.gen.Return("bad load") if not self.validate_token(payload, required=True): - return "bad load" - # The token won't always be present in the payload for and + raise tornado.gen.Return("bad load") + # The token won't always be present in the payload for v2 and # below, but if it is we always wanto validate it. elif not self.validate_token(payload, required=False): - return "bad load" + raise tornado.gen.Return("bad load") # TODO: test try: - # intercept the "_auth" commands, since the main daemon shouldn't know - # anything about our key auth - if ( - payload["enc"] == "clear" - and payload.get("load", {}).get("cmd") == "_auth" - ): - # Store time at the beginning of serving _auth call - # to calculate duration of the call with master_stats - start = time.time() - ret = self._auth(payload["load"], sign_messages, version) - if self.opts.get("master_stats", False): - await self.payload_handler({"cmd": "_auth", "_start": start}) - return ret - - # Block non-_auth requests until this node is a committed Raft voter. - if not _cluster_is_ready(self.opts): - log.debug( - "Cluster not ready yet — deferring request from %s", - payload.get("load", {}).get("id", "unknown"), - ) - return {"enc": "clear", "load": {"ret": False, "cluster_retry": True}} - # Take the payload_handler function that was registered when we created the channel # and call it, returning control to the caller until it completes + ret, req_opts = yield self.payload_handler(payload) + except Exception as e: # pylint: disable=broad-except + # always attempt to return an error to the minion + log.error("Some exception handling a payload from minion", exc_info=True) + raise tornado.gen.Return("Some exception handling minion payload") - load = payload.get("load") if isinstance(payload, dict) else None - trace_ctx = ( - salt.utils.tracing.extract(load) if isinstance(load, dict) else None - ) - cmd = load.get("cmd") if isinstance(load, dict) else None - span_name = f"salt.req.recv.{cmd}" if cmd else "salt.req.recv" - with salt.utils.tracing.start_span( - span_name, - kind=salt.utils.tracing.SpanKind.SERVER, - attributes={ - "salt.req.cmd": cmd or "", - "salt.req.minion_id": ( - payload.get("id", "") if isinstance(payload, dict) else "" - ), - }, - context=trace_ctx, - ): - ret, req_opts = await self.payload_handler(payload) - - req_fun = req_opts.get("fun", "send") - if req_fun == "send_clear": - return ret - elif req_fun == "send": - if version > 2: - return _get_crypticle(self.opts, self.session_key(id_)).dumps( + req_fun = req_opts.get("fun", "send") + if req_fun == "send_clear": + raise tornado.gen.Return(ret) + elif req_fun == "send": + if version > 2: + raise tornado.gen.Return( + salt.crypt.Crypticle(self.opts, self.session_key(id_)).dumps( ret, nonce ) - else: - return self.crypticle.dumps(ret, nonce) - elif req_fun == "send_private": - return self._encrypt_private( + ) + else: + raise tornado.gen.Return(self.crypticle.dumps(ret, nonce)) + elif req_fun == "send_private": + raise tornado.gen.Return( + self._encrypt_private( ret, req_opts["key"], req_opts["tgt"], @@ -475,15 +288,11 @@ async def handle_message(self, payload): sign_messages, payload.get("enc_algo", salt.crypt.OAEP_SHA1), payload.get("sig_algo", salt.crypt.PKCS1v15_SHA1), - ) - log.error("Unknown req_fun %s", req_fun) - # always attempt to return an error to the minion - return "Server-side exception handling payload" - - except Exception as e: # pylint: disable=broad-except - # always attempt to return an error to the minion - log.error("Some exception handling a payload from minion", exc_info=True) - return "Some exception handling minion payload" + ), + ) + log.error("Unknown req_fun %s", req_fun) + # always attempt to return an error to the minion + raise tornado.gen.Return("Server-side exception handling payload") def _encrypt_private( self, @@ -499,26 +308,20 @@ def _encrypt_private( The server equivalent of ReqChannel.crypted_transfer_decode_dictentry """ # encrypt with a specific AES key + if self.master_key.cluster_key: + pubfn = os.path.join(self.opts["cluster_pki_dir"], "minions", target) + else: + pubfn = os.path.join(self.opts["pki_dir"], "minions", target) + key = salt.crypt.Crypticle.generate_key_string() + pcrypt = salt.crypt.Crypticle(self.opts, key) try: - key = salt.crypt.Crypticle.generate_key_string() - pcrypt = _get_crypticle(self.opts, key) - pub = self.cache.fetch("keys", target) - if not isinstance(pub, dict) or "pub" not in pub: - log.error( - "No pub key found for target %s, its pub key was likely deleted mid-request.", - target, - ) - return self.crypticle.dumps({}) - - pub = salt.crypt.PublicKey.from_str(pub["pub"]) - except Exception as exc: # pylint: disable=broad-except - log.error( - 'Corrupt or missing public key "%s": %s', - target, - exc, - exc_info_on_loglevel=logging.DEBUG, - ) - return self.crypticle.dumps({}) + pub = salt.crypt.PublicKey(pubfn) + except (ValueError, IndexError, TypeError): + log.error("Bad load from minion") + return {"error": "bad load"} + except OSError: + log.error("AES key not found") + return {"error": "AES key not found"} pret = {} pret["key"] = pub.encrypt(key, encryption_algorithm) if ret is False: @@ -531,13 +334,32 @@ def _encrypt_private( ) signed_msg = { "data": tosign, - "sig": self.master_key.sign(tosign, algorithm=signing_algorithm), + "sig": salt.crypt.PrivateKey(self.master_key.rsa_path).sign( + tosign, algorithm=signing_algorithm + ), } pret[dictkey] = pcrypt.dumps(signed_msg) else: pret[dictkey] = pcrypt.dumps(ret) return pret + def _clear_signed(self, load, algorithm): + try: + tosign = salt.payload.dumps(load) + return { + "enc": "clear", + "load": tosign, + "sig": salt.crypt.PrivateKey(self.master_key.rsa_path).sign( + tosign, algorithm=algorithm + ), + } + except UnsupportedAlgorithm: + log.info( + "Minion tried to authenticate with unsupported signing algorithm: %s", + algorithm, + ) + return {"enc": "clear", "load": {"ret": "bad sig algo"}} + def _update_aes(self): """ Check to see if a fresh AES key is available and update the components @@ -553,7 +375,7 @@ def _update_aes(self): salt.master.SMaster.secrets[key]["secret"].value != self.crypticle.key_string ): - self.crypticle = _get_crypticle( + self.crypticle = salt.crypt.Crypticle( self.opts, salt.master.SMaster.secrets[key]["secret"].value ) return True @@ -564,7 +386,7 @@ def _decode_payload(self, payload, version): if payload["enc"] == "aes": if version > 2: if salt.utils.verify.valid_id(self.opts, payload["id"]): - payload["load"] = _get_crypticle( + payload["load"] = salt.crypt.Crypticle( self.opts, self.session_key(payload["id"]), ).loads(payload["load"]) @@ -604,7 +426,7 @@ def validate_token(self, payload, required=True): log.warning("Invalid minion id: %s", id_) return False try: - pub = salt.crypt.PublicKey.from_file(pub_path) + pub = salt.crypt.PublicKey(pub_path) except OSError: log.warning( "Salt minion claiming to be %s attempted to communicate with " @@ -625,680 +447,465 @@ def validate_token(self, payload, required=True): def _auth(self, load, sign_messages=False, version=0): """ - Authenticate a minion by delegating to :class:`salt.master.AuthFuncs`. - - The implementation lives in :mod:`salt.master` so that auth can run - in a dedicated worker pool. This method threads the channel's - existing state (cache, event manager, master key, session cache, - auto-accept config, con_cache client, ckminions) into the - ``AuthFuncs`` handler so that callers (and tests) that monkey-patch - attributes on the channel see those changes reflected in the auth - handler without having to construct a new ``AuthFuncs`` themselves. - """ - af = salt.master.AuthFuncs.__new__(salt.master.AuthFuncs) - af.opts = self.opts - af.cache = self.cache - af.event = self.event - af.master_key = self.master_key - af.sessions = self.sessions - af.auto_key = getattr(self, "auto_key", None) - af.cache_cli = getattr(self, "cache_cli", False) - af.ckminions = getattr(self, "ckminions", None) - return af._auth(load, sign_messages, version) - - def close(self): - self.transport.close() - if self.event is not None: - self.event.destroy() - if hasattr(self, "ckminions") and self.ckminions is not None: - if hasattr(self.ckminions, "cache") and self.ckminions.cache is not None: - if hasattr(self.ckminions.cache, "destroy"): - self.ckminions.cache.destroy() - self.ckminions.cache = None - self.ckminions = None - - -class PoolRoutingChannel: - """ - Request channel that routes incoming messages to per-pool worker processes - using transport-native IPC (the pooled path). - - This class is returned by :meth:`ReqServerChannel.factory` when - ``worker_pools_enabled=True`` (the default). It is mutually exclusive - with the plain :class:`ReqServerChannel` — only one of the two is ever - active for a given master process. - - Architecture:: - - External Transport -> PoolRoutingChannel -> RequestClient (IPC) -> - Pool RequestServer (IPC) -> MWorkers - - ``_auth`` handling - ------------------ - Under a fully started master, ``_auth`` is looked up in the routing table - and forwarded to the mapped pool's IPC RequestServer, then handled in a - worker by :meth:`~salt.master.MWorker._handle_clear` -> - :meth:`~salt.master.ClearFuncs._auth`. - - If the pool's IPC client is not connected yet (e.g. tests calling - :meth:`handle_message` without ``post_fork``), clear-text ``_auth`` is - handled inline with the same logic as :meth:`ReqServerChannel.handle_message`. - - See :meth:`ReqServerChannel.factory` for the authoritative description of - the two mutually exclusive paths. - - Key advantages over the legacy single-pool design: - - No multiprocessing.Queue overhead - - Uses transport-native IPC (ZeroMQ/TCP/WebSocket) - - Clean separation of concerns - - Works across all transports without transport modifications - """ - - def __init__(self, opts, transport, worker_pools): - """ - Initialize the pool routing channel. + Authenticate the client, use the sent public key to encrypt the AES key + which was generated at start up. - Args: - opts: Master configuration options - transport: The external transport instance (port 4506) - worker_pools: Dict of pool configurations {pool_name: config} - """ - self.opts = opts - self.transport = transport - self.worker_pools = worker_pools - self.pool_clients = {} # pool_name -> RequestClient - self.pool_servers = {} # pool_name -> RequestServer - self.io_loop = None - self.event = None - self.router = None - self.crypticle = None - self.master_key = None - self.auto_key = None - - (pathlib.Path(self.opts["cachedir"]) / "sessions").mkdir(exist_ok=True) - self.sessions = {} - - # Defer CacheCli/CkMinions construction: ``salt.cache.Cache`` holds locks and - # breaks pickling ``PoolRoutingChannel`` into ``MWorker`` on Windows (spawn). - # Workers delegate to per-pool ``ReqServerChannel`` and never need this state. - self.cache = None - self.cache_cli = False - self.ckminions = None - - # Build routing table for command-based routing - self._build_routing_table() - - log.info( - "PoolRoutingChannel initialized with pools: %s", - list(worker_pools.keys()), - ) - - def _ensure_auth_support(self): - """Lazily init key-cache state needed for inline clear-text ``_auth`` only.""" - if self.cache is not None: - return - self.cache = salt.cache.Cache(self.opts, driver=self.opts["keys.cache_driver"]) - if self.opts["con_cache"]: - self.cache_cli = CacheCli(self.opts) - else: - self.cache_cli = False - self.ckminions = salt.utils.minions.CkMinions(self.opts) + This method fires an event over the master event manager. The event is + tagged "auth" and returns a dict with information about the auth + event - def _build_routing_table(self): - """ - Build command-to-pool routing table from configuration. - - Exactly one pool must include ``"*"`` in its commands and becomes - :attr:`default_pool`. Pool configuration is validated during master - startup (see - :func:`salt.config.worker_pools.validate_worker_pools_config`), so - this method only translates the validated layout into the lookup - table used at routing time. + - Verify that the key we are receiving matches the stored key + - Store the key if it is not there + - Make an RSA key with the pub key + - Encrypt the AES key as an encrypted salt.payload + - Package the return and return it """ - self.command_to_pool = {} - self.default_pool = None + import salt.master - for pool_name, config in self.worker_pools.items(): - for cmd in config.get("commands", []): - if cmd == "*": - self.default_pool = pool_name - else: - self.command_to_pool[cmd] = pool_name + enc_algo = load.get("enc_algo", salt.crypt.OAEP_SHA1) + sig_algo = load.get("sig_algo", salt.crypt.PKCS1v15_SHA1) - if self.worker_pools and not self.default_pool: - raise ValueError( - "Worker pool configuration must have exactly one pool with " - "catchall ('*') in its commands." - ) + if not salt.utils.verify.valid_id(self.opts, load["id"]): + log.info("Authentication request from invalid id %s", load["id"]) + if sign_messages: + return self._clear_signed( + {"ret": False, "nonce": load["nonce"]}, sig_algo + ) + else: + return {"enc": "clear", "load": {"ret": False}} + log.info("Authentication request from %s", load["id"]) + + # 0 is default which should be 'unlimited' + if self.opts["max_minions"] > 0: + # use the ConCache if enabled, else use the minion utils + if self.cache_cli: + minions = self.cache_cli.get_cached() + else: + minions = self.ckminions.connected_ids() + if len(minions) > 1000: + log.info( + "With large numbers of minions it is advised " + "to enable the ConCache with 'con_cache: True' " + "in the masters configuration file." + ) - @property - def aes_key(self): - if self.opts.get("cluster_id", None): - return salt.master.SMaster.secrets["cluster_aes"]["secret"].value - return salt.master.SMaster.secrets["aes"]["secret"].value + if not len(minions) <= self.opts["max_minions"]: + # we reject new minions, minions that are already + # connected must be allowed for the mine, highstate, etc. + if load["id"] not in minions: + log.info( + "Too many minions connected (max_minions=%s). " + "Rejecting connection from id %s", + self.opts["max_minions"], + load["id"], + ) + eload = { + "result": False, + "act": "full", + "id": load["id"], + "pub": load["pub"], + } - def session_key(self, minion): - """ - Returns a session key for the given minion id. - """ - now = time.time() - path = pathlib.Path(self.opts["cachedir"]) / "sessions" / minion - if minion in self.sessions: - if now - self.sessions[minion][0] < self.opts["publish_session"]: - # Master cluster deployments share ``sessions/`` - # on a shared filesystem so a peer master's rotation must - # invalidate our in-memory cache. Comparing the file - # mtime against the mtime we cached catches that case - # without penalising the single-master fast path -- the - # ``stat`` is cheap and only runs on cache hits. - try: - disk_mtime = path.stat().st_mtime - except FileNotFoundError: - disk_mtime = None - if disk_mtime is not None and disk_mtime <= self.sessions[minion][0]: - return self.sessions[minion][1] + if self.opts.get("auth_events") is True: + self.event.fire_event( + eload, salt.utils.event.tagify(prefix="auth") + ) + if sign_messages: + return self._clear_signed( + {"ret": "full", "nonce": load["nonce"]}, sig_algo + ) + else: + return {"enc": "clear", "load": {"ret": "full"}} - try: - if now - path.stat().st_mtime > self.opts["publish_session"]: - salt.crypt.Crypticle.write_key(path) - except FileNotFoundError: - salt.crypt.Crypticle.write_key(path) + pki_dir = self.opts["pki_dir"] + if self.opts["cluster_id"]: + if self.opts["cluster_pki_dir"]: + pki_dir = self.opts["cluster_pki_dir"] - self.sessions[minion] = ( - path.stat().st_mtime, - salt.crypt.Crypticle.read_key(path), + # Check if key is configured to be auto-rejected/signed + auto_reject = self.auto_key.check_autoreject(load["id"]) + auto_sign = self.auto_key.check_autosign( + load["id"], load.get("autosign_grains", None) ) - return self.sessions[minion][1] - - def _update_aes(self): - """ - Check to see if a fresh AES key is available and update the components - of the worker - """ - key = "aes" - if self.opts.get("cluster_id", None): - key = "cluster_aes" - if ( - salt.master.SMaster.secrets[key]["secret"].value - != self.crypticle.key_string - ): - self.crypticle = _get_crypticle( - self.opts, salt.master.SMaster.secrets[key]["secret"].value + pubfn = os.path.join(pki_dir, "minions", load["id"]) + pubfn_pend = os.path.join(pki_dir, "minions_pre", load["id"]) + pubfn_rejected = os.path.join(pki_dir, "minions_rejected", load["id"]) + pubfn_denied = os.path.join(pki_dir, "minions_denied", load["id"]) + if self.opts["open_mode"]: + # open mode is turned on, nuts to checks and overwrite whatever + # is there + pass + elif os.path.isfile(pubfn_rejected): + # The key has been rejected, don't place it in pending + log.info( + "Public key rejected for %s. Key is present in rejection key dir.", + load["id"], ) - return True - return False - - def pre_fork(self, process_manager, *args, **kwargs): - """ - Pre-fork setup: Initialize external transport and create RequestServer - for each worker pool on IPC. - """ - import salt.master - import salt.transport.base - from salt.utils.channel import create_server_transport - - # Pass secrets if not present (critical for decryption in routing) - if "secrets" not in kwargs: - kwargs["secrets"] = salt.master.SMaster.secrets + eload = {"result": False, "id": load["id"], "pub": load["pub"]} + if self.opts.get("auth_events") is True: + self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) + if sign_messages: + return self._clear_signed( + {"ret": False, "nonce": load["nonce"]}, sig_algo + ) + else: + return {"enc": "clear", "load": {"ret": False}} + elif os.path.isfile(pubfn): + # The key has been accepted, check it + with salt.utils.files.fopen(pubfn, "r") as pubfn_handle: + if not self.compare_keys(pubfn_handle.read(), load["pub"]): + log.error( + "Authentication attempt from %s failed, the public " + "keys did not match. This may be an attempt to compromise " + "the Salt cluster.", + load["id"], + ) + # put denied minion key into minions_denied + with salt.utils.files.fopen(pubfn_denied, "w+") as fp_: + fp_.write(load["pub"]) + eload = { + "result": False, + "id": load["id"], + "act": "denied", + "pub": load["pub"], + } + if self.opts.get("auth_events") is True: + self.event.fire_event( + eload, salt.utils.event.tagify(prefix="auth") + ) + if sign_messages: + return self._clear_signed( + {"ret": False, "nonce": load["nonce"]}, sig_algo + ) + else: + return {"enc": "clear", "load": {"ret": False}} + + elif not os.path.isfile(pubfn_pend): + # The key has not been accepted, this is a new minion + if os.path.isdir(pubfn_pend): + # The key path is a directory, error out + log.info("New public key %s is a directory", load["id"]) + eload = {"result": False, "id": load["id"], "pub": load["pub"]} + if self.opts.get("auth_events") is True: + self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) + if sign_messages: + return self._clear_signed( + {"ret": False, "nonce": load["nonce"]}, sig_algo + ) + else: + return {"enc": "clear", "load": {"ret": False}} - # Setup external transport (this binds the actual network ports 4505/4506) - if hasattr(self.transport, "pre_fork"): - self.transport.pre_fork(process_manager, *args, **kwargs) - - # Create a RequestServer for each pool on IPC - for pool_name, config in self.worker_pools.items(): - # Create pool-specific opts for IPC - pool_opts = self.opts.copy() - pool_opts["pool_name"] = pool_name - # Disable worker pools for internal routing to avoid circular dependency - pool_opts["worker_pools_enabled"] = False - - # Configure IPC for this pool - if pool_opts.get("ipc_mode") == "tcp": - # TCP IPC mode: use unique port per pool - base_port = pool_opts.get("tcp_master_workers", 4515) - port_offset = zlib.adler32(pool_name.encode()) % 1000 - pool_opts["ret_port"] = base_port + port_offset + if auto_reject: + key_path = pubfn_rejected log.info( - "Pool '%s' RequestServer using TCP IPC on port %d", - pool_name, - pool_opts["ret_port"], + "New public key for %s rejected via autoreject_file", load["id"] ) + key_act = "reject" + key_result = False + elif not auto_sign: + key_path = pubfn_pend + log.info("New public key for %s placed in pending", load["id"]) + key_act = "pend" + key_result = True else: - # Standard IPC mode: use unique socket per pool - sock_dir = pool_opts.get("sock_dir", "/tmp/salt") - os.makedirs(sock_dir, exist_ok=True) - pool_opts["workers_ipc_name"] = f"workers-{pool_name}.ipc" - log.debug( - "Pool '%s' RequestServer using IPC socket: %s", - pool_name, - pool_opts["workers_ipc_name"], + # The key is being automatically accepted, don't do anything + # here and let the auto accept logic below handle it. + key_path = None + + if key_path is not None: + # Write the key to the appropriate location + with salt.utils.files.fopen(key_path, "w+") as fp_: + fp_.write(load["pub"]) + eload = { + "result": key_result, + "act": key_act, + "id": load["id"], + "pub": load["pub"], + } + if self.opts.get("auth_events") is True: + self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) + if sign_messages: + return self._clear_signed( + {"ret": key_result, "nonce": load["nonce"]}, + sig_algo, + ) + else: + return {"enc": "clear", "load": {"ret": key_result}} + + elif os.path.isfile(pubfn_pend): + # This key is in the pending dir and is awaiting acceptance + if auto_reject: + # We don't care if the keys match, this minion is being + # auto-rejected. Move the key file from the pending dir to the + # rejected dir. + try: + shutil.move(pubfn_pend, pubfn_rejected) + except OSError: + pass + log.info( + "Pending public key for %s rejected via autoreject_file", + load["id"], ) + eload = { + "result": False, + "act": "reject", + "id": load["id"], + "pub": load["pub"], + } + if self.opts.get("auth_events") is True: + self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) + if sign_messages: + return self._clear_signed( + {"ret": False, "nonce": load["nonce"]}, sig_algo + ) + else: + return {"enc": "clear", "load": {"ret": False}} + + elif not auto_sign: + # This key is in the pending dir and is not being auto-signed. + # Check if the keys are the same and error out if this is the + # case. Otherwise log the fact that the minion is still + # pending. + with salt.utils.files.fopen(pubfn_pend, "r") as pubfn_handle: + if not self.compare_keys(pubfn_handle.read(), load["pub"]): + log.error( + "Authentication attempt from %s failed, the public " + "key in pending did not match. This may be an " + "attempt to compromise the Salt cluster.", + load["id"], + ) + # put denied minion key into minions_denied + with salt.utils.files.fopen(pubfn_denied, "w+") as fp_: + fp_.write(load["pub"]) + eload = { + "result": False, + "id": load["id"], + "act": "denied", + "pub": load["pub"], + } + if self.opts.get("auth_events") is True: + self.event.fire_event( + eload, salt.utils.event.tagify(prefix="auth") + ) + if sign_messages: + return self._clear_signed( + {"ret": False, "nonce": load["nonce"]}, sig_algo + ) + else: + return {"enc": "clear", "load": {"ret": False}} + else: + log.info( + "Authentication failed from host %s, the key is in " + "pending and needs to be accepted with salt-key " + "-a %s", + load["id"], + load["id"], + ) + eload = { + "result": True, + "act": "pend", + "id": load["id"], + "pub": load["pub"], + } + if self.opts.get("auth_events") is True: + self.event.fire_event( + eload, salt.utils.event.tagify(prefix="auth") + ) + if sign_messages: + return self._clear_signed( + {"ret": True, "nonce": load["nonce"]}, sig_algo + ) + else: + return {"enc": "clear", "load": {"ret": True}} + else: + # This key is in pending and has been configured to be + # auto-signed. Check to see if it is the same key, and if + # so, pass on doing anything here, and let it get automatically + # accepted below. + with salt.utils.files.fopen(pubfn_pend, "r") as pubfn_handle: + if not self.compare_keys(pubfn_handle.read(), load["pub"]): + log.error( + "Authentication attempt from %s failed, the public " + "keys in pending did not match. This may be an " + "attempt to compromise the Salt cluster.", + load["id"], + ) + # put denied minion key into minions_denied + with salt.utils.files.fopen(pubfn_denied, "w+") as fp_: + fp_.write(load["pub"]) + eload = {"result": False, "id": load["id"], "pub": load["pub"]} + if self.opts.get("auth_events") is True: + self.event.fire_event( + eload, salt.utils.event.tagify(prefix="auth") + ) + if sign_messages: + return self._clear_signed( + {"ret": False, "nonce": load["nonce"]}, sig_algo + ) + else: + return {"enc": "clear", "load": {"ret": False}} + else: + os.remove(pubfn_pend) - # Create RequestServer for this pool using transport factory - try: - pool_transport = create_server_transport(pool_opts) - # We wrap it in a minimal ReqServerChannel for compatibility - pool_server = ReqServerChannel(pool_opts, pool_transport) - pool_server.pre_fork(process_manager, *args, **kwargs) - self.pool_servers[pool_name] = pool_server - log.info("Created RequestServer for pool '%s'", pool_name) - except Exception as exc: # pylint: disable=broad-except - log.error( - "Failed to create RequestServer for pool '%s': %s", pool_name, exc + else: + # Something happened that I have not accounted for, FAIL! + log.warning("Unaccounted for authentication failure") + eload = {"result": False, "id": load["id"], "pub": load["pub"]} + if self.opts.get("auth_events") is True: + self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) + if sign_messages: + return self._clear_signed( + {"ret": False, "nonce": load["nonce"]}, sig_algo ) - raise - - # Transports without a built-in pooled router (e.g. ``salt.transport.tcp``) - # leave the bound external socket without an ``accept`` loop because - # MWorker's ``post_fork`` only sets up its IPC pool socket. ZeroMQ - # avoids this with ``zmq_device_pooled``; TCP/WS need an equivalent - # router process here. The forked process inherits the already-bound - # socket from ``self.transport.pre_fork`` above. - if not _transport_has_builtin_router(self.transport): - process_manager.add_process( - self._run_pool_router, - kwargs={"secrets": kwargs.get("secrets")}, - name="PoolRouter", - ) - - log.info( - "PoolRoutingChannel pre_fork complete for %d pools", len(self.worker_pools) - ) - - def _run_pool_router(self, secrets=None): - """ - Routing-process entry point for transports without a built-in router. + else: + return {"enc": "clear", "load": {"ret": False}} + + log.info("Authentication accepted from %s", load["id"]) + # only write to disk if you are adding the file, and in open mode, + # which implies we accept any key from a minion. + if not os.path.isfile(pubfn) and not self.opts["open_mode"]: + with salt.utils.files.fopen(pubfn, "w+") as fp_: + fp_.write(load["pub"]) + elif self.opts["open_mode"]: + disk_key = "" + if os.path.isfile(pubfn): + with salt.utils.files.fopen(pubfn, "r") as fp_: + disk_key = fp_.read() + if load["pub"] and load["pub"] != disk_key: + log.debug("Host key change detected in open mode.") + with salt.utils.files.fopen(pubfn, "w+") as fp_: + fp_.write(load["pub"]) + elif not load["pub"]: + log.error("Public key is empty: %s", load["id"]) + if sign_messages: + return self._clear_signed( + {"ret": False, "nonce": load["nonce"]}, sig_algo + ) + else: + return {"enc": "clear", "load": {"ret": False}} - Inherits the bound external socket from ``pre_fork``, runs an asyncio - event loop, and dispatches incoming requests to pool MWorkers via the - per-pool IPC RequestClients set up by :meth:`post_fork` (no - ``pool_name`` branch). - """ - if secrets is not None: - import salt.master # pylint: disable=import-outside-toplevel + pub = None - salt.master.SMaster.secrets = secrets + # the con_cache is enabled, send the minion id to the cache + if self.cache_cli: + self.cache_cli.put_cache([load["id"]]) - io_loop = asyncio.new_event_loop() - asyncio.set_event_loop(io_loop) + # The key payload may sometimes be corrupt when using auto-accept + # and an empty request comes in try: - self.post_fork(self.handle_and_route_message, io_loop) - io_loop.run_forever() - except (KeyboardInterrupt, SystemExit): - pass - finally: - try: - io_loop.stop() - except Exception: # pylint: disable=broad-except - pass - io_loop.close() - - def post_fork(self, payload_handler, io_loop, **kwargs): - """ - Post-fork setup in the routing process. - - This is where we: - 1. Set up the master infrastructure (crypticle, events, keys) - 2. Create RequestClient connections to each pool's RequestServer - 3. Connect the external transport to our routing handler - """ - pool_name = kwargs.get("pool_name") - if pool_name: - # We are in an MWorker process for a specific pool. - # Delegate to the pool's RequestServer. - if pool_name in self.pool_servers: - pool_server = self.pool_servers[pool_name] - return pool_server.post_fork(payload_handler, io_loop, **kwargs) + pub = salt.crypt.PublicKey(pubfn) + except salt.crypt.InvalidKeyError as err: + log.error('Corrupt public key "%s": %s', pubfn, err) + if sign_messages: + return self._clear_signed( + {"ret": False, "nonce": load["nonce"]}, sig_algo + ) else: - log.error("Pool '%s' not found in pool_servers", pool_name) - return + return {"enc": "clear", "load": {"ret": False}} - import salt.master - from salt.utils.channel import create_request_client + ret = { + "enc": "pub", + "pub_key": self.master_key.get_pub_str(), + "publish_port": self.opts["publish_port"], + } - self.io_loop = io_loop + # sign the master's pubkey (if enabled) before it is + # sent to the minion that was just authenticated + if self.opts["master_sign_pubkey"]: + # append the pre-computed signature to the auth-reply + if self.master_key.pubkey_signature(): + log.debug("Adding pubkey signature to auth-reply") + log.debug(self.master_key.pubkey_signature()) + ret.update({"pub_sig": self.master_key.pubkey_signature()}) + else: + # the master has its own signing-keypair, compute the master.pub's + # signature and append that to the auth-reply - # Routing process only (not pool workers): needs cache-backed auth helpers. - self._ensure_auth_support() + # get the key_pass for the signing key + key_pass = salt.utils.sdb.sdb_get( + self.opts["signing_key_pass"], self.opts + ) + log.debug("Signing master public key before sending") + pub_sign = salt.crypt.sign_message( + self.master_key.get_sign_paths()[1], + ret["pub_key"], + key_pass, + algorithm=sig_algo, + ) + ret.update({"pub_sig": binascii.b2a_base64(pub_sign)}) - # Setup master infrastructure (same as ReqServerChannel) - if ( - self.opts.get("pub_server_niceness") - and not salt.utils.platform.is_windows() - ): - log.debug( - "setting Publish daemon niceness to %i", - self.opts["pub_server_niceness"], - ) - os.nice(self.opts["pub_server_niceness"]) - - # Create event manager for the routing process - self.event = salt.utils.event.get_master_event( - self.opts, self.opts["sock_dir"], listen=False, io_loop=io_loop - ) - - # Set up crypticle for payload decryption during routing - self.crypticle = _get_crypticle(self.opts, self.aes_key) - - self.master_key = salt.crypt.MasterKeys(self.opts) - - # Create RequestClient for each pool (connects to pool's IPC RequestServer) - for pool_name in self.worker_pools.keys(): - # Create pool-specific opts matching the pool's RequestServer - pool_opts = self.opts.copy() - pool_opts["pool_name"] = pool_name - # Disable worker pools for internal routing to avoid circular dependency - pool_opts["worker_pools_enabled"] = False - - if pool_opts.get("ipc_mode") == "tcp": - # TCP IPC: connect to pool's port - base_port = pool_opts.get("tcp_master_workers", 4515) - port_offset = zlib.adler32(pool_name.encode()) % 1000 - pool_opts["ret_port"] = base_port + port_offset - pool_opts["master_uri"] = f"tcp://127.0.0.1:{pool_opts['ret_port']}" - log.debug( - "Pool '%s' client connecting to TCP port %d", - pool_name, - pool_opts["ret_port"], - ) - else: - # IPC socket: connect to pool's socket - pool_opts["workers_ipc_name"] = f"workers-{pool_name}.ipc" - ipc_path = os.path.join( - self.opts["sock_dir"], pool_opts["workers_ipc_name"] - ) - pool_opts["master_uri"] = f"ipc://{ipc_path}" - log.debug( - "Pool '%s' client connecting to IPC socket: %s", - pool_name, - pool_opts["workers_ipc_name"], - ) - - try: - # Use our dedicated request client factory for routing - client = create_request_client(pool_opts, io_loop) - self.pool_clients[pool_name] = client - log.info("Created RequestClient for pool '%s'", pool_name) - except Exception as exc: # pylint: disable=broad-except - log.error( - "Failed to create RequestClient for pool '%s': %s", pool_name, exc - ) - raise - - # Connect external transport to our routing handler - if hasattr(self.transport, "post_fork"): - self.transport.post_fork(self.handle_and_route_message, io_loop, **kwargs) - - log.info( - "PoolRoutingChannel post_fork complete with %d pool clients", - len(self.pool_clients), - ) - - def _req_channel_auth_delegate(self): - """ - Build a minimal :class:`ReqServerChannel` view for running - :meth:`ReqServerChannel._auth` with this channel's opts, keys, and - cache (used when pool IPC clients are not connected yet). - """ - ch = ReqServerChannel.__new__(ReqServerChannel) - ch.opts = self.opts - ch.transport = self.transport - ch.cache = self.cache - ch.event = self.event - ch.master_key = self.master_key - ch.sessions = self.sessions - ch.auto_key = getattr(self, "auto_key", None) - ch.cache_cli = getattr(self, "cache_cli", False) - ch.ckminions = getattr(self, "ckminions", None) - ch.crypticle = getattr(self, "crypticle", None) - return ch - - async def _handle_clear_auth_local(self, payload, version): - """ - Run clear-text ``_auth`` the same way :meth:`ReqServerChannel.handle_message` - does, without forwarding to a worker pool (no IPC client yet). - """ - self._ensure_auth_support() - proxy = self._req_channel_auth_delegate() - try: - payload = ReqServerChannel._decode_payload(proxy, payload, version) - except Exception as exc: # pylint: disable=broad-except - exc_type = type(exc).__name__ - if exc_type == "AuthenticationError": - log.debug( - "Minion failed to auth to master. Since the payload is " - "encrypted, it is not known which minion failed to " - "authenticate. It is likely that this is a transient " - "failure due to the master rotating its public key." - ) - else: - log.error("Bad load from minion: %s: %s", exc_type, exc) - return "bad load" - - if not isinstance(payload, dict) or not isinstance(payload.get("load"), dict): - log.error( - "payload and load must be a dict. Payload was: %s", - payload, - ) - return "payload and load must be a dict" - - try: - id_ = payload["load"].get("id", "") - if "\0" in id_: - log.error("Payload contains an id with a null byte: %s", payload) - return "bad load: id contains a null byte" - except TypeError: - log.error("Payload contains non-string id: %s", payload) - return f"bad load: id {id_} is not a string" - - sign_messages = version > 1 - - if ( - payload.get("enc") == "clear" - and payload.get("load", {}).get("cmd") == "_auth" - ): - start = time.time() - ret = ReqServerChannel._auth(proxy, payload["load"], sign_messages, version) - if self.opts.get("master_stats", False) and getattr( - self, "payload_handler", None - ): - await self.payload_handler({"cmd": "_auth", "_start": start}) - return ret - - log.error("clear-auth local handler called for non-auth payload: %s", payload) - return {"error": "Internal routing error", "success": False} - - async def handle_and_route_message(self, payload): - """ - Route an incoming request to the appropriate worker pool (pooled path). - - Determines the target pool by inspecting the ``cmd`` field of the - payload load (decrypting first if the load is encrypted), looks it up - in the routing table, then forwards the raw payload to that pool's - IPC RequestServer via a RequestClient. - - Clear-text ``_auth`` is normally routed like any other command. When - no IPC client exists for the target pool yet (e.g. functional tests - that call :meth:`handle_message` without a full ``post_fork``), it is - handled inline using the same logic as :meth:`ReqServerChannel.handle_message`. - - See :class:`PoolRoutingChannel` and :meth:`ReqServerChannel.factory` - for the full explanation of the two mutually exclusive request paths. - """ - if ( - not isinstance(payload, dict) - or "enc" not in payload - or "load" not in payload - ): - log.warning("bad load received on socket") - return "bad load" - try: - version = int(payload.get("version", 0)) - except ValueError: - version = 0 - - # Enforce minimum authentication protocol version to prevent downgrade attacks - minimum_version = self.opts.get("minimum_auth_version", 0) - if minimum_version > 0 and version < minimum_version: - load = payload.get("load") - if isinstance(load, dict): - minion_id = load.get("id", "unknown minion") + if self.opts["auth_mode"] >= 2: + if "token" in load: + try: + mtoken = self.master_key.key.decrypt(load["token"], enc_algo) + aes = "{}_|-{}".format( + salt.master.SMaster.secrets["aes"]["secret"].value, mtoken + ) + except UnsupportedAlgorithm as exc: + log.info( + "Minion %s tried to authenticate with unsupported encryption algorithm: %s", + load["id"], + enc_algo, + ) + return {"enc": "clear", "load": {"ret": "bad enc algo"}} + except Exception as exc: # pylint: disable=broad-except + log.warning("Token failed to decrypt %s", exc) + # Token failed to decrypt, send back the salty bacon to + # support older minions else: - minion_id = "unknown minion" - log.warning( - "Rejected authentication attempt from minion '%s' using " - "protocol version %d (minimum required: %d)", - minion_id, - version, - minimum_version, - ) - return "bad load" + aes = self.aes_key - # Clear-text ``_auth`` is handled locally like legacy ReqServerChannel so - # bootstrap sign-in does not rely on pool IPC (flaky on Windows with pooled routing). - if ( - payload.get("enc") == "clear" - and isinstance(payload.get("load"), dict) - and payload["load"].get("cmd") == "_auth" - ): - return await self._handle_clear_auth_local(payload, version) - - try: - # Simple command-based routing from our routing table - load = payload.get("load", {}) - if isinstance(load, dict): - cmd = load.get("cmd", "unknown") - else: - # This is likely an encrypted payload. We need to decrypt - # to determine the command for routing. + ret["aes"] = pub.encrypt(aes, enc_algo) + ret["session"] = pub.encrypt(self.session_key(load["id"]), enc_algo) + else: + if "token" in load: try: - # Determine which key to use based on the 'enc' field - enc = payload.get("enc", "aes") - if enc == "aes": - import salt.master - - key = ( - salt.master.SMaster.secrets.get("aes", {}) - .get("secret", {}) - .value - ) - if key: - import salt.crypt - - crypticle = salt.crypt.Crypticle(self.opts, key) - decrypted = crypticle.loads(load) - if isinstance(decrypted, dict) and "cmd" in decrypted: - cmd = decrypted.get("cmd", "unknown") - elif isinstance(decrypted, dict) and "load" in decrypted: - cmd = decrypted["load"].get("cmd", "unknown") - else: - cmd = "unknown" - else: - cmd = "unknown" - elif enc == "pub": - # RSA encryption - import salt.crypt - - mkey = salt.crypt.MasterKeys(self.opts) - decrypted = mkey.priv_decrypt(load) - if isinstance(decrypted, bytes): - import salt.payload - - decrypted = salt.payload.loads(decrypted) - if isinstance(decrypted, dict) and "cmd" in decrypted: - cmd = decrypted.get("cmd", "unknown") - elif isinstance(decrypted, dict) and "load" in decrypted: - cmd = decrypted["load"].get("cmd", "unknown") - else: - cmd = "unknown" - else: - cmd = "unknown" - except Exception: # pylint: disable=broad-except - cmd = "unknown" - - pool_name = self.command_to_pool.get(cmd, self.default_pool) - - log.debug( - "Routing: cmd=%s -> pool='%s' (pools: %s)", - cmd, - pool_name, - list(self.worker_pools.keys()), - ) - - # Block non-_auth requests until this node is a committed Raft voter. - if cmd != "_auth" and not _cluster_is_ready(self.opts): - log.debug("Cluster not ready yet — deferring %s request", cmd) - return {"enc": "clear", "load": {"ret": False, "cluster_retry": True}} - - if pool_name not in self.pool_clients: - log.error( - "No client available for pool '%s'. Available: %s", - pool_name, - list(self.pool_clients.keys()), - ) - return {"error": f"No client for pool {pool_name}"} - - # Forward to the appropriate pool's RequestServer via IPC - client = self.pool_clients[pool_name] - reply = await client.send(payload) + mtoken = self.master_key.key.decrypt(load["token"], enc_algo) + ret["token"] = pub.encrypt(mtoken, enc_algo) + except UnsupportedAlgorithm as exc: + log.info( + "Minion %s tried to authenticate with unsupported encryption algorithm: %s", + load["id"], + enc_algo, + ) + return {"enc": "clear", "load": {"ret": "bad enc algo"}} + except Exception as exc: # pylint: disable=broad-except + # Token failed to decrypt, send back the salty bacon to + # support older minions + log.warning("Token failed to decrypt: %r", exc) - return reply + aes = self.aes_key + ret["aes"] = pub.encrypt(aes, enc_algo) + ret["session"] = pub.encrypt(self.session_key(load["id"]), enc_algo) - except Exception as exc: # pylint: disable=broad-except - log.error( - "Error in pool routing: %s", - exc, - exc_info=True, + if version < 3: + log.warning( + "Minion using legacy request server protocol, please upgrade %s", + load["id"], ) - return {"error": "Internal routing error", "success": False} - # Alias for compatibility with older tests and code that expect handle_message - handle_message = handle_and_route_message + # Be aggressive about the signature + digest = salt.utils.stringutils.to_bytes(hashlib.sha256(aes).hexdigest()) + ret["sig"] = self.master_key.key.encrypt(digest) + eload = {"result": True, "act": "accept", "id": load["id"], "pub": load["pub"]} + if self.opts.get("auth_events") is True: + self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) + if sign_messages: + ret["nonce"] = load["nonce"] + return self._clear_signed(ret, sig_algo) + return ret def close(self): - """ - Close all resources: pool clients, pool servers, event manager, and external transport. - """ - log.info("Closing PoolRoutingChannel") - - # Close all pool clients (RequestClients to pool RequestServers) - for pool_name, client in self.pool_clients.items(): - try: - if hasattr(client, "close"): - client.close() - elif hasattr(client, "destroy"): - client.destroy() - except Exception as exc: # pylint: disable=broad-except - log.error("Error closing client for pool '%s': %s", pool_name, exc) - self.pool_clients.clear() - - # Close all pool servers - for pool_name, server in self.pool_servers.items(): - try: - if hasattr(server, "close"): - server.close() - except Exception as exc: # pylint: disable=broad-except - log.error("Error closing server for pool '%s': %s", pool_name, exc) - self.pool_servers.clear() - - # Close event manager + self.transport.close() if self.event is not None: - try: - self.event.destroy() - except Exception as exc: # pylint: disable=broad-except - log.error("Error closing event manager: %s", exc) - self.event = None - - # Close external transport - if hasattr(self.transport, "close"): - try: - self.transport.close() - except Exception as exc: # pylint: disable=broad-except - log.error("Error closing external transport: %s", exc) - - log.info("PoolRoutingChannel closed") + self.event.destroy() + if hasattr(self, "ckminions") and self.ckminions is not None: + if hasattr(self.ckminions, "cache") and self.ckminions.cache is not None: + if hasattr(self.ckminions.cache, "destroy"): + self.ckminions.cache.destroy() + self.ckminions.cache = None + self.ckminions = None class PubServerChannel: @@ -1370,7 +977,7 @@ def close(self): self.ckminions.cache = None self.ckminions = None - def pre_fork(self, process_manager, *args, **kwargs): + def pre_fork(self, process_manager, kwargs=None): """ Do anything necessary pre-fork. Since this is on the master side this will primarily be used to create IPC channels and create our daemon process to @@ -1379,45 +986,28 @@ def pre_fork(self, process_manager, *args, **kwargs): :param func process_manager: A ProcessManager, from salt.utils.process.ProcessManager """ if hasattr(self.transport, "publish_daemon"): - # Extract kwargs for the process. - # We check for a named 'kwargs' key first (from salt/master.py), - # then fallback to the entire kwargs dict. - proc_kwargs = kwargs.pop("kwargs", kwargs).copy() - if "secrets" not in proc_kwargs: - import salt.master - - proc_kwargs["secrets"] = salt.master.SMaster.secrets - if "started" not in proc_kwargs: - proc_kwargs["started"] = self.transport.started - process_manager.add_process(self._publish_daemon, kwargs=proc_kwargs) + process_manager.add_process(self._publish_daemon, kwargs=kwargs) def _publish_daemon(self, **kwargs): - import salt.master - if self.opts["pub_server_niceness"] and not salt.utils.platform.is_windows(): log.debug( "setting Publish daemon niceness to %i", self.opts["pub_server_niceness"], ) os.nice(self.opts["pub_server_niceness"]) - secrets = kwargs.pop("secrets", None) - started = kwargs.pop("started", None) + secrets = kwargs.get("secrets", None) if secrets is not None: salt.master.SMaster.secrets = secrets self.master_key = salt.crypt.MasterKeys(self.opts) self.transport.publish_daemon( - self.publish_payload, - self.presence_callback, - self.remove_presence_callback, - secrets=secrets, - started=started, + self.publish_payload, self.presence_callback, self.remove_presence_callback ) def presence_callback(self, subscriber, msg): if msg["enc"] != "aes": # We only accept 'aes' encoded messages for 'id' return - crypticle = _get_crypticle(self.opts, self.aes_key) + crypticle = salt.crypt.Crypticle(self.opts, self.aes_key) load = crypticle.loads(msg["load"]) load = salt.transport.frame.decode_embedded_strs(load) if not self.aes_funcs.verify_minion(load["id"], load["tok"]): @@ -1493,14 +1083,14 @@ def wrap_payload(self, load): payload = {"enc": "aes"} if not self.opts.get("cluster_id", None): load["serial"] = salt.master.SMaster.get_serial() - crypticle = _get_crypticle(self.opts, self.aes_key) + crypticle = salt.crypt.Crypticle(self.opts, self.aes_key) payload["load"] = crypticle.dumps(load) if self.opts["sign_pub_messages"]: log.debug("Signing data packet") payload["sig_algo"] = self.opts["publish_signing_algorithm"] - payload["sig"] = self.master_key.sign( - payload["load"], self.opts["publish_signing_algorithm"] - ) + payload["sig"] = salt.crypt.PrivateKey( + self.master_key.rsa_path, + ).sign(payload["load"], self.opts["publish_signing_algorithm"]) int_payload = {"payload": salt.payload.dumps(payload)} @@ -1533,31 +1123,8 @@ async def publish(self, load): load.get("jid", None), repr(load)[:40], ) - salt.utils.metrics.counter( - "salt.jobs.published", - description="Jobs published from the master to minions.", - ).add( - 1, - attributes={ - "fun": load.get("fun", "") if isinstance(load, dict) else "", - }, - ) - if isinstance(load, dict): - salt.utils.tracing.inject(load) - with salt.utils.tracing.start_span( - "salt.pub.send", - attributes={ - "salt.pub.jid": ( - str(load.get("jid", "")) if isinstance(load, dict) else "" - ), - "salt.pub.fun": load.get("fun", "") if isinstance(load, dict) else "", - "salt.pub.tgt_type": ( - load.get("tgt_type", "") if isinstance(load, dict) else "" - ), - }, - ): - payload = salt.payload.dumps(load) - await self.transport.publish(payload) + payload = salt.payload.dumps(load) + await self.transport.publish(payload) class MasterPubServerChannel: @@ -1565,2174 +1132,129 @@ class MasterPubServerChannel: @classmethod def factory(cls, opts, **kwargs): - if opts.get("cluster_id"): - # Cluster mode: Use TCP-based transport for peer communication while - # preserving normal local IPC behavior for internal processes. - port = opts.get("cluster_port", 55596) - pull_path = os.path.join(opts["sock_dir"], "master_event_pull.ipc") - pub_path = os.path.join(opts["sock_dir"], "master_event_pub.ipc") - bind_host = opts.get("interface", "127.0.0.1") - - try: - transport = salt.transport.tcp.PublishServer( - opts, - pub_host=bind_host, - pub_port=opts.get("publish_port", 4505), - pub_path=pub_path, - pull_host=bind_host, - pull_port=port, - pull_path=pull_path, - ) - except OSError as exc: - if exc.errno == errno.EADDRINUSE: - transport = salt.transport.tcp.PublishServer( - opts, - pub_host=bind_host, - pub_port=opts.get("publish_port", 4505), - pub_path=pub_path, - pull_host=bind_host, - pull_port=0, - pull_path=pull_path, - ) - else: - raise - else: - transport = salt.transport.ipc_publish_server("master", opts) - + transport = salt.transport.ipc_publish_server("master", opts) return cls(opts, transport) - def __init__( - self, - opts, - transport, - presence_events=False, - ): + def __init__(self, opts, transport, presence_events=False): self.opts = opts self.transport = transport self.io_loop = tornado.ioloop.IOLoop.current() self.master_key = salt.crypt.MasterKeys(self.opts) self.peer_keys = {} - self.cluster_peers = self.opts["cluster_peers"] - self._discover_event = None - self._discover_token = None - self._discover_candidates = {} - # Set by service.py once the Raft node is started. - self._raft_dispatcher = None - - def _start_raft_as_founding_voter(self): - """ - Start Raft as a voting founding member. - - Called by a timer in ``_publish_daemon`` when no ``join-reply`` was - received within ``cluster_join_timeout`` seconds. This indicates that - the node is part of a brand-new cluster where all peers are starting - simultaneously and none have sent a join-reply yet. - """ - if self._raft_service is not None: - return # already started by a join-reply race - - log.info( - "No join-reply received — starting Raft as founding voter for cluster %r", - self.opts["cluster_id"], - ) - try: - import salt.utils.asynchronous # pylint: disable=import-outside-toplevel - from salt.cluster.consensus.service import ( # pylint: disable=import-outside-toplevel - RaftService, - build_peer_pushers, - ) - aio_loop = salt.utils.asynchronous.aioloop(self.io_loop) - peer_pushers = build_peer_pushers(self.opts, self.pushers) - self._raft_service = RaftService( - self.opts, - aio_loop, - peer_pushers, - on_ready=self._signal_cluster_ready, + def send_aes_key_event(self): + # ``cluster_peers`` is documented to hold bare master names so the + # cluster identity used on the wire and on disk must match that + # form. ``apply_master_config`` auto-appends ``_master`` to + # ``opts["id"]`` when ``id`` is not configured, leaving sibling + # masters unable to find their own entry in ``data["peers"]``. + # See https://github.com/saltstack/salt/issues/68462. + master_id = self.opts["id"].removesuffix("_master") + data = {"peer_id": master_id, "peers": {}} + for peer in self.opts.get("cluster_peers", []): + peer_pub = ( + pathlib.Path(self.opts["cluster_pki_dir"]) / "peers" / f"{peer}.pub" ) - self._raft_service.attach(self) - self._raft_service.start() - # Write the join sentinel so future restarts skip discover/join. - self._mark_joined_cluster() - log.info( - "Raft consensus service started as founding voter for cluster %r", - self.opts["cluster_id"], + if peer_pub.exists(): + pub = salt.crypt.PublicKey(peer_pub) + aes = salt.master.SMaster.secrets["aes"]["secret"].value + digest = salt.utils.stringutils.to_bytes( + hashlib.sha256(aes).hexdigest() + ) + data["peers"][peer] = { + "aes": pub.encrypt(aes, algorithm="OAEP-SHA224"), + "sig": self.master_key.master_key.encrypt(digest), + } + else: + log.warning("Peer key missing %r", peer_pub) + data["peers"][peer] = {} + with salt.utils.event.get_master_event( + self.opts, self.opts["sock_dir"], listen=False + ) as event: + success = event.fire_event( + data, + salt.utils.event.tagify(master_id, "peer", "cluster"), + timeout=30000, # 30 second timeout ) - except Exception: # pylint: disable=broad-except - log.exception("Failed to start Raft as founding voter") + if not success: + log.error("Unable to send aes key event") - def _start_raft_as_learner(self, known_peers): - """ - Start ``RaftService`` as a non-voting learner after a dynamic join. + def __getstate__(self): + return { + "opts": self.opts, + "transport": self.transport, + } + + def __setstate__(self, state): + self.opts = state["opts"] + self.transport = state["transport"] - Called from ``handle_pool_publish`` when ``cluster/peer/join-reply`` - is received. At this point ``_publish_daemon`` is inside - ``io_loop.start()`` so the asyncio loop is already running. + def close(self): + self.transport.close() - :param known_peers: dict ``{peer_id: pub_key_pem}`` from the - ``join-reply`` payload — the addresses of all - existing cluster members. + def pre_fork(self, process_manager, kwargs=None): """ - if self._raft_service is not None: - return - - import salt.utils.asynchronous # pylint: disable=import-outside-toplevel + Do anything necessary pre-fork. Since this is on the master side this will + primarily be used to create IPC channels and create our daemon process to + do the actual publishing - try: - from salt.cluster.consensus.service import ( # pylint: disable=import-outside-toplevel - RaftService, + :param func process_manager: A ProcessManager, from salt.utils.process.ProcessManager + """ + if hasattr(self.transport, "publish_daemon"): + process_manager.add_process( + self._publish_daemon, kwargs=kwargs, name="EventPublisher" ) - aio_loop = salt.utils.asynchronous.aioloop(self.io_loop) - port = self.opts.get("cluster_port", 55596) - - # One pusher per remote host. Do not use ``build_peer_pushers`` here: - # discover-reply appends duplicate hosts to ``opts["cluster_peers"]`` and - # extra pushers, which would mis-align a plain zip with the static opts list. - peer_pushers = {p.pull_host: p for p in self.pushers} - for peer_id in known_peers: - if peer_id not in peer_pushers: - pusher = self.pusher(peer_id, port) - self._add_pusher(pusher) - peer_pushers[peer_id] = pusher - - self._raft_service = RaftService( + def _publish_daemon(self, **kwargs): + if ( + self.opts["event_publisher_niceness"] + and not salt.utils.platform.is_windows() + ): + log.info( + "setting EventPublisher niceness to %i", + self.opts["event_publisher_niceness"], + ) + os.nice(self.opts["event_publisher_niceness"]) + self.io_loop = tornado.ioloop.IOLoop.current() + tcp_master_pool_port = self.opts["cluster_pool_port"] + self.pushers = [] + self.auth_errors = {} + for peer in self.opts.get("cluster_peers", []): + pusher = salt.transport.tcp.PublishServer( self.opts, - aio_loop, - peer_pushers, - voting=False, - on_ready=self._signal_cluster_ready, + pull_host=peer, + pull_port=tcp_master_pool_port, ) - self._raft_service.attach(self) - aio_loop.call_soon(self._raft_service.start) - log.info( - "Raft consensus service started as learner for cluster %r after dynamic join", - self.opts["cluster_id"], + self.auth_errors[peer] = collections.deque() + self.pushers.append(pusher) + if self.opts.get("cluster_id", None): + self.pool_puller = salt.transport.tcp.TCPPuller( + host=self.opts["interface"], + port=tcp_master_pool_port, + io_loop=self.io_loop, + payload_handler=self.handle_pool_publish, ) - except Exception: # pylint: disable=broad-except - log.exception("Failed to start Raft consensus service after join") - - def gen_token(self): - return "".join(random.choices(string.ascii_letters + string.digits, k=32)) + self.pool_puller.start() + self.io_loop.add_callback( + self.transport.publisher, + self.publish_payload, + io_loop=self.io_loop, + ) + # run forever + try: + self.io_loop.start() + except (KeyboardInterrupt, SystemExit): + pass + finally: + self.close() - def _handle_multi_ring_runner_event(self, tag, data): + async def handle_pool_publish(self, payload): """ - Dispatch a ``cluster/runner/*`` event into the local - ``RaftService`` propose helpers. - - Called from :meth:`publish_payload` for the multi-ring - runners (``ring_create``, ``ring_destroy``, ``route_set``, - ``route_clear``, ``ring_set``). Each runner's payload shape - is bespoke; we keep the dispatch table close to the - intercept so a new runner is a one-line addition here plus a - runner stub in ``salt/runners/cluster.py``. - """ - svc = self._raft_service - if svc is None: - log.warning( - "Multi-ring runner event %s arrived but RaftService is not " - "wired up on this master; dropping", - tag, - ) - return - try: - if tag == "cluster/runner/ring_create": - svc.propose_ring_create( - data["ring_id"], - data.get("founding_voters") or [], - ) - elif tag == "cluster/runner/ring_destroy": - svc.propose_ring_destroy(data["ring_id"]) - elif tag == "cluster/runner/route_set": - svc.propose_route(data["data_type"], data["ring_id"]) - elif tag == "cluster/runner/route_clear": - svc.propose_route(data["data_type"], None) - elif tag == "cluster/runner/ring_set": - # Per-ring policy commit — proposes RING_CONFIG on - # the named ring's *own* log. The ring's Node must - # be the leader of its own group on this master. - ring_id = data["ring_id"] - ring_node = svc._nodes.get(ring_id) - if ring_node is None: - log.warning( - "cluster.ring_set %s: ring not hosted locally, " - "operator must invoke on a ring member", - ring_id, - ) - return - # Reuse the existing single-ring propose plumbing on - # the per-ring Node. ``RingConfigStateMachine.apply`` - # merges partial updates so omitted args preserve the - # current value. - from salt.cluster.consensus.raft.log import ( # pylint: disable=import-outside-toplevel - RING_MEMBERS_VALID, - LogEntryType, - ) - from salt.cluster.consensus.raft.node import ( # pylint: disable=import-outside-toplevel - NodeState, - ) - - members = data.get("members") - replicas = data.get("replicas") - if members is not None and members not in RING_MEMBERS_VALID: - log.warning( - "cluster.ring_set %s: unknown members policy %r", - ring_id, - members, - ) - return - if ring_node.state != NodeState.LEADER: - log.warning( - "cluster.ring_set %s: not the leader of this ring " - "(state=%s)", - ring_id, - ring_node.state, - ) - return - cmd = {} - if members is not None: - cmd["members"] = members - if replicas is not None: - cmd["replicas"] = int(replicas) - if not cmd: - return - ring_node.log_add(cmd, entry_type=LogEntryType.RING_CONFIG) - else: - log.warning("Unhandled multi-ring runner tag %s", tag) - except Exception: # pylint: disable=broad-except - log.exception("Multi-ring runner dispatch failed for %s", tag) - - async def _fanout_multi_ring_request(self, tag, data): - """ - Broadcast a multi-ring runner event to every peer. - - Originator side: when the operator runs ``cluster.ring_create`` / - ``ring_destroy`` / ``route_set`` / ``route_clear`` / ``ring_set`` - on this master, we may not be the Raft leader of the cluster - group (or the relevant ring's group). Wrap the payload in a - cluster_aes-encrypted ``cluster/peer/multi-ring-request`` event - and push it to every peer; each peer's handler re-dispatches via - :meth:`_handle_multi_ring_runner_event`, but only the leader's - propose actually appends a log entry — followers log "not - leader" and skip. This makes the runner location-independent - without requiring an explicit "who's the leader" lookup. - """ - try: - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - except Exception: # pylint: disable=broad-except - log.exception("multi-ring fan-out: cluster_aes unavailable") - return - payload = {"runner_tag": tag, "data": data} - event = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("multi-ring-request", "peer", "cluster"), - crypticle.dumps(payload), - ) - for pusher in self.pushers: - try: - await pusher.publish(event) - except Exception: # pylint: disable=broad-except - log.exception( - "multi-ring fan-out: failed to send %s to %s", - tag, - pusher.pull_host, - ) - - async def _run_delegate_write(self, payload): - """ - Forward a single delegated write to the named ring owner. - - Originator side of delegate-on-miss. The local - ``cluster/runner/delegate_write`` event (fired by - ``EventMonitor._delegate_on_miss``) carries a fully-formed - write request — we just have to find the owner's pusher and - send it. - """ - owner = payload.get("owner") - if not owner: - return - pusher = None - for candidate in self.pushers: - if candidate.pull_host == owner: - pusher = candidate - break - if pusher is None: - log.warning( - "delegate-on-miss: no pusher for owner %s; dropping %s write " - "for %s/%s", - owner, - payload.get("write_kind"), - payload.get("data_type"), - payload.get("ring_id"), - ) - return - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - event = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("delegate-write", "peer", "cluster"), - crypticle.dumps(payload), - ) - try: - await pusher.publish(event) - except Exception: # pylint: disable=broad-except - log.exception("delegate-on-miss: failed to forward to %s", owner) - else: - log.info( - "delegate-on-miss: forwarded %s write for %s to %s", - payload.get("write_kind"), - payload.get("data_type"), - owner, - ) - - def _handle_delegate_write(self, payload): - """ - Peer-side handler for ``cluster/peer/delegate-write``. - - Applies the delegated write directly via - :mod:`salt.utils.job` *without* re-running the gate (which - would loop the event back through the cluster bus). The - salt_cache returner's replay protection ensures duplicate - delegates of the same (jid, minion_id) drop cleanly. - """ - import salt.utils.job # pylint: disable=import-outside-toplevel - - write_kind = payload.get("write_kind") - body = payload.get("payload") or {} - try: - if write_kind == "store_minions": - salt.utils.job.store_minions( - self.opts, body.get("jid"), body.get("minions") or [] - ) - elif write_kind == "store_job": - salt.utils.job.store_job(self.opts, body) - else: - log.warning( - "delegate-on-miss: unknown write_kind %r; dropping", - write_kind, - ) - except Exception: # pylint: disable=broad-except - log.exception( - "delegate-on-miss: failed to apply %s for %s/%s", - write_kind, - payload.get("data_type"), - payload.get("ring_id"), - ) - - async def _run_shed_unowned_all(self, request_payload): - """ - Fan out a shed-unowned request to every peer. - - Originator side of ``cluster.shed_unowned_all`` — wraps the - runner-supplied payload in a ``cluster_aes``-encrypted event - and publishes it to every peer's pool channel. Each peer's - :meth:`handle_pool_publish` intercepts the event and runs the - same shed code path locally via - :func:`salt.cluster.migration.perform_shed`. - """ - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - event = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("shed-request", "peer", "cluster"), - crypticle.dumps(request_payload), - ) - for pusher in self.pushers: - try: - await pusher.publish(event) - except Exception: # pylint: disable=broad-except - log.exception( - "cluster.shed_unowned_all: failed to send shed-request to %s", - pusher.pull_host, - ) - else: - log.info( - "cluster.shed_unowned_all: requested shed of %s from %s", - request_payload.get("ring_id"), - pusher.pull_host, - ) - - def _handle_shed_request(self, request_payload): - """ - Peer-side handler for ``cluster/peer/shed-request``. - - Runs :func:`salt.cluster.migration.perform_shed` with the - payload's parameters and writes the result into the local - shed sentinel so the originator can poll for it via - ``cluster.shed_status``. - """ - from salt.cluster import migration # pylint: disable=import-outside-toplevel - - try: - result = migration.perform_shed( - self.opts, - request_payload.get("ring_id"), - banks=tuple( - request_payload.get("banks") - or migration.perform_shed.__defaults__[0] - ), - subbank_template=request_payload.get("subbank_template"), - driver=request_payload.get("driver"), - dry_run=bool(request_payload.get("dry_run")), - ) - except Exception as exc: # pylint: disable=broad-except - log.exception( - "cluster.shed_unowned_all: peer-side shed failed for ring=%s", - request_payload.get("ring_id"), - ) - result = { - "status": "error", - "ring": request_payload.get("ring_id"), - "error": str(exc), - } - migration.write_shed_status(self.opts, result, source="peer_request") - - async def _run_collect_from_peers(self, channels): - """ - Operator-driven *pull* of cache contents from every peer. - Mirror image of :meth:`_run_root_sync_to_peers` — instead of - this master being the sender, this master is the receiver - and asks each peer to send. - - Accepts both the fixed ``keys`` / ``denied_keys`` channels - and any ``bank:`` channel (the multi-ring - migration uses the latter for the salt_cache returner's - ``jobs/*`` banks). - - Wire shape: - - 1. For each peer, emit a ``cluster/peer/collect-request`` - event tagged with this master's interface as the - ``requester`` and the channel list. - 2. The peer's ``publish_payload`` sees the event, opens an - outbound state-sync session pointed back at the - requester, and streams the requested channels (same wire - format as ``cluster.sync_roots``). - 3. The requester's existing - ``cluster/peer/state-sync-chunk`` receiver applies each - chunk to its local cache. ``bank:`` channels route to - :func:`salt.cluster.state_sync.install_bank_chunk`. - - Fire-and-forget: the operator polls for completion by - inspecting the local cache or master log. ``cluster_aes`` - protects every payload. - """ - from salt.cluster.state_sync import ( # pylint: disable=import-outside-toplevel - BANK_CHANNEL_PREFIX, - ) - - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - requester = self.opts.get("interface") - if requester is None: - log.warning( - "cluster.collect_from_peers: opts['interface'] not set; aborting" - ) - return - valid_channels = [ - ch - for ch in channels - if ch in ("keys", "denied_keys") or ch.startswith(BANK_CHANNEL_PREFIX) - ] - if not valid_channels: - log.warning( - "cluster.collect_from_peers: no valid channels in %r; aborting", - channels, - ) - return - request_payload = {"requester": requester, "channels": valid_channels} - expected_peers = [p.pull_host for p in self.pushers] - self._init_collect_sentinel(valid_channels, expected_peers, requester) - for pusher in self.pushers: - peer_id = pusher.pull_host - event = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("collect-request", "peer", "cluster"), - crypticle.dumps(request_payload), - ) - try: - await pusher.publish(event) - except Exception: # pylint: disable=broad-except - log.exception( - "cluster.collect_from_peers: failed to send " - "collect-request to %s", - peer_id, - ) - else: - log.info( - "cluster.collect_from_peers: requested %s from %s", - valid_channels, - peer_id, - ) - - def _init_collect_sentinel(self, channels, expected_peers, requester): - """ - Initialise the per-process collect-status structure and - flush it to disk. Subsequent - :meth:`_record_collect_chunk` calls update the same file - as chunks land, so an operator polling - ``cachedir/cluster-collect-status.json`` can confirm - completion without grepping logs. - """ - import time # pylint: disable=import-outside-toplevel - - self._collect_status = { - "started_at": time.time(), - "updated_at": time.time(), - "requester": requester, - "channels": list(channels), - "expected_peers": sorted(expected_peers), - "items_installed": 0, - "chunks_installed": 0, - "per_channel": { - ch: {"chunks": 0, "items": 0, "eofs_seen": 0} for ch in channels - }, - "complete": False, - } - self._write_collect_sentinel() - - def _record_collect_chunk(self, channel, items_installed, eof): - """ - Update the in-memory collect status with one chunk's - outcome and flush. - - ``complete`` flips to True when every channel has seen at - least ``len(expected_peers)`` eofs — the heuristic for "every - peer responded for every requested channel." Operators - watching the file poll for the boolean, not the per-channel - counts. - """ - import time # pylint: disable=import-outside-toplevel - - status = getattr(self, "_collect_status", None) - if status is None: - return - per_ch = status["per_channel"].setdefault( - channel, {"chunks": 0, "items": 0, "eofs_seen": 0} - ) - per_ch["chunks"] += 1 - per_ch["items"] += int(items_installed) - if eof: - per_ch["eofs_seen"] += 1 - status["chunks_installed"] += 1 - status["items_installed"] += int(items_installed) - status["updated_at"] = time.time() - expected_eofs = len(status["expected_peers"]) - if expected_eofs > 0 and all( - ch_state["eofs_seen"] >= expected_eofs - for ch_state in status["per_channel"].values() - ): - status["complete"] = True - self._write_collect_sentinel() - - def _write_collect_sentinel(self): - """ - Persist ``self._collect_status`` to - ``cachedir/cluster-collect-status.json``. - - Atomic write — tmp file + rename — so an operator polling - the sentinel during a high-rate collect never sees a torn - write. Each chunk-arrival path calls back here, so the - write frequency is bounded only by chunk arrival rate. - """ - import json # pylint: disable=import-outside-toplevel - import os # pylint: disable=import-outside-toplevel - - import salt.utils.atomicfile # pylint: disable=import-outside-toplevel - - cachedir = self.opts.get("cachedir") - if not cachedir: - return - path = os.path.join(cachedir, "cluster-collect-status.json") - try: - with salt.utils.atomicfile.atomic_open(path, "w") as fp: - json.dump(self._collect_status, fp) - except OSError as exc: - log.warning( - "cluster.collect_from_peers: failed to write status sentinel %s: %s", - path, - exc, - ) - - async def _handle_collect_request(self, requester, channels): - """ - Peer-side handler for ``cluster/peer/collect-request``. - - Opens an outbound state-sync session back to *requester* and - streams the requested cache channels. Three channel shapes - are accepted: - - * ``keys`` / ``denied_keys`` — minion-keys banks shipped via - :func:`salt.cluster.state_sync.iter_keys_chunks`. - * ``bank:`` — arbitrary :class:`salt.cache.Cache` - banks shipped via - :func:`salt.cluster.state_sync.iter_bank_chunks`. Used by - the multi-ring jobs migration. - - The requester's existing - ``cluster/peer/state-sync-chunk`` receiver routes installs by - the same channel string. - """ - from salt.cluster.state_sync import ( # pylint: disable=import-outside-toplevel - bank_from_channel, - iter_bank_chunks, - iter_keys_chunks, - new_session_id, - ) - - pusher = None - for candidate in self.pushers: - if candidate.pull_host == requester: - pusher = candidate - break - if pusher is None: - log.warning( - "cluster/peer/collect-request from %s: no pusher for that " - "requester; ignoring", - requester, - ) - return - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - session_id = new_session_id() - - # Announce the session so the requester's receiver registers - # it before chunks arrive. Reuse the sync-roots-begin shape - # — the on-complete on that path is a plain teardown, which - # is what we want here too. Mark the origin so the - # requester's chunk handler updates the collect-status - # sentinel and not the sync_roots one. - begin_payload = { - "session": session_id, - "channels": channels, - "origin": "collect", - } - begin_event = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("sync-roots-begin", "peer", "cluster"), - crypticle.dumps(begin_payload), - ) - try: - await pusher.publish(begin_event) - except Exception: # pylint: disable=broad-except - log.exception( - "cluster.collect_from_peers: failed to announce session %s to %s", - session_id, - requester, - ) - return - - for channel in channels: - bank = bank_from_channel(channel) - if bank is not None: - chunks = iter_bank_chunks(self.opts, bank) - else: - chunks = iter_keys_chunks(self.opts, channel) - await self._send_sync_roots_channel( - pusher, - crypticle, - session_id, - requester, - channel, - chunks, - ) - - async def _run_root_sync_to_peers(self, channels): - """ - Operator-driven push of ``file_roots`` and/or ``pillar_roots`` to - every peer in the cluster. Triggered by the ``cluster.sync_roots`` - runner via the ``cluster/runner/sync_roots`` local event. - - For each peer: - - 1. Allocate a fresh session id. - 2. Emit a ``cluster/peer/sync-roots-begin`` event to the peer so - the receiver pre-registers a state-sync session — same - contract as the join-reply flow, but the receiver's - ``on_complete`` is a no-op (this is an ad-hoc push, not a - Raft-learner bootstrap). - 3. Stream the requested channels to that peer via the standard - state-sync chunk format. - - Errors per-peer are logged but do not stop the fan-out — a - partially-online cluster can still receive the update on - reachable peers. - """ - from salt.cluster.state_sync import ( # pylint: disable=import-outside-toplevel - FILE_ROOTS_CHANNEL, - PILLAR_ROOTS_CHANNEL, - new_session_id, - ) - - roots_for = { - FILE_ROOTS_CHANNEL: self.opts.get("file_roots"), - PILLAR_ROOTS_CHANNEL: self.opts.get("pillar_roots"), - } - # Honour the channel filter — operator may want only one of the - # two trees synced. - active_channels = [ - ch for ch in (FILE_ROOTS_CHANNEL, PILLAR_ROOTS_CHANNEL) if ch in channels - ] - if not active_channels: - log.warning( - "cluster.sync_roots: no valid channels (got %r), skipping", - channels, - ) - return - - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - - for pusher in self.pushers: - await self._send_sync_roots_to_peer( - pusher, - active_channels, - roots_for, - crypticle, - new_session_id(), - ) - - async def _send_sync_roots_to_peer( - self, pusher, active_channels, roots_for, crypticle, session_id - ): - """ - Push one ``cluster.sync_roots`` session to a single peer. - - Split out of :meth:`_run_root_sync_to_peers` so the per-peer - loop variables are explicit method arguments — avoids - cell-var-from-loop closure captures on the per-channel send. - """ - from salt.cluster.state_sync import ( # pylint: disable=import-outside-toplevel - iter_root_chunks, - ) - - peer_id = pusher.pull_host - log.info( - "cluster.sync_roots: starting %s to peer %s (session %s)", - active_channels, - peer_id, - session_id, - ) - - # Pre-announce the session to the receiver. Encrypted under - # cluster_aes; the receiver decrypts and registers the session - # with an on_complete that tears down the registry entry (vs. - # join-reply's ``_start_raft_as_learner``). - begin_payload = {"session": session_id, "channels": active_channels} - begin_event = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("sync-roots-begin", "peer", "cluster"), - crypticle.dumps(begin_payload), - ) - try: - await pusher.publish(begin_event) - except Exception: # pylint: disable=broad-except - log.exception( - "cluster.sync_roots: failed to send sync-roots-begin to %s", - peer_id, - ) - return - - for channel in active_channels: - await self._send_sync_roots_channel( - pusher, - crypticle, - session_id, - peer_id, - channel, - iter_root_chunks(roots_for[channel]), - ) - - async def _send_sync_roots_channel( - self, pusher, crypticle, session_id, peer_id, channel, chunks - ): - """ - Stream one state-sync channel's chunks to a single peer for an - operator-driven ``cluster.sync_roots`` session. - """ - chunks = list(chunks) - if not chunks: - chunks = [[]] - total = len(chunks) - for seq, items in enumerate(chunks): - payload = { - "session": session_id, - "channel": channel, - "seq": seq, - "total": total, - "eof": seq == total - 1, - "items": items, - } - chunk_event = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("state-sync-chunk", "peer", "cluster"), - crypticle.dumps(payload), - ) - try: - await pusher.publish(chunk_event) - except Exception: # pylint: disable=broad-except - log.exception( - "cluster.sync_roots: chunk %s/%s seq=%d to %s failed", - session_id, - channel, - seq, - peer_id, - ) - return - log.info( - "cluster.sync_roots: %s/%s sent %d chunks (%d items) to %s", - session_id, - channel, - total, - sum(len(c) for c in chunks), - peer_id, - ) - - async def _send_state_sync_chunks(self, session_id, peer_id): - """ - Stream the four state-sync channels (keys, denied_keys, - file_roots, pillar_roots) to a freshly joined peer. - - Each channel runs to completion independently and emits at least - one chunk (an empty chunk with ``eof=True`` if the channel has - no data). All chunks are encrypted with the cluster session AES - key — the joiner has it from the join-reply we just sent. - """ - from salt.cluster.state_sync import ( # pylint: disable=import-outside-toplevel - DENIED_CHANNEL, - FILE_ROOTS_CHANNEL, - KEYS_CHANNEL, - PILLAR_ROOTS_CHANNEL, - iter_keys_chunks, - iter_root_chunks, - ) - - pusher = self.pusher(peer_id) - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - - async def send_channel(channel, chunk_iter): - chunks = list(chunk_iter) - if not chunks: - # Defensive: every iter_*_chunks must yield >= 1 (empty - # for empty data). Synthesize an eof-only chunk so the - # receiver doesn't hang on a missing channel. - chunks = [[]] - total = len(chunks) - for seq, items in enumerate(chunks): - payload = { - "session": session_id, - "channel": channel, - "seq": seq, - "total": total, - "eof": seq == total - 1, - "items": items, - } - event_data = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("state-sync-chunk", "peer", "cluster"), - crypticle.dumps(payload), - ) - try: - await pusher.publish(event_data) - except Exception: # pylint: disable=broad-except - log.exception( - "state-sync %s/%s seq=%d publish failed to %s", - session_id, - channel, - seq, - peer_id, - ) - return - log.info( - "state-sync %s/%s sent %d chunks (%d items total) to %s", - session_id, - channel, - total, - sum(len(c) for c in chunks), - peer_id, - ) - - # Run the four channels concurrently — each finishes when it - # finishes, and a slow file_roots stream does not block keys. - try: - await asyncio.gather( - send_channel(KEYS_CHANNEL, iter_keys_chunks(self.opts, KEYS_CHANNEL)), - send_channel( - DENIED_CHANNEL, iter_keys_chunks(self.opts, DENIED_CHANNEL) - ), - send_channel( - FILE_ROOTS_CHANNEL, - iter_root_chunks(self.opts.get("file_roots")), - ), - send_channel( - PILLAR_ROOTS_CHANNEL, - iter_root_chunks(self.opts.get("pillar_roots")), - ), - ) - except Exception: # pylint: disable=broad-except - log.exception("state-sync session %s aborted unexpectedly", session_id) - - def _apply_state_sync_chunk(self, chunk): - """ - Install one ``cluster/peer/state-sync-chunk`` payload locally. - - The chunk has already been Crypticle-decrypted by the caller. - We dispatch to the right install helper by ``chunk["channel"]``, - then ping the matching :class:`StateSyncSession` so - :meth:`_start_raft_as_learner` fires once all four channels eof. - """ - from salt.cluster.state_sync import ( # pylint: disable=import-outside-toplevel - DENIED_CHANNEL, - FILE_ROOTS_CHANNEL, - KEYS_CHANNEL, - PILLAR_ROOTS_CHANNEL, - bank_from_channel, - install_bank_chunk, - install_keys_chunk, - install_root_chunk, - ) - - if not isinstance(chunk, dict): - log.warning("state-sync chunk is not a dict: %r", type(chunk).__name__) - return - session_id = chunk.get("session") - channel = chunk.get("channel") - seq = chunk.get("seq", -1) - eof = bool(chunk.get("eof")) - items = chunk.get("items") or [] - sessions = getattr(self, "_state_sync_sessions", None) or {} - session = sessions.get(session_id) - if session is None: - log.warning( - "state-sync chunk for unknown session %r (channel=%s seq=%s); dropping", - session_id, - channel, - seq, - ) - return - - installed = 0 - try: - if channel in (KEYS_CHANNEL, DENIED_CHANNEL): - installed = install_keys_chunk(self.opts, channel, items) - elif channel == FILE_ROOTS_CHANNEL: - installed = install_root_chunk(self.opts.get("file_roots"), items) - elif channel == PILLAR_ROOTS_CHANNEL: - installed = install_root_chunk(self.opts.get("pillar_roots"), items) - else: - # ``bank:`` channels carry arbitrary cache - # entries — used by ``cluster.collect_from_peers`` - # for caches outside the four join-time channels. - bank = bank_from_channel(channel) - if bank: - installed = install_bank_chunk(self.opts, bank, items) - else: - log.warning( - "state-sync chunk for unknown channel %r " - "(session=%s seq=%s)", - channel, - session_id, - seq, - ) - return - except Exception: # pylint: disable=broad-except - log.exception( - "state-sync %s/%s seq=%s install failed", - session_id, - channel, - seq, - ) - - log.info( - "state-sync %s/%s seq=%s installed %d items%s", - session_id, - channel, - seq, - installed, - " (eof)" if eof else "", - ) - # Collect-origin sessions update the operator-visible - # status sentinel — bumps a counter the operator polls - # while waiting for the runner's fan-out to finish. - if getattr(session, "origin", "sync_roots") == "collect": - self._record_collect_chunk(channel, installed, eof) - session.record_chunk(channel, seq, eof, installed) - - def _begin_state_sync_session(self, session_id, known_peers, discover_event): - """ - Register a state-sync session and arm its watchdog timer. - - Called from the join-reply handler once we know the responder is - running with ``cluster_isolated_filesystem=True`` and intends to - push the four chunked channels at us. The session's - ``on_complete`` callback fires - :meth:`_start_raft_as_learner` exactly once, either when all four - channels report eof or when the deadline expires. - """ - from salt.cluster.state_sync import ( # pylint: disable=import-outside-toplevel - DEFAULT_RECEIVE_TIMEOUT, - StateSyncSession, - ) - - if not hasattr(self, "_state_sync_sessions"): - self._state_sync_sessions = {} - - if session_id in self._state_sync_sessions: - log.warning( - "Duplicate state-sync session id %s; ignoring second join-reply", - session_id, - ) - return - - completed_holder = {"done": False} - - def _on_complete(): - if completed_holder["done"]: - return - completed_holder["done"] = True - try: - self._start_raft_as_learner(known_peers) - except Exception: # pylint: disable=broad-except - log.exception( - "state-sync %s: _start_raft_as_learner failed", session_id - ) - if discover_event is not None: - discover_event.set() - # Cancel the watchdog if it hasn't fired yet. - handle = session.watchdog_handle - if handle is not None: - try: - handle.cancel() - except Exception: # pylint: disable=broad-except - pass - # Drop the session from the registry — keep memory bounded. - self._state_sync_sessions.pop(session_id, None) - - session = StateSyncSession(session_id, _on_complete) - # Stash the watchdog handle on the session so on_complete can - # cancel it; created below. - session.watchdog_handle = None - self._state_sync_sessions[session_id] = session - - try: - loop = asyncio.get_event_loop() - session.watchdog_handle = loop.call_later( - DEFAULT_RECEIVE_TIMEOUT, session.force_complete - ) - except RuntimeError: - # No running event loop in this context (defensive — the - # join-reply handler runs inside ``_publish_daemon``'s loop, - # so this branch should not execute in production). - log.warning( - "state-sync %s: no event loop for watchdog; running without timeout", - session_id, - ) - - def _begin_root_sync_session(self, session_id, channels, origin="sync_roots"): - """ - Pre-register a state-sync session for an operator-driven - content push. - - *origin* distinguishes between session shapes that share the - same wire format: - - * ``"sync_roots"`` (default) — push from - ``cluster.sync_roots``; the on-complete only tears down the - registry entry. - * ``"collect"`` — pull driven by - ``cluster.collect_from_peers``; the chunk handler also - updates ``cachedir/cluster-collect-status.json`` so an - operator can confirm completion without tailing logs. - - Idempotent: a duplicate begin for an already-registered - session is logged and ignored. - """ - from salt.cluster.state_sync import ( # pylint: disable=import-outside-toplevel - ALL_CHANNELS, - DEFAULT_RECEIVE_TIMEOUT, - StateSyncSession, - ) - - if not session_id: - log.warning("sync-roots-begin without session_id; dropping") - return - if not hasattr(self, "_state_sync_sessions"): - self._state_sync_sessions = {} - if session_id in self._state_sync_sessions: - log.warning( - "Duplicate sync-roots-begin session id %s; ignoring", session_id - ) - return - - # Defensive: empty channels list defaults to all four (matches - # join-time semantics). - if not channels: - channels = list(ALL_CHANNELS) - - completed_holder = {"done": False} - - def _on_complete(): - if completed_holder["done"]: - return - completed_holder["done"] = True - log.info( - "cluster.sync_roots: session %s complete (channels=%s)", - session_id, - channels, - ) - handle = session.watchdog_handle - if handle is not None: - try: - handle.cancel() - except Exception: # pylint: disable=broad-except - pass - self._state_sync_sessions.pop(session_id, None) - - # The StateSyncSession state machine tracks per-channel eof. We - # tell it about only the channels we expect — when each fires - # eof, the session triggers on_complete. - session = StateSyncSession(session_id, _on_complete, channels=channels) - session.watchdog_handle = None - session.origin = origin - self._state_sync_sessions[session_id] = session - - try: - loop = asyncio.get_event_loop() - session.watchdog_handle = loop.call_later( - DEFAULT_RECEIVE_TIMEOUT, session.force_complete - ) - except RuntimeError: - log.warning( - "cluster.sync_roots: no event loop for watchdog on session %s", - session_id, - ) - - def _join_sentinel_path(self): - """ - Return the path to the per-master join sentinel file. - - The filename is namespaced by the master's interface address so that - deployments which share ``cachedir`` between cluster members (and the - cluster integration tests, which point every master at the same - ``cluster_cache_path``) keep distinct sentinels — without that, the - first master to join writes ``.cluster_joined`` and every later - master sees it on startup, takes the "rejoining" path, and skips - the deterministic founding-voter election. - """ - interface = self.opts.get("interface") or "unknown" - return pathlib.Path(self.opts["cachedir"]) / f".cluster_joined.{interface}" - - def _has_joined_cluster(self): - """ - Return True if this master has previously completed the cluster join - handshake. The sentinel is per-master (see :meth:`_join_sentinel_path`). - """ - return self._join_sentinel_path().exists() - - def _mark_joined_cluster(self): - """Write the join sentinel to signal that this master has joined.""" - sentinel = self._join_sentinel_path() - try: - sentinel.touch() - except OSError as exc: - log.warning("Could not write cluster join sentinel %s: %s", sentinel, exc) - - def discover_peers(self): - """ - Send a ``cluster/peer/discover`` event to each configured peer. - - Called during master startup when this node has no Raft history (term=0, - empty log), meaning it is joining an existing cluster for the first time. - Existing peers will reply with ``cluster/peer/discover-reply``, which - triggers the full join handshake and eventually ``cluster/peer/join-reply`` - received by ``handle_pool_publish``. - """ - path = self.master_key.master_pub_path - with salt.utils.files.fopen(path, "r") as fp: - pub = fp.read() - - self._discover_token = self.gen_token() - - for peer in self.cluster_peers: - log.info("Sending cluster discover to peer %s", peer) - tosign = salt.payload.package( - { - "peer_id": self.opts["id"], - "pub": pub, - "token": self._discover_token, - } - ) - key = salt.crypt.PrivateKeyString(self.private_key()) - sig = key.sign(tosign, algorithm=self.opts["publish_signing_algorithm"]) - data = { - "sig": sig, - "payload": tosign, - } - with salt.utils.event.get_master_event( - self.opts, self.opts["sock_dir"], listen=False - ) as event: - success = event.fire_event( - data, - salt.utils.event.tagify("discover", "peer", "cluster"), - timeout=30000, - ) - if not success: - log.error("Unable to send cluster discover event to %s", peer) - - def send_aes_key_event(self): - log.debug("Sending AES key event") - # ``cluster_peers`` is documented to hold bare master names so the - # cluster identity used on the wire and on disk must match that - # form. ``apply_master_config`` auto-appends ``_master`` to - # ``opts["id"]`` when ``id`` is not configured, leaving sibling - # masters unable to find their own entry in ``data["peers"]``. - # See https://github.com/saltstack/salt/issues/68462. - master_id = self.opts["id"].removesuffix("_master") - data = {"peer_id": master_id, "peers": {}} - for peer in self.cluster_peers: - peer_pub = ( - pathlib.Path(self.opts["cluster_pki_dir"]) / "peers" / f"{peer}.pub" - ) - if peer_pub.exists(): - pub = salt.crypt.PublicKey.from_file(peer_pub) - aes = salt.master.SMaster.secrets["aes"]["secret"].value - digest = salt.utils.stringutils.to_bytes( - hashlib.sha256(aes).hexdigest() - ) - data["peers"][peer] = { - "aes": pub.encrypt( - aes, algorithm=self.opts["cluster_encryption_algorithm"] - ), - "sig": self.master_key.master_key.encrypt(digest), - } - else: - log.warning("Peer key missing %r", peer_pub) - # request peer key - data["peers"][peer] = {} - with salt.utils.event.get_master_event( - self.opts, self.opts["sock_dir"], listen=False - ) as event: - success = event.fire_event( - data, - salt.utils.event.tagify(master_id, "peer", "cluster"), - timeout=30000, # 30 second timeout - ) - if not success: - log.error("Unable to send aes key event") - - def __getstate__(self): - return { - "opts": self.opts, - "transport": self.transport, - } - - def __setstate__(self, state): - self.opts = state["opts"] - self.transport = state["transport"] - self._discover_event = None - self._raft_dispatcher = None - self._raft_service = None - - def close(self): - self.transport.close() - - def pre_fork(self, process_manager, *args, **kwargs): - """ - Do anything necessary pre-fork. Since this is on the master side this will - primarily be used to create IPC channels and create our daemon process to - do the actual publishing - - :param func process_manager: A ProcessManager, from salt.utils.process.ProcessManager - """ - if hasattr(self.transport, "publish_daemon"): - proc_kwargs = kwargs.pop("kwargs", kwargs) - process_manager.add_process( - self._publish_daemon, kwargs=proc_kwargs, name="EventPublisher" - ) - - def _publish_daemon(self, **kwargs): - """Clean implementation: separate local IPC from cluster peer communication.""" - import salt.master # pylint: disable=import-outside-toplevel - - if ( - self.opts.get("event_publisher_niceness") - and not salt.utils.platform.is_windows() - ): - log.info( - "setting EventPublisher niceness to %i", - self.opts["event_publisher_niceness"], - ) - os.nice(self.opts["event_publisher_niceness"]) - - self.io_loop = tornado.ioloop.IOLoop.current() - - # Always set up the local IPC-based event publisher first - # This ensures internal processes (like pytest_engine) can communicate reliably - if hasattr(self.transport, "publisher"): - aio_loop = salt.utils.asynchronous.aioloop(self.io_loop) - aio_loop.create_task( - self.transport.publisher( - self.publish_payload, - io_loop=self.io_loop, - ) - ) - - # Initialize cluster peer state unconditionally so that non-cluster - # masters also have an empty ``pushers`` list -- publish_payload - # iterates ``self.pushers`` on every event. - self.pushers = [] - - # Cluster-specific peer communication (separate from local IPC) - if self.opts.get("cluster_id"): - self.tcp_master_pool_port = self.opts.get("cluster_port", 55596) - self.auth_errors = collections.defaultdict(collections.deque) - self.peer_map = {} - - for peer in self.opts.get("cluster_peers", []): - host, port = ( - peer.rsplit(":", 1) - if ":" in peer - else (peer, self.tcp_master_pool_port) - ) - pusher = self.pusher(host, int(port)) - self._add_pusher(pusher) - - # Set up the cluster pool puller for incoming peer events - self.pool_puller = salt.transport.tcp.TCPPuller( - host=self.opts.get("interface", "127.0.0.1"), - port=self.tcp_master_pool_port, - io_loop=self.io_loop, - payload_handler=self.handle_pool_publish, - ) - self.pool_puller.start() - - # Start the Raft node when this master is part of a cluster. - # A node without a cluster private key hasn't completed the join - # handshake yet — defer Raft startup to _start_raft_as_learner, which - # is called from handle_pool_publish when join-reply arrives. - self._raft_service = None - if self.opts.get("cluster_id") and self.opts.get("cluster_peers"): - _is_new_node = not self._has_joined_cluster() - - if not _is_new_node: - aio_loop = salt.utils.asynchronous.aioloop(self.io_loop) - try: - from salt.cluster.consensus.service import ( - RaftService, - build_peer_pushers, - ) - - peer_pushers = build_peer_pushers(self.opts, self.pushers) - self._raft_service = RaftService( - self.opts, - aio_loop, - peer_pushers, - on_ready=self._signal_cluster_ready, - ) - self._raft_service.attach(self) - aio_loop.call_soon(self._raft_service.start) - log.info( - "Raft consensus service started for cluster %r", - self.opts["cluster_id"], - ) - except Exception: # pylint: disable=broad-except - log.exception("Failed to start Raft consensus service") - else: - # Deterministic bootstrap: only the lowest interface address - # in the configured cluster bootstraps as the founding - # voter. Every other master comes up as a learner via the - # join-reply path. This eliminates the race where several - # masters' timers expire before they can exchange - # join-replies and each one bootstraps its own single-member - # cluster, leaving the cluster with multiple disjoint - # leaders or — when join-replies land first — zero voters. - bootstrap_pool = sorted( - {self.opts["interface"], *self.opts.get("cluster_peers", [])} - ) - aio_loop_deferred = salt.utils.asynchronous.aioloop(self.io_loop) - if bootstrap_pool and bootstrap_pool[0] == self.opts["interface"]: - log.info( - "New node bootstrapping cluster %r as designated founder", - self.opts["cluster_id"], - ) - # The founder is the lowest-IP master and never runs - # discover (see ``salt.master.Master.start``), so no - # inbound join-reply can race this start-up. Still - # delay by ``cluster_join_timeout`` before starting - # Raft so peer masters have time to bring up their - # cluster pool pullers — the very first ``pre-vote`` - # the founder fires must reach at least one peer to - # form quorum, otherwise the node never re-arms its - # election timer. - _join_timeout = self.opts.get("cluster_join_timeout", 5) - aio_loop_deferred.call_later( - _join_timeout, self._start_raft_as_founding_voter - ) - else: - log.info( - "New node joining cluster %r — waiting for join-reply to start Raft as learner", - self.opts["cluster_id"], - ) - # run forever - try: - self.io_loop.start() - except (KeyboardInterrupt, SystemExit): - pass - finally: - if self._raft_service is not None: - self._raft_service.stop() - self.close() - - def _signal_cluster_ready(self): - """ - Set the ``cluster_ready`` event in ``SMaster.secrets`` so that request - workers know this node is a committed Raft voter and may serve traffic. - - Called exactly once by ``RaftService._on_membership_change`` when the - founding or promotion CONFIG entry commits with this node in the voter set. - - Also writes the Kubernetes readiness sentinel so an exec probe - can route traffic to this master. - """ - import salt.cluster.healthchecks # pylint: disable=import-outside-toplevel - import salt.master # pylint: disable=import-outside-toplevel - - entry = salt.master.SMaster.secrets.get("cluster_ready") - if entry is not None: - log.info("MasterPubServerChannel: cluster ready — opening request gate") - entry["event"].set() - salt.cluster.healthchecks.mark_cluster_ready(self.opts) - - def private_key(self): - """ - The public key string associated with this node. - """ - # XXX Do not read every time - path = self.master_key.master_rsa_path - with salt.utils.files.fopen(path, "r") as fp: - return fp.read() - - def public_key(self): - """ - The public key string associated with this node. - """ - # XXX Do not read every time - path = self.master_key.master_pub_path - with salt.utils.files.fopen(path, "r") as fp: - return fp.read() - - def cluster_key(self): - """ - The private key associated with this cluster. - """ - # XXX Do not read every time - path = pathlib.Path(self.master_key.cluster_rsa_path) - if path.exists(): - return path.read_text(encoding="utf-8") - - def cluster_public_key(self): - """ - The private key associated with this cluster. - """ - # XXX Do not read every time - path = pathlib.Path(self.master_key.cluster_pub_path) - if path.exists(): - return path.read_text(encoding="utf-8") - - def pusher(self, peer, port=None): - if port is None: - port = self.tcp_master_pool_port - return salt.transport.tcp.PublishServer( - self.opts, - pull_host=peer, - pull_port=port, - ) - - def _add_pusher(self, pusher): - """ - Append *pusher* to :attr:`self.pushers` only if no existing - pusher already targets the same ``(pull_host, pull_port)``. - - The list of pushers is what every fan-out path - (publish_payload's cluster-event broadcast, sync_roots, - collect_from_peers, shed_unowned_all, delegate-on-miss) - iterates over. A duplicate entry causes every event to ship - twice — wasted bandwidth and, more dangerously, non-atomic - sentinel writes that interleave under concurrent arrivals on - the peer side. Multiple code paths add pushers (static - config, join-reply, discover-reply, late-joiner - ``_start_raft_as_learner``); pre-this-fix, a master that - statically configured a peer AND saw a join-reply for it - ended up with the peer twice in the list. - """ - host = getattr(pusher, "pull_host", None) - port = getattr(pusher, "pull_port", None) - for existing in self.pushers: - if ( - getattr(existing, "pull_host", None) == host - and getattr(existing, "pull_port", None) == port - ): - return - self.pushers.append(pusher) - - async def handle_pool_publish(self, payload): - """ - Handle incoming events from cluster peer. + Handle incomming events from cluster peer. """ try: tag, data = salt.utils.event.SaltEvent.unpack(payload) - if salt.cluster.consensus.rpc.is_raft_tag(tag): - if self._raft_dispatcher is not None: - try: - ( - _, - src, - rpc_id, - raft_group_id, - rpc_payload, - ) = salt.cluster.consensus.rpc.unpack(payload) - await self._raft_dispatcher.dispatch( - tag, src, rpc_id, rpc_payload, raft_group_id=raft_group_id - ) - except Exception: # pylint: disable=broad-except - log.exception("Error dispatching Raft RPC tag %s", tag) - else: - log.debug( - "Raft RPC received but dispatcher not initialised: %s", tag - ) - return - log.debug("Incomming from peer %s %r", tag, data) - if tag.startswith("cluster/peer/state-sync-chunk"): - # Encrypted with the shared cluster_aes the joiner just - # installed in the matching join-reply. Each chunk - # belongs to one of four channels; install items - # in-order, mark eof when announced, and let the - # session's ``on_complete`` fire ``_start_raft_as_learner``. - try: - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - chunk = crypticle.loads(data) - except Exception: # pylint: disable=broad-except - log.exception("Failed to decrypt state-sync-chunk") - return - self._apply_state_sync_chunk(chunk) - return - if tag.startswith("cluster/peer/sync-roots-begin"): - # Operator-driven push from a peer (via the - # ``cluster.sync_roots`` runner). Pre-register a state- - # sync session keyed by the announced session_id so the - # subsequent ``state-sync-chunk`` events have somewhere - # to land. ``on_complete`` here is a no-op (just removes - # the registry entry) because this is an ad-hoc content - # push, not a join-time Raft-learner bootstrap. - try: - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - begin = crypticle.loads(data) - except Exception: # pylint: disable=broad-except - log.exception("Failed to decrypt sync-roots-begin") - return - self._begin_root_sync_session( - begin.get("session"), - begin.get("channels") or [], - origin=begin.get("origin", "sync_roots"), - ) - return - if tag.startswith("cluster/peer/delegate-write"): - # Delegate-on-miss arrival: a peer forwarded a - # routed write because it isn't the ring owner. - # Apply the write directly without re-running the - # gate (which would loop). Idempotent returners - # absorb the rare double-delivery (bus - # replication already landed the event here under - # symmetric topology). - try: - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - payload = crypticle.loads(data) - except Exception: # pylint: disable=broad-except - log.exception("Failed to decrypt delegate-write") - return - loop = asyncio.get_event_loop() - loop.run_in_executor(None, self._handle_delegate_write, payload) - return - if tag.startswith("cluster/peer/shed-request"): - # Operator-driven shed fan-out from a peer that ran - # ``cluster.shed_unowned_all``. Decrypt the payload - # (cluster_aes) and run the local shed in a worker - # task so we don't block the publish loop on cache - # I/O. The result lands in the per-master shed - # sentinel for ``cluster.shed_status`` to surface. - try: - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - request_payload = crypticle.loads(data) - except Exception: # pylint: disable=broad-except - log.exception("Failed to decrypt shed-request") - return - # Run the shed in an executor so cache.list/flush - # don't block the event loop. - loop = asyncio.get_event_loop() - loop.run_in_executor(None, self._handle_shed_request, request_payload) - return - if tag.startswith("cluster/peer/multi-ring-request"): - # Peer forwarded a ``cluster.ring_create`` / - # ``ring_destroy`` / ``route_set`` / ``route_clear`` / - # ``ring_set`` runner invocation to us because they - # didn't know who the leader was. Decrypt the - # payload (cluster_aes) and dispatch through the - # same multi-ring handler used for local runner - # events. If this master is the Raft leader the - # propose appends a log entry; otherwise it logs - # "not leader" and skips. - try: - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - request_payload = crypticle.loads(data) - except Exception: # pylint: disable=broad-except - log.exception("Failed to decrypt multi-ring-request") - return - runner_tag = request_payload.get("runner_tag") - runner_data = request_payload.get("data") or {} - if not runner_tag: - log.warning( - "cluster/peer/multi-ring-request missing runner_tag; " - "ignoring" - ) - return - self._handle_multi_ring_runner_event(runner_tag, runner_data) - return - if tag.startswith("cluster/peer/collect-request"): - # Operator-driven pull from a peer (via the - # ``cluster.collect_from_peers`` runner on the - # requester). The requester names the channels it - # wants; we open an outbound state-sync session back - # to it and stream those channels. Reuses the - # existing sync-roots-begin + state-sync-chunk wire - # format so the requester's existing receiver - # handles the chunks unchanged. - try: - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - request = crypticle.loads(data) - except Exception: # pylint: disable=broad-except - log.exception("Failed to decrypt collect-request") - return - requester = request.get("requester") - channels = request.get("channels") or [] - if not requester or not channels: - log.warning( - "cluster/peer/collect-request missing requester or " - "channels (got %r); ignoring", - request, - ) - return - asyncio.create_task(self._handle_collect_request(requester, channels)) - return - if tag.startswith("cluster/peer/join-notify"): - # join-notify is encrypted with the shared cluster AES key. - try: - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - notify = crypticle.loads(data) - except Exception: # pylint: disable=broad-except - log.exception("Failed to decrypt join-notify") - return - log.info( - "Cluster join notify from %s for %s", - notify["peer_id"], - notify["join_peer_id"], - ) - peer_pub = ( - pathlib.Path(self.opts["cluster_pki_dir"]) - / "peers" - / f"{notify['join_peer_id']}.pub" - ) - # Match ``cluster/peer/join``: only create the file when missing. - # Peer pubs are often mode 0400; reopening for write raises - # ``PermissionError`` on peers that already received the key. - if not peer_pub.exists(): - with salt.utils.files.fopen(peer_pub, "w") as fp: - fp.write(notify["pub"]) - elif ( - peer_pub.read_text(encoding="utf-8").strip() - != notify["pub"].strip() - ): - log.warning( - "Cluster join-notify: peer %s pub on disk does not " - "match wire copy; keeping disk file.", - notify["join_peer_id"], - ) - # Tell the Raft service about the new peer so it can be added - # as a learner and eventually promoted to voter. - if self._raft_service is not None: - self._raft_service.notify_peer_joined(notify["join_peer_id"]) - elif tag.startswith("cluster/peer/join-reply"): - # The join-reply carries a signed, packed inner payload. - inner = salt.payload.loads(data["payload"]) - log.info("Cluster join reply from %s", inner.get("peer_id", "unknown")) - # ``cluster_aes`` (the cluster's shared session AES key) is - # encrypted to our master pub here so a joiner without access - # to a shared ``cluster_pki_dir/.aes`` can adopt the cluster - # session. ``cluster.pem`` (cluster RSA private) is still - # expected to be present locally — wire delivery for it is - # tracked separately. - token = self._discover_token or "" - if isinstance(token, str): - token = token.encode() - if "cluster_aes" in inner: - try: - salted = salt.crypt.PrivateKey.from_file( - self.master_key.master_rsa_path - ).decrypt( - inner["cluster_aes"], - algorithm=self.opts["cluster_encryption_algorithm"], - ) - new_cluster_aes = salted[len(token) :] - with salt.master.SMaster.secrets["cluster_aes"][ - "secret" - ].get_lock(): - salt.master.SMaster.secrets["cluster_aes"][ - "secret" - ].value = new_cluster_aes - # Persist locally so the joiner survives restart - # without re-running the join handshake. - aes_path = pathlib.Path(self.opts["cluster_pki_dir"]) / ".aes" - aes_path.parent.mkdir(parents=True, exist_ok=True) - with salt.utils.files.set_umask(0o177): - with salt.utils.files.fopen(aes_path, "wb") as fp: - fp.write(new_cluster_aes) - log.info( - "Installed cluster_aes from join-reply (%d bytes)", - len(new_cluster_aes), - ) - except Exception: # pylint: disable=broad-except - log.exception("Failed to install cluster_aes from join-reply") - # Install the cluster RSA key pair (private + public) from - # the wire so a joiner without shared ``cluster_pki_dir`` - # can sign cluster events and serve discover-reply. - if "cluster_key_session" in inner and "cluster_pem" in inner: - try: - salted_session = salt.crypt.PrivateKey.from_file( - self.master_key.master_rsa_path - ).decrypt( - inner["cluster_key_session"], - algorithm=self.opts["cluster_encryption_algorithm"], - ) - session_key_str = salted_session[len(token) :].decode() - cluster_key_crypt = salt.crypt.Crypticle( - self.opts, session_key_str - ) - pem_bytes = cluster_key_crypt.decrypt(inner["cluster_pem"]) - pub_pem = inner.get("cluster_pub") or "" - if isinstance(pub_pem, bytes): - pub_pem = pub_pem.decode() - cluster_pki = pathlib.Path(self.opts["cluster_pki_dir"]) - cluster_pki.mkdir(parents=True, exist_ok=True) - # ``find_or_create_keys`` may have already written a - # locally-generated cluster.pem at mode 0400; unlink - # before writing so the wire-delivered version wins. - pem_path = cluster_pki / "cluster.pem" - pub_path = cluster_pki / "cluster.pub" - for p in (pem_path, pub_path): - try: - p.unlink() - except FileNotFoundError: - pass - with salt.utils.files.set_umask(0o277): - with salt.utils.files.fopen(pem_path, "wb") as fp: - fp.write(pem_bytes) - if pub_pem: - with salt.utils.files.fopen(pub_path, "w") as fp: - fp.write(pub_pem) - log.info( - "Installed cluster.pem (%d bytes) and cluster.pub from join-reply", - len(pem_bytes), - ) - except Exception: # pylint: disable=broad-except - log.exception( - "Failed to install cluster RSA key pair from join-reply" - ) - event = self._discover_event - self._discover_event = None - # Write the join sentinel so future restarts skip discover/join. - self._mark_joined_cluster() - # Paged bulk state-sync: the join-reply names a session id - # and the responder follows up with chunked - # ``cluster/peer/state-sync-chunk`` events, four channels - # in parallel (keys, denied_keys, file_roots, pillar_roots). - # Defer the Raft-learner start until all four channels eof - # (or the per-session deadline elapses). - known_peers = {p: inner["peers"][p] for p in inner.get("peers", {})} - state_sync_session = inner.get("state_sync_session") - if state_sync_session and self.opts.get("cluster_isolated_filesystem"): - self._begin_state_sync_session( - state_sync_session, known_peers, event - ) - else: - # Either the responder isn't running with isolated-FS - # mode or the session announcement is missing. Fall - # back to immediate learner start; event-driven - # replication will fill any gaps. - self._start_raft_as_learner(known_peers) - if event is not None: - event.set() - elif tag.startswith("cluster/peer/join"): - - payload = salt.payload.loads(data["payload"]) - - pub, token = self._discover_candidates[payload["peer_id"]] - - if payload["pub"] != pub: - log.warning("Cluster join, peer public keys do not match") - return - if payload["return_token"] != token: - log.warning("Cluster join, token does not not match") - return - pubk = salt.crypt.PublicKeyString(payload["pub"]) - if not pubk.verify( - data["payload"], - data["sig"], - algorithm=self.opts["publish_signing_algorithm"], - ): - log.warning("Cluster join signature invalid.") - return - - log.info("Cluster join from %s", payload["peer_id"]) - salted_secret = ( - salt.crypt.PrivateKey.from_file(self.master_key.master_rsa_path) - .decrypt( - payload["secret"], - algorithm=self.opts["cluster_encryption_algorithm"], - ) - .decode() - ) - - secret = salted_secret[len(token) :] - - if secret != (self.opts.get("cluster_secret") or ""): - log.warning("Cluster secret invalid.") - return - - log.info("Peer %s joined cluster", payload["peer_id"]) - salted_aes = ( - salt.crypt.PrivateKey.from_file(self.master_key.master_rsa_path) - .decrypt( - payload["key"], - algorithm=self.opts["cluster_encryption_algorithm"], - ) - .decode() - ) - - aes_key = salted_aes[len(token) :] - - # XXX needs safe join - peer_pub = ( - pathlib.Path(self.opts["cluster_pki_dir"]) - / "peers" - / f"{payload['peer_id']}.pub" - ) - # For statically-configured peers the pub key is already on - # disk with restrictive perms. Only write when missing. - if not peer_pub.exists(): - with salt.utils.files.fopen(peer_pub, "w") as fp: - fp.write(payload["pub"]) - elif ( - peer_pub.read_text(encoding="utf-8").strip() - != payload["pub"].strip() - ): - log.warning( - "Cluster peer %s pub key on disk does not match the " - "key received during join; keeping disk copy.", - payload["peer_id"], - ) - - self.cluster_peers.append(payload["peer_id"]) - self._add_pusher(self.pusher(payload["peer_id"])) - - # Add the joining peer to our own Raft state as a learner. - # The join-notify broadcast below tells *other* peers about - # the new node, but the receiver of the join request never - # sees its own broadcast — without this call the leader - # learned about its peers via cluster_peers but never began - # replicating to a freshly joined master, so promotion to - # voter (and the joiner's gate opening) stalled. - if self._raft_service is not None: - try: - self._raft_service.notify_peer_joined(payload["peer_id"]) - except Exception: # pylint: disable=broad-except - log.exception( - "RaftService.notify_peer_joined failed for %s", - payload["peer_id"], - ) - - for pusher in self.pushers: - # XXX Send new peer id and public key to other nodes - # XXX This needs to be able to be validated by receiveing peers - # XXX Send other nodes pub (and aes?) keys to new node - # Use the cluster-wide AES key so all members can decrypt. - crypticle = salt.crypt.Crypticle( - self.opts, - salt.master.SMaster.secrets["cluster_aes"]["secret"].value, - ) - event_data = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("join-notify", "peer", "cluster"), - crypticle.dumps( - { - "peer_id": self.opts["id"], - "join_peer_id": payload["peer_id"], - "pub": payload["pub"], - "aes": aes_key, - } - ), - ) - - # XXX gather tasks instead of looping - try: - await pusher.publish(event_data) - except Exception as exc: # pylint: disable=broad-except - log.warning( - "Unable to publish join-notify to peer %s:%s: %s", - pusher.pull_host, - pusher.pull_port, - exc, - ) - - # XXX Kick off minoins key repair - - self.send_aes_key_event() - - joiner_pub = salt.crypt.PublicKeyString(payload["pub"]) - token_bytes = ( - payload["token"].encode() - if isinstance(payload["token"], str) - else payload["token"] - ) - aes_secret = salt.master.SMaster.secrets["aes"]["secret"].value - if isinstance(aes_secret, str): - aes_secret = aes_secret.encode() - cluster_aes_secret = salt.master.SMaster.secrets["cluster_aes"][ - "secret" - ].value - if isinstance(cluster_aes_secret, str): - cluster_aes_secret = cluster_aes_secret.encode() - # No-shared-filesystem support: the join-reply carries - # ``cluster_aes`` and the cluster RSA key pair so a joiner - # without access to a shared ``cluster_pki_dir`` can adopt - # the cluster identity from the wire alone. - # - # ``cluster.pem`` is too large for direct RSA encryption, so - # it travels under a fresh Crypticle session key wrapped to - # the joiner's RSA pub. ``cluster.pub`` is not secret so it - # rides in the inner payload unencrypted (and the inner - # payload is signed with this master's private key). - cluster_pem_pem = self.cluster_key() or "" - cluster_pub_pem = self.cluster_public_key() or "" - cluster_key_session = salt.crypt.Crypticle.generate_key_string() - cluster_key_crypt = salt.crypt.Crypticle(self.opts, cluster_key_session) - cluster_pem_ciphertext = cluster_key_crypt.encrypt( - cluster_pem_pem.encode() - ) - wrapped_session = joiner_pub.encrypt( - token_bytes + cluster_key_session.encode(), - algorithm=self.opts["cluster_encryption_algorithm"], - ) - inner_payload = { - "return_token": payload["token"], - "peer_id": self.opts["id"], - "aes": joiner_pub.encrypt( - token_bytes + aes_secret, - algorithm=self.opts["cluster_encryption_algorithm"], - ), - "cluster_aes": joiner_pub.encrypt( - token_bytes + cluster_aes_secret, - algorithm=self.opts["cluster_encryption_algorithm"], - ), - "cluster_key_session": wrapped_session, - "cluster_pem": cluster_pem_ciphertext, - "cluster_pub": cluster_pub_pem, - "peers": {}, - } - # Isolated-FS bulk state sync: announce a session id in the - # join-reply, then push the per-channel chunks separately. - # The joiner waits on all four channel eofs before becoming - # a Raft learner; per-channel chunking lets each stream - # progress independently and isolates failures. - state_sync_session_id = None - if self.opts.get("cluster_isolated_filesystem"): - from salt.cluster.state_sync import ( # pylint: disable=import-outside-toplevel - new_session_id, - ) - - state_sync_session_id = new_session_id() - inner_payload["state_sync_session"] = state_sync_session_id - tosign = salt.payload.package(inner_payload) - sig = salt.crypt.PrivateKeyString(self.private_key()).sign( - tosign, algorithm=self.opts["publish_signing_algorithm"] - ) - event_data = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("join-reply", "peer", "cluster"), - { - "sig": sig, - "payload": tosign, - }, - ) - await self.pusher(payload["peer_id"]).publish(event_data) - if state_sync_session_id is not None: - asyncio.get_event_loop().create_task( - self._send_state_sync_chunks( - state_sync_session_id, payload["peer_id"] - ) - ) - elif tag.startswith("cluster/peer/discover-reply"): - payload = salt.payload.loads(data["payload"]) - - if not cluster_pub_matches_fingerprint( - self.opts, payload["cluster_pub"] - ): - log.warning( - "cluster_pub fingerprint mismatch in discover-reply " - "from %s; rejecting", - payload.get("peer_id"), - ) - return - - cluster_pub = salt.crypt.PublicKeyString(payload["cluster_pub"]) - if not cluster_pub.verify( - data["payload"], - data["sig"], - algorithm=self.opts["publish_signing_algorithm"], - ): - log.warning("Invalid signature of cluster discover payload") - return - - # XXX First token created in different process - # if payload.get("return_token", None) != self._discover_token: - # log.warning("Invalid token in discover reply %s != %s", - # payload.get("return_token", None), self._discover_token - # ) - # return - - log.info("Cluster discover reply from %s", payload["peer_id"]) - key = salt.crypt.PublicKeyString(payload["pub"]) - self._discover_token = self.gen_token() - tosign = salt.payload.package( - { - "return_token": payload["token"], - "token": self._discover_token, - "peer_id": self.opts["id"], - "secret": key.encrypt( - payload["token"].encode() - + (self.opts.get("cluster_secret") or "").encode(), - algorithm=self.opts["cluster_encryption_algorithm"], - ), - "key": key.encrypt( - payload["token"].encode() - + salt.master.SMaster.secrets["aes"]["secret"].value, - algorithm=self.opts["cluster_encryption_algorithm"], - ), - "pub": self.public_key(), - } - ) - sig = salt.crypt.PrivateKeyString(self.private_key()).sign( - tosign, algorithm=self.opts["publish_signing_algorithm"] - ) - self.cluster_peers.append(payload["peer_id"]) - event_data = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("join", "peer", "cluster"), - {"sig": sig, "payload": tosign}, - ) - peer_pub = ( - pathlib.Path(self.opts["cluster_pki_dir"]) - / "peers" - / f"{payload['peer_id']}.pub" - ) - # For statically-configured peers the pub key is already on - # disk with restrictive perms (0400). Only write when it is - # missing, otherwise verify the key on disk matches. - if not peer_pub.exists(): - with salt.utils.files.fopen(peer_pub, "w") as fp: - fp.write(payload["pub"]) - else: - existing = peer_pub.read_text(encoding="utf-8") - if existing.strip() != payload["pub"].strip(): - log.warning( - "Cluster peer %s pub key on disk does not match " - "the key received during discovery; keeping disk " - "copy.", - payload["peer_id"], - ) - pusher = self.pusher(payload["peer_id"]) - self._add_pusher(pusher) - try: - await pusher.publish(event_data) - except Exception as exc: # pylint: disable=broad-except - log.warning( - "Unable to publish join to peer %s:%s: %s", - pusher.pull_host, - pusher.pull_port, - exc, - ) - elif tag.startswith("cluster/peer/discover"): - payload = salt.payload.loads(data["payload"]) - peer_key = salt.crypt.PublicKeyString(payload["pub"]) - if not peer_key.verify( - data["payload"], - data["sig"], - algorithm=self.opts["publish_signing_algorithm"], - ): - log.warning("Invalid signature of cluster discover payload") - return - log.info("Cluster discovery from %s", payload["peer_id"]) - token = self.gen_token() - # Store this peer as a candidate. - # XXX Add timestamp so we can clean up old candidates - self._discover_candidates[payload["peer_id"]] = (payload["pub"], token) - tosign = salt.payload.package( - { - "return_token": payload["token"], - "token": token, - "peer_id": self.opts["id"], - "pub": self.public_key(), - "cluster_pub": self.cluster_public_key(), - } - ) - key = salt.crypt.PrivateKeyString(self.cluster_key()) - sig = key.sign(tosign, algorithm=self.opts["publish_signing_algorithm"]) - _ = salt.payload.package( - { - "sig": sig, - "payload": tosign, - } - ) - event_data = salt.utils.event.SaltEvent.pack( - salt.utils.event.tagify("discover-reply", "peer", "cluster"), - {"sig": sig, "payload": tosign}, - ) - await self.pusher(payload["peer_id"]).publish(event_data) - elif tag.startswith("cluster/peer"): + if tag.startswith("cluster/peer"): peer = data["peer_id"] # Sibling masters key ``data["peers"]`` by the bare names # in their ``cluster_peers``, so look our own entry up by @@ -3740,23 +1262,23 @@ async def handle_pool_publish(self, payload): # form ``apply_master_config`` may have produced. See # https://github.com/saltstack/salt/issues/68462. master_id = self.opts["id"].removesuffix("_master") - if peer == master_id: - log.debug("Skip our own cluster peer event %s", tag) - return aes = data["peers"][master_id]["aes"] sig = data["peers"][master_id]["sig"] key_str = self.master_key.master_key.decrypt( - aes, algorithm=self.opts["cluster_encryption_algorithm"] + aes, algorithm="OAEP-SHA224" ) digest = salt.utils.stringutils.to_bytes( hashlib.sha256(key_str).hexdigest() ) - key = self.master_key.fetch(f"peers/{peer}.pub") + pub_path = ( + pathlib.Path(self.opts["cluster_pki_dir"]) / "peers" / f"{peer}.pub" + ) + key = salt.crypt.PublicKey(pub_path) m_digest = key.decrypt(sig) if m_digest != digest: log.error("Invalid aes signature from peer: %s", peer) return - log.info("Received new AES key from peer %s", peer) + log.info("Received new key from peer %s", peer) if peer in self.peer_keys: if self.peer_keys[peer] != key_str: self.peer_keys[peer] = key_str @@ -3815,7 +1337,7 @@ def parse_cluster_tag(self, tag): def extract_cluster_event(self, peer_id, data): if peer_id in self.peer_keys: - crypticle = _get_crypticle(self.opts, self.peer_keys[peer_id]) + crypticle = salt.crypt.Crypticle(self.opts, self.peer_keys[peer_id]) event_data = crypticle.loads(data)["event_payload"] # __peer_id can be used to know if this event came from a # different master. @@ -3825,62 +1347,6 @@ def extract_cluster_event(self, peer_id, data): async def publish_payload(self, load, *args): tag, data = salt.utils.event.SaltEvent.unpack(load) - # Operator-triggered cluster operations originate as ``cluster/runner/*`` - # events fired by the runner subprocess. Intercept them here so the - # event is consumed locally rather than broadcast as a regular - # cluster event. - if tag == "cluster/runner/sync_roots": - channels = data.get("channels") or ["file_roots", "pillar_roots"] - asyncio.create_task(self._run_root_sync_to_peers(channels)) - return - if tag == "cluster/runner/collect_from_peers": - # Operator-driven pull of cache contents from peers. The - # runner subprocess on this master fired the event; we - # broadcast a collect-request to every peer so each one - # initiates an outbound state-sync send to us. Receiver - # side reuses the existing state-sync chunk handler at - # ``cluster/peer/state-sync-chunk``. - channels = data.get("channels") or ["keys", "denied_keys"] - asyncio.create_task(self._run_collect_from_peers(channels)) - return - if tag == "cluster/runner/shed_unowned_all": - # Operator-driven fan-out of cluster.shed_unowned. We - # broadcast a cluster_aes-encrypted shed-request to every - # peer; each peer's daemon runs the same shed logic and - # writes a per-master sentinel. The originator runner - # subprocess (which fired this event) also ran its own - # local shed inline — no need to repeat that here. - asyncio.create_task(self._run_shed_unowned_all(data)) - return - if tag == "cluster/runner/delegate_write": - # Delegate-on-miss: the EventMonitor on this master saw - # a routed write it didn't own and looked up the ring's - # owner. Forward the write to that owner via a - # cluster_aes-encrypted ``cluster/peer/delegate-write`` - # event. In the standard cluster topology bus - # replication already delivered the original event to - # the owner — this delegate is a safety net for - # asymmetric topologies (or a guard against bus drops). - asyncio.create_task(self._run_delegate_write(data)) - return - if tag in ( - "cluster/runner/ring_create", - "cluster/runner/ring_destroy", - "cluster/runner/route_set", - "cluster/runner/route_clear", - "cluster/runner/ring_set", - ): - # Multi-ring operator runners. ``propose_*`` only works - # on the Raft leader, and the operator may have invoked - # the runner on any master. Try locally first (no-op - # warning if we're not the leader) and *also* fan out a - # cluster_aes-encrypted peer event so whichever master is - # currently the leader picks it up. Followers that - # receive the fan-out log "not leader" and skip — no - # double-commit because the leader is unique. - self._handle_multi_ring_runner_event(tag, data) - asyncio.create_task(self._fanout_multi_ring_request(tag, data)) - return tasks = [] if not tag.startswith("cluster/peer"): tasks = [ @@ -3889,14 +1355,13 @@ async def publish_payload(self, load, *args): ) ] for pusher in self.pushers: - log.info("Publish event to peer %s:%s", pusher.pull_host, pusher.pull_port) + log.debug("Publish event to peer %s:%s", pusher.pull_host, pusher.pull_port) if tag.startswith("cluster/peer"): - # log.info("Send %s %r", tag, load) tasks.append( asyncio.create_task(pusher.publish(load), name=pusher.pull_host) ) continue - crypticle = _get_crypticle( + crypticle = salt.crypt.Crypticle( self.opts, salt.master.SMaster.secrets["aes"]["secret"].value ) load = {"event_payload": data} @@ -3914,25 +1379,9 @@ async def publish_payload(self, load, *args): if task.get_name() == self.opts["id"]: log.error("Unable to forward event to local ipc bus") else: - peer = task.get_name() log.warning( - "Unable to forward event to cluster peer %s; " - "resetting pusher for reconnect", - peer, + "Unable to forward event to cluster peer %s", task.get_name() ) - # Reset the broken pub_sock so the next publish attempt - # triggers a fresh TCP connection rather than reusing a - # dead stream. - for pusher in self.pushers: - if pusher.pull_host == peer and pusher.pub_sock is not None: - try: - pusher.pub_sock.close() - except Exception: # pylint: disable=broad-except - pass - pusher.pub_sock = None - # Schedule an AES-key re-announcement so the peer - # learns our key after it reconnects. - self.io_loop.call_later(2.0, self.send_aes_key_event) except Exception as exc: # pylint: disable=broad-except log.error( "Unhandled error sending task %s", task.get_name(), exc_info=True diff --git a/salt/cli/batch.py b/salt/cli/batch.py index 14b7c833556e..da94d48b0d46 100644 --- a/salt/cli/batch.py +++ b/salt/cli/batch.py @@ -1,33 +1,16 @@ """ -Execute batch runs. - -The sync CLI driver (``Batch``) is a thin shell around the shared -state machine in :mod:`salt.utils.batch_state` and the CLI output -adapter in :mod:`salt.utils.batch_output`. The state machine owns -slot accounting, failhard, timeout, and ``batch_wait`` — the driver -only manages the iterator plumbing that turns master-side -``cmd_iter_no_block`` polling into ``new_returns`` inputs and -``timed_out`` signals. - -The observable behavior (yield shape, stdout formatting, JID reuse, -failhard early-exit, ``batch_wait`` dispatch delay) is preserved -byte-for-byte against the pre-refactor implementation. The async -``BatchManager`` uses the same state machine, so both drivers are -guaranteed to produce the same sequence of minion transitions given -the same inputs. +Execute batch runs """ import copy import logging import math import time +from datetime import datetime, timedelta import salt.client import salt.exceptions import salt.output -import salt.utils.batch_output -import salt.utils.batch_state -import salt.utils.jid import salt.utils.stringutils log = logging.getLogger(__name__) @@ -35,7 +18,8 @@ class Batch: """ - Manage the execution of batch runs. + Manage the execution of batch runs + """ def __init__(self, opts, eauth=None, quiet=False, _parser=None): @@ -56,12 +40,12 @@ def __init__(self, opts, eauth=None, quiet=False, _parser=None): self.quiet = quiet self.options = _parser # Passing listen True to local client will prevent it from purging - # cached events while iterating over the batches. + # cahced events while iterating over the batches. self.local = salt.client.get_local_client(opts["conf_file"], listen=True) def gather_minions(self): """ - Return a list of minions to use for the batch run. + Return a list of minions to use for the batch run """ args = [ self.opts["tgt"], @@ -78,11 +62,10 @@ def gather_minions(self): self.pub_kwargs["yield_pub_data"] = True ping_gen = self.local.cmd_iter( - *args, - gather_job_timeout=self.opts["gather_job_timeout"], - **self.pub_kwargs, + *args, gather_job_timeout=self.opts["gather_job_timeout"], **self.pub_kwargs ) + # Broadcast to targets fret = set() nret = set() for ret in ping_gen: @@ -114,14 +97,7 @@ def gather_minions(self): def get_bnum(self): """ - Return the active number of minions to maintain. - - Preserves the legacy return values (``None`` for invalid - input, ``0`` for an empty minion list with a percentage spec, - ``0`` for ``batch=0``) for backward compatibility with any - callers that rely on them. The shared state machine uses the - hardened :func:`salt.utils.batch_state.get_batch_size` which - always returns at least 1. + Return the active number of minions to maintain """ def partition(x): @@ -132,8 +108,10 @@ def partition(x): res = partition(float(self.opts["batch"].strip("%"))) if res < 1: return int(math.ceil(res)) - return int(res) - return int(self.opts["batch"]) + else: + return int(res) + else: + return int(self.opts["batch"]) except ValueError: if not self.quiet: salt.utils.stringutils.print_cli( @@ -141,52 +119,37 @@ def partition(x): "form of %10, 10% or 3".format(self.opts["batch"]) ) + def __update_wait(self, wait): + now = datetime.now() + i = 0 + while i < len(wait) and wait[i] <= now: + i += 1 + if i: + del wait[:i] + def run(self): """ - Execute the batch run. - - Generator. For each minion return, yields - ``({minion_id: ret_data}, retcode)`` (or the raw event envelope - when ``raw=True``). Minion returns carrying ``failed: True`` - (and ``failhard`` halt events) are recorded internally but not - yielded, preserving the pre-Phase-2 yield shape. + Execute the batch run """ self.minions, self.ping_gen, self.down_minions = self.gather_minions() - + args = [ + [], + self.opts["fun"], + self.opts["arg"], + self.opts["timeout"], + "list", + ] + bnum = self.get_bnum() + # No targets to run if not self.minions: return - - batch_jid = salt.utils.jid.gen_jid(self.opts) - state = salt.utils.batch_state.create_batch_state( - self.opts, self.minions, batch_jid, driver="cli" - ) - - # The sync CLI driver does not write under the master's - # ``cachedir`` itself. ``cachedir`` is owned by the master - # daemon's user (typically ``salt``); the CLI is normally - # invoked as ``root``, so any direct write would pre-create - # the JID directory with the wrong ownership and trip a - # ``PermissionError`` in ``local_cache.prep_jid`` when the - # master tries to write the ``jid`` file (issue #69418). - # - # Instead, we ship every state change to the master-side - # ``BatchManager`` via ``salt/batch//{new,progress, - # complete,halted}`` events. The manager — already running - # as the master daemon's user — persists ``.batch.p`` and - # maintains the active-batch index on the CLI's behalf. - # ``batch.status`` / ``batch.list_active`` / ``batch.stop`` - # see sync batches because of that handoff. All event ops - # are best-effort: if the master event bus is unreachable - # the CLI batch still completes correctly with no visibility. - self._fire_event( - salt.utils.batch_output.state_payload(state), - salt.utils.batch_output.tag_new(batch_jid), - ) - self._subscribe_to_halt(batch_jid) - - output = salt.utils.batch_output.CLIOutput(self.opts, quiet=self.quiet) - for down_minion in self.down_minions: - output.on_minion_down(down_minion) + to_run = copy.deepcopy(self.minions) + active = [] + ret = {} + iters = [] + # wait the specified time before decide a job is actually done + bwait = self.opts.get("batch_wait", 0) + wait = [] if self.options: show_jid = self.options.show_jid @@ -195,383 +158,230 @@ def run(self): show_jid = False show_verbose = False - return_value = self.opts.get("return", self.opts.get("ret", "")) - raw_mode = bool(self.opts.get("raw")) + # the minion tracker keeps track of responses and iterators + # - it removes finished iterators from iters[] + # - if a previously detected minion does not respond, its + # added with an empty answer to ret{} once the timeout is reached + # - unresponsive minions are removed from active[] to make + # sure that the main while loop finishes even with unresp minions + minion_tracker = {} + + if not self.quiet: + # We already know some minions didn't respond to the ping, so inform + # the user we won't be attempting to run a job on them + for down_minion in self.down_minions: + salt.utils.stringutils.print_cli( + "Minion {} did not respond. No job will be sent.".format( + down_minion + ) + ) - iters = [] - minion_tracker = {} # iter -> {"minions": [...], "active": bool} - # We retain the raw return objects (event envelopes in raw mode, - # minion-return dicts otherwise) separately from the normalized - # data fed to the state machine so display_output and the yield - # shape can be reconstructed after progress_batch() has moved - # the minion out of ``active``. - raw_by_minion = {} + # Iterate while we still have things to execute + while len(ret) < len(self.minions): + next_ = [] + if bwait and wait: + self.__update_wait(wait) + if len(to_run) <= bnum - len(wait) and not active: + # last bit of them, add them all to next iterator + while to_run: + next_.append(to_run.pop()) + else: + for i in range(bnum - len(active) - len(wait)): + if to_run: + minion_id = to_run.pop() + if isinstance(minion_id, dict): + next_.append(next(iter(minion_id))) + else: + next_.append(minion_id) - try: - while not salt.utils.batch_state.is_batch_done(state): - new_returns, timed_out = self._poll_iterators( - iters, minion_tracker, raw_mode, raw_by_minion - ) - self._discover_late_minions(state) - - # Observe halt requests from the master-side - # ``batch.stop`` runner before deciding what to do - # this tick. The runner fires ``salt/batch// - # stop`` which the BatchManager translates into - # ``salt/batch//halted``; we subscribed to the - # halted tag during startup. - if self._consume_halt_event(batch_jid, state): - # progress_batch already short-circuits on a - # halted state, but we still want to fall - # through the existing failhard reporting path. - pass - - now = time.time() - action = salt.utils.batch_state.progress_batch( - state, new_returns, now=now, timed_out=timed_out - ) + active += next_ + args[0] = next_ - if ( - action.publish - or action.finished_minions - or action.timed_out_minions - or state["halted"] - ): - self._fire_event( - salt.utils.batch_output.state_payload(state), - salt.utils.batch_output.tag_progress(batch_jid), + if next_: + if not self.quiet: + salt.utils.stringutils.print_cli( + f"\nExecuting run on {sorted(next_)}\n" ) + # create a new iterator for this batch of minions + return_value = self.opts.get("return", self.opts.get("ret", "")) + new_iter = self.local.cmd_iter_no_block( + *args, + raw=self.opts.get("raw", False), + ret=return_value, + show_jid=show_jid, + verbose=show_verbose, + gather_job_timeout=self.opts["gather_job_timeout"], + **self.eauth, + ) + # add it to our iterators and to the minion_tracker + iters.append(new_iter) + minion_tracker[new_iter] = {} + # every iterator added is 'active' and has its set of minions + minion_tracker[new_iter]["minions"] = next_ + minion_tracker[new_iter]["active"] = True - if action.publish: - output.on_batch_start(action.publish) - args = [ - list(action.publish), - self.opts["fun"], - self.opts["arg"], - self.opts["timeout"], - "list", - ] - new_iter = self.local.cmd_iter_no_block( - *args, - raw=raw_mode, - ret=return_value, - show_jid=show_jid, - verbose=show_verbose, - gather_job_timeout=self.opts["gather_job_timeout"], - jid=batch_jid, - **self.eauth, - ) - iters.append(new_iter) - minion_tracker[new_iter] = { - "minions": list(action.publish), - "active": True, - } - - halted_mid_yield = False - for minion_id, data in new_returns.items(): - if data.get("failed") is True: - output.on_minion_failed(minion_id) - else: - retcode = salt.utils.batch_state._collapse_retcode(data) - if raw_mode: - yield raw_by_minion.get(minion_id, data), retcode - else: - yield {minion_id: data.get("ret")}, retcode - output.on_minion_return( - minion_id, raw_by_minion.get(minion_id, data) - ) - if state["halted"]: - halted_mid_yield = True - break + else: + time.sleep(0.02) + parts = {} - if halted_mid_yield: - log.error( - "Batch run stopped due to failhard", - ) + # see if we found more minions + for ping_ret in self.ping_gen: + if ping_ret is None: break + m = next(iter(ping_ret.keys())) + if not isinstance(m, str) or m == "error": + log.debug( + "Skipping error payload in late-minion discovery: %s", + ping_ret, + ) + continue + if m not in self.minions: + self.minions.append(m) + to_run.append(m) - for minion_id in timed_out: - envelope = raw_by_minion.get(minion_id) - if raw_mode and envelope is not None: - yield envelope, 0 - elif raw_mode: - yield {"data": {"id": minion_id, "return": {}, "retcode": 0}}, 0 - else: - yield {minion_id: {}}, 0 - output.on_minion_timeout(minion_id) - - # Prune finished iterators; progress_batch already - # cleared their minions from state["active"]. - iters = [ - queue - for queue in iters - if minion_tracker.get(queue, {}).get("active") - ] - - # When neither a dispatch nor a poll happened, idle - # briefly so we don't hot-spin waiting for batch_wait - # to expire. - if ( - not action.publish - and not iters - and not new_returns - and not timed_out - ): - if not salt.utils.batch_state.is_batch_done(state): - time.sleep(0.02) - - output.on_batch_done(state) - finally: - terminal_tag = ( - salt.utils.batch_output.tag_halted(batch_jid) - if state.get("halted") - else salt.utils.batch_output.tag_complete(batch_jid) - ) - self._fire_event( - salt.utils.batch_output.state_payload(state), - terminal_tag, - ) - self._unsubscribe_from_halt(batch_jid) - # Tear the event handle down explicitly **before** - # ``LocalClient.destroy``. The new visibility code lazily - # creates a ``SyncWrapper(ipc_publish_server)`` (and its - # nested ``SyncWrapper(PubServerClient)``) the first time - # we ``fire_event``; each wrapper owns its own asyncio - # loop. ``LocalClient.destroy`` will close them, but - # leaving that to the implicit teardown means the - # asyncio cleanup races interpreter shutdown — on Python - # 3.14 / Windows that race drops post-``shutdown_asyncgens`` - # Handles from ``_ready`` before they're awaited and - # spills ``RuntimeWarning: coroutine ... was never awaited`` - # onto the CLI's stderr. Calling ``event.destroy`` here - # (while we still control the loop) plus a deterministic - # drain quiesces those warnings at the source. - self._teardown_event_handle() - self.local.destroy() - - def _poll_iterators(self, iters, minion_tracker, raw_mode, raw_by_minion): - """ - Drain every active ``cmd_iter_no_block`` iterator once. - - Returns ``(new_returns, timed_out)`` — a dict of normalized - minion returns and a list of minion IDs the iterator exhausted - without yielding (i.e. ``cmd_iter_no_block``'s own timeout - tripped). ``raw_by_minion`` is populated with the raw - payloads so the caller can preserve yield shape. - - Iterators that raise ``StopIteration`` are marked inactive in - ``minion_tracker`` but not removed from ``iters`` — the caller - filters them after the state-machine step so yields happen in - iterator order. - """ - new_returns = {} - timed_out = [] - for queue in list(iters): - try: - ncnt = 0 - while True: - part = next(queue) - if part is None: - time.sleep(0.01) - ncnt += 1 - if ncnt > 5: - break - continue - if raw_mode: - if "data" not in part or part.get("error"): - log.debug( - "Skipping error payload in batch return (raw mode): %s", - part, - ) - continue - minion_id = part["data"]["id"] - if not isinstance(minion_id, str) or minion_id == "error": - log.debug( - "Skipping error payload in batch return (raw mode): %s", - part, - ) + for queue in iters: + try: + # Gather returns until we get to the bottom + ncnt = 0 + while True: + part = next(queue) + if part is None: + time.sleep(0.01) + ncnt += 1 + if ncnt > 5: + break continue - raw_by_minion[minion_id] = part - new_returns[minion_id] = { - "ret": part["data"].get("return"), - "retcode": part["data"].get("retcode", 0), - "failed": part["data"].get("failed", False), - } - if minion_id in minion_tracker[queue]["minions"]: - minion_tracker[queue]["minions"].remove(minion_id) - else: - if not self.quiet: - salt.utils.stringutils.print_cli( - "minion {} was already deleted from tracker," - " probably a duplicate key".format(minion_id) + if self.opts.get("raw"): + if "data" not in part or part.get("error"): + log.debug( + "Skipping error payload in batch return (raw mode): %s", + part, ) - else: - if "error" in part: - log.debug( - "Skipping error payload in batch return: %s", - part, - ) - continue - for minion_id, mret in part.items(): - if not isinstance(minion_id, str): + continue + minion_id = part["data"]["id"] + if not isinstance(minion_id, str) or minion_id == "error": log.debug( - "Skipping non-string key in batch return: %s", + "Skipping error payload in batch return (raw mode): %s", part, ) continue - raw_by_minion[minion_id] = copy.copy(mret) - new_returns[minion_id] = mret + parts.update({minion_id: part}) if minion_id in minion_tracker[queue]["minions"]: minion_tracker[queue]["minions"].remove(minion_id) else: - if not self.quiet: + salt.utils.stringutils.print_cli( + "minion {} was already deleted from tracker," + " probably a duplicate key".format(minion_id) + ) + else: + if "error" in part: + log.debug( + "Skipping error payload in batch return: %s", + part, + ) + continue + for id in part: + if not isinstance(id, str): + log.debug( + "Skipping non-string key in batch return: %s", + part, + ) + continue + parts[id] = part[id] + if id in minion_tracker[queue]["minions"]: + minion_tracker[queue]["minions"].remove(id) + else: salt.utils.stringutils.print_cli( "minion {} was already deleted from tracker," - " probably a duplicate key".format(minion_id) + " probably a duplicate key".format(id) ) - except StopIteration: - if queue in minion_tracker: - minion_tracker[queue]["active"] = False - for minion_id in minion_tracker[queue]["minions"]: - if minion_id not in new_returns: - timed_out.append(minion_id) - return new_returns, timed_out - - def _discover_late_minions(self, state): - """ - Pull newly discovered minions off ``ping_gen`` and append them - to ``state["pending"]`` so the state machine will schedule - them. - """ - for ping_ret in self.ping_gen: - if ping_ret is None: - break - try: - minion_id = next(iter(ping_ret.keys())) - except StopIteration: - break - if not isinstance(minion_id, str) or minion_id == "error": - log.debug( - "Skipping error payload in late-minion discovery: %s", - ping_ret, - ) - continue - if minion_id not in state["all_minions"]: - state["all_minions"].append(minion_id) - state["pending"].append(minion_id) - if minion_id not in self.minions: - self.minions.append(minion_id) - - # ------------------------------------------------------------------ - # Event-bus glue — best-effort visibility for ``batch.status`` / - # ``batch.list_active`` / ``batch.stop``. Every method here - # swallows its own errors so a broken or absent event bus never - # blocks the run; the worst case is "no visibility into the - # batch from master-side runners," same as on 3007.x. - # ------------------------------------------------------------------ - - def _event(self): - """Return the master event handle attached to our LocalClient.""" - return getattr(self.local, "event", None) - - def _fire_event(self, payload, tag): - """Fire a batch lifecycle event; never raise.""" - event = self._event() - if event is None: - return - try: - event.fire_event(payload, tag) - except Exception: # pylint: disable=broad-except - log.debug("Failed to fire %s; continuing without it", tag, exc_info=True) - - def _subscribe_to_halt(self, jid): - """Subscribe to ``salt/batch//halted`` so we observe stops.""" - event = self._event() - if event is None: - return - try: - event.subscribe( - salt.utils.batch_output.tag_halted(jid), match_type="startswith" - ) - except Exception: # pylint: disable=broad-except - log.debug( - "Failed to subscribe to halted tag for %s; " - "batch.stop will not be observable from this CLI", - jid, - exc_info=True, - ) - - def _unsubscribe_from_halt(self, jid): - """Counterpart to ``_subscribe_to_halt``.""" - event = self._event() - if event is None: - return - try: - event.unsubscribe( - salt.utils.batch_output.tag_halted(jid), match_type="startswith" - ) - except Exception: # pylint: disable=broad-except - log.debug( - "Failed to unsubscribe from halted tag for %s", jid, exc_info=True - ) - - def _teardown_event_handle(self): - """ - Destroy the LocalClient's event handle in-place. - - Safe to call on a half-initialized or already-destroyed - client. Any failure is swallowed: the worst case is that - ``LocalClient.destroy`` cleans up instead, and the - Python 3.14 / Windows teardown warning resurfaces — never - a functional regression. - - After ``event.destroy``, both wrappers' asyncio loops have - been closed; a follow-on ``LocalClient.destroy`` call is a - no-op (``SaltEvent.destroy`` is idempotent — it only acts - when ``subscriber`` / ``pusher`` are still set). - """ - local = getattr(self, "local", None) - if local is None: - return - event = getattr(local, "event", None) - if event is None: - return - try: - event.destroy() - except Exception: # pylint: disable=broad-except - log.debug( - "Failed to tear down event handle cleanly; deferring " - "to LocalClient.destroy", - exc_info=True, - ) - - def _consume_halt_event(self, jid, state): - """ - Non-blocking poll for ``salt/batch//halted``. + except StopIteration: + # if a iterator is done: + # - set it to inactive + # - add minions that have not responded to parts{} + + # check if the tracker contains the iterator + if queue in minion_tracker: + minion_tracker[queue]["active"] = False + + # add all minions that belong to this iterator and + # that have not responded to parts{} with an empty response + for minion in minion_tracker[queue]["minions"]: + if minion not in parts: + parts[minion] = {} + parts[minion]["ret"] = {} + + for minion, data in parts.items(): + if minion in active: + active.remove(minion) + if bwait: + wait.append(datetime.now() + timedelta(seconds=bwait)) + failhard = False + + # need to check if Minion failed to respond to job sent + failed_check = data.get("failed", False) + if failed_check: + log.debug( + "Minion '%s' failed to respond to job sent, data '%s'", + minion, + data, + ) + if not self.quiet: + # We already know some minions didn't respond to the ping, so inform + # inform user attempt to run a job failed + salt.utils.stringutils.print_cli( + f"Minion '{minion}' failed to respond to job sent" + ) - Returns ``True`` when a halt event was observed (and mutates - *state* in place to record it); ``False`` otherwise. Spurious - bus failures degrade silently to ``False``. - """ - event = self._event() - if event is None: - return False - try: - payload = event.get_event( - wait=0, - tag=salt.utils.batch_output.tag_halted(jid), - match_type="startswith", - no_block=True, - ) - except Exception: # pylint: disable=broad-except - log.debug("Failed to poll halted tag for %s", jid, exc_info=True) - return False - if not isinstance(payload, dict): - return False - if payload.get("jid") != jid: - # Stray event (or a mock returning truthy garbage in - # tests). Ignore — only an explicit match counts. - return False - state["halted"] = True - state["halted_reason"] = payload.get("reason") or "stop" - return True + if self.opts.get("failhard"): + failhard = True + ret[minion] = data + else: + # If we are executing multiple modules with the same cmd, + # We use the highest retcode. + retcode = 0 + if "retcode" in data: + if isinstance(data["retcode"], dict): + try: + data["retcode"] = max(data["retcode"].values()) + except ValueError: + data["retcode"] = 0 + if self.opts.get("failhard") and data["retcode"] > 0: + failhard = True + retcode = data["retcode"] + + if self.opts.get("raw"): + ret[minion] = data + yield data, retcode + else: + ret[minion] = data["ret"] + yield {minion: data["ret"]}, retcode + if not self.quiet: + ret[minion] = data["ret"] + data[minion] = data.pop("ret") + if "out" in data: + out = data.pop("out") + else: + out = None + salt.output.display_output(data, out, self.opts) + + if failhard: + log.error( + "Minion %s returned with non-zero exit code. " + "Batch run stopped due to failhard", + minion, + ) + return + + # remove inactive iterators from the iters list + for queue in minion_tracker: + # only remove inactive queues + if not minion_tracker[queue]["active"] and queue in iters: + iters.remove(queue) + # also remove the iterator's minions from the active list + for minion in minion_tracker[queue]["minions"]: + if minion in active: + active.remove(minion) + if bwait: + wait.append(datetime.now() + timedelta(seconds=bwait)) + self.local.destroy() diff --git a/salt/cli/call.py b/salt/cli/call.py index 1ae441d98a36..71c8de4bac97 100644 --- a/salt/cli/call.py +++ b/salt/cli/call.py @@ -4,9 +4,8 @@ import salt.cli.caller import salt.defaults.exitcodes import salt.utils.parsers -import salt.utils.tracing import salt.utils.verify -from salt.config import _expand_glob_path, prepend_root_dir +from salt.config import _expand_glob_path class SaltCall(salt.utils.parsers.SaltCallOptionParser): @@ -21,25 +20,19 @@ def run(self): self.parse_args() if self.options.file_root: - file_roots = [] - for file_root in self.options.file_root: - # check if the argument is pointing to a file on disk - file_roots.append(os.path.abspath(file_root)) - self.config["file_roots"] = {"base": _expand_glob_path(file_roots)} + # check if the argument is pointing to a file on disk + file_root = os.path.abspath(self.options.file_root) + self.config["file_roots"] = {"base": _expand_glob_path([file_root])} if self.options.pillar_root: - pillar_roots = [] - for pillar_root in self.options.pillar_root: - # check if the argument is pointing to a file on disk - pillar_roots.append(os.path.abspath(pillar_root)) - self.config["pillar_roots"] = {"base": _expand_glob_path(pillar_roots)} + # check if the argument is pointing to a file on disk + pillar_root = os.path.abspath(self.options.pillar_root) + self.config["pillar_roots"] = {"base": _expand_glob_path([pillar_root])} if self.options.states_dir: - states_dirs = [] - for states_dir in self.options.states_dir: - # check if the argument is pointing to a file on disk - states_dirs.append(os.path.abspath(states_dir)) - self.config["states_dirs"] = states_dirs + # check if the argument is pointing to a file on disk + states_dir = os.path.abspath(self.options.states_dir) + self.config["states_dirs"] = [states_dir] # Warn when the user passed local-roots overrides without --local. # Without --local the remote file client retrieves state/pillar data @@ -69,15 +62,6 @@ def run(self): if self.options.master: self.config["master"] = self.options.master - if self.options.cachedir and self.config.get( - "extension_modules" - ) == os.path.join(self.config.get("__cachedir"), "extmods"): - # Override `extension_modules`, but only in case if it was autogenerated - cache_dir = os.path.abspath(self.options.cachedir) - self.config["cachedir"] = cache_dir - self.config["extension_modules"] = os.path.join(cache_dir, "extmods") - prepend_root_dir(self.config, ["cachedir", "extension_modules"]) - if self.config["verify_env"]: # When --priv is used, MergeConfigMixIn has already overwritten # config["user"] with the --priv value during parse_args(). We need @@ -126,26 +110,4 @@ def run(self): caller.print_grains() self.exit(salt.defaults.exitcodes.EX_OK) - salt.utils.tracing.configure({**self.config, "__role": "minion"}) - env_carrier = { - k: v - for k, v in ( - ("traceparent", os.environ.get("TRACEPARENT", "")), - ("tracestate", os.environ.get("TRACESTATE", "")), - ) - if v - } - trace_ctx = salt.utils.tracing.extract(env_carrier) - with salt.utils.tracing.start_span( - ( - f"salt-call.{self.config.get('fun', '')}" - if self.config.get("fun") - else "salt-call" - ), - attributes={"salt.fun": self.config.get("fun", "")}, - context=trace_ctx, - ): - try: - caller.run() - finally: - salt.utils.tracing.shutdown() + caller.run() diff --git a/salt/cli/caller.py b/salt/cli/caller.py index 4d34cd5396c6..ffc39356ba95 100644 --- a/salt/cli/caller.py +++ b/salt/cli/caller.py @@ -123,8 +123,6 @@ def call(self): """ Call the module """ - if self.opts.get("resources_dispatch"): - return self._call_with_resources() ret = {} fun = self.opts["fun"] ret["jid"] = salt.utils.jid.gen_jid(self.opts) @@ -288,258 +286,6 @@ def call(self): return ret - def _call_with_resources(self): - """ - Dispatch a salt-call invocation to the managing minion and/or its - managed resources. - - Triggered by ``salt-call -r/--resources``. Reuses - ``Minion._resolve_resource_targets`` (exposed on ``MinionBase``) to - compute the resource target list from ``--tgt`` / ``--tgt-type``, - runs the function once per matched target, and combines results - into the same shape the master CLI produces: - - * Non-merge functions (e.g. ``test.ping``) — ``{target_id: result}`` - when more than one target matched, bare value when only one did. - * Merge functions (``state.apply``, ``state.highstate``, …) — one - combined state dict per managing minion with each resource's - state IDs prefixed by the resource id (matches the master - merge-mode output exactly). - """ - # Lazy import — only paid for on -r. - import salt.loader.context as _loader_ctx # noqa: PLC0415 - - fun = self.opts["fun"] - tgt = self.opts.get("resources_tgt", "*") - tgt_type = self.opts.get("resources_tgt_type", "glob") - - # Resolve resources via the inherited helper (set up on MinionBase - # so SMinion sees it). The helper consults the minion's pillar - # (``opts["resources"]``) and per-resource grains cache. - load = {"fun": fun, "tgt": tgt, "tgt_type": tgt_type} - try: - resource_targets = self.minion._resolve_resource_targets(load) - except Exception: # pylint: disable=broad-except - log.exception("Failed to resolve resource targets for -r dispatch") - resource_targets = [] - - # When the target is purely T@/M@ compound terms (e.g. - # ``T@dummy:dummy-01``), the managing minion should NOT also receive - # the call — the operator is addressing resources, not the minion. - # Mirrors Minion._target_load behaviour for master-driven jobs. - is_pure_resource_tgt = self.minion._is_pure_resource_target(load) - minion_matches = ( - False - if is_pure_resource_tgt - else self._target_matches_managing_minion(tgt, tgt_type) - ) - - # Argument parsing — pick any reachable copy of the function so - # ``load_args_and_kwargs`` can introspect its signature. - parsed = salt.utils.args.parse_input( - self.opts["arg"], no_parse=self.opts.get("no_parse", []) - ) - sig_func = self.minion.functions.get(fun) - if sig_func is None: - for loader in getattr(self.minion, "resource_loaders", {}).values(): - if fun in loader: - sig_func = loader[fun] - break - if sig_func is None: - sys.stderr.write( - f"Function '{fun}' is not available on the managing minion or " - "any per-resource loader.\n" - ) - sys.exit(salt.defaults.exitcodes.EX_GENERIC) - args, kwargs = salt.minion.load_args_and_kwargs(sig_func, parsed) - - merge_funs = getattr(self.minion, "_MERGE_RESOURCE_FUNS", frozenset()) - is_merge = fun in merge_funs - - results = {} - minion_id = self.minion.opts["id"] - minion_ret = None - - # 1. Run the function on the managing minion if it matches the target. - if minion_matches: - try: - minion_ret = self.minion.functions[fun](*args, **kwargs) - except KeyError: - minion_ret = f"Function '{fun}' is not available on the managing minion" - except Exception as exc: # pylint: disable=broad-except - log.exception("Managing minion %s raised running %s", minion_id, fun) - minion_ret = f"ERROR running {fun}: {exc}" - results[minion_id] = minion_ret - - # 2. Run the function once per matched resource. - resource_funcs = getattr(self.minion, "resource_funcs", None) - for resource in resource_targets: - rid = resource["id"] - rtype = resource["type"] - loader = getattr(self.minion, "resource_loaders", {}).get(rtype) - if loader is None: - results[rid] = ( - f"No resource loader for type '{rtype}'. Ensure the " - "resource module exists and the minion is configured " - "to manage resources of this type." - ) - continue - if fun not in loader: - results[rid] = ( - f"Function '{fun}' is not supported for resource " - f"type '{rtype}'." - ) - continue - token = _loader_ctx.resource_ctxvar.set(resource) - # Swap ``__grains__`` for this resource so functions like - # ``grains.items`` return the resource's grains rather than the - # managing minion's. Mirrors what Minion._thread_return does for - # master-driven resource jobs (salt/minion.py ~2724). - grains_fn = f"{rtype}.grains" - prior_grains = loader.pack.get("__grains__") - grains_swapped = False - if resource_funcs is not None and grains_fn in resource_funcs: - try: - loader.pack["__grains__"] = resource_funcs[grains_fn]() - grains_swapped = True - except Exception as exc: # pylint: disable=broad-except - log.warning( - "Failed to render grains for resource %s:%s — falling " - "back to managing minion's grains: %s", - rtype, - rid, - exc, - ) - try: - results[rid] = loader[fun](*args, **kwargs) - except Exception as exc: # pylint: disable=broad-except - log.exception("Resource %s raised running %s", rid, fun) - results[rid] = f"ERROR running {fun} for '{rid}': {exc}" - finally: - _loader_ctx.resource_ctxvar.reset(token) - if grains_swapped: - if prior_grains is None: - loader.pack.pop("__grains__", None) - else: - loader.pack["__grains__"] = prior_grains - - # 3. For merge funs, fold per-resource state dicts into the - # managing minion's state dict with prefixed IDs (master shape). - if is_merge and isinstance(minion_ret, dict): - merged = self._merge_resource_state_results( - minion_ret, resource_targets, results - ) - return {"return": merged, "retcode": self._aggregate_retcode(merged)} - - # 4. Output: bare value if exactly one target ran, dict otherwise. - if not results: - return {"return": {}, "retcode": salt.defaults.exitcodes.EX_OK} - if len(results) == 1: - value = next(iter(results.values())) - return {"return": value, "retcode": self._aggregate_retcode(value)} - return {"return": results, "retcode": self._aggregate_retcode(results)} - - def _target_matches_managing_minion(self, tgt, tgt_type): - """ - Return True if the target expression matches the managing minion's - own id (so the function should also run on the minion in addition - to its resources). - - Uses the minion's already-loaded matcher modules. Falls back to - ``False`` if the matcher isn't available — the operator still gets - the per-resource dispatch. - """ - matchers = getattr(self.minion, "matchers", None) - if not matchers: - return False - match_fn = matchers.get(f"{tgt_type}_match.match") - if match_fn is None: - return False - try: - if tgt_type in ("grain", "grain_pcre", "pillar", "pillar_pcre"): - delimiter = self.opts.get("delimiter") or ":" - return bool(match_fn(tgt, delimiter=delimiter)) - return bool(match_fn(tgt)) - except Exception: # pylint: disable=broad-except - log.debug( - "Managing-minion match check failed for %s/%s", - tgt_type, - tgt, - exc_info=True, - ) - return False - - def _merge_resource_state_results(self, base, resource_targets, results): - """ - Combine per-resource state-result dicts into ``base`` with each - state ID prefixed by the resource id. Matches the master merge - path in :meth:`Minion._thread_return`. - """ - # Bypass instance binding — the rebind in salt/minion.py loses the - # @staticmethod wrapper, so accessing via the class avoids self - # injection. - prefix = salt.minion.Minion.__dict__["_prefix_resource_state_key"] - if hasattr(prefix, "__func__"): - prefix = prefix.__func__ - merged = dict(base) - run_num_base = ( - max( - ( - v.get("__run_num__", 0) - for v in merged.values() - if isinstance(v, dict) - ), - default=0, - ) - + 1 - ) - for resource in resource_targets: - rid = resource["id"] - r_ret = results.get(rid) - if isinstance(r_ret, dict): - for sid, sval in r_ret.items(): - if isinstance(sval, dict): - entry = dict(sval) - entry["__run_num__"] = run_num_base - else: - entry = { - "result": True, - "comment": str(sval), - "name": f"[{rid}]", - "changes": {}, - "__run_num__": run_num_base, - } - run_num_base += 1 - merged[prefix(sid, rid)] = entry - else: - merged[f"no_|-{rid}_|-{rid}_|-None"] = { - "result": False, - "comment": str(r_ret), - "name": rid, - "changes": {}, - "__run_num__": run_num_base, - } - run_num_base += 1 - return merged - - @staticmethod - def _aggregate_retcode(payload): - """ - Best-effort retcode aggregation across all targets. - - Returns ``EX_GENERIC`` when any target returned a falsy ``result`` / - ``success`` key; otherwise ``EX_OK``. Preserves the existing - salt-call non-resource ``call()`` semantics where retcode is taken - from ``__context__["retcode"]`` first; here we don't have a single - context, so we fall back to inspecting the return shape. - """ - if isinstance(payload, dict): - for value in payload.values(): - if isinstance(value, dict): - if not all(value.get(k, True) for k in ("result", "success")): - return salt.defaults.exitcodes.EX_GENERIC - return salt.defaults.exitcodes.EX_OK - class ZeroMQCaller(BaseCaller): """ diff --git a/salt/cli/daemons.py b/salt/cli/daemons.py index 9e2124cb8f7b..0413aa77dcad 100644 --- a/salt/cli/daemons.py +++ b/salt/cli/daemons.py @@ -9,7 +9,6 @@ import salt.utils.kinds as kinds from salt.exceptions import SaltClientError, SaltSystemExit, get_error_message from salt.utils import migrations -from salt.utils import ostruststore as _ostruststore from salt.utils.platform import is_junos from salt.utils.process import HAS_PSUTIL @@ -148,7 +147,7 @@ def verify_environment(self): if ( self.config["cluster_id"] and self.config["cluster_pki_dir"] - # and self.config["cluster_pki_dir"] != self.config["pki_dir"] + and self.config["cluster_pki_dir"] != self.config["pki_dir"] ): v_dirs.extend( [ @@ -180,7 +179,6 @@ def prepare(self): super(YourSubClass, self).prepare() """ super().prepare() - _ostruststore.apply_if_enabled(self.config) try: self.verify_environment() @@ -263,7 +261,6 @@ def prepare(self): super(YourSubClass, self).prepare() """ super().prepare() - _ostruststore.apply_if_enabled(self.config) try: if self.config["verify_env"]: @@ -447,7 +444,6 @@ def prepare(self): super(YourSubClass, self).prepare() """ super().prepare() - _ostruststore.apply_if_enabled(self.config) ## allow for native minion if not is_junos(): @@ -590,7 +586,6 @@ def prepare(self): super(YourSubClass, self).prepare() """ super().prepare() - _ostruststore.apply_if_enabled(self.config) try: if self.config["verify_env"]: verify_env( diff --git a/salt/cli/salt.py b/salt/cli/salt.py index 344bf848a974..913e47f439e7 100644 --- a/salt/cli/salt.py +++ b/salt/cli/salt.py @@ -8,7 +8,6 @@ import salt.defaults.exitcodes import salt.utils.parsers import salt.utils.stringutils -import salt.utils.tracing from salt.exceptions import ( AuthenticationError, AuthorizationError, @@ -30,29 +29,9 @@ def run(self): """ Execute the salt command line """ - import salt.client # noqa: F401 + import salt.client self.parse_args() - salt.utils.tracing.configure({**self.config, "__role": "cli"}) - span_name = ( - f"salt.cli.{self.config.get('fun', '')}" - if self.config.get("fun") - else "salt.cli" - ) - with salt.utils.tracing.start_span( - span_name, - attributes={ - "salt.cli.tgt": str(self.config.get("tgt", "")), - "salt.cli.fun": self.config.get("fun", ""), - }, - ): - try: - self._run() - finally: - salt.utils.tracing.shutdown() - - def _run(self): - import salt.client try: # We don't need to bail on config file permission errors @@ -147,9 +126,6 @@ def _run(self): if getattr(self.options, "metadata"): kwargs["metadata"] = yamlify_arg(getattr(self.options, "metadata")) - if getattr(self.options, "start_event", False): - kwargs["start_event"] = True - # If using eauth and a token hasn't already been loaded into # kwargs, prompt the user to enter auth credentials if "token" not in kwargs and "key" not in kwargs and self.options.eauth: @@ -283,14 +259,6 @@ def _run_batch(self): eauth.update(res) eauth["eauth"] = self.options.eauth - # When --async and --batch are both set, hand off to the - # master-side BatchManager: publish the first sub-batch under - # a shared JID, persist BatchState, and exit. The CLI does - # not wait for returns. - if self.config.get("async") and self.options.batch: - self._run_batch_async(eauth) - return - if self.options.static: if not self.options.batch: @@ -331,123 +299,6 @@ def _run_batch(self): sys.exit(2) sys.exit(retcode) - def _run_batch_async(self, eauth): - """ - Hand off an async batch to the master-side BatchManager. - - Resolves the target (sync gather_minions via - :class:`salt.cli.batch.Batch`), generates a single JID, - publishes the first sub-batch under that JID via the CLI's - ``LocalClient`` (so eauth runs the normal auth pipeline - exactly once), writes ``.batch.p``, registers the JID in the - active index, fires ``salt/batch//new`` so BatchManager - adopts it, prints the JID, and exits. - - Subsequent sub-batch publishes are issued by the BatchManager - using ``state["user"]`` captured here so publisher-ACL and - audit still attribute every sub-batch publish to the original - operator. - """ - import time - - import salt.cli.batch - import salt.utils.batch_output - import salt.utils.batch_state - import salt.utils.event - import salt.utils.jid - import salt.utils.user - - batch_jid = salt.utils.jid.gen_jid(self.config) - - try: - helper = salt.cli.batch.Batch(self.config, eauth=eauth, quiet=True) - except SaltClientError: - sys.exit(2) - try: - minions, _ping_gen, down_minions = helper.gather_minions() - finally: - try: - helper.local.destroy() - except Exception: # pylint: disable=broad-except - pass - - if not minions: - salt.utils.stringutils.print_cli("No minions matched the target.") - sys.exit(0) - - for m in sorted(down_minions or ()): - salt.utils.stringutils.print_cli( - f"Minion {m} did not respond. No job will be sent." - ) - - opts_for_state = dict(self.config) - opts_for_state["batch"] = self.options.batch - opts_for_state["user"] = ( - eauth.get("username") if eauth else salt.utils.user.get_user() - ) - state = salt.utils.batch_state.create_batch_state( - opts_for_state, minions, batch_jid, driver="master" - ) - - batch_size = state["batch_size"] - initial = state["pending"][:batch_size] - state["pending"] = state["pending"][batch_size:] - - pub_kwargs = dict(eauth or {}) - try: - self.local_client.run_job( - tgt=list(initial), - fun=self.config["fun"], - arg=list(self.config.get("arg") or []), - tgt_type="list", - ret=self.config.get("ret", "") or "", - timeout=self.config.get("timeout", 60), - jid=batch_jid, - listen=False, - **pub_kwargs, - ) - except ( - AuthenticationError, - AuthorizationError, - EauthAuthenticationError, - SaltClientError, - SaltInvocationError, - ) as exc: - sys.stderr.write(f"ERROR: {exc}\n") - sys.exit(2) - - now = time.time() - for m in initial: - state["active"][m] = now - state["last_progress"] = now - - salt.utils.batch_state.write_batch_state(batch_jid, state, self.config) - salt.utils.batch_state.add_to_active_index(batch_jid, self.config) - - try: - with salt.utils.event.get_master_event( - self.config, self.config["sock_dir"], listen=False - ) as event: - event.fire_event( - salt.utils.batch_output.new_payload(state), - salt.utils.batch_output.tag_new(batch_jid), - ) - except Exception: # pylint: disable=broad-except - # Non-fatal — BatchManager's _tick() reconciliation will - # pick this batch up from the active index within one - # loop interval. Log the failure so operators see it. - import logging - - logging.getLogger(__name__).exception( - "Failed to fire salt/batch/%s/new; BatchManager will adopt " - "via active-index reconciliation on its next tick", - batch_jid, - ) - - salt.utils.stringutils.print_cli( - f"Executed batch command with job ID: {batch_jid}" - ) - def _print_errors_summary(self, errors): if errors: salt.utils.stringutils.print_cli("\n") diff --git a/salt/client/__init__.py b/salt/client/__init__.py index f99fd70d50cc..e0ecf876e5e8 100644 --- a/salt/client/__init__.py +++ b/salt/client/__init__.py @@ -24,6 +24,8 @@ import time from datetime import datetime +import tornado.gen + import salt.cache import salt.channel.client import salt.config @@ -35,11 +37,9 @@ import salt.utils.event import salt.utils.files import salt.utils.jid -import salt.utils.metrics import salt.utils.minions import salt.utils.network import salt.utils.platform -import salt.utils.resources import salt.utils.stringutils import salt.utils.user import salt.utils.verify @@ -65,158 +65,6 @@ log = logging.getLogger(__name__) -def _resource_ids_from_minion_grains_cache(opts, minion_id): - """ - Return bare resource IDs last synced for ``minion_id`` in the master's - minion grains cache (``salt_resources``), or [] if unavailable. - - Used when the mmap resource registry no longer lists that minion (e.g. it - just went offline) but the operator still needs per-resource missing lines. - """ - if not opts.get("minion_data_cache"): - return [] - try: - cache = salt.cache.factory(opts) - if not cache.contains("grains", minion_id): - return [] - grains = cache.fetch("grains", minion_id) or {} - except Exception as exc: # pylint: disable=broad-except - log.debug( - "Grains cache read for minion %s failed while expanding missing returns: %s", - minion_id, - exc, - exc_info=True, - ) - return [] - return salt.utils.resources.bare_resource_ids_from_decl( - grains.get("salt_resources") - ) - - -def _resource_ids_from_minion_pillar_cache(opts, minion_id): - """ - Return bare resource IDs from the minion's cached pillar subtree under - :func:`~salt.utils.resources.resource_pillar_key` (default ``resources``). - - ``salt_resources`` is often absent from grains even when pillar (and thus - ``resource_ids``) was synced to the master — this path closes that gap. - """ - if not opts.get("minion_data_cache"): - return [] - try: - cache = salt.cache.factory(opts) - if not cache.contains("pillar", minion_id): - return [] - pillar = cache.fetch("pillar", minion_id) or {} - except Exception as exc: # pylint: disable=broad-except - log.debug( - "Pillar cache read for minion %s failed while expanding missing returns: %s", - minion_id, - exc, - exc_info=True, - ) - return [] - key = salt.utils.resources.resource_pillar_key(opts) - subtree = pillar.get(key) - if not isinstance(subtree, dict): - return [] - return salt.utils.resources.bare_resource_ids_from_decl(subtree) - - -def _job_ret_display_id(data): - """ - Key for one job return in CLI / nested job-return events. - - Resource jobs keep ``id`` as the managing minion (signing / transport) and - set ``resource_id`` to the bare resource id. Some masters instead rewrite - ``id`` to the resource id and omit ``resource_id``. - """ - if not isinstance(data, dict): - return None - rid = data.get("resource_id") - if rid is not None and rid != "": - return rid - return data.get("id") - - -def _iter_failed_missing_returns(opts, found, missing_root_ids): - """ - Yield ``{id: {"failed\": True}}`` for each missing target, and for each - managing minion also yield its managed resource IDs when those resources - did not send a return. - - Resource IDs are taken from the master's resource registry when present, - then merged with IDs from :conf_master:`minion_data_cache` **grains** - (``salt_resources``) and **pillar** (``resources`` / ``resource_pillar_key``), - so offline minions still expand to their last-known resource rows. - """ - ck = salt.utils.minions.CkMinions(opts) - reported = set() - missing_set = set(missing_root_ids) - try: - pki_minions = ck._pki_minions() - except Exception as exc: # pylint: disable=broad-except - log.debug( - "Could not list PKI minions while expanding missing returns: %s", - exc, - exc_info=True, - ) - pki_minions = set() - # Glob targets augmented with resource IDs sort those IDs before the - # managing minion; handle PKI minions first so pillar/grains expansion is - # not skipped after bare resource rows were already marked reported. - minion_first = [m for m in sorted(missing_set) if m in pki_minions] - remainder = sorted(missing_set - set(minion_first)) - - def _emit_missing_for_minion(mid): - if mid in reported: - return - yield {mid: {"failed": True}} - reported.add(mid) - by_type = None - try: - by_type = ck.registry.get_resources_for_minion(mid) - except Exception as exc: # pylint: disable=broad-except - log.debug( - "Could not read resource registry for minion %s: %s", - mid, - exc, - exc_info=True, - ) - rid_order = [] - seen_rid = set() - if by_type: - for rids in by_type.values(): - if not isinstance(rids, (list, tuple)): - continue - for rid in rids: - if rid in seen_rid: - continue - rid_order.append(rid) - seen_rid.add(rid) - for rid in _resource_ids_from_minion_grains_cache(opts, mid): - if rid in seen_rid: - continue - rid_order.append(rid) - seen_rid.add(rid) - for rid in _resource_ids_from_minion_pillar_cache(opts, mid): - if rid in seen_rid: - continue - rid_order.append(rid) - seen_rid.add(rid) - for rid in rid_order: - if rid in reported or rid in found: - continue - yield {rid: {"failed": True}} - reported.add(rid) - - for mid in minion_first: - yield from _emit_missing_for_minion(mid) - - for mid in remainder: - yield from _emit_missing_for_minion(mid) - - def get_local_client( c_path=os.path.join(syspaths.CONFIG_DIR, "master"), mopts=None, @@ -568,7 +416,8 @@ def gather_minions(self, tgt, expr_form): ) return _res["minions"] - async def run_job_async( + @tornado.gen.coroutine + def run_job_async( self, tgt, fun, @@ -599,7 +448,7 @@ async def run_job_async( arg = salt.utils.args.condition_input(arg, kwarg) try: - pub_data = await self.pub_async( + pub_data = yield self.pub_async( tgt, fun, arg, @@ -624,7 +473,7 @@ async def run_job_async( # Convert to generic client error and pass along message raise SaltClientError(general_exception) - return self._check_pub_data(pub_data, listen=listen) + raise tornado.gen.Return(self._check_pub_data(pub_data, listen=listen)) def cmd_async( self, tgt, fun, arg=(), tgt_type="glob", ret="", jid="", kwarg=None, **kwargs @@ -1135,8 +984,6 @@ def cmd_iter_no_block( if not pub_data: yield pub_data else: - # Filter out 'jid' to avoid conflict with the positional arg - iter_kwargs = {k: v for k, v in kwargs.items() if k != "jid"} for fn_ret in self.get_iter_returns( pub_data["jid"], pub_data["minions"], @@ -1144,7 +991,7 @@ def cmd_iter_no_block( tgt=tgt, tgt_type=tgt_type, block=False, - **iter_kwargs, + **kwargs, ): if fn_ret and any([show_jid, verbose]): for minion in fn_ret: @@ -1301,10 +1148,6 @@ def get_iter_returns( kwargs.get("gather_job_timeout", self.opts["gather_job_timeout"]) ) start = int(time.time()) - # Float start kept solely for the ``salt.job.duration`` histogram. - # Keep the integer ``start`` above intact so the existing timeout - # arithmetic isn't perturbed. - _metric_start = time.time() # timeouts per minion, id_ -> timeout time minion_timeouts = {} @@ -1371,42 +1214,21 @@ def get_iter_returns( if "return" not in raw["data"]: log.warning("Malformed event return: %s", raw["tag"]) continue - display_id = _job_ret_display_id(raw["data"]) - if display_id is None: - log.warning("Malformed job return (no id): %s", raw["tag"]) - continue - # Drop duplicate events for the same logical target (same JID + - # resource id or minion id). External caches can replay returns. - if display_id in found: - log.debug( - "Skipping duplicate return for jid %s from %s", - jid, - display_id, - ) - continue - salt.utils.metrics.histogram( - "salt.job.duration", - description="CLI-to-master-return wall-clock per minion return.", - unit="ms", - ).record( - (time.time() - _metric_start) * 1000.0, - attributes={"fun": raw["data"].get("fun", "")}, - ) if kwargs.get("raw", False): - found.add(display_id) + found.add(raw["data"]["id"]) yield raw else: - found.add(display_id) - ret = {display_id: {"ret": raw["data"]["return"]}} + found.add(raw["data"]["id"]) + ret = {raw["data"]["id"]: {"ret": raw["data"]["return"]}} if "out" in raw["data"]: - ret[display_id]["out"] = raw["data"]["out"] + ret[raw["data"]["id"]]["out"] = raw["data"]["out"] if "retcode" in raw["data"]: - ret[display_id]["retcode"] = raw["data"]["retcode"] + ret[raw["data"]["id"]]["retcode"] = raw["data"]["retcode"] if "jid" in raw["data"]: - ret[display_id]["jid"] = raw["data"]["jid"] + ret[raw["data"]["id"]]["jid"] = raw["data"]["jid"] if kwargs.get("_cmd_meta", False): - ret[display_id].update(raw["data"]) - log.debug("jid %s return from %s", jid, display_id) + ret[raw["data"]["id"]].update(raw["data"]) + log.debug("jid %s return from %s", jid, raw["data"]["id"]) yield ret # if we have all of the returns (and we aren't a syndic), no need for anything fancy @@ -1446,19 +1268,8 @@ def get_iter_returns( # re-do the ping if time.time() > timeout_at and minions_running: # since this is a new ping, no one has responded yet - # Only send gather_job_info to IDs that are accepted minions. - # Resource IDs (e.g. "dummy-01") are not PKI keys; sending - # saltutil.find_job to them as a list target would fail and - # print a misleading "No minions matched" message. - pending = minions - found - accepted_minions = set( - salt.utils.minions.CkMinions(self.opts)._pki_minions() - ) - minion_pending = list(pending & accepted_minions) - jinfo = ( - self.gather_job_info(jid, minion_pending, "list", **kwargs) - if minion_pending - else {} + jinfo = self.gather_job_info( + jid, list(minions - found), "list", **kwargs ) minions_running = False # if we weren't assigned any jid that means the master thinks @@ -1564,7 +1375,8 @@ def get_iter_returns( self.event.unsubscribe(jid) if expect_minions: - yield from _iter_failed_missing_returns(self.opts, found, minions - found) + for minion in list(minions - found): + yield {minion: {"failed": True}} # Filter out any minions marked as missing for which we received # returns (prevents false events sent due to higher-level masters not @@ -1573,7 +1385,8 @@ def get_iter_returns( # Report on missing minions if missing: - yield from _iter_failed_missing_returns(self.opts, found, missing) + for minion in missing: + yield {minion: {"failed": True}} def get_returns(self, jid, minions, timeout=None): """ @@ -1781,14 +1594,11 @@ def get_cli_static_event_returns( if "minions" in raw.get("data", {}): minions.update(raw["data"]["minions"]) continue - display_id = _job_ret_display_id(raw) - if display_id is None: - continue - found.add(display_id) - ret[display_id] = {"ret": raw["return"]} - ret[display_id]["success"] = raw.get("success", False) + found.add(raw["id"]) + ret[raw["id"]] = {"ret": raw["return"]} + ret[raw["id"]]["success"] = raw.get("success", False) if "out" in raw: - ret[display_id]["out"] = raw["out"] + ret[raw["id"]]["out"] = raw["out"] if len(found.intersection(minions)) >= len(minions): # All minions have returned, break out of the loop break @@ -1806,14 +1616,8 @@ def get_cli_static_event_returns( ): if len(found) < len(minions): fail = sorted(list(minions.difference(found))) - for fid in ( - k - for chunk in _iter_failed_missing_returns( - self.opts, found, fail - ) - for k in chunk - ): - ret[fid] = { + for minion in fail: + ret[minion] = { "out": "no_return", "ret": "Minion did not return", } @@ -1861,10 +1665,8 @@ def get_cli_event_returns( # (gtmanfred) expect_minions is popped here in case it is passed from a client # call. If this is not popped, then it would be passed twice to # get_iter_returns. - # Default True: ``salt`` must still emit per-target timeout rows (and - # resource-id expansion for missing managers) even without ``-v``. expect_minions=( - kwargs.pop("expect_minions", True) or verbose or show_timeout + kwargs.pop("expect_minions", False) or verbose or show_timeout ), **kwargs, ): @@ -1879,21 +1681,20 @@ def get_cli_event_returns( } # replace the return structure for missing minions for id_, min_ret in ret.items(): - # Do not use ``is True``; some payloads deserialize ``failed`` as a - # non-singleton truthy value, which would skip this branch, hit the - # generic ``yield`` below without a ``ret`` field, and make the salt - # CLI drop the row on :func:`~salt.cli.salt.Salt._format_ret` KeyError. - if min_ret.get("failed"): + if min_ret.get("failed") is True: if connected_minions is None: connected_minions = salt.utils.minions.CkMinions( self.opts ).connected_ids() if ( self.opts["minion_data_cache"] - and salt.cache.factory(self.opts).contains("grains", id_) + and salt.cache.factory(self.opts).contains( + f"minions/{id_}", "data" + ) and connected_minions and id_ not in connected_minions ): + yield { id_: { "out": "no_return", @@ -1961,20 +1762,15 @@ def get_event_iter_returns(self, jid, minions, timeout=None): try: # There might be two jobs for the same minion, so we have to check for the jid if jid == raw["jid"]: - display_id = _job_ret_display_id(raw) - if display_id is None: - continue - if display_id in found: - continue - found.add(display_id) - ret = {display_id: {"ret": raw["return"]}} + found.add(raw["id"]) + ret = {raw["id"]: {"ret": raw["return"]}} else: continue except KeyError: # Ignore other erroneous messages continue if "out" in raw: - ret[display_id]["out"] = raw["out"] + ret[raw["id"]]["out"] = raw["out"] yield ret time.sleep(0.02) @@ -2139,9 +1935,6 @@ def pub( payload_kwargs["key"] = self.key payload = channel.send(payload_kwargs) - if isinstance(payload, str): - payload = {"error": payload} - error = payload.pop("error", None) if error is not None: if isinstance(error, dict): @@ -2159,7 +1952,8 @@ def pub( return {"jid": payload["load"]["jid"], "minions": payload["load"]["minions"]} - async def pub_async( + @tornado.gen.coroutine + def pub_async( self, tgt, fun, @@ -2225,7 +2019,7 @@ async def pub_async( # If not, we won't get a response, so error out if listen and not self.event.connect_pub(timeout=timeout): raise SaltReqTimeoutError() - payload = await channel.send(payload_kwargs, timeout=timeout) + payload = yield channel.send(payload_kwargs, timeout=timeout) except SaltReqTimeoutError: raise SaltReqTimeoutError( "Salt request timed out. The master is not responding. You " @@ -2242,10 +2036,10 @@ async def pub_async( # and try again if the key has changed key = self.__read_master_key() if key == self.key: - return payload + raise tornado.gen.Return(payload) self.key = key payload_kwargs["key"] = self.key - payload = await channel.send(payload_kwargs) + payload = yield channel.send(payload_kwargs) error = payload.pop("error", None) if error is not None: @@ -2260,9 +2054,11 @@ async def pub_async( raise PublishError(error) if not payload: - return payload + raise tornado.gen.Return(payload) - return {"jid": payload["load"]["jid"], "minions": payload["load"]["minions"]} + raise tornado.gen.Return( + {"jid": payload["load"]["jid"], "minions": payload["load"]["minions"]} + ) # pylint: disable=W1701 def __del__(self): diff --git a/salt/client/mixins.py b/salt/client/mixins.py index 6f74eb382ffb..e8e22728a280 100644 --- a/salt/client/mixins.py +++ b/salt/client/mixins.py @@ -378,10 +378,6 @@ def low(self, fun, low, print_event=True, full_return=False): data["fun_args"] = list(args) + ([kwargs] if kwargs else []) func_globals["__jid_event__"].fire_event(data, "new") - proc_fn = os.path.join(self.opts["cachedir"], "proc", jid) - with salt.utils.files.fopen(proc_fn, "w+b") as fp_: - fp_.write(salt.payload.dumps(dict(data, pid=os.getpid()))) - func = self.functions[fun] try: data["return"] = func(*args, **kwargs) @@ -412,12 +408,6 @@ def low(self, fun, low, print_event=True, full_return=False): ) data["success"] = False data["retcode"] = 1 - finally: - # Job has finished or issue found, so let's clean up after ourselves - try: - os.remove(proc_fn) - except OSError as err: - log.debug("Error attempting to remove master job tracker: %s", err) if self.store_job: try: diff --git a/salt/client/netapi.py b/salt/client/netapi.py index 2aefeb126f71..27029af85a3e 100644 --- a/salt/client/netapi.py +++ b/salt/client/netapi.py @@ -2,7 +2,6 @@ The main entry point for salt-api """ -import asyncio import logging import signal @@ -64,7 +63,7 @@ def run(self): # No custom signal handling was added, install our own signal.signal(signal.SIGTERM, self._handle_signals) - asyncio.run(self.process_manager.run()) + self.process_manager.run() def _handle_signals(self, signum, sigframe): # escalate the signals to the process manager diff --git a/salt/client/ssh/__init__.py b/salt/client/ssh/__init__.py index 71bc99acb131..f29bb7539e7d 100644 --- a/salt/client/ssh/__init__.py +++ b/salt/client/ssh/__init__.py @@ -30,7 +30,6 @@ import salt.loader import salt.minion import salt.output -import salt.pillar import salt.roster import salt.serializers.yaml import salt.state @@ -43,11 +42,8 @@ import salt.utils.network import salt.utils.path import salt.utils.platform -import salt.utils.relenv import salt.utils.stringutils import salt.utils.thin -import salt.utils.timeutil -import salt.utils.tracing import salt.utils.url import salt.utils.verify from salt._logging import LOG_LEVELS @@ -221,99 +217,6 @@ def _ssh_cli_process_exit_code(retcode): ] ) - -SSH_SH_SHIM_RELENV = "\n".join( - [ - s.strip() - for s in """ -/bin/sh << 'EOF' -set -e -set -u -DEBUG="{DEBUG}" -if [ -n "$DEBUG" ]; then set -x; fi - -SET_PATH="{SET_PATH}" -if [ -n "$SET_PATH" ]; then export PATH=$SET_PATH; fi - -SUDO="" -if [ -n "{SUDO}" ]; then SUDO="{SUDO} "; fi - -SUDO_USER="{SUDO_USER}" -if [ "$SUDO" ] && [ "$SUDO_USER" ]; then SUDO="$SUDO -u $SUDO_USER"; fi - -RELENV_TAR="{THIN_DIR}/salt-relenv.tar.xz" -EXT_MODS_TAR="{THIN_DIR}/salt-ext_mods.tgz" -EXT_MODS_VERSION="{EXT_MODS_VERSION}" -mkdir -p "{THIN_DIR}" -SALT_CALL_BIN="{THIN_DIR}/salt-call" - -# Extract relenv tarball if not already extracted -if [ ! -x "$SALT_CALL_BIN" ]; then - if [ ! -f "$RELENV_TAR" ]; then - echo "ERROR: relenv tarball not found at $RELENV_TAR" >&2 - exit 11 - fi - - # Create directory if not exists and extract the tarball - tar --strip-components=1 -xf "$RELENV_TAR" -C "{THIN_DIR}" -fi - -# BUG-WORKAROUND: salt-ssh relenv path never writes the minion config that -# Single.__init__ builds in self.minion_config. The non-relenv (salt-thin) -# path embeds it in SSH_PY_SHIM via OPTIONS.config, which the Python shim -# writes to thin_dir/minion. The relenv shim has no equivalent, so salt-call -# falls back to system defaults (/var/cache/salt, /var/log/salt) and fails for -# any unprivileged user. Writing it here replicates the salt-thin behaviour. -# See: https://github.com/saltstack/salt (file as issue against salt-ssh relenv) -mkdir -p "{THIN_DIR}/running_data/pki" -cat > "{THIN_DIR}/minion" << 'SALT_MINION_CONF_EOF' -__SALT_MINION_CONFIG__ -SALT_MINION_CONF_EOF - -# Check if Python binary is executable -if [ ! -x "$SALT_CALL_BIN" ]; then - echo "ERROR: salt-call binary not found or not executable at $SALT_CALL_BIN" >&2 - exit 1 -fi - -# Handle extension modules with version checking (similar to thin) -if [ -n "$EXT_MODS_VERSION" ]; then - # Check if we already have the correct version - EXT_VERSION_FILE="{THIN_DIR}/ext_version" - CURRENT_VERSION=$(cat "$EXT_VERSION_FILE" 2>/dev/null || echo "") - - if [ "$CURRENT_VERSION" != "$EXT_MODS_VERSION" ]; then - # Version mismatch or no version file - need fresh ext_mods - if [ -f "$EXT_MODS_TAR" ]; then - # Extract the tarball - EXTMODS_DIR="{THIN_DIR}/running_data/var/cache/salt/minion/extmods" - mkdir -p "$EXTMODS_DIR" - tar -xzf "$EXT_MODS_TAR" -C "$EXTMODS_DIR" - rm -f "$EXT_MODS_TAR" - # Version file should be in the tarball, move it to thin_dir - if [ -f "$EXTMODS_DIR/ext_version" ]; then - mv "$EXTMODS_DIR/ext_version" "{THIN_DIR}/ext_version" - fi - else - # No tarball present - request from master - echo "{RSTR}" - echo ext_mods - exit 13 - fi - fi -fi - -echo "{RSTR}" -echo "{RSTR}" >&2 - -exec $SUDO "$SALT_CALL_BIN" --retcode-passthrough --local --metadata --out=json -lquiet -c "{THIN_DIR}" -- {ARGS} -EOF -""".split( - "\n" - ) - ] -) - if not salt.utils.platform.is_windows() and not salt.utils.platform.is_junos(): shim_file = os.path.join(os.path.dirname(__file__), "ssh_py_shim.py") if not os.path.exists(shim_file): @@ -453,19 +356,12 @@ def __init__(self, opts): # initial refresh of the fileserver backends. self.opts.pop("__fs_update", None) self.fsclient = salt.fileclient.FSClient(self.opts) - if self.opts.get("relenv"): - self.thin = None - else: - self.thin = salt.utils.thin.gen_thin( - self.opts["cachedir"], - extra_mods=self.opts.get("thin_extra_mods"), - overwrite=self.opts["regen_thin"], - extended_cfg=self.opts.get("ssh_ext_alternatives"), - exclude_saltexts=self.opts.get("thin_exclude_saltexts", False), - saltext_allowlist=self.opts.get("thin_saltext_allowlist"), - saltext_blocklist=self.opts.get("thin_saltext_blocklist"), - ) - + self.thin = salt.utils.thin.gen_thin( + self.opts["cachedir"], + extra_mods=self.opts.get("thin_extra_mods"), + overwrite=self.opts["regen_thin"], + extended_cfg=self.opts.get("ssh_ext_alternatives"), + ) self.mods = mod_data(self.fsclient) # __setstate__ and __getstate__ are only used on spawning platforms. @@ -557,7 +453,7 @@ def _update_roster(self): '# Automatically added by "{s_user}" at {s_time}\n{hostname}:\n' " host: {hostname}\n user: {user}\n passwd: {passwd}\n".format( s_user=getpass.getuser(), - s_time=salt.utils.timeutil.utcnow().isoformat(), + s_time=datetime.datetime.utcnow().isoformat(), hostname=self.opts.get("tgt", ""), user=self.opts.get("ssh_user", ""), passwd=self.opts.get("ssh_passwd", ""), @@ -1177,10 +1073,7 @@ def __init__( self.python_env = kwargs.get("ssh_python_env") else: if user: - thin_dir = DEFAULT_THIN_DIR.replace( - "%%USER%%", - re.sub(r"[^a-zA-Z0-9\._\-@]", "_", user), - ) + thin_dir = DEFAULT_THIN_DIR.replace("%%USER%%", user) else: thin_dir = DEFAULT_THIN_DIR.replace("%%USER%%", "root") self.thin_dir = thin_dir.replace( @@ -1189,18 +1082,10 @@ def __init__( :6 ], ) - # Differentiate between thin and relenv deployments to avoid contamination - if self.opts.get("relenv"): - self.thin_dir = self.thin_dir.replace("_salt", "_salt_relenv") - log.info( - "RELENV: Configured thin_dir=%s for relenv deployment", - self.thin_dir, - ) self.opts["thin_dir"] = self.thin_dir self.fsclient = fsclient self.context = {"master_opts": self.opts, "fileclient": self.fsclient} - self.ssh_pre_hook = kwargs.get("ssh_pre_hook", None) self.ssh_pre_flight = kwargs.get("ssh_pre_flight", None) self.ssh_pre_flight_args = kwargs.get("ssh_pre_flight_args", None) @@ -1263,99 +1148,7 @@ def __init__( # Determine if Windows client is x86 or AMD64 arch, _, _ = self.shell.exec_cmd("powershell $ENV:PROCESSOR_ARCHITECTURE") self.arch = arch.strip() - - if self.opts.get("relenv"): - if thin: - # Caller pre-resolved the relenv tarball path — skip the SSH - # round-trip that detect_os_arch() would otherwise make during - # __init__. This is important when Single is created inside a - # minion job worker where every extra SSH connection adds latency - # and can cause hangs. - self.thin = thin - else: - kernel, os_arch = self.detect_os_arch() - self.thin = salt.utils.relenv.gen_relenv( - opts["cachedir"], kernel=kernel, os_arch=os_arch - ) - - # Add file_roots and related config to minion config - # (required for slsutil functions and other fileserver operations) - self.minion_opts["file_roots"] = self.opts["file_roots"] - self.minion_opts["pillar_roots"] = self.opts["pillar_roots"] - self.minion_opts["ext_pillar"] = self.opts.get("ext_pillar", []) - # For relenv, override extension_modules to point to where the shim - # extracts the tarball on the remote system. - self.minion_opts["extension_modules"] = ( - f"{self.thin_dir}/running_data/var/cache/salt/minion/extmods" - ) - self.minion_opts["module_dirs"] = self.opts["module_dirs"] - self.minion_opts["__master_opts__"] = self.context["master_opts"] - - # Re-serialize the minion config after updating relenv-specific paths - self.minion_config = salt.serializers.yaml.serialize(self.minion_opts) - else: - self.thin = thin if thin else salt.utils.thin.thin_path(opts["cachedir"]) - - def detect_os_arch(self): - """ - Detect the OS and architecture of the target machine. - This is specifically for the purpose of downloading the latest onedir tarball from the Salt repos. - Returns a tuple of (kernel, architecture) or raises an error if detection fails. - """ - # Unified command for Unix-based systems (including fallback to OSTYPE and MACHTYPE) - unix_cmd = 'uname -s -m || echo "$OSTYPE $MACHTYPE"' - - # Command for Windows systems (PowerShell) - windows_cmd = 'echo "$env:PROCESSOR_ARCHITECTURE"' - - # Try Unix command first - stdout, stderr, retcode = self.shell.exec_cmd(unix_cmd) - - if retcode == 0 and stdout: - # Unix-based detection succeeded - stdout = stdout.lower().strip() - - # Determine OS and architecture for Unix - if "linux" in stdout: - kernel = "linux" - elif "darwin" in stdout or "macos" in stdout: - kernel = "macos" - else: - raise ValueError(f"Unsupported Unix-based kernel: {stdout}") - - # Set architecture - if re.search(r"x86_64|amd64", stdout): - os_arch = "x86_64" - elif re.search(r"aarch64|arm64", stdout): - os_arch = "arm64" - else: - os_arch = stdout.split()[-1] if stdout.split() else "unknown" - else: - # If Unix detection fails, check for Windows-specific detection - stdout, stderr, retcode = self.shell.exec_cmd(windows_cmd) - - if retcode == 0 and stdout: - # Windows detection - stdout = stdout.lower().strip() - - # Set Windows architecture based on environment variable - if "64" in stdout: - os_arch = "amd64" - elif "x86" in stdout: - os_arch = "x86" - else: - raise ValueError(f"Unsupported architecture for Windows: {stdout}") - - kernel = "windows" - else: - # Neither Unix nor Windows detection succeeded - raise ValueError( - f"Failed to detect OS and architecture. Commands failed with output: {stdout}, {stderr}" - ) - - log.info(f'Detected kernel "{kernel}" and architecture "{os_arch}" on target') - - return kernel, os_arch + self.thin = thin if thin else salt.utils.thin.thin_path(opts["cachedir"]) def __arg_comps(self): """ @@ -1381,12 +1174,6 @@ def _escape_arg(self, arg): return arg return "".join(["\\" + char if re.match(r"\W", char) else char for char in arg]) - def run_ssh_pre_hook(self): - """ - Run a pre_hook script on the host machine before running any ssh commands - """ - return self.shell.exec_cmd(self.ssh_pre_hook) - def run_ssh_pre_flight(self): """ Run our pre_flight script before running any ssh commands @@ -1429,16 +1216,10 @@ def deploy(self): """ Deploy salt-thin """ - if self.opts.get("relenv"): - self.shell.send( - self.thin, - os.path.join(self.thin_dir, "salt-relenv.tar.xz"), - ) - else: - self.shell.send( - self.thin, - os.path.join(self.thin_dir, "salt-thin.tgz"), - ) + self.shell.send( + self.thin, + os.path.join(self.thin_dir, "salt-thin.tgz"), + ) self.deploy_ext() return True @@ -1468,13 +1249,6 @@ def run(self, deploy_attempted=False): stdout = stderr = "" retcode = salt.defaults.exitcodes.EX_OK - if self.ssh_pre_hook: - stdout, stderr, retcode = self.run_ssh_pre_hook() - if retcode != salt.defaults.exitcodes.EX_OK: - log.error("Error running ssh_pre_hook script %s", self.ssh_pre_hook) - return stdout, stderr, retcode - log.info("Successfully ran the ssh_pre_hook script: %s", self.ssh_pre_hook) - if self.ssh_pre_flight: if not self.opts.get("ssh_run_pre_flight", False) and self.check_thin_dir(): log.info( @@ -1512,20 +1286,6 @@ def run_wfunc(self): """ Execute a wrapper function - Both thin and relenv use the wrapper system (FunctionWrapper). - The wrapper system handles pillar compilation correctly: - - 1x compilation without pillar overrides - - 2x compilation with pillar overrides (re-compiled in wrapper modules) - - Returns tuple of (json_data, '') - """ - return self._run_wfunc_thin() - - def _run_wfunc_thin(self): - """ - Execute a wrapper function using the thin/wrapper architecture. - This is the original implementation for thin deployments. - Returns tuple of (json_data, '') """ # Ensure that opts/grains are up to date @@ -1568,19 +1328,7 @@ def _run_wfunc_thin(self): opts_pkg["file_roots"] = self.opts["file_roots"] opts_pkg["pillar_roots"] = self.opts["pillar_roots"] opts_pkg["ext_pillar"] = self.opts["ext_pillar"] - # For SSH, don't override extension_modules if it's already set correctly in minion_opts - # (pointing to the remote system's cache, not the master's cache) - if ( - "extension_modules" not in opts_pkg - or opts_pkg["extension_modules"] == self.opts["extension_modules"] - ): - # Only override if it's still using the master's path or not set - if "extension_modules" in self.minion_opts: - opts_pkg["extension_modules"] = self.minion_opts[ - "extension_modules" - ] - else: - opts_pkg["extension_modules"] = self.opts["extension_modules"] + opts_pkg["extension_modules"] = self.opts["extension_modules"] opts_pkg["module_dirs"] = self.opts["module_dirs"] opts_pkg["_ssh_version"] = self.opts["_ssh_version"] opts_pkg["thin_dir"] = self.opts["thin_dir"] @@ -1634,15 +1382,13 @@ def _run_wfunc_thin(self): opts = data.get("opts", {}) opts["grains"] = data.get("grains") - # Restore master grains and roster grains - # Use dict merge instead of nested mutations to avoid OptsDict deepcopy - grains_updates = {} - grains_updates.update(conf_grains) + # Restore master grains + for grain in conf_grains: + opts["grains"][grain] = conf_grains[grain] + # Enable roster grains support if "grains" in self.target: - grains_updates.update(self.target["grains"]) - - if grains_updates: - opts["grains"] = {**opts["grains"], **grains_updates} + for grain in self.target["grains"]: + opts["grains"][grain] = self.target["grains"][grain] opts["pillar"] = data.get("pillar") @@ -1652,14 +1398,6 @@ def _run_wfunc_thin(self): # above always evaluates to True. TODO: cleanup? opts["ssh_wipe"] = self.opts.get("ssh_wipe", False) - # Propagate relenv settings to nested Single instances (wrapper-initiated calls) - # This ensures nested calls use relenv code path instead of falling back to thin - if self.opts.get("relenv"): - opts["relenv"] = True - if "relenv_kernel" in self.opts and "relenv_os_arch" in self.opts: - opts["relenv_kernel"] = self.opts["relenv_kernel"] - opts["relenv_os_arch"] = self.opts["relenv_os_arch"] - wrapper = salt.client.ssh.wrapper.FunctionWrapper( opts, self.id, @@ -1760,136 +1498,6 @@ def _run_wfunc_thin(self): ret = salt.utils.json.dumps({"local": {"return": result}}) return ret, retcode - def _run_wfunc_relenv(self): - """ - Execute a function using salt-call from relenv deployment. - Bypasses the wrapper system entirely since relenv includes a full salt-call binary. - - Returns tuple of (json_data, retcode) - """ - log.info( - "RELENV WFUNC: Starting execution - fun=%s, thin_dir=%s", - self.fun, - self.thin_dir, - ) - - # Build salt-call command - relenv has full salt-call binary - salt_call = f"{self.thin_dir}/salt-call" - args_str = self._build_salt_call_args() - - log.info("RELENV WFUNC: Built args string: %s", args_str) - - # Determine output level - log_level = self.opts.get("log_level", "error") - - # Config directory for relenv (where minion config with file_roots/pillar is located) - config_dir = f"{self.thin_dir}/conf" - - # Build full command with config-dir so salt-call can find the minion config - cmd = f"{salt_call} --local --config-dir={config_dir} {self.fun} {args_str} --out=json --log-level={log_level}" - - log.info("RELENV WFUNC: About to execute command: %s", cmd) - - # Execute via shell - log.info("RELENV WFUNC: Calling self.shell.exec_cmd()...") - stdout, stderr, retcode = self.shell.exec_cmd(cmd) - log.info( - "RELENV WFUNC: exec_cmd() returned - retcode=%s, stdout_len=%d, stderr_len=%d", - retcode, - len(stdout) if stdout else 0, - len(stderr) if stderr else 0, - ) - - log.trace("RELENV WFUNC STDOUT: %s", stdout) - log.trace("RELENV WFUNC STDERR: %s", stderr) - log.debug("RELENV WFUNC RETCODE: %s", retcode) - - # Parse JSON output (same format as wrappers) - log.info("RELENV WFUNC: Calling _parse_salt_call_output()...") - result = self._parse_salt_call_output(stdout, stderr, retcode) - log.info("RELENV WFUNC: Returning result") - return result - - def _build_salt_call_args(self): - """ - Convert self.args and self.kwargs to salt-call command line format. - - Examples: - - args=['foo', 'bar'] -> "foo bar" - - kwargs={'name': 'test', 'value': 123} -> "name=test value=123" - """ - import shlex - - args_list = [] - - # Positional arguments - properly quote and escape - for arg in self.args: - if isinstance(arg, (dict, list)): - # Complex types need JSON encoding - args_list.append(shlex.quote(salt.utils.json.dumps(arg))) - elif isinstance(arg, str): - # Simple strings just need quoting - args_list.append(shlex.quote(arg)) - else: - # Numbers, booleans, etc. - args_list.append(shlex.quote(str(arg))) - - # Keyword arguments - salt-call expects key=value format - for key, value in self.kwargs.items(): - if isinstance(value, (dict, list)): - # Complex types need JSON encoding - args_list.append(f"{key}={shlex.quote(salt.utils.json.dumps(value))}") - elif isinstance(value, str): - args_list.append(f"{key}={shlex.quote(value)}") - else: - args_list.append(f"{key}={shlex.quote(str(value))}") - - return " ".join(args_list) - - def _parse_salt_call_output(self, stdout, stderr, retcode): - """ - Parse JSON output from salt-call --local --out=json. - - Salt-call outputs: {"local": } - This matches the format expected by the wrapper system. - - Returns tuple of (json_data, retcode) - """ - try: - # Try to parse JSON output - result = salt.utils.json.loads(stdout) - - # salt-call --local outputs: {"local": } - # This is already in the correct format - if isinstance(result, dict) and "local" in result: - # Return retcode=0 for successful execution - # The retcode from salt-call is not what we want - that's the shell exit code - # We want to indicate success (0) when we successfully parsed the output - return salt.utils.json.dumps(result), 0 - else: - # Unexpected format, wrap it - return salt.utils.json.dumps({"local": {"return": result}}), 0 - - except (ValueError, TypeError) as exc: - # JSON parsing failed - likely an error occurred - log.error( - "RELENV: Failed to parse salt-call output as JSON: %s\nSTDOUT: %s\nSTDERR: %s", - exc, - stdout, - stderr, - ) - - # Return error in the expected format with non-zero retcode - error_result = { - "local": { - "error": "Failed to parse salt-call output", - "stdout": stdout, - "stderr": stderr, - "exception": str(exc), - } - } - return salt.utils.json.dumps(error_result), retcode if retcode != 0 else 1 - def _cmd_str(self): """ Prepare the command string @@ -1907,45 +1515,12 @@ def _cmd_str(self): cachedir = self.opts["_caller_cachedir"] else: cachedir = self.opts["cachedir"] + thin_code_digest, thin_sum = salt.utils.thin.thin_sum(cachedir, "sha1") debug = "" if not self.opts.get("log_level"): self.opts["log_level"] = "info" if LOG_LEVELS["debug"] >= LOG_LEVELS[self.opts.get("log_level", "info")]: debug = "1" - - if self.opts.get("relenv"): - # Properly quote arguments for shell execution - import shlex - - # If argv is a list with a single string element (common with wrappers), - # split it into proper arguments - if ( - len(self.argv) == 1 - and isinstance(self.argv[0], str) - and " " in self.argv[0] - ): - argv_to_use = shlex.split(self.argv[0]) - else: - argv_to_use = self.argv - - quoted_args = " ".join(shlex.quote(str(arg)) for arg in argv_to_use) - - # Note: Config is sent separately via SCP in cmd_block() to avoid ARG_MAX issues - # Use .replace() for minion_config — it is YAML flow-style and - # may contain literal { } which would break .format(). - shim = SSH_SH_SHIM_RELENV.format( - DEBUG=debug, - SUDO=sudo, - SUDO_USER=sudo_user or "", - THIN_DIR=self.thin_dir, - SET_PATH=self.set_path, - RSTR=RSTR, - ARGS=quoted_args, - EXT_MODS_VERSION=self.mods.get("version", ""), - ) - return shim.replace("__SALT_MINION_CONFIG__", self.minion_config) - - thin_code_digest, thin_sum = salt.utils.thin.thin_sum(cachedir, "sha1") arg_str = ''' OPTIONS.config = \ """ @@ -2024,21 +1599,6 @@ def shim_cmd(self, cmd_str, extension="py"): execute it there """ if not self.tty and not self.winrm: - # Debug: Log command string size to diagnose ARG_MAX issues - cmd_size = len(cmd_str) - if cmd_size > 100000: # Log if > 100KB - log.warning( - "RELENV: Large shim command detected: %d bytes (ARG_MAX is typically ~2MB). " - "This may cause 'Argument list too long' errors.", - cmd_size, - ) - # Log first 500 and last 500 chars to see what's in it - log.debug( - "RELENV: Command preview - first 500 chars: %s", cmd_str[:500] - ) - log.debug( - "RELENV: Command preview - last 500 chars: %s", cmd_str[-500:] - ) return self.shell.exec_cmd(cmd_str) # Write the shim to a temporary file in the default temp directory @@ -2078,98 +1638,12 @@ def cmd_block(self, is_retry=False): 5. split SHIM results from command results 6. return command results """ - # For both thin and relenv, use the shim system - # The shim handles extraction and execution - # For relenv, the shim (SSH_SH_SHIM_RELENV) calls salt-call directly self.argv = _convert_args(self.argv) log.debug( "Performing shimmed, blocking command as follows:\n%s", " ".join([str(arg) for arg in self.argv]), ) - - # For relenv, send minion config via SCP to avoid ARG_MAX issues - # The config file is expected to be at {THIN_DIR}/minion by the shim - if self.opts.get("relenv"): - remote_config_path = f"{self.thin_dir}/minion" - - # Check if config file already exists on remote (for nested/wrapper calls) - # This avoids ARG_MAX issues when wrappers create nested Single instances - check_cmd = f"test -f {remote_config_path} && echo exists || echo missing" - check_result = self.shell.exec_cmd(check_cmd) - - config_exists = ( - check_result[0].strip() == "exists" if check_result[2] == 0 else False - ) - - if config_exists: - log.debug( - "RELENV: Config file already exists at %s, skipping transfer (nested/wrapper call)", - remote_config_path, - ) - else: - # Write minion config to a temporary file - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".conf" - ) as config_tmp_file: - config_tmp_file.write(self.minion_config) - local_config_path = config_tmp_file.name - - try: - # SCP the config file to the target - # makedirs=True ensures the thin_dir exists - log.debug( - "RELENV: Sending minion config to %s:%s", - self.target["host"], - remote_config_path, - ) - send_result = self.shell.send( - local_config_path, remote_config_path, makedirs=True - ) - - # Check if send failed - if send_result and send_result[2] != 0: - log.error( - "RELENV: Failed to send minion config - stdout: %s, stderr: %s, retcode: %s", - send_result[0], - send_result[1], - send_result[2], - ) - return ( - f"ERROR: Failed to transfer minion config (retcode {send_result[2]}): {send_result[0] or send_result[1]}", - send_result[1], - send_result[2], - ) - - log.debug("RELENV: Successfully sent minion config") - finally: - # Clean up temporary file - try: - os.unlink(local_config_path) - except OSError as e: - log.warning( - "RELENV: Failed to delete temporary config file %s: %s", - local_config_path, - e, - ) - - # Regenerate extension modules tarball with fresh fileserver scan - # This ensures that any dynamically-added modules (like test fixtures) - # are included and the version hash is up-to-date - log.debug("Regenerating extension modules tarball before command execution") - self.mods = mod_data(self.fsclient) - - # Deploy the fresh tarball to the remote system - log.debug("Deploying extension modules tarball to remote system") - self.deploy_ext() - cmd_str = self._cmd_str() - trace_carrier = {} - salt.utils.tracing.inject(trace_carrier) - if trace_carrier: - trace_prefix = " ".join( - f"{k.upper()}={shlex.quote(v)}" for k, v in trace_carrier.items() - ) - cmd_str = f"{trace_prefix} {cmd_str}" stdout, stderr, retcode = self.shim_cmd(cmd_str) log.trace("STDOUT %s\n%s", self.target["host"], stdout) @@ -2182,9 +1656,9 @@ def cmd_block(self, is_retry=False): saltwinshell.deploy_python(self) stdout, stderr, retcode = self.shim_cmd(cmd_str) while re.search(RSTR_RE, stdout): - stdout = re.split(RSTR_RE, stdout, maxsplit=1)[1].strip() + stdout = re.split(RSTR_RE, stdout, 1)[1].strip() while re.search(RSTR_RE, stderr): - stderr = re.split(RSTR_RE, stderr, maxsplit=1)[1].strip() + stderr = re.split(RSTR_RE, stderr, 1)[1].strip() elif error == "Undefined SHIM state": self.deploy() stdout, stderr, retcode = self.shim_cmd(cmd_str) @@ -2199,31 +1673,31 @@ def cmd_block(self, is_retry=False): retcode, ) while re.search(RSTR_RE, stdout): - stdout = re.split(RSTR_RE, stdout, maxsplit=1)[1].strip() + stdout = re.split(RSTR_RE, stdout, 1)[1].strip() while re.search(RSTR_RE, stderr): - stderr = re.split(RSTR_RE, stderr, maxsplit=1)[1].strip() + stderr = re.split(RSTR_RE, stderr, 1)[1].strip() else: return f"ERROR: {error}", stderr, retcode # FIXME: this discards output from ssh_shim if the shim succeeds. It should # always save the shim output regardless of shim success or failure. while re.search(RSTR_RE, stdout): - stdout = re.split(RSTR_RE, stdout, maxsplit=1)[1].strip() + stdout = re.split(RSTR_RE, stdout, 1)[1].strip() if re.search(RSTR_RE, stderr): # Found RSTR in stderr which means SHIM completed and only # and remaining output is only from salt. while re.search(RSTR_RE, stderr): - stderr = re.split(RSTR_RE, stderr, maxsplit=1)[1].strip() + stderr = re.split(RSTR_RE, stderr, 1)[1].strip() else: # RSTR was found in stdout but not stderr - which means there # is a SHIM command for the master. - shim_command = re.split(r"\r?\n", stdout, maxsplit=1)[0].strip() + shim_command = re.split(r"\r?\n", stdout, 1)[0].strip() log.debug("SHIM retcode(%s) and command: %s", retcode, shim_command) if ( - retcode == salt.defaults.exitcodes.EX_THIN_DEPLOY - or "deploy" == shim_command + "deploy" == shim_command + and retcode == salt.defaults.exitcodes.EX_THIN_DEPLOY ): self.deploy() stdout, stderr, retcode = self.shim_cmd(cmd_str) @@ -2250,17 +1724,13 @@ def cmd_block(self, is_retry=False): retcode, ) while re.search(RSTR_RE, stdout): - stdout = re.split(RSTR_RE, stdout, maxsplit=1)[1].strip() + stdout = re.split(RSTR_RE, stdout, 1)[1].strip() if self.tty: stderr = "" else: while re.search(RSTR_RE, stderr): - stderr = re.split(RSTR_RE, stderr, maxsplit=1)[1].strip() + stderr = re.split(RSTR_RE, stderr, 1)[1].strip() elif "ext_mods" == shim_command: - # Regenerate extension modules tarball with fresh fileserver scan - # This ensures dynamically-added modules are included - log.info("ext_mods requested - regenerating extension modules tarball") - self.mods = mod_data(self.fsclient) self.deploy_ext() stdout, stderr, retcode = self.shim_cmd(cmd_str) if not re.search(RSTR_RE, stdout) or not re.search(RSTR_RE, stderr): @@ -2272,9 +1742,9 @@ def cmd_block(self, is_retry=False): retcode, ) while re.search(RSTR_RE, stdout): - stdout = re.split(RSTR_RE, stdout, maxsplit=1)[1].strip() + stdout = re.split(RSTR_RE, stdout, 1)[1].strip() while re.search(RSTR_RE, stderr): - stderr = re.split(RSTR_RE, stderr, maxsplit=1)[1].strip() + stderr = re.split(RSTR_RE, stderr, 1)[1].strip() return stdout, stderr, retcode @@ -2426,8 +1896,6 @@ def mod_data(fsclient): "renderers", "returners", "utils", - "wrapper", - "tops", ] ret = {} @@ -2435,15 +1903,8 @@ def mod_data(fsclient): opts = fsclient.opts for ref in sync_refs: try: - # Use salt.loader._module_dirs but skip entry-points (saltexts handled by gen_thin) - # This still discovers extension_modules from config and module_dirs from CLI - kwargs = {"load_extensions": False} - if ref == "wrapper": - kwargs["tag"] = "wrapper" - kwargs["base_path"] = str(salt.loader.SALT_BASE_PATH / "client" / "ssh") - else: - kwargs["tag"] = ref.rstrip("s") - module_dirs = salt.loader._module_dirs(opts, ref, **kwargs) + # Use salt.loader._module_dirs to get all module paths (including entry-points) + module_dirs = salt.loader._module_dirs(opts, ref, tag=ref.rstrip("s")) for mod_dir in module_dirs: if not os.path.isdir(mod_dir): @@ -2463,8 +1924,7 @@ def mod_data(fsclient): if ref not in ret: ret[ref] = {} - if fn_ not in ret[ref]: - ret[ref][fn_] = mod_path + ret[ref][fn_] = mod_path except Exception as exc: # pylint: disable=broad-except log.debug( "Failed to load %s modules from global loader: %s", @@ -2498,8 +1958,7 @@ def mod_data(fsclient): if ref not in ret: ret[ref] = {} # Use basename to avoid duplicates - if fn_ not in ret[ref]: - ret[ref][fn_] = mod_path + ret[ref][fn_] = mod_path except Exception as exc: # pylint: disable=broad-except log.debug( "Failed to scan directory %s: %s", @@ -2527,23 +1986,9 @@ def mod_data(fsclient): ver = hashlib.sha1(ver_base).hexdigest() ext_tar_path = os.path.join(fsclient.opts["cachedir"], f"ext_mods.{ver}.tgz") mods = {"version": ver, "file": ext_tar_path} - - # Debug logging to track extension modules - states_found = ret.get("states", {}) - log.debug( - "EXTMODS DEBUG: Found %d state modules: %s", - len(states_found), - list(states_found.keys()), - ) - log.debug("EXTMODS DEBUG: Version hash: %s", ver) - log.debug("EXTMODS DEBUG: Tarball path: %s", ext_tar_path) - log.debug("EXTMODS DEBUG: Tarball exists: %s", os.path.isfile(ext_tar_path)) - if os.path.isfile(ext_tar_path): - log.debug("EXTMODS DEBUG: Using cached tarball") return mods - log.debug("EXTMODS DEBUG: Creating new tarball") # Ensure cache directory exists cache_dir = fsclient.opts["cachedir"] if not os.path.isdir(cache_dir): diff --git a/salt/client/ssh/client.py b/salt/client/ssh/client.py index 94456e660e4f..5d76611edd83 100644 --- a/salt/client/ssh/client.py +++ b/salt/client/ssh/client.py @@ -56,9 +56,6 @@ def sanitize_kwargs(self, kwargs): ("ssh_priv_passwd", str), ("ssh_identities_only", bool), ("ssh_remote_port_forwards", str), - ("ssh_keepalive", bool), - ("ssh_keepalive_interval", int), - ("ssh_keepalive_count_max", int), ("ssh_options", list), ("ssh_max_procs", int), ("ssh_askpass", bool), @@ -68,7 +65,6 @@ def sanitize_kwargs(self, kwargs): ("ssh_scan_timeout", int), ("ssh_timeout", int), ("ssh_log_file", str), - ("ssh_pre_hook", str), ("raw_shell", bool), ("refresh_cache", bool), ("roster", str), diff --git a/salt/client/ssh/shell.py b/salt/client/ssh/shell.py index 10ba155bf342..7b20714e7860 100644 --- a/salt/client/ssh/shell.py +++ b/salt/client/ssh/shell.py @@ -19,7 +19,7 @@ log = logging.getLogger(__name__) SSH_PASSWORD_PROMPT_RE = re.compile(r"(?:.*)[Pp]assword(?: for .*)?:\s*$", re.M) -KEY_VALID_RE = re.compile(r".*\(yes\/no(/\[fingerprint\])?\).*") +KEY_VALID_RE = re.compile(r".*\(yes\/no\).*") SSH_PRIVATE_KEY_PASSWORD_PROMPT_RE = re.compile(r"Enter passphrase for key", re.M) # sudo prompt is used to recognize sudo prompting for a password and should @@ -156,7 +156,7 @@ def _key_opts(self): if self.priv and self.priv != "agent-forwarding": options.append(f"IdentityFile={self.priv}") if self.user: - options.append(f"User={shlex.quote(self.user)}") + options.append(f"User={self.user}") if self.identities_only: options.append("IdentitiesOnly=yes") @@ -202,7 +202,7 @@ def _passwd_opts(self): if self.port: options.append(f"Port={self.port}") if self.user: - options.append(f"User={shlex.quote(self.user)}") + options.append(f"User={self.user}") if self.identities_only: options.append("IdentitiesOnly=yes") diff --git a/salt/client/ssh/ssh_py_shim.py b/salt/client/ssh/ssh_py_shim.py index 4a9471e187f4..679fb52cbbb4 100644 --- a/salt/client/ssh/ssh_py_shim.py +++ b/salt/client/ssh/ssh_py_shim.py @@ -169,10 +169,7 @@ def unpack_thin(thin_path): """ tfile = tarfile.TarFile.gzopen(thin_path) old_umask = os.umask(0o077) # pylint: disable=blacklisted-function - if sys.version_info >= (3, 12): - tfile.extractall(path=OPTIONS.saltdir, filter="data") # nosec B202 - else: - tfile.extractall(path=OPTIONS.saltdir) # nosec B202 + tfile.extractall(path=OPTIONS.saltdir) # nosec tfile.close() os.umask(old_umask) # pylint: disable=blacklisted-function try: @@ -199,10 +196,7 @@ def unpack_ext(ext_path): ) tfile = tarfile.TarFile.gzopen(ext_path) old_umask = os.umask(0o077) # pylint: disable=blacklisted-function - if sys.version_info >= (3, 12): - tfile.extractall(path=modcache, filter="data") # nosec B202 - else: - tfile.extractall(path=modcache) # nosec B202 + tfile.extractall(path=modcache) # nosec tfile.close() os.umask(old_umask) # pylint: disable=blacklisted-function os.unlink(ext_path) diff --git a/salt/client/ssh/wrapper/mine.py b/salt/client/ssh/wrapper/mine.py index 4e1372d50559..6656ac6b56bb 100644 --- a/salt/client/ssh/wrapper/mine.py +++ b/salt/client/ssh/wrapper/mine.py @@ -10,7 +10,6 @@ import copy import logging -import os.path import salt.client.ssh import salt.daemons.masterapi @@ -70,24 +69,7 @@ def get( """ rets = {} if regular_minions: - # Fix OptsDict cachedir issue: ensure we use the master's cachedir, - # not the minion's cachedir that may have been mutated in the parent chain. - # When running in SSH wrapper context, the master_opts OptsDict has a parent - # that was mutated with the minion's cachedir. We construct the correct master - # cachedir from the master's config_dir. - master_opts = __context__["master_opts"] - - # Construct master cachedir from config_dir - # e.g., /tmp/stsuite/master-abc/conf -> /tmp/stsuite/master-abc/cache - if "config_dir" in master_opts: - config_dir = master_opts["config_dir"] - base_dir = os.path.dirname(config_dir) - correct_cachedir = os.path.join(base_dir, "cache") - - # Override the mutated cachedir by setting it in this OptsDict (copy-on-write) - master_opts["cachedir"] = correct_cachedir - - masterapi = salt.daemons.masterapi.RemoteFuncs(master_opts) + masterapi = salt.daemons.masterapi.RemoteFuncs(__context__["master_opts"]) load = { "id": __opts__["id"], "fun": fun, diff --git a/salt/client/ssh/wrapper/pillar.py b/salt/client/ssh/wrapper/pillar.py index 2c36ec8d24c0..f085771614ce 100644 --- a/salt/client/ssh/wrapper/pillar.py +++ b/salt/client/ssh/wrapper/pillar.py @@ -5,7 +5,6 @@ import salt.pillar import salt.utils.data import salt.utils.dictupdate -import salt.utils.secret from salt.defaults import DEFAULT_TARGET_DELIM try: @@ -57,13 +56,13 @@ def get(key, default="", merge=False, delimiter=DEFAULT_TARGET_DELIM): """ if merge: ret = salt.utils.data.traverse_dict_and_list( - salt.utils.secret.expose(__pillar__.value()), key, {}, delimiter + __pillar__.value(), key, {}, delimiter ) if isinstance(ret, Mapping) and isinstance(default, Mapping): return salt.utils.dictupdate.update(default, ret) return salt.utils.data.traverse_dict_and_list( - salt.utils.secret.expose(__pillar__.value()), key, default, delimiter + __pillar__.value(), key, default, delimiter ) @@ -83,7 +82,7 @@ def item(*args): ret = {} for arg in args: try: - ret[arg] = salt.utils.secret.serial(__pillar__[arg]) + ret[arg] = __pillar__[arg] except KeyError: pass return ret @@ -110,7 +109,7 @@ def raw(key=None): else: ret = __pillar__.value() - return salt.utils.secret.expose(ret) + return ret def keys(key, delimiter=DEFAULT_TARGET_DELIM): @@ -132,7 +131,7 @@ def keys(key, delimiter=DEFAULT_TARGET_DELIM): salt '*' pillar.keys web:sites """ ret = salt.utils.data.traverse_dict_and_list( - salt.utils.secret.expose(__pillar__.value()), key, KeyError, delimiter + __pillar__.value(), key, KeyError, delimiter ) if ret is KeyError: @@ -192,15 +191,14 @@ def filter_by(lookup_dict, pillar, merge=None, default="default", base=None): salt '*' pillar.filter_by '{web: Serve it up, db: I query, default: x_x}' role """ - ret = salt.utils.data.filter_by( + return salt.utils.data.filter_by( lookup_dict=lookup_dict, lookup=pillar, - traverse=salt.utils.secret.expose(__pillar__.value()), + traverse=__pillar__.value(), merge=merge, default=default, base=base, ) - return ret # Allow pillar.data to also be used to return pillar data diff --git a/salt/client/ssh/wrapper/publish.py b/salt/client/ssh/wrapper/publish.py index c0bdef7ab7b0..7d1e378db30f 100644 --- a/salt/client/ssh/wrapper/publish.py +++ b/salt/client/ssh/wrapper/publish.py @@ -99,13 +99,7 @@ def _publish( # Set up opts for the SSH object opts = copy.deepcopy(__context__["master_opts"]) minopts = copy.deepcopy(__opts__) - - # Don't overwrite master-specific keys with minion values - # Preserve the master's cachedir to avoid authentication failures - master_cachedir = opts.get("cachedir") opts.update(minopts) - if master_cachedir: - opts["cachedir"] = master_cachedir if roster: opts["roster"] = roster if timeout: @@ -154,19 +148,7 @@ def _publish_regular( return {} arg = _parse_args(arg) - - # Fix OptsDict cachedir issue: master_opts may have wrong cachedir from minion - master_opts = __context__["master_opts"] - if "config_dir" in master_opts: - import os - - config_dir = master_opts["config_dir"] - base_dir = os.path.dirname(config_dir) - correct_cachedir = os.path.join(base_dir, "cache") - if master_opts.get("cachedir") != correct_cachedir: - master_opts["cachedir"] = correct_cachedir - - masterapi = salt.daemons.masterapi.RemoteFuncs(master_opts) + masterapi = salt.daemons.masterapi.RemoteFuncs(__context__["master_opts"]) log.info("Publishing '%s'", fun) load = { diff --git a/salt/client/ssh/wrapper/slsutil.py b/salt/client/ssh/wrapper/slsutil.py index a08072abf2ea..a94a6b16df1a 100644 --- a/salt/client/ssh/wrapper/slsutil.py +++ b/salt/client/ssh/wrapper/slsutil.py @@ -7,7 +7,6 @@ import salt.utils.args import salt.utils.dictupdate import salt.utils.stringio -from salt.client.ssh.wrapper.state import _merge_extra_filerefs CONTEXT_BASE = "slsutil" @@ -158,46 +157,23 @@ def run(): if not path and not string: raise salt.exceptions.SaltInvocationError("Must pass either path or string") - # Use the same FSClient as cp/get_url so Jinja SaltCacheLoader reads the - # cache paths ssh cp.cache_file populates (loader.render defaults to no client). - renderers = salt.loader.render( - __opts__, __salt__, file_client=__context__.get("fileclient") - ) - # Falsy saltenv (e.g. None injected on salt-ssh) makes Jinja use - # FileSystemLoader on the temp copy only, so imports like map.jinja fail. - saltenv = kwargs.get("saltenv") or "base" + renderers = salt.loader.render(__opts__, __salt__) if path: - # salt-ssh does not ship the whole fileserver tree; Jinja ``import`` / - # ``from`` targets must be present on the target like ``state.*`` runs - # (``lowstate_file_refs`` + ``extra_filerefs``). Honor the same - # ``--extra-filerefs`` / ``__opts__`` / ``cp.cache_file`` context keys by - # caching each ref before rendering (see ssh ``state`` wrapper). - extra_filerefs = _merge_extra_filerefs( - kwargs.get("extra_filerefs") or "", - __opts__.get("extra_filerefs") or "", - __context__.get("_cp_extra_filerefs") or "", + path_or_string = __context__["fileclient"].get_url( + path, "", saltenv=kwargs.get("saltenv", "base") ) - if extra_filerefs: - for ref in extra_filerefs.split(","): - ref = ref.strip() - if ref: - __salt__["cp.cache_file"](ref, saltenv=saltenv) - path_or_string = __context__["fileclient"].get_url(path, "", saltenv=saltenv) elif string: path_or_string = ":string:" kwargs["input_data"] = string - compile_kwargs = dict(kwargs) - compile_kwargs.pop("extra_filerefs", None) - compile_kwargs["saltenv"] = saltenv ret = salt.template.compile_template( path_or_string, renderers, default_renderer, __opts__["renderer_blacklist"], __opts__["renderer_whitelist"], - **compile_kwargs, + **kwargs, ) return ret.read() if salt.utils.stringio.is_readable(ret) else ret diff --git a/salt/client/ssh/wrapper/ssh_pki.py b/salt/client/ssh/wrapper/ssh_pki.py deleted file mode 100644 index 512ea0956d1f..000000000000 --- a/salt/client/ssh/wrapper/ssh_pki.py +++ /dev/null @@ -1,680 +0,0 @@ -""" -Manage OpenSSH certificates -=========================== - -.. versionadded:: 3008.0 - -:depends: cryptography - -Wraps the ``ssh_pki`` execution module for salt-ssh. This is required for -remote signing via peer publishing. - -Additionally, this module provides a wrapper for ``ssh_pki.certificate_managed`` -analog to a sophisticated Jinja macro. This allows to statefully manage certificates, -even if the certificate creation backend does not work on the managed remote. - -General configuration instructions and general remarks are documented -in the :ref:`execution module docs `. - -.. note:: - - The dependent modules must be present on the remote, they are not delivered - with the Salt-SSH thin tarball. - Operations with encrypted private keys additionally require the ``bcrypt`` - Python module. -""" - -import copy -import logging - -from salt.exceptions import CommandExecutionError, SaltInvocationError - -try: - import salt.utils.sshpki as sshpki - - HAS_CRYPTOGRAPHY = True -except ImportError: - HAS_CRYPTOGRAPHY = False - - -log = logging.getLogger(__name__) - -__virtualname__ = "ssh_pki" - - -def __virtual__(): - if not HAS_CRYPTOGRAPHY: - return (False, "Could not load cryptography") - return __virtualname__ - - -def create_certificate( - ca_server=None, - signing_policy=None, - path=None, - overwrite=False, - raw=False, - **kwargs, -): - """ - Create an OpenSSH certificate and return an encoded version of it. - - .. note:: - - All parameters that take a public key or private key - can be specified either as a string or a path to a - local file encoded for OpenSSH. - - CLI Example: - - .. code-block:: bash - - salt-ssh '*' ssh_pki.create_certificate private_key=/root/.ssh/id_rsa signing_private_key='/etc/pki/ssh/myca.key' - - ca_server - Request a remotely signed certificate from another minion acting as - a CA server. For this to work, a ``signing_policy`` must be specified, - and that same policy must be configured on the ca_server. See `Signing policies`_ - for details. Also, the Salt master must permit peers to call the - ``sign_remote_certificate`` function, see `Peer communication`_. - - signing_policy - The name of a configured signing policy. Parameters specified in there - are hardcoded and cannot be overridden. This is required for remote signing, - otherwise optional. See `Signing policies`_ for details. - - copypath - Create a copy of the issued certificate in this directory. - The file will be named ``.crt``. - - path - Instead of returning the certificate, write it to this file path. - - overwrite - If ``path`` is specified and the file exists, do not overwrite it. - Defaults to false. - - raw - Return the encoded raw bytes instead of a string. Defaults to false. - - cert_type - The certificate type to generate. Either ``user`` or ``host``. - Required if not specified in the signing policy. - - private_key - The private key corresponding to the public key the certificate should - be issued for. Either this or ``public_key`` is required. - - private_key_passphrase - If ``private_key`` is specified and encrypted, the passphrase to decrypt it. - - public_key - The public key the certificate should be issued for. Either this or - ``private_key`` is required. - - signing_private_key - The private key of the CA that should be used to sign the certificate. Required. - - signing_private_key_passphrase - If ``signing_private_key`` is encrypted, the passphrase to decrypt it. - - serial_number - A serial number to be embedded in the certificate. If unspecified, will - autogenerate one. This should be an integer, either in decimal or - hexadecimal notation. - - not_before - Set a specific date the certificate should not be valid before. - The format should follow ``%Y-%m-%d %H:%M:%S`` and will be interpreted as GMT/UTC. - Defaults to the time of issuance. - - not_after - Set a specific date the certificate should not be valid after. - The format should follow ``%Y-%m-%d %H:%M:%S`` and will be interpreted as GMT/UTC. - If unspecified, defaults to the current time plus ``ttl``. - - ttl - If ``not_after`` is unspecified, a time string (like ``30d`` or ``12h``) - or the number of seconds from the time of issuance the certificate - should be valid for. Defaults to ``30d`` for host certificates - and ``24h`` for client certificates. - - critical_options - A mapping of critical option name to option value to set on the certificate. - If an option does not take a value, specify it as ``true``. - - Example: - - .. code-block:: bash - - salt-ssh '*' ssh_pki.create_certificate [...] \ - critical_options='{"force-command": "/usr/bin/id", "verify-required": true}' - - extensions - A mapping of extension name to extension value to set on the certificate. - If an extension does not take a value, specify it as ``true``. - - Example: - - .. code-block:: bash - - salt-ssh '*' ssh_pki.create_certificate [...] \ - extensions='{"custom-option@my.org": "foobar", "permit-pty": true}' - - valid_principals - A list of valid principals. - - all_principals - Allow any principals. Defaults to false. - - key_id - Specify a string-valued key ID for the signed public key. - When the certificate is used for authentication, this value will be - logged in plaintext. - """ - kwargs = {k: v for k, v in kwargs.items() if not k.startswith("_")} - - if not ca_server: - return __salt__["ssh_pki.create_certificate_ssh"]( - signing_policy=signing_policy, - path=path, - overwrite=overwrite, - raw=raw, - **kwargs, - ) - - if path and not overwrite and __salt__["file.file_exists"](path): - raise CommandExecutionError( - f"The file at {path} exists and overwrite was set to false" - ) - if signing_policy is None: - raise SaltInvocationError( - "signing_policy must be specified to request a certificate from " - "a remote ca_server" - ) - cert = _create_certificate_remote(ca_server, signing_policy, **kwargs) - - out = cert.public_bytes() - - if path is None: - if raw: - return out - return out.decode() - __salt__["file.write"](*out.decode().splitlines()) - return f"Certificate written to {path}" - - -def _create_certificate_remote( - ca_server, signing_policy, private_key=None, private_key_passphrase=None, **kwargs -): - if private_key: - kwargs["public_key"] = __salt__["ssh_pki.get_public_key"]( - private_key, passphrase=private_key_passphrase - ) - elif kwargs.get("public_key"): - kwargs["public_key"] = __salt__["ssh_pki.get_public_key"](kwargs["public_key"]) - - result = _query_remote(ca_server, signing_policy, kwargs) - try: - return sshpki.load_cert(result) - except (CommandExecutionError, SaltInvocationError) as err: - raise CommandExecutionError( - f"ca_server did not return a certificate: {result}" - ) from err - - -def _query_remote(ca_server, signing_policy, kwargs, get_signing_policy_only=False): - result = __salt__["publish.publish"]( - ca_server, - "ssh_pki.sign_remote_certificate", - arg=[signing_policy, kwargs, get_signing_policy_only], - regular_minions=True, - ) - - if not result: - raise SaltInvocationError( - "ca_server did not respond." - " Salt master must permit peers to" - " call the sign_remote_certificate function." - ) - result = result[next(iter(result))] - if not isinstance(result, dict) or "data" not in result: - log.error(f"Received invalid return value from ca_server: {result}") - raise CommandExecutionError( - "Received invalid return value from ca_server. See minion log for details" - ) - if result.get("errors"): - raise CommandExecutionError( - "ca_server reported errors:\n" + "\n".join(result["errors"]) - ) - return result["data"] - - -def get_signing_policy(signing_policy, ca_server=None): - """ - Returns the specified named signing policy. - - CLI Example: - - .. code-block:: bash - - salt '*' ssh_pki.get_signing_policy www - - signing_policy - The name of the signing policy to return. - - ca_server - If this is set, the CA server will be queried for the - signing policy instead of looking it up locally. - """ - if ca_server is None: - return _get_signing_policy(signing_policy) - # Cache signing policies from remote during this run - # to reduce unnecessary resource usage. - ckey = "_ssh_pki_policies" - if ckey not in __context__: - __context__[ckey] = {} - if ca_server not in __context__[ckey]: - __context__[ckey][ca_server] = {} - if signing_policy not in __context__[ckey][ca_server]: - policy_ = _query_remote( - ca_server, signing_policy, {}, get_signing_policy_only=True - ) - __context__[ckey][ca_server][signing_policy] = policy_ - # only hand out copies of the cached policy - return copy.deepcopy(__context__[ckey][ca_server][signing_policy]) - - -def _get_signing_policy(name): - if name is None: - return {} - policies = __salt__["pillar.get"]("ssh_signing_policies", {}).get(name) - policies = policies or __salt__["config.get"]("ssh_signing_policies", {}).get(name) - return policies or {} - - -def certificate_managed_wrapper( - name, - ca_server, - signing_policy, - backend=None, - backend_args=None, - private_key_managed=None, - private_key=None, - private_key_passphrase=None, - public_key=None, - certificate_managed=None, - test=None, -): - """ - This function essentially behaves like a sophisticated Jinja macro. - It is intended to provide a replacement for the ``ssh_pki.certificate_managed`` - state with peer publishing or some backends, which does not work via salt-ssh. - It performs necessary checks during rendering and returns an appropriate - highstate structure that does work via salt-ssh (if a certificate needs to be - reissued, it is done during rendering and the actual state just manages the file). - - Required arguments are ``name``, ``ca_server`` and ``signing_policy``. - If you want this function to manage a private key, it should be specified - in ``private_key_managed``, which should contain all arguments to the - respective state. Note that the private key will not be checked for changes. - If you want to use a public key as a source, it must exist during state - rendering and you cannot manage a private key. - - All optional keyword arguments to ``certificate_managed`` can be specified - in the dict param ``certificate_managed``. - Key rotation can be activated by including ``new: true`` in the dict for - ``private_key_managed``. - - As an example, for Jinja templates, you can serialize this function's output - directly into the state file. Note that you need to pass ``opts.get("test")`` - explicitly for test mode to work reliably! - - .. code-block:: jinja - - {%- set private_key_params = { - "name": "/root/.ssh/id_foo", - "algo": "ed25519", - "new": true - } %} - {%- set certificate_params = { - "ttl_remaining": "7d", - "ttl": "30d", - "valid_principals": ["min.ion.example.org"] - } %} - {{ - salt["ssh_pki.certificate_managed_wrapper"]( - "/root/.ssh/id_foo.crt", - ca_server="ca_minion", - signing_policy="user_cert", - private_key_managed=private_key_params, - certificate_managed=certificate_params, - test=opts.get("test") - ) | yaml(false) - }} - - - name - The path of the certificate to manage. - - ca_server - The CA server to contact. This is required since this function - is not necessary for locally signed certificates. - - signing_policy - The name of the signing policy to use. Required since remotely - signing a certificate requires a policy. - - backend - Instead of using the ``ssh_pki`` execution module for certificate - creation, use this backend. It must provide a compatible API for - ``create_certificate`` and ``get_signing_policy``. - It should have a wrapper module for this function to make sense, - otherwise you can just use the state module directly. - - backend_args - If ``backend`` is specified, pass these additional keyword arguments - to it. Must be a mapping (dict). - - private_key_managed - A dictionary of keyword arguments to ``ssh_pki.private_key_managed``. - This is required if ``private_key`` or ``public_key`` - have not been specified. - Key rotation will be performed automatically if ``new: true``. - Note that the specified file path must not be a symlink. - - private_key - The path of a private key to use for public key derivation - (it will not be managed). - Does not accept the key itself. Mutually exclusive with - ``private_key_managed`` and ``public_key``. - - private_key_passphrase - If the specified private key needs a passphrase, specify it here. - - public_key - The path of a public key to use. - Does not accept the key itself. Mutually exclusive with - ``private_key_managed`` and ``private_key``. - - certificate_managed - A dictionary of keyword arguments to ``ssh_pki.certificate_managed``. - - test - Run in test mode. This should be passed explicitly because the value - is not loaded into wrapper modules (reliably?). Pass it like - ``test=opts.get("test")``. - If this is forgotten, the files on the remote will still not be updated, - but a certificate might be issued unnecessarily. - - .. note:: - - This function does not claim feature parity, but it uses the same - change check as the regular state module. Special handling for symlinks - and other edge cases is not implemented. - - There will be one or two resulting states, depending on the presence of - ``private_key_managed``. Both states will have the managed file path as - their state ID (suffixed with either _key or _crt), the state module - will always be ``ssh_pki``. - - Private keys will not leave the remote machine. - """ - if not (private_key_managed or private_key or public_key): - raise SaltInvocationError( - "Need to specify either private_key_managed, private_key or public_key" - ) - - backend = backend or "ssh_pki" - create_private_key = False - recreate_private_key = False - new_certificate = False - certificate_managed = certificate_managed or {} - private_key_managed = private_key_managed or {} - public_key = None - - cert_file_args, cert_args = sshpki.split_file_kwargs(certificate_managed) - pk_file_args, pk_args = sshpki.split_file_kwargs(private_key_managed) - ret = {} - current = None - cert_changes = {} - pk_changes = {} - pk_temp_file = None - - try: - # Check if we have a source for a public key - if pk_args: - private_key = pk_args["name"] - if not __salt__["file.file_exists"](private_key): - create_private_key = True - elif __salt__["file.is_link"](private_key): - if not pk_args.get("overwrite"): - raise CommandExecutionError( - "Specified private key path exists, but is a symlink, " - "which is disallowed. Either specify the target path of " - "the link or pass overwrite: true to force regeneration" - ) - if not (test or __opts__.get("test")): - # The link would be written over anyways by `file.move`, but - # let's remove it here in case that assumption fails - __salt__["file.remove"](private_key) - pk_changes["removed_link"] = pk_args["name"] - create_private_key = True - else: - public_key, create_private_key = _load_privkey( - pk_args["name"], - pk_args.get("passphrase"), - pk_args.get("overwrite", False), - ) - elif private_key: - if not __salt__["file.file_exists"](private_key): - raise SaltInvocationError("Specified private key does not exist") - public_key, _ = _load_privkey(private_key, private_key_passphrase) - elif public_key: - # todo usually can be specified as the key itself - if not __salt__["file.file_exists"](public_key): - raise SaltInvocationError("Specified public key does not exist") - public_key = __salt__["ssh_pki.get_public_key"](public_key) - - if create_private_key: - # A missing private key means we need to create a certificate regardless - new_certificate = True - elif not __salt__["file.file_exists"](name): - new_certificate = True - else: - # We check the certificate the same way the state does - crt = __salt__["file.read"](name) - signing_policy_contents = __salt__[f"{backend}.get_signing_policy"]( - signing_policy, ca_server=ca_server, **(backend_args or {}) - ) - current, cert_changes, replace = sshpki.check_cert_changes( - crt, - **cert_args, - ca_server=ca_server, - signing_policy_contents=signing_policy_contents, - backend=backend, - public_key=public_key, - ) - new_certificate = new_certificate or replace - - if pk_args and pk_args.get("new") and not create_private_key: - if new_certificate or cert_changes: - recreate_private_key = True - - if test or __opts__.get("test"): - if pk_args: - pk_ret = { - "name": pk_args["name"], - "result": True, - "comment": "The private key is in the correct state", - "changes": {}, - "require_in": [ - name + "_crt", - ], - } - if create_private_key or recreate_private_key: - pp = "created" if not recreate_private_key else "recreated" - pk_ret["changes"] = pk_changes - pk_ret["changes"][pp] = pk_args["name"] - pk_ret["comment"] = f"The private key would have been {pp}" - ret[pk_args["name"] + "_key"] = { - "ssh_pki.private_key_managed_ssh": [ - {k: v} for k, v in pk_ret.items() - ] - } - ret[pk_args["name"] + "_key"]["ssh_pki.private_key_managed_ssh"].extend( - {k: v} for k, v in pk_file_args.items() - ) - - cert_ret = { - "name": name, - "result": True, - "changes": {}, - } - if new_certificate: - pp = ("re" if current else "") + "created" - cert_ret["comment"] = f"The certificate would have been {pp}" - cert_ret["changes"][pp] = name - elif cert_changes: - cert_ret["comment"] = "The certificate would have been updated" - cert_ret["changes"] = cert_changes - else: - cert_ret["comment"] = "The certificate is in the correct state" - cert_ret["changes"] = {} - - ret[name + "_crt"] = { - "ssh_pki.certificate_managed_ssh": [{k: v} for k, v in cert_ret.items()] - } - ret[name + "_crt"]["ssh_pki.certificate_managed_ssh"].extend( - {k: v} for k, v in cert_file_args.items() - ) - return ret - - if create_private_key or recreate_private_key: - pk_temp_file = __salt__["temp.file"]() - __salt__["file.set_mode"](pk_temp_file, "0600") - cpk_args = {"path": pk_temp_file, "overwrite": True} - for arg in ( - "algo", - "keysize", - "passphrase", - ): - if arg in pk_args: - cpk_args[arg] = pk_args[arg] - __salt__["ssh_pki.create_private_key"](**cpk_args) - public_key = __salt__["ssh_pki.get_public_key"]( - pk_temp_file, pk_args.get("passphrase") - ) - if pk_args: - pk_ret = { - "name": pk_args["name"], - "result": True, - "comment": "The private key is in the correct state", - "changes": {}, - "require_in": [ - name + "_crt", - ], - } - if create_private_key or recreate_private_key: - pp = "created" if not recreate_private_key else "recreated" - pk_ret["changes"] = pk_changes - pk_ret["changes"][pp] = pk_args["name"] - pk_ret["comment"] = f"The private key has been {pp}" - ret[pk_args["name"] + "_key"] = { - "ssh_pki.private_key_managed_ssh": [{k: v} for k, v in pk_ret.items()] - } - ret[pk_args["name"] + "_key"]["ssh_pki.private_key_managed_ssh"].extend( - {k: v} for k, v in pk_file_args.items() - ) - ret[pk_args["name"] + "_key"]["ssh_pki.private_key_managed_ssh"].append( - {"tempfile": pk_temp_file} - ) - - cert_ret = { - "name": name, - "result": True, - "changes": {}, - } - if new_certificate or cert_changes: - pp = ("re" if current else "") + "created" - cert_ret["contents"] = __salt__[f"{backend}.create_certificate"]( - **_filter_cert_managed_state_args(cert_args), - **(backend_args or {}), - ca_server=ca_server, - signing_policy=signing_policy, - public_key=public_key, - ) - cert_ret["comment"] = f"The certificate has been {pp}" - if not cert_changes: - cert_ret["changes"][pp] = name - else: - cert_ret["changes"] = cert_changes - else: - cert_ret["comment"] = "The certificate is in the correct state" - cert_ret["changes"] = {} - - ret[name + "_crt"] = { - "ssh_pki.certificate_managed_ssh": [{k: v} for k, v in cert_ret.items()] - } - ret[name + "_crt"]["ssh_pki.certificate_managed_ssh"].extend( - {k: v} for k, v in cert_file_args.items() - ) - except (CommandExecutionError, SaltInvocationError) as err: - if pk_temp_file: - if __salt__["file.file_exists"](pk_temp_file): - try: - # otherwise, get rid of it - __salt__["file.remove"](pk_temp_file) - except Exception as err: # pylint: disable=broad-except - log.error(str(err), exc_info_on_loglevel=logging.DEBUG) - ret = { - name - + "_crt": { - "ssh_pki.certificate_managed_ssh": [ - {"name": name}, - {"result": False}, - {"comment": str(err)}, - {"changes": {}}, - ] - } - } - if pk_args and "name" in pk_args: - ret[pk_args["name"] + "_key"] = { - "ssh_pki.private_key_managed_ssh": [ - {"name": pk_args["name"]}, - {"result": False}, - {"comment": str(err)}, - {"changes": {}}, - ] - } - return ret - - -def _filter_cert_managed_state_args(kwargs): - return {k: v for k, v in kwargs.items() if k != "ttl_remaining"} - - -def _load_privkey(pk, passphrase, overwrite=False): - public_key = None - create_private_key = False - try: - public_key = __salt__["ssh_pki.get_public_key"]( - pk, - passphrase, - ) - except CommandExecutionError as err: - # All errors currently get mangled into this one. - # TODO: Subclass more specific errors to CommandExecutionError - # and reraise them in get_public_key - if "Could not load key as" in str(err): - if not overwrite: - raise CommandExecutionError( - "The private key file could not be loaded. This can either mean " - "the file is encrypted and the provided passphrase is wrong " - "or the file is not a private key at all. Either way, you can " - "pass overwrite: true to force regeneration if the file is managed" - ) - create_private_key = True - else: - raise - return public_key, create_private_key diff --git a/salt/client/ssh/wrapper/state.py b/salt/client/ssh/wrapper/state.py index 7d747267f827..79667a4dedea 100644 --- a/salt/client/ssh/wrapper/state.py +++ b/salt/client/ssh/wrapper/state.py @@ -28,20 +28,6 @@ log = logging.getLogger(__name__) -def _set_grains_shared(): - """ - Set grains on __opts__ in a way that's visible to all loaders. - - If __opts__ is an OptsDict, use set_shared() to set grains on the root - so all children/loaders can see it. Otherwise, use direct assignment. - """ - grains = __grains__.value() if hasattr(__grains__, "value") else __grains__ - if hasattr(__opts__, "set_shared"): - __opts__.set_shared("grains", grains) - else: - __opts__["grains"] = grains - - def _ssh_state(chunks, st_kwargs, kwargs, pillar, test=False): """ Function to run a state with the given chunk via salt-ssh @@ -162,7 +148,7 @@ def sls(mods, saltenv="base", test=None, exclude=None, **kwargs): Create the seed file for a state.sls run """ st_kwargs = __salt__.kwargs - _set_grains_shared() + __opts__["grains"] = __grains__.value() opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) opts["test"] = _get_test_value(test, **kwargs) initial_pillar = _get_initial_pillar(opts) @@ -213,10 +199,7 @@ def sls(mods, saltenv="base", test=None, exclude=None, **kwargs): __context__["retcode"] = salt.defaults.exitcodes.EX_STATE_COMPILER_ERROR return errors # Compile and verify the raw chunks - chunks, errors = st_.state.compile_high_data(high_data) - if errors: - __context__["retcode"] = salt.defaults.exitcodes.EX_STATE_COMPILER_ERROR - return errors + chunks = st_.state.compile_high_data(high_data) file_refs = salt.client.ssh.state.lowstate_file_refs( chunks, _merge_extra_filerefs( @@ -340,7 +323,7 @@ def low(data, **kwargs): salt '*' state.low '{"state": "pkg", "fun": "installed", "name": "vi"}' """ st_kwargs = __salt__.kwargs - _set_grains_shared() + __opts__["grains"] = __grains__.value() chunks = [data] with salt.client.ssh.state.SSHHighState( __opts__, @@ -427,7 +410,7 @@ def high(data, **kwargs): salt '*' state.high '{"vim": {"pkg": ["installed"]}}' """ st_kwargs = __salt__.kwargs - _set_grains_shared() + __opts__["grains"] = __grains__.value() opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) pillar_override = kwargs.get("pillar") initial_pillar = _get_initial_pillar(opts) @@ -447,10 +430,7 @@ def high(data, **kwargs): # Ensure other wrappers use the correct pillar __pillar__.update(pillar) st_.push_active() - chunks, errors = st_.state.compile_high_data(data) - if errors: - __context__["retcode"] = salt.defaults.exitcodes.EX_STATE_COMPILER_ERROR - return errors + chunks = st_.state.compile_high_data(data) file_refs = salt.client.ssh.state.lowstate_file_refs( chunks, _merge_extra_filerefs( @@ -670,7 +650,7 @@ def highstate(test=None, **kwargs): salt '*' state.highstate exclude="[{'id': 'id_to_exclude'}, {'sls': 'sls_to_exclude'}]" """ st_kwargs = __salt__.kwargs - _set_grains_shared() + __opts__["grains"] = __grains__.value() opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) opts["test"] = _get_test_value(test, **kwargs) pillar_override = kwargs.get("pillar") @@ -696,9 +676,9 @@ def highstate(test=None, **kwargs): # Ensure other wrappers use the correct pillar __pillar__.update(pillar) st_.push_active() - chunks_or_errors = st_.compile_low_chunks(context=__context__.value()) + chunks = st_.compile_low_chunks(context=__context__.value()) file_refs = salt.client.ssh.state.lowstate_file_refs( - chunks_or_errors, + chunks, _merge_extra_filerefs( kwargs.get("extra_filerefs", ""), opts.get("extra_filerefs", ""), @@ -706,19 +686,19 @@ def highstate(test=None, **kwargs): ), ) # Check for errors - for chunk in chunks_or_errors: + for chunk in chunks: if not isinstance(chunk, dict): __context__["retcode"] = salt.defaults.exitcodes.EX_STATE_COMPILER_ERROR - return chunks_or_errors + return chunks roster = salt.roster.Roster(opts, opts.get("roster", "flat")) roster_grains = roster.opts["grains"] # Create the tar containing the state pkg and relevant files. - _cleanup_slsmod_low_data(chunks_or_errors) + _cleanup_slsmod_low_data(chunks) trans_tar = salt.client.ssh.state.prep_trans_tar( __context__["fileclient"], - chunks_or_errors, + chunks, file_refs, pillar, st_kwargs["id_"], @@ -760,7 +740,7 @@ def top(topfn, test=None, **kwargs): salt '*' state.top reverse_top.sls exclude="[{'id': 'id_to_exclude'}, {'sls': 'sls_to_exclude'}]" """ st_kwargs = __salt__.kwargs - _set_grains_shared() + __opts__["grains"] = __grains__.value() opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) opts["test"] = _get_test_value(test, **kwargs) pillar_override = kwargs.get("pillar") @@ -787,14 +767,14 @@ def top(topfn, test=None, **kwargs): __pillar__.update(pillar) st_.opts["state_top"] = os.path.join("salt://", topfn) st_.push_active() - chunks_or_errors = st_.compile_low_chunks(context=__context__.value()) + chunks = st_.compile_low_chunks(context=__context__.value()) # Check for errors - for chunk in chunks_or_errors: + for chunk in chunks: if not isinstance(chunk, dict): __context__["retcode"] = salt.defaults.exitcodes.EX_STATE_COMPILER_ERROR - return chunks_or_errors + return chunks file_refs = salt.client.ssh.state.lowstate_file_refs( - chunks_or_errors, + chunks, _merge_extra_filerefs( kwargs.get("extra_filerefs", ""), opts.get("extra_filerefs", ""), @@ -806,10 +786,10 @@ def top(topfn, test=None, **kwargs): roster_grains = roster.opts["grains"] # Create the tar containing the state pkg and relevant files. - _cleanup_slsmod_low_data(chunks_or_errors) + _cleanup_slsmod_low_data(chunks) trans_tar = salt.client.ssh.state.prep_trans_tar( __context__["fileclient"], - chunks_or_errors, + chunks, file_refs, pillar, st_kwargs["id_"], @@ -848,7 +828,7 @@ def show_highstate(**kwargs): salt '*' state.show_highstate """ - _set_grains_shared() + __opts__["grains"] = __grains__.value() opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) pillar_override = kwargs.get("pillar") initial_pillar = _get_initial_pillar(opts) @@ -892,7 +872,7 @@ def show_lowstate(**kwargs): salt '*' state.show_lowstate """ - _set_grains_shared() + __opts__["grains"] = __grains__.value() opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) with salt.client.ssh.state.SSHHighState( opts, @@ -908,9 +888,9 @@ def show_lowstate(**kwargs): err += st_.opts["pillar"]["_errors"] return err st_.push_active() - chunks_or_errors = st_.compile_low_chunks(context=__context__.value()) - _cleanup_slsmod_low_data(chunks_or_errors) - return chunks_or_errors + chunks = st_.compile_low_chunks(context=__context__.value()) + _cleanup_slsmod_low_data(chunks) + return chunks def sls_id(id_, mods, test=None, queue=False, **kwargs): @@ -997,10 +977,7 @@ def sls_id(id_, mods, test=None, queue=False, **kwargs): if errors: __context__["retcode"] = salt.defaults.exitcodes.EX_STATE_COMPILER_ERROR return errors - chunks, errors = st_.state.compile_high_data(high_) - if errors: - __context__["retcode"] = salt.defaults.exitcodes.EX_STATE_COMPILER_ERROR - return errors + chunks = st_.state.compile_high_data(high_) chunk = [x for x in chunks if x.get("__id__", "") == id_] if not chunk: @@ -1028,7 +1005,7 @@ def show_sls(mods, saltenv="base", test=None, **kwargs): salt '*' state.show_sls core,edit.vim dev """ - _set_grains_shared() + __opts__["grains"] = __grains__.value() opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) opts["test"] = _get_test_value(test, **kwargs) pillar_override = kwargs.get("pillar") @@ -1088,7 +1065,7 @@ def show_low_sls(mods, saltenv="base", test=None, **kwargs): salt '*' state.show_low_sls core,edit.vim dev """ - _set_grains_shared() + __opts__["grains"] = __grains__.value() opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) opts["test"] = _get_test_value(test, **kwargs) pillar_override = kwargs.get("pillar") @@ -1131,10 +1108,7 @@ def show_low_sls(mods, saltenv="base", test=None, **kwargs): if errors: __context__["retcode"] = salt.defaults.exitcodes.EX_STATE_COMPILER_ERROR return errors - ret, errors = st_.state.compile_high_data(high_data) - if errors: - __context__["retcode"] = salt.defaults.exitcodes.EX_STATE_COMPILER_ERROR - return errors + ret = st_.state.compile_high_data(high_data) _cleanup_slsmod_low_data(ret) return ret @@ -1171,7 +1145,7 @@ def show_top(**kwargs): salt '*' state.show_top """ - _set_grains_shared() + __opts__["grains"] = __grains__ opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) with salt.client.ssh.state.SSHHighState( opts, @@ -1211,7 +1185,7 @@ def single(fun, name, test=None, **kwargs): """ st_kwargs = __salt__.kwargs - _set_grains_shared() + __opts__["grains"] = __grains__.value() # state.fun -> [state, fun] comps = fun.split(".") diff --git a/salt/client/ssh/wrapper/x509_v2.py b/salt/client/ssh/wrapper/x509_v2.py deleted file mode 100644 index 530ae4c49aca..000000000000 --- a/salt/client/ssh/wrapper/x509_v2.py +++ /dev/null @@ -1,1001 +0,0 @@ -""" -Manage X.509 certificates -========================= - -.. versionadded:: 3008.0 - -General configuration instructions and general remarks are documented -in the :ref:`execution module docs `. - -.. note:: - - Compound matching allowed callers is **not supported** with salt-ssh - minions. They will always be denied. -""" - -import copy -import logging -from pathlib import Path - -try: - import salt.utils.x509 as x509util - - HAS_CRYPTOGRAPHY = True -except ImportError: - HAS_CRYPTOGRAPHY = False - -import salt.utils.dictupdate -import salt.utils.files -import salt.utils.stringutils -from salt.exceptions import CommandExecutionError, SaltInvocationError - -log = logging.getLogger(__name__) - - -__virtualname__ = "x509" - - -def __virtual__(): - if not HAS_CRYPTOGRAPHY: - return (False, "Could not load cryptography") - return __virtualname__ - - -def create_certificate( - ca_server=None, - signing_policy=None, - encoding="pem", - append_certs=None, - pkcs12_passphrase=None, - pkcs12_encryption_compat=False, - pkcs12_friendlyname=None, - path=None, - overwrite=True, - raw=False, - **kwargs, -): - """ - Create an X.509 certificate and return an encoded version of it. - - .. note:: - - All parameters that take a public key, private key or certificate - can be specified either as a PEM/hex/base64 string or a path to a - local file encoded in all supported formats for the type. - - CLI Example: - - .. code-block:: bash - - salt '*' x509.create_certificate signing_private_key='/etc/pki/myca.key' csr='/etc/pki/my.csr' - - ca_server - Request a remotely signed certificate from ca_server. For this to - work, a ``signing_policy`` must be specified, and that same policy - must be configured on the ca_server. See `Signing policies`_ for - details. Also, the Salt master must permit peers to call the - ``sign_remote_certificate`` function, see `Peer communication`_. - - signing_policy - The name of a configured signing policy. Parameters specified in there - are hardcoded and cannot be overridden. This is required for remote signing, - otherwise optional. See `Signing policies`_ for details. - - encoding - Specify the encoding of the resulting certificate. It can be returned - as a ``pem`` (or ``pkcs7_pem``) string or several (base64-encoded) - binary formats (``der``, ``pkcs7_der``, ``pkcs12``). Defaults to ``pem``. - - append_certs - A list of additional certificates to append to the new one, e.g. to create a CA chain. - - .. note:: - - Mind that when ``der`` encoding is in use, appending certificatees is prohibited. - - copypath - Create a copy of the issued certificate in PEM format in this directory. - The file will be named ``.crt`` if prepend_cn is False. - - prepend_cn - When ``copypath`` is set, prepend the common name of the certificate to - the file name like so: ``-.crt``. Defaults to false. - - pkcs12_passphrase - When encoding a certificate as ``pkcs12``, encrypt it with this passphrase. - - .. note:: - - PKCS12 encryption is very weak and `should not be relied on for security `_. - - pkcs12_encryption_compat - OpenSSL 3 and cryptography v37 switched to a much more secure default - encryption for PKCS12, which might be incompatible with some systems. - This forces the legacy encryption. Defaults to False. - - pkcs12_friendlyname - When encoding a certificate as ``pkcs12``, a name for the certificate can be included. - - path - Instead of returning the certificate, write it to this file path. - - overwrite - If ``path`` is specified and the file exists, overwrite it. - Defaults to true. - - raw - Return the encoded raw bytes instead of a string. Defaults to false. - - digest - The hashing algorithm to use for the signature. Valid values are: - sha1, sha224, sha256, sha384, sha512, sha512_224, sha512_256, sha3_224, - sha3_256, sha3_384, sha3_512. Defaults to ``sha256``. - This will be ignored for ``ed25519`` and ``ed448`` key types. - - private_key - The private key corresponding to the public key the certificate should - be issued for. This is one way of specifying the public key that will - be included in the certificate, the other ones being ``public_key`` and ``csr``. - - private_key_passphrase - If ``private_key`` is specified and encrypted, the passphrase to decrypt it. - - public_key - The public key the certificate should be issued for. Other ways of passing - the required information are ``private_key`` and ``csr``. If neither are set, - the public key of the ``signing_private_key`` will be included, i.e. - a self-signed certificate is generated. - - csr - A certificate signing request to use as a base for generating the certificate. - The following information will be respected, depending on configuration: - * public key - * extensions, if not otherwise specified (arguments, signing_policy) - - signing_cert - The CA certificate to be used for signing the issued certificate. - - signing_private_key - The private key corresponding to the public key in ``signing_cert``. Required. - - signing_private_key_passphrase - If ``signing_private_key`` is encrypted, the passphrase to decrypt it. - - serial_number - A serial number to be embedded in the certificate. If unspecified, will - autogenerate one. This should be an integer, either in decimal or - hexadecimal notation. - - not_before - Set a specific date the certificate should not be valid before. - The format should follow ``%Y-%m-%d %H:%M:%S`` and will be interpreted as GMT/UTC. - Defaults to the time of issuance. - - not_after - Set a specific date the certificate should not be valid after. - The format should follow ``%Y-%m-%d %H:%M:%S`` and will be interpreted as GMT/UTC. - If unspecified, defaults to the current time plus ``days_valid`` days. - - days_valid - If ``not_after`` is unspecified, the number of days from the time of issuance - the certificate should be valid for. Defaults to ``30``. - - subject - The subject's distinguished name embedded in the certificate. This is one way of - passing this information (see ``kwargs`` below for the other). - This argument will be preferred and allows to control the order of RDNs in the DN - as well as to embed RDNs with multiple attributes. - This can be specified as an RFC4514-encoded string (``CN=example.com,O=Example Inc,C=US``, - mind that the rendered order is reversed from what is embedded), a list - of RDNs encoded as in RFC4514 (``["C=US", "O=Example Inc", "CN=example.com"]``) - or a dictionary (``{"CN": "example.com", "C": "US", "O": "Example Inc"}``, - default ordering). - Multiple name attributes per RDN are concatenated with a ``+``. - - .. note:: - - Parsing of RFC4514 strings requires at least cryptography release 37. - - kwargs - Embedded X.509v3 extensions and the subject's distinguished name can be - controlled via supplemental keyword arguments. See the following for an overview. - - Subject properties in kwargs - C, ST, L, STREET, O, OU, CN, MAIL, SN, GN, UID, SERIALNUMBER - - X.509v3 extensions in kwargs - Most extensions can be configured using the same string format as OpenSSL, - while some require adjustments. In general, since the strings are - parsed to dicts/lists, you can always use the latter formats directly. - Marking an extension as critical is done by including it at the beginning - of the configuration string, in the list or as a key in the dictionary - with the value ``true``. - - Examples (some showcase dict/list correspondance): - - basicConstraints - ``critical, CA:TRUE, pathlen:1`` or - - .. code-block:: yaml - - - basicConstraints: - critical: true - ca: true - pathlen: 1 - - keyUsage - ``critical, cRLSign, keyCertSign`` or - - .. code-block:: yaml - - - keyUsage: - - critical - - cRLSign - - keyCertSign - - subjectKeyIdentifier - This can be an explicit value or ``hash``, in which case the value - will be set to the SHA1 hash of some encoding of the associated public key, - depending on the underlying algorithm (RSA/ECDSA/EdDSA). - - authorityKeyIdentifier - ``keyid:always, issuer`` - - subjectAltName - There is support for all OpenSSL-defined types except ``otherName``. - - ``email:me@example.com,DNS:example.com`` or - - .. code-block:: yaml - - # mind this being a list, not a dict - - subjectAltName: - - email:me@example.com - - DNS:example.com - - issuerAltName - The syntax is the same as for ``subjectAltName``, except that the additional - value ``issuer:copy`` is supported, which will copy the values of - ``subjectAltName`` in the issuer's certificate. - - authorityInfoAccess - ``OCSP;URI:http://ocsp.example.com/,caIssuers;URI:http://myca.example.com/ca.cer`` - - crlDistributionPoints - When set to a string value, items are interpreted as fullnames: - - ``URI:http://example.com/myca.crl, URI:http://example.org/my.crl`` - - There is also support for more attributes using the full form: - - .. code-block:: yaml - - - crlDistributionPoints: - - fullname: URI:http://example.com/myca.crl - crlissuer: DNS:example.org - reasons: - - keyCompromise - - URI:http://example.org/my.crl - - certificatePolicies - ``critical, 1.2.4.5, 1.1.3.4`` - - Again, there is support for more attributes using the full form: - - .. code-block:: yaml - - - certificatePolicies: - critical: true - 1.2.3.4.5: https://my.ca.com/pratice_statement - 1.2.4.5.6: - - https://my.ca.com/pratice_statement - - organization: myorg - noticeNumbers: [1, 2, 3] - text: mytext - - policyConstraints - ``requireExplicitPolicy:3,inhibitPolicyMapping:1`` - - inhibitAnyPolicy - The value is just an integer: ``- inhibitAnyPolicy: 1`` - - nameConstraints - ``critical,permitted;IP:192.168.0.0/255.255.0.0,permitted;email:.example.com,excluded;email:.com`` - - .. code-block:: yaml - - - nameConstraints: - critical: true - permitted: - - IP:192.168.0.0/24 - - email:.example.com - excluded: - - email:.com - noCheck - This extension does not take any values, except ``critical``. Just the presence - in the keyword args will include it. - - tlsfeature - ``status_request`` - - For more information, visit the `OpenSSL docs `_. - """ - if raw: - # returns are json-serialized, which does not support bytes - raise SaltInvocationError("salt-ssh does not support the `raw` parameter") - - kwargs = {k: v for k, v in kwargs.items() if not k.startswith("_")} - - if not ca_server: - return __salt__["x509.create_certificate_ssh"]( - signing_policy=signing_policy, - encoding=encoding, - append_certs=append_certs, - pkcs12_passphrase=pkcs12_passphrase, - pkcs12_encryption_compat=pkcs12_encryption_compat, - pkcs12_friendlyname=pkcs12_friendlyname, - path=path, - overwrite=overwrite, - raw=raw, - **kwargs, - ) - - # Deprecation checks vs the old x509 module - if "algorithm" in kwargs: - salt.utils.versions.warn_until( - 3009, - "`algorithm` has been renamed to `digest`. Please update your code.", - ) - kwargs["digest"] = kwargs.pop("algorithm") - - ignored_params = {"text", "version", "serial_bits"}.intersection( - kwargs - ) # path, overwrite - if ignored_params: - salt.utils.versions.kwargs_warn_until(ignored_params, "Potassium") - kwargs = x509util.ensure_cert_kwargs_compat(kwargs) - - if "days_valid" not in kwargs and "not_after" not in kwargs: - try: - salt.utils.versions.warn_until( - 3009, - "The default value for `days_valid` will change to 30. Please adapt your code accordingly.", - ) - kwargs["days_valid"] = 365 - except RuntimeError: - pass - - if encoding not in ["der", "pem", "pkcs7_der", "pkcs7_pem", "pkcs12"]: - raise CommandExecutionError( - f"Invalid value '{encoding}' for encoding. Valid: " - "der, pem, pkcs7_der, pkcs7_pem, pkcs12" - ) - if kwargs.get("digest", "sha256").lower() not in [ - "sha1", - "sha224", - "sha256", - "sha384", - "sha512", - "sha512_224", - "sha512_256", - "sha3_224", - "sha3_256", - "sha3_384", - "sha3_512", - ]: - raise CommandExecutionError( - f"Invalid value '{kwargs['digest']}' for digest. Valid: sha1, sha224, " - "sha256, sha384, sha512, sha512_224, sha512_256, sha3_224, sha3_256, " - "sha3_384, sha3_512" - ) - if encoding == "der" and append_certs: - raise SaltInvocationError("Cannot encode a certificate chain in DER") - if encoding == "pkcs12" and "private_key" not in kwargs: - # The creation will work, but it will be listed in additional certs, not - # as the main certificate. This might confuse other parts of the code. - raise SaltInvocationError( - "Creating a PKCS12-encoded certificate without embedded private key " - "is unsupported" - ) - - if path and not overwrite and __salt__["file.file_exists"](path): - return f"The file at {path} exists and overwrite was set to false" - if signing_policy is None: - raise SaltInvocationError( - "signing_policy must be specified to request a certificate from " - "a remote ca_server" - ) - cert, private_key_loaded = _create_certificate_remote( - ca_server, signing_policy, **kwargs - ) - - if encoding == "pkcs12": - out = __salt__["x509.encode_certificate"]( - x509util.to_pem(cert).decode(), - append_certs=append_certs, - encoding=encoding, - private_key=private_key_loaded, - pkcs12_passphrase=pkcs12_passphrase, - pkcs12_encryption_compat=pkcs12_encryption_compat, - pkcs12_friendlyname=pkcs12_friendlyname, - raw=False, - ) - else: - out = __salt__["x509.encode_certificate"]( - x509util.to_pem(cert).decode(), - append_certs=append_certs, - encoding=encoding, - raw=False, - ) - - if path is None: - return out - - if encoding == "pem": - return __salt__["x509.write_pem"]( - out, path, overwrite=overwrite, pem_type="CERTIFICATE" - ) - __salt__["hashutil.base64_decodefile"](out, path) - return f"Certificate written to {path}" - - -def _query_remote(ca_server, signing_policy, kwargs, get_signing_policy_only=False): - result = __salt__["publish.publish"]( - ca_server, - "x509.sign_remote_certificate", - arg=[signing_policy, kwargs, get_signing_policy_only], - regular_minions=True, - ) - - if not result: - raise SaltInvocationError( - "ca_server did not respond." - " Salt master must permit peers to" - " call the sign_remote_certificate function." - ) - result = result[next(iter(result))] - if not isinstance(result, dict) or "data" not in result: - log.error("Received invalid return value from ca_server: %s", result) - raise CommandExecutionError( - "Received invalid return value from ca_server. See minion log for details" - ) - if result.get("errors"): - raise CommandExecutionError( - "ca_server reported errors:\n" + "\n".join(result["errors"]) - ) - return result["data"] - - -def _create_certificate_remote( - ca_server, signing_policy, private_key=None, private_key_passphrase=None, **kwargs -): - private_key_loaded = None - if private_key: - kwargs["public_key"] = __salt__["x509.get_public_key"]( - private_key, passphrase=private_key_passphrase - ) - elif kwargs.get("public_key"): - kwargs["public_key"] = __salt__["x509.get_public_key"](kwargs["public_key"]) - - if kwargs.get("csr"): - try: - # Check if the data can be interpreted as a Path at all - Path(kwargs["csr"]) - except TypeError: - pass - else: - if __salt__["file.file_exists"](kwargs["csr"]): - kwargs["csr"] = __salt__["hashutil.base64_encodefile"](kwargs["csr"]) - - result = _query_remote(ca_server, signing_policy, kwargs) - try: - return x509util.load_cert(result), private_key_loaded - except (CommandExecutionError, SaltInvocationError) as err: - raise CommandExecutionError( - f"ca_server did not return a certificate: {result}" - ) from err - - -def get_signing_policy(signing_policy, ca_server=None): - """ - Returns the specified named signing policy. - - CLI Example: - - .. code-block:: bash - - salt '*' x509.get_signing_policy www - - signing_policy - The name of the signing policy to return. - - ca_server - If this is set, the CA server will be queried for the - signing policy instead of looking it up locally. - """ - if ca_server is None: - policy = _get_signing_policy(signing_policy) - else: - # Cache signing policies from remote during this run - # to reduce unnecessary resource usage. - ckey = "_x509_policies" - if ckey not in __context__: - __context__[ckey] = {} - if ca_server not in __context__[ckey]: - __context__[ckey][ca_server] = {} - if signing_policy not in __context__[ckey][ca_server]: - policy_ = _query_remote( - ca_server, signing_policy, {}, get_signing_policy_only=True - ) - if "signing_cert" in policy_: - policy_["signing_cert"] = x509util.to_pem( - x509util.load_cert(policy_["signing_cert"]) - ).decode() - __context__[ckey][ca_server][signing_policy] = policy_ - # only hand out copies of the cached policy - policy = copy.deepcopy(__context__[ckey][ca_server][signing_policy]) - - # Don't immediately break for the long form of name attributes - for name, long_names in x509util.NAME_ATTRS_ALT_NAMES.items(): - for long_name in long_names: - if long_name in policy: - salt.utils.versions.warn_until( - 3009, - f"Found {long_name} in {signing_policy}. Please migrate to the short name: {name}", - ) - policy[name] = policy.pop(long_name) - - # Don't immediately break for the long form of extensions - for extname, long_names in x509util.EXTENSIONS_ALT_NAMES.items(): - for long_name in long_names: - if long_name in policy: - salt.utils.versions.warn_until( - 3009, - f"Found {long_name} in {signing_policy}. Please migrate to the short name: {extname}", - ) - policy[extname] = policy.pop(long_name) - return policy - - -def _get_signing_policy(name): - if name is None: - return {} - policies = __salt__["pillar.get"]("x509_signing_policies", {}).get(name) - policies = policies or __salt__["config.get"]("x509_signing_policies", {}).get(name) - if isinstance(policies, list): - dict_ = {} - for item in policies: - dict_.update(item) - policies = dict_ - return policies or {} - - -def certificate_managed_wrapper( - name, - ca_server, - signing_policy, - private_key_managed=None, - private_key=None, - private_key_passphrase=None, - csr=None, - public_key=None, - certificate_managed=None, - test=None, -): - """ - This function essentially behaves like a sophisticated Jinja macro. - It is intended to provide a replacement for the ``x509.certificate_managed`` - state with peer publishing, which does not work via salt-ssh. - It performs necessary checks during rendering and returns an appropriate - highstate structure that does work via salt-ssh (if a certificate needs to be - reissued, it is done during rendering and the actual state just manages the file). - - Required arguments are ``name``, ``ca_server`` and ``signing_policy``. - If you want this function to manage a private key, it should be specified - in ``private_key_managed``, which should contain all arguments to the - respective state. Note that the private key will not be checked for changes. - If you want to use a CSR or a public key as a source, - it must exist during state rendering and you cannot manage a private key. - - All optional keyword arguments to ``certificate_managed`` can be specified - in the dict param ``certificate_managed``. - Key rotation can be activated by including ``new: true`` in the dict for - ``private_key_managed``. - - As an example, for Jinja templates, you can serialize this function's output - directly into the state file. Note that you need to pass ``opts.get("test")`` - explicitly for test mode to work reliably! - - .. code-block:: jinja - - {%- set private_key_params = { - "name": "/opt/app/certs/app.key", - "algo": "ed25519", - "new": true - } %} - {%- set certificate_params = { - "basicConstraints": "critical, CA:false", - "subjectKeyIdentifier": "hash", - "authorityKeyIdentifier": "keyid:always", - "subjectAltName": ["DNS:my.minion.example.com"], - "CN": "my.minion.example.com", - "days_remaining": 7, - "days_valid": 30 - } %} - {{ - salt["x509.certificate_managed_wrapper"]( - "/opt/app/certs/app.crt", - ca_server="ca_minion", - signing_policy="www", - private_key_managed=private_key_params, - certificate_managed=certificate_params, - test=opts.get("test") - ) | yaml(false) - }} - - - name - The path of the certificate to manage. - - ca_server - The CA server to contact. This is required since this function - is not necessary for locally signed certificates. - - signing_policy - The name of the signing policy to use. Required since remotely - signing a certificate requires a policy. - - private_key_managed - A dictionary of keyword arguments to ``x509.private_key_managed``. - This is required if ``private_key``, ``csr`` or ``public_key`` - have not been specified. - Key rotation will be performed automatically if ``new: true``. - Note that the specified file path must not be a symlink. - - private_key - The path of a private key to use for public key derivation - (it will not be managed). - Does not accept the key itself. Mutually exclusive with ``private_key_managed``, - ``csr`` and ``public_key``. - - private_key_passphrase - If the specified private key needs a passphrase, specify it here. - - csr - The path of a CSR to use for public key derivation. - Does not accept the CSR itself. Mutually exclusive with ``private_key_managed``, - ``private_key`` and ``public_key``. - - public_key - The path of a public key to use. - Does not accept the key itself. Mutually exclusive with ``private_key_managed``, - ``private_key`` and ``csr``. - - certificate_managed - A dictionary of keyword arguments to ``x509.certificate_managed``. - - test - Run in test mode. This should be passed explicitly because the value - is not loaded into wrapper modules (reliably?). Pass it like - ``test=opts.get("test")``. - If this is forgotten, the files on the remote will still not be updated, - but a certificate might be issued unnecessarily. - - .. note:: - - This function does not claim feature parity, but it uses the same - change check as the regular state module. Special handling for symlinks - and other edge cases is not implemented. - - There will be one or two resulting states, depending on the presence of - ``private_key_managed``. Both states will have the managed file path as - their state ID (suffixed with either _key or _crt), the state module - will always be ``x509``. - - Private keys will not leave the remote machine, unless you're managing - PKCS12 certificates. - """ - if not (private_key_managed or private_key or csr or public_key): - raise SaltInvocationError( - "Need to specify either private_key_managed, private_key, csr or public_key" - ) - - create_private_key = False - recreate_private_key = False - new_certificate = False - reencode_certificate = False - certificate_managed = certificate_managed or {} - private_key_managed = private_key_managed or {} - public_key = None - cm_defaults = { - "days_remaining": 7, - "days_valid": 30, - "not_before": None, - "not_after": None, - "encoding": "pem", - "append_certs": [], - "digest": "sha256", - } - for param, val in cm_defaults.items(): - certificate_managed.setdefault(param, val) - - cert_file_args, cert_args = x509util.split_file_kwargs(certificate_managed) - pk_file_args, pk_args = x509util.split_file_kwargs(private_key_managed) - ret = {} - current = None - cert_changes = {} - pk_changes = {} - pk_temp_file = None - - try: - # Check if we have a source for a public key - if pk_args: - private_key = pk_args["name"] - if not __salt__["file.file_exists"](private_key): - create_private_key = True - elif __salt__["file.is_link"](private_key): - if not pk_args.get("overwrite"): - raise CommandExecutionError( - "Specified private key path exists, but is a symlink, " - "which is disallowed. Either specify the target path of " - "the link or pass overwrite: true to force regeneration" - ) - if not (test or __opts__.get("test")): - # The link would be written over anyways by `file.move`, but - # let's remove it here in case that assumption fails - __salt__["file.remove"](private_key) - pk_changes["removed_link"] = pk_args["name"] - create_private_key = True - else: - public_key, create_private_key = _load_privkey( - pk_args["name"], - pk_args.get("passphrase"), - pk_args.get("overwrite", False), - ) - elif private_key: - if not __salt__["file.file_exists"](private_key): - raise SaltInvocationError("Specified private key does not exist") - public_key, _ = _load_privkey(private_key, private_key_passphrase) - elif public_key: - # todo usually can be specified as the key itself - if not __salt__["file.file_exists"](public_key): - raise SaltInvocationError("Specified public key does not exist") - public_key = __salt__["x509.get_public_key"](public_key) - elif csr: - # todo usually can be specified as the csr itself - if not __salt__["file.file_exists"](csr): - raise SaltInvocationError("Specified csr does not exist") - csr = __salt__["hashutil.base64_encodefile"](csr) - - if create_private_key: - # A missing private key means we need to create a certificate regardless - new_certificate = True - elif not __salt__["file.file_exists"](name): - new_certificate = True - else: - # We check the certificate the same way the state does - crt = __salt__["hashutil.base64_encodefile"](name) - signing_policy_contents = get_signing_policy( - signing_policy, ca_server=ca_server - ) - current, cert_changes, replace, _ = x509util.check_cert_changes( - crt, - **cert_args, - ca_server=ca_server, - signing_policy_contents=signing_policy_contents, - public_key=public_key, - csr=csr, - ) - new_certificate = new_certificate or replace - reencode_certificate = bool(cert_changes) and not bool( - set(cert_changes) - - { - "additional_certs", - "encoding", - "pkcs12_friendlyname", - } - ) - - if pk_args and pk_args.get("new") and not create_private_key: - if new_certificate or (cert_changes and not reencode_certificate): - recreate_private_key = True - - if test or __opts__.get("test"): - if pk_args: - pk_ret = { - "name": pk_args["name"], - "result": True, - "comment": "The private key is in the correct state", - "changes": {}, - "require_in": [ - name + "_crt", - ], - } - if create_private_key or recreate_private_key: - pp = "created" if not recreate_private_key else "recreated" - pk_ret["changes"] = pk_changes - pk_ret["changes"][pp] = pk_args["name"] - pk_ret["comment"] = f"The private key would have been {pp}" - ret[pk_args["name"] + "_key"] = { - "x509.private_key_managed_ssh": [{k: v} for k, v in pk_ret.items()] - } - ret[pk_args["name"] + "_key"]["x509.private_key_managed_ssh"].extend( - {k: v} for k, v in pk_file_args.items() - ) - - cert_ret = { - "name": name, - "result": True, - "changes": {}, - } - if new_certificate: - pp = ("re" if current else "") + "created" - cert_ret["comment"] = f"The certificate would have been {pp}" - cert_ret["changes"][pp] = name - elif reencode_certificate: - cert_ret["comment"] = "The certificate would have been reencoded" - cert_ret["changes"] = cert_changes - elif cert_changes: - cert_ret["comment"] = "The certificate would have been updated" - cert_ret["changes"] = cert_changes - else: - cert_ret["comment"] = "The certificate is in the correct state" - cert_ret["changes"] = {} - - ret[name + "_crt"] = { - "x509.certificate_managed_ssh": [{k: v} for k, v in cert_ret.items()] - } - ret[name + "_crt"]["x509.certificate_managed_ssh"].extend( - {k: v} for k, v in cert_file_args.items() - ) - return ret - - if create_private_key or recreate_private_key: - pk_temp_file = __salt__["temp.file"]() - __salt__["file.set_mode"](pk_temp_file, "0600") - cpk_args = {"path": pk_temp_file} - for arg in ( - "algo", - "keysize", - "passphrase", - "encoding", - "pkcs12_encryption_compat", - ): - if arg in pk_args: - cpk_args[arg] = pk_args[arg] - __salt__["x509.create_private_key"](**cpk_args) - public_key = __salt__["x509.get_public_key"]( - pk_temp_file, pk_args.get("passphrase") - ) - if pk_args: - pk_ret = { - "name": pk_args["name"], - "result": True, - "comment": "The private key is in the correct state", - "changes": {}, - "require_in": [ - name + "_crt", - ], - } - if create_private_key or recreate_private_key: - pp = "created" if not recreate_private_key else "recreated" - pk_ret["changes"] = pk_changes - pk_ret["changes"][pp] = pk_args["name"] - pk_ret["comment"] = f"The private key has been {pp}" - ret[pk_args["name"] + "_key"] = { - "x509.private_key_managed_ssh": [{k: v} for k, v in pk_ret.items()] - } - ret[pk_args["name"] + "_key"]["x509.private_key_managed_ssh"].extend( - {k: v} for k, v in pk_file_args.items() - ) - ret[pk_args["name"] + "_key"]["x509.private_key_managed_ssh"].append( - {"tempfile": pk_temp_file} - ) - - cert_ret = { - "name": name, - "result": True, - "changes": {}, - "encoding": certificate_managed["encoding"], - } - if reencode_certificate: - cert_ret["contents"] = __salt__["x509.encode_certificate"]( - x509util.to_pem(current), - encoding=certificate_managed["encoding"], - append_certs=certificate_managed["append_certs"], - private_key=pk_args["name"] if pk_args else private_key, - private_key_passphrase=( - pk_args.get("passphrase") if pk_args else private_key - ), - pkcs12_passphrase=certificate_managed.get("pkcs12_passphrase"), - pkcs12_encryption_compat=certificate_managed.get( - "pkcs12_encryption_compat" - ), - pkcs12_friendlyname=certificate_managed.get("pkcs12_friendlyname"), - raw=False, - ) - cert_ret["comment"] = "The certificate has been reencoded" - cert_ret["changes"] = cert_changes - elif new_certificate or cert_changes: - pp = ("re" if current else "") + "created" - cert_ret["contents"] = create_certificate( - **_filter_cert_managed_state_args(cert_args), - ca_server=ca_server, - signing_policy=signing_policy, - csr=csr, - public_key=public_key, - ) - cert_ret["comment"] = f"The certificate has been {pp}" - if not cert_changes: - cert_ret["changes"][pp] = name - else: - cert_ret["changes"] = cert_changes - else: - cert_ret["comment"] = "The certificate is in the correct state" - cert_ret["changes"] = {} - - ret[name + "_crt"] = { - "x509.certificate_managed_ssh": [{k: v} for k, v in cert_ret.items()] - } - ret[name + "_crt"]["x509.certificate_managed_ssh"].append( - {k: v} for k, v in cert_file_args.items() - ) - except (CommandExecutionError, SaltInvocationError) as err: - if pk_temp_file: - if __salt__["file.file_exists"](pk_temp_file): - try: - # otherwise, get rid of it - __salt__["file.remove"](pk_temp_file) - except Exception as err: # pylint: disable=broad-except - log.error(str(err), exc_info_on_loglevel=logging.DEBUG) - ret = { - name - + "_crt": { - "x509.certificate_managed_ssh": [ - {"name": name}, - {"result": False}, - {"comment": str(err)}, - {"changes": {}}, - ] - } - } - if pk_args and "name" in pk_args: - ret[pk_args["name"] + "_key"] = { - "x509.private_key_managed_ssh": [ - {"name": pk_args["name"]}, - {"result": False}, - {"comment": str(err)}, - {"changes": {}}, - ] - } - return ret - - -def _filter_cert_managed_state_args(kwargs): - return {k: v for k, v in kwargs.items() if k != "days_remaining"} - - -def _load_privkey(pk, passphrase, overwrite=False): - public_key = None - create_private_key = False - try: - public_key = __salt__["x509.get_public_key"]( - pk, - passphrase, - ) - except CommandExecutionError as err: - # All errors currently get mangled into this one. - # TODO: Subclass more specific errors to CommandExecutionError - # and reraise them in get_public_key - if "Could not load key as" in str(err): - if not overwrite: - raise CommandExecutionError( - "The private key file could not be loaded. This can either mean " - "the file is encrypted and the provided passphrase is wrong " - "or the file is not a private key at all. Either way, you can " - "pass overwrite: true to force regeneration if the file is managed" - ) - create_private_key = True - else: - raise - return public_key, create_private_key diff --git a/salt/cloud/__init__.py b/salt/cloud/__init__.py index cc81db9ffea8..db657d097fdc 100644 --- a/salt/cloud/__init__.py +++ b/salt/cloud/__init__.py @@ -16,7 +16,6 @@ import salt.client import salt.config import salt.loader -import salt.minion import salt.syspaths import salt.utils.args import salt.utils.cloud diff --git a/salt/cloud/clouds/aliyun.py b/salt/cloud/clouds/aliyun.py new file mode 100644 index 000000000000..f7109ec87547 --- /dev/null +++ b/salt/cloud/clouds/aliyun.py @@ -0,0 +1,1006 @@ +""" +AliYun ECS Cloud Module +======================= + +.. versionadded:: 2014.7.0 + +The Aliyun cloud module is used to control access to the aliyun ECS. +http://www.aliyun.com/ + +Use of this module requires the ``id`` and ``key`` parameter to be set. +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or +``/etc/salt/cloud.providers.d/aliyun.conf``: + +.. code-block:: yaml + + my-aliyun-config: + # aliyun Access Key ID + id: wFGEwgregeqw3435gDger + # aliyun Access Key Secret + key: GDE43t43REGTrkilg43934t34qT43t4dgegerGEgg + location: cn-qingdao + driver: aliyun + +:depends: requests +""" + +import base64 +import hmac +import logging +import pprint +import sys +import time +import urllib.parse +import uuid +from hashlib import sha1 + +import salt.config as config +import salt.utils.cloud +import salt.utils.data +import salt.utils.json +from salt.exceptions import ( + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudNotFound, + SaltCloudSystemExit, +) +from salt.utils.stringutils import to_bytes + +try: + import requests + + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +# Get logging started +log = logging.getLogger(__name__) + +ALIYUN_LOCATIONS = { + # 'us-west-2': 'ec2_us_west_oregon', + "cn-hangzhou": "AliYun HangZhou Region", + "cn-beijing": "AliYun BeiJing Region", + "cn-hongkong": "AliYun HongKong Region", + "cn-qingdao": "AliYun QingDao Region", + "cn-shanghai": "AliYun ShangHai Region", + "cn-shenzhen": "AliYun ShenZheng Region", + "ap-northeast-1": "AliYun DongJing Region", + "ap-southeast-1": "AliYun XinJiaPo Region", + "ap-southeast-2": "AliYun XiNi Region", + "eu-central-1": "EU FalaKeFu Region", + "me-east-1": "ME DiBai Region", + "us-east-1": "US FuJiNiYa Region", + "us-west-1": "US GuiGu Region", +} +DEFAULT_LOCATION = "cn-hangzhou" + +DEFAULT_ALIYUN_API_VERSION = "2014-05-26" + +__virtualname__ = "aliyun" + + +# Only load in this module if the aliyun configurations are in place +def __virtual__(): + """ + Check for aliyun configurations + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("id", "key") + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + return config.check_driver_dependencies(__virtualname__, {"requests": HAS_REQUESTS}) + + +def avail_locations(call=None): + """ + Return a dict of all available VM locations on the cloud provider with + relevant data + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + params = {"Action": "DescribeRegions"} + items = query(params=params) + + ret = {} + for region in items["Regions"]["Region"]: + ret[region["RegionId"]] = {} + for item in region: + ret[region["RegionId"]][item] = str(region[item]) + + return ret + + +def avail_images(kwargs=None, call=None): + """ + Return a list of the images that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + if not isinstance(kwargs, dict): + kwargs = {} + + provider = get_configured_provider() + location = provider.get("location", DEFAULT_LOCATION) + + if "location" in kwargs: + location = kwargs["location"] + + params = { + "Action": "DescribeImages", + "RegionId": location, + "PageSize": "100", + } + items = query(params=params) + + ret = {} + for image in items["Images"]["Image"]: + ret[image["ImageId"]] = {} + for item in image: + ret[image["ImageId"]][item] = str(image[item]) + + return ret + + +def avail_sizes(call=None): + """ + Return a list of the image sizes that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + + params = {"Action": "DescribeInstanceTypes"} + items = query(params=params) + + ret = {} + for image in items["InstanceTypes"]["InstanceType"]: + ret[image["InstanceTypeId"]] = {} + for item in image: + ret[image["InstanceTypeId"]][item] = str(image[item]) + + return ret + + +def get_location(vm_=None): + """ + Return the aliyun region to use, in this order: + - CLI parameter + - VM parameter + - Cloud profile setting + """ + return __opts__.get( + "location", + config.get_cloud_config_value( + "location", + vm_ or get_configured_provider(), + __opts__, + default=DEFAULT_LOCATION, + search_global=False, + ), + ) + + +def list_availability_zones(call=None): + """ + List all availability zones in the current region + """ + ret = {} + + params = {"Action": "DescribeZones", "RegionId": get_location()} + items = query(params) + + for zone in items["Zones"]["Zone"]: + ret[zone["ZoneId"]] = {} + for item in zone: + ret[zone["ZoneId"]][item] = str(zone[item]) + + return ret + + +def list_nodes_min(call=None): + """ + Return a list of the VMs that are on the provider. Only a list of VM names, + and their state, is returned. This is the minimum amount of information + needed to check for existing VMs. + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_min function must be called with -f or --function." + ) + + ret = {} + location = get_location() + params = { + "Action": "DescribeInstanceStatus", + "RegionId": location, + } + nodes = query(params) + + log.debug("Total %s instance found in Region %s", nodes["TotalCount"], location) + if "Code" in nodes or nodes["TotalCount"] == 0: + return ret + + for node in nodes["InstanceStatuses"]["InstanceStatus"]: + ret[node["InstanceId"]] = {} + for item in node: + ret[node["InstanceId"]][item] = node[item] + + return ret + + +def list_nodes(call=None): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + nodes = list_nodes_full() + ret = {} + for instanceId in nodes: + node = nodes[instanceId] + ret[node["name"]] = { + "id": node["id"], + "name": node["name"], + "public_ips": node["public_ips"], + "private_ips": node["private_ips"], + "size": node["size"], + "state": str(node["state"]), + } + return ret + + +def list_nodes_full(call=None): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + ret = {} + location = get_location() + params = { + "Action": "DescribeInstanceStatus", + "RegionId": location, + "PageSize": "50", + } + result = query(params=params) + + log.debug("Total %s instance found in Region %s", result["TotalCount"], location) + if "Code" in result or result["TotalCount"] == 0: + return ret + + # aliyun max 100 top instance in api + result_instancestatus = result["InstanceStatuses"]["InstanceStatus"] + if result["TotalCount"] > 50: + params["PageNumber"] = "2" + result = query(params=params) + result_instancestatus.update(result["InstanceStatuses"]["InstanceStatus"]) + + for node in result_instancestatus: + + instanceId = node.get("InstanceId", "") + + params = {"Action": "DescribeInstanceAttribute", "InstanceId": instanceId} + items = query(params=params) + if "Code" in items: + log.warning("Query instance:%s attribute failed", instanceId) + continue + + name = items["InstanceName"] + ret[name] = { + "id": items["InstanceId"], + "name": name, + "image": items["ImageId"], + "size": "TODO", + "state": items["Status"], + } + for item in items: + value = items[item] + if value is not None: + value = str(value) + if item == "PublicIpAddress": + ret[name]["public_ips"] = items[item]["IpAddress"] + if item == "InnerIpAddress" and "private_ips" not in ret[name]: + ret[name]["private_ips"] = items[item]["IpAddress"] + if item == "VpcAttributes": + vpc_ips = items[item]["PrivateIpAddress"]["IpAddress"] + if vpc_ips: + ret[name]["private_ips"] = vpc_ips + ret[name][item] = value + + provider = _get_active_provider_name() or "aliyun" + if ":" in provider: + comps = provider.split(":") + provider = comps[0] + + __opts__["update_cachedir"] = True + __utils__["cloud.cache_node_list"](ret, provider, __opts__) + + return ret + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full("function"), + __opts__["query.selection"], + call, + ) + + +def list_securitygroup(call=None): + """ + Return a list of security group + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + params = { + "Action": "DescribeSecurityGroups", + "RegionId": get_location(), + "PageSize": "50", + } + + result = query(params) + if "Code" in result: + return {} + + ret = {} + for sg in result["SecurityGroups"]["SecurityGroup"]: + ret[sg["SecurityGroupId"]] = {} + for item in sg: + ret[sg["SecurityGroupId"]][item] = sg[item] + + return ret + + +def get_image(vm_): + """ + Return the image object to use + """ + images = avail_images() + vm_image = str( + config.get_cloud_config_value("image", vm_, __opts__, search_global=False) + ) + + if not vm_image: + raise SaltCloudNotFound("No image specified for this VM.") + + if vm_image and str(vm_image) in images: + return images[vm_image]["ImageId"] + raise SaltCloudNotFound(f"The specified image, '{vm_image}', could not be found.") + + +def get_securitygroup(vm_): + """ + Return the security group + """ + sgs = list_securitygroup() + securitygroup = config.get_cloud_config_value( + "securitygroup", vm_, __opts__, search_global=False + ) + + if not securitygroup: + raise SaltCloudNotFound("No securitygroup ID specified for this VM.") + + if securitygroup and str(securitygroup) in sgs: + return sgs[securitygroup]["SecurityGroupId"] + raise SaltCloudNotFound( + f"The specified security group, '{securitygroup}', could not be found." + ) + + +def get_size(vm_): + """ + Return the VM's size. Used by create_node(). + """ + sizes = avail_sizes() + vm_size = str( + config.get_cloud_config_value("size", vm_, __opts__, search_global=False) + ) + + if not vm_size: + raise SaltCloudNotFound("No size specified for this VM.") + + if vm_size and str(vm_size) in sizes: + return sizes[vm_size]["InstanceTypeId"] + + raise SaltCloudNotFound(f"The specified size, '{vm_size}', could not be found.") + + +def __get_location(vm_): + """ + Return the VM's location + """ + locations = avail_locations() + vm_location = str( + config.get_cloud_config_value("location", vm_, __opts__, search_global=False) + ) + + if not vm_location: + raise SaltCloudNotFound("No location specified for this VM.") + + if vm_location and str(vm_location) in locations: + return locations[vm_location]["RegionId"] + raise SaltCloudNotFound( + f"The specified location, '{vm_location}', could not be found." + ) + + +def start(name, call=None): + """ + Start a node + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a start myinstance + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + log.info("Starting node %s", name) + + instanceId = _get_node(name)["InstanceId"] + + params = {"Action": "StartInstance", "InstanceId": instanceId} + result = query(params) + + return result + + +def stop(name, force=False, call=None): + """ + Stop a node + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a stop myinstance + salt-cloud -a stop myinstance force=True + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + log.info("Stopping node %s", name) + + instanceId = _get_node(name)["InstanceId"] + + params = { + "Action": "StopInstance", + "InstanceId": instanceId, + "ForceStop": str(force).lower(), + } + result = query(params) + + return result + + +def reboot(name, call=None): + """ + Reboot a node + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a reboot myinstance + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + log.info("Rebooting node %s", name) + + instance_id = _get_node(name)["InstanceId"] + + params = {"Action": "RebootInstance", "InstanceId": instance_id} + result = query(params) + + return result + + +def create_node(kwargs): + """ + Convenience function to make the rest api call for node creation. + """ + if not isinstance(kwargs, dict): + kwargs = {} + + # Required parameters + params = { + "Action": "CreateInstance", + "InstanceType": kwargs.get("size_id", ""), + "RegionId": kwargs.get("region_id", DEFAULT_LOCATION), + "ImageId": kwargs.get("image_id", ""), + "SecurityGroupId": kwargs.get("securitygroup_id", ""), + "InstanceName": kwargs.get("name", ""), + } + + # Optional parameters' + optional = [ + "InstanceName", + "InternetChargeType", + "InternetMaxBandwidthIn", + "InternetMaxBandwidthOut", + "HostName", + "Password", + "SystemDisk.Category", + "VSwitchId", + # 'DataDisk.n.Size', 'DataDisk.n.Category', 'DataDisk.n.SnapshotId' + ] + + for item in optional: + if item in kwargs: + params.update({item: kwargs[item]}) + + # invoke web call + result = query(params) + return result["InstanceId"] + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "aliyun", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", vm_["name"]) + kwargs = { + "name": vm_["name"], + "size_id": get_size(vm_), + "image_id": get_image(vm_), + "region_id": __get_location(vm_), + "securitygroup_id": get_securitygroup(vm_), + } + if "vswitch_id" in vm_: + kwargs["VSwitchId"] = vm_["vswitch_id"] + if "internet_chargetype" in vm_: + kwargs["InternetChargeType"] = vm_["internet_chargetype"] + if "internet_maxbandwidthin" in vm_: + kwargs["InternetMaxBandwidthIn"] = str(vm_["internet_maxbandwidthin"]) + if "internet_maxbandwidthout" in vm_: + kwargs["InternetMaxBandwidthOut"] = str(vm_["internet_maxbandwidthOut"]) + if "hostname" in vm_: + kwargs["HostName"] = vm_["hostname"] + if "password" in vm_: + kwargs["Password"] = vm_["password"] + if "instance_name" in vm_: + kwargs["InstanceName"] = vm_["instance_name"] + if "systemdisk_category" in vm_: + kwargs["SystemDisk.Category"] = vm_["systemdisk_category"] + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args=__utils__["cloud.filter_event"]("requesting", kwargs, list(kwargs)), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + ret = create_node(kwargs) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on Aliyun ECS\n\n" + "The following exception was thrown when trying to " + "run the initial deployment: %s", + vm_["name"], + str(exc), + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + # repair ip address error and start vm + time.sleep(8) + params = {"Action": "StartInstance", "InstanceId": ret} + query(params) + + def __query_node_data(vm_name): + data = show_instance(vm_name, call="action") + if not data: + # Trigger an error in the wait_for_ip function + return False + if data.get("PublicIpAddress", None) is not None: + return data + + try: + data = salt.utils.cloud.wait_for_ip( + __query_node_data, + update_args=(vm_["name"],), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=10 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + if data["public_ips"]: + ssh_ip = data["public_ips"][0] + elif data["private_ips"]: + ssh_ip = data["private_ips"][0] + else: + log.info("No available ip:cant connect to salt") + return False + log.debug("VM %s is now running", ssh_ip) + vm_["ssh_host"] = ssh_ip + + # The instance is booted and accessible, let's Salt it! + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + ret.update(data) + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def _compute_signature(parameters, access_key_secret): + """ + Generate aliyun request signature + """ + + def percent_encode(line): + if not isinstance(line, str): + return line + + s = line + if sys.stdin.encoding is None: + s = line.decode().encode("utf8") + else: + s = line.decode(sys.stdin.encoding).encode("utf8") + res = urllib.parse.quote(s, "") + res = res.replace("+", "%20") + res = res.replace("*", "%2A") + res = res.replace("%7E", "~") + return res + + sortedParameters = sorted(list(parameters.items()), key=lambda items: items[0]) + + canonicalizedQueryString = "" + for k, v in sortedParameters: + canonicalizedQueryString += "&" + percent_encode(k) + "=" + percent_encode(v) + + # All aliyun API only support GET method + stringToSign = "GET&%2F&" + percent_encode(canonicalizedQueryString[1:]) + + h = hmac.new(to_bytes(access_key_secret + "&"), stringToSign, sha1) + signature = base64.encodestring(h.digest()).strip() + return signature + + +def query(params=None): + """ + Make a web call to aliyun ECS REST API + """ + path = "https://ecs-cn-hangzhou.aliyuncs.com" + + access_key_id = config.get_cloud_config_value( + "id", get_configured_provider(), __opts__, search_global=False + ) + access_key_secret = config.get_cloud_config_value( + "key", get_configured_provider(), __opts__, search_global=False + ) + + timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + # public interface parameters + parameters = { + "Format": "JSON", + "Version": DEFAULT_ALIYUN_API_VERSION, + "AccessKeyId": access_key_id, + "SignatureVersion": "1.0", + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": str(uuid.uuid1()), + "TimeStamp": timestamp, + } + + # include action or function parameters + if params: + parameters.update(params) + + # Calculate the string for Signature + signature = _compute_signature(parameters, access_key_secret) + parameters["Signature"] = signature + + request = requests.get(path, params=parameters, verify=True, timeout=120) + if request.status_code != 200: + raise SaltCloudSystemExit( + "An error occurred while querying aliyun ECS. HTTP Code: {} " + "Error: '{}'".format(request.status_code, request.text) + ) + + log.debug(request.url) + + content = request.text + + result = salt.utils.json.loads(content) + if "Code" in result: + raise SaltCloudSystemExit(pprint.pformat(result.get("Message", {}))) + return result + + +def script(vm_): + """ + Return the script deployment object + """ + deploy_script = salt.utils.cloud.os_script( + config.get_cloud_config_value("script", vm_, __opts__), + vm_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, vm_) + ), + ) + return deploy_script + + +def show_disk(name, call=None): + """ + Show the disk details of the instance + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a show_disk aliyun myinstance + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_disks action must be called with -a or --action." + ) + + ret = {} + params = {"Action": "DescribeInstanceDisks", "InstanceId": name} + items = query(params=params) + + for disk in items["Disks"]["Disk"]: + ret[disk["DiskId"]] = {} + for item in disk: + ret[disk["DiskId"]][item] = str(disk[item]) + + return ret + + +def list_monitor_data(kwargs=None, call=None): + """ + Get monitor data of the instance. If instance name is + missing, will show all the instance monitor data on the region. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f list_monitor_data aliyun + salt-cloud -f list_monitor_data aliyun name=AY14051311071990225bd + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_monitor_data must be called with -f or --function." + ) + + if not isinstance(kwargs, dict): + kwargs = {} + + ret = {} + params = {"Action": "GetMonitorData", "RegionId": get_location()} + if "name" in kwargs: + params["InstanceId"] = kwargs["name"] + + items = query(params=params) + + monitorData = items["MonitorData"] + + for data in monitorData["InstanceMonitorData"]: + ret[data["InstanceId"]] = {} + for item in data: + ret[data["InstanceId"]][item] = str(data[item]) + + return ret + + +def show_instance(name, call=None): + """ + Show the details from aliyun instance + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + return _get_node(name) + + +def _get_node(name): + attempts = 5 + while attempts >= 0: + try: + return list_nodes_full()[name] + except KeyError: + attempts -= 1 + log.debug( + "Failed to get the data for node '%s'. Remaining attempts: %s", + name, + attempts, + ) + # Just a little delay between attempts... + time.sleep(0.5) + raise SaltCloudNotFound(f"The specified instance {name} not found") + + +def show_image(kwargs, call=None): + """ + Show the details from aliyun image + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_images function must be called with -f or --function" + ) + + if not isinstance(kwargs, dict): + kwargs = {} + + location = get_location() + if "location" in kwargs: + location = kwargs["location"] + + params = { + "Action": "DescribeImages", + "RegionId": location, + "ImageId": kwargs["image"], + } + + ret = {} + items = query(params=params) + # DescribeImages so far support input multi-image. And + # if not found certain image, the response will include + # blank image list other than 'not found' error message + if "Code" in items or not items["Images"]["Image"]: + raise SaltCloudNotFound("The specified image could not be found.") + + log.debug("Total %s image found in Region %s", items["TotalCount"], location) + + for image in items["Images"]["Image"]: + ret[image["ImageId"]] = {} + for item in image: + ret[image["ImageId"]][item] = str(image[item]) + + return ret + + +def destroy(name, call=None): + """ + Destroy a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a destroy myinstance + salt-cloud -d myinstance + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + instanceId = _get_node(name)["InstanceId"] + + # have to stop instance before del it + stop_params = {"Action": "StopInstance", "InstanceId": instanceId} + query(stop_params) + + params = {"Action": "DeleteInstance", "InstanceId": instanceId} + + node = query(params) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return node diff --git a/salt/cloud/clouds/clc.py b/salt/cloud/clouds/clc.py new file mode 100644 index 000000000000..b7d8cbd3a4ab --- /dev/null +++ b/salt/cloud/clouds/clc.py @@ -0,0 +1,441 @@ +""" +CenturyLink Cloud Module +======================== + +.. versionadded:: 2018.3.0 + +The CLC cloud module allows you to manage CLC Via the CLC SDK. + +:codeauthor: Stephan Looney + + +Dependencies +============ + +- clc-sdk Python Module +- flask + +CLC SDK +------- + +clc-sdk can be installed via pip: + +.. code-block:: bash + + pip install clc-sdk + +.. note:: + For sdk reference see: https://github.com/CenturyLinkCloud/clc-python-sdk + +Flask +----- + +flask can be installed via pip: + +.. code-block:: bash + + pip install flask + +Configuration +============= + +To use this module: set up the clc-sdk, user, password, key in the +cloud configuration at +``/etc/salt/cloud.providers`` or ``/etc/salt/cloud.providers.d/clc.conf``: + +.. code-block:: yaml + + my-clc-config: + driver: clc + user: 'web-user' + password: 'verybadpass' + token: '' + token_pass:'' + accountalias: 'ACT' +.. note:: + + The ``provider`` parameter in cloud provider configuration was renamed to ``driver``. + This change was made to avoid confusion with the ``provider`` parameter that is + used in cloud profile configuration. Cloud provider configuration now uses ``driver`` + to refer to the salt-cloud driver that provides the underlying functionality to + connect to a cloud provider, while cloud profile configuration continues to use + ``provider`` to refer to the cloud provider configuration that you define. + +""" + +import importlib +import logging +import time + +import salt.config as config +import salt.utils.json +from salt.exceptions import SaltCloudSystemExit + +# Attempt to import clc-sdk lib +try: + # when running this in linode's Ubuntu 16.x version the following line is required + # to get the clc sdk libraries to load + importlib.import_module("clc") + import clc + + HAS_CLC = True +except ImportError: + HAS_CLC = False +# Disable InsecureRequestWarning generated on python > 2.6 +try: + from requests.packages.urllib3 import ( # pylint: disable=no-name-in-module + disable_warnings, + ) + + disable_warnings() +except Exception: # pylint: disable=broad-except + pass + +log = logging.getLogger(__name__) + + +__virtualname__ = "clc" + + +# Only load in this module if the CLC configurations are in place +def __virtual__(): + """ + Check for CLC configuration and if required libs are available. + """ + if get_configured_provider() is False or get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ( + "token", + "token_pass", + "user", + "password", + ), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + deps = { + "clc": HAS_CLC, + } + return config.check_driver_dependencies(__virtualname__, deps) + + +def get_creds(): + user = config.get_cloud_config_value( + "user", get_configured_provider(), __opts__, search_global=False + ) + password = config.get_cloud_config_value( + "password", get_configured_provider(), __opts__, search_global=False + ) + accountalias = config.get_cloud_config_value( + "accountalias", get_configured_provider(), __opts__, search_global=False + ) + token = config.get_cloud_config_value( + "token", get_configured_provider(), __opts__, search_global=False + ) + token_pass = config.get_cloud_config_value( + "token_pass", get_configured_provider(), __opts__, search_global=False + ) + creds = { + "user": user, + "password": password, + "token": token, + "token_pass": token_pass, + "accountalias": accountalias, + } + return creds + + +def list_nodes_full(call=None, for_output=True): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + creds = get_creds() + clc.v1.SetCredentials(creds["token"], creds["token_pass"]) + servers_raw = clc.v1.Server.GetServers(location=None) + servers_raw = salt.utils.json.dumps(servers_raw) + servers = salt.utils.json.loads(servers_raw) + return servers + + +def get_queue_data(call=None, for_output=True): + creds = get_creds() + clc.v1.SetCredentials(creds["token"], creds["token_pass"]) + cl_queue = clc.v1.Queue.List() + return cl_queue + + +def get_monthly_estimate(call=None, for_output=True): + """ + Return a list of the VMs that are on the provider + """ + creds = get_creds() + clc.v1.SetCredentials(creds["token"], creds["token_pass"]) + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + try: + billing_raw = clc.v1.Billing.GetAccountSummary(alias=creds["accountalias"]) + billing_raw = salt.utils.json.dumps(billing_raw) + billing = salt.utils.json.loads(billing_raw) + billing = round(billing["MonthlyEstimate"], 2) + return {"Monthly Estimate": billing} + except RuntimeError: + return {"Monthly Estimate": 0} + + +def get_month_to_date(call=None, for_output=True): + """ + Return a list of the VMs that are on the provider + """ + creds = get_creds() + clc.v1.SetCredentials(creds["token"], creds["token_pass"]) + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + try: + billing_raw = clc.v1.Billing.GetAccountSummary(alias=creds["accountalias"]) + billing_raw = salt.utils.json.dumps(billing_raw) + billing = salt.utils.json.loads(billing_raw) + billing = round(billing["MonthToDateTotal"], 2) + return {"Month To Date": billing} + except RuntimeError: + return 0 + + +def get_server_alerts(call=None, for_output=True, **kwargs): + """ + Return a list of alerts from CLC as reported by their infra + """ + for key, value in kwargs.items(): + servername = "" + if key == "servername": + servername = value + creds = get_creds() + clc.v2.SetCredentials(creds["user"], creds["password"]) + alerts = clc.v2.Server(servername).Alerts() + return alerts + + +def get_group_estimate(call=None, for_output=True, **kwargs): + """ + Return a list of the VMs that are on the provider + usage: "salt-cloud -f get_group_estimate clc group=Dev location=VA1" + """ + for key, value in kwargs.items(): + group = "" + location = "" + if key == "group": + group = value + if key == "location": + location = value + creds = get_creds() + clc.v1.SetCredentials(creds["token"], creds["token_pass"]) + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + try: + billing_raw = clc.v1.Billing.GetGroupEstimate( + group=group, alias=creds["accountalias"], location=location + ) + billing_raw = salt.utils.json.dumps(billing_raw) + billing = salt.utils.json.loads(billing_raw) + estimate = round(billing["MonthlyEstimate"], 2) + month_to_date = round(billing["MonthToDate"], 2) + return {"Monthly Estimate": estimate, "Month to Date": month_to_date} + except RuntimeError: + return 0 + + +def avail_images(call=None): + """ + returns a list of images available to you + """ + all_servers = list_nodes_full() + templates = {} + for server in all_servers: + if server["IsTemplate"]: + templates.update({"Template Name": server["Name"]}) + return templates + + +def avail_locations(call=None): + """ + returns a list of locations available to you + """ + creds = get_creds() + clc.v1.SetCredentials(creds["token"], creds["token_pass"]) + locations = clc.v1.Account.GetLocations() + return locations + + +def avail_sizes(call=None): + """ + use templates for this + """ + return {"Sizes": "Sizes are built into templates. Choose appropriate template"} + + +def get_build_status(req_id, nodename): + """ + get the build status from CLC to make sure we don't return to early + """ + counter = 0 + req_id = str(req_id) + while counter < 10: + queue = clc.v1.Blueprint.GetStatus(request_id=req_id) + if queue["PercentComplete"] == 100: + server_name = queue["Servers"][0] + creds = get_creds() + clc.v2.SetCredentials(creds["user"], creds["password"]) + ip_addresses = clc.v2.Server(server_name).ip_addresses + internal_ip_address = ip_addresses[0]["internal"] + return internal_ip_address + else: + counter = counter + 1 + log.info( + "Creating Cloud VM %s Time out in %s minutes", + nodename, + str(10 - counter), + ) + time.sleep(60) + + +def create(vm_): + """ + get the system build going + """ + creds = get_creds() + clc.v1.SetCredentials(creds["token"], creds["token_pass"]) + cloud_profile = config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("token",) + ) + group = config.get_cloud_config_value( + "group", + vm_, + __opts__, + search_global=False, + default=None, + ) + name = vm_["name"] + description = config.get_cloud_config_value( + "description", + vm_, + __opts__, + search_global=False, + default=None, + ) + ram = config.get_cloud_config_value( + "ram", + vm_, + __opts__, + search_global=False, + default=None, + ) + backup_level = config.get_cloud_config_value( + "backup_level", + vm_, + __opts__, + search_global=False, + default=None, + ) + template = config.get_cloud_config_value( + "template", + vm_, + __opts__, + search_global=False, + default=None, + ) + password = config.get_cloud_config_value( + "password", + vm_, + __opts__, + search_global=False, + default=None, + ) + cpu = config.get_cloud_config_value( + "cpu", + vm_, + __opts__, + search_global=False, + default=None, + ) + network = config.get_cloud_config_value( + "network", + vm_, + __opts__, + search_global=False, + default=None, + ) + location = config.get_cloud_config_value( + "location", + vm_, + __opts__, + search_global=False, + default=None, + ) + if len(name) > 6: + name = name[0:6] + if len(password) < 9: + password = "" + clc_return = clc.v1.Server.Create( + alias=None, + location=(location), + name=(name), + template=(template), + cpu=(cpu), + ram=(ram), + backup_level=(backup_level), + group=(group), + network=(network), + description=(description), + password=(password), + ) + req_id = clc_return["RequestID"] + vm_["ssh_host"] = get_build_status(req_id, name) + __utils__["cloud.fire_event"]( + "event", + "waiting for ssh", + f"salt/cloud/{name}/waiting_for_ssh", + sock_dir=__opts__["sock_dir"], + args={"ip_address": vm_["ssh_host"]}, + transport=__opts__["transport"], + ) + + # Bootstrap! + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + return_message = {"Server Name": name, "IP Address": vm_["ssh_host"]} + ret.update(return_message) + return return_message + + +def destroy(name, call=None): + """ + destroy the vm + """ + return {"status": "destroying must be done via https://control.ctl.io at this time"} diff --git a/salt/cloud/clouds/cloudstack.py b/salt/cloud/clouds/cloudstack.py new file mode 100644 index 000000000000..ff526aa82e0f --- /dev/null +++ b/salt/cloud/clouds/cloudstack.py @@ -0,0 +1,580 @@ +""" +CloudStack Cloud Module +======================= + +The CloudStack cloud module is used to control access to a CloudStack based +Public Cloud. + +:depends: libcloud >= 0.15 + +Use of this module requires the ``apikey``, ``secretkey``, ``host`` and +``path`` parameters. + +.. code-block:: yaml + + my-cloudstack-cloud-config: + apikey: + secretkey: + host: localhost + path: /client/api + driver: cloudstack + +""" + +# pylint: disable=function-redefined + +import logging +import pprint + +import salt.config as config +import salt.utils.cloud +import salt.utils.event +from salt.cloud.libcloudfuncs import * # pylint: disable=redefined-builtin,wildcard-import,unused-wildcard-import +from salt.exceptions import SaltCloudSystemExit +from salt.utils.functools import namespaced_function +from salt.utils.versions import Version + +# CloudStackNetwork will be needed during creation of a new node +# pylint: disable=import-error +try: + from libcloud.compute.drivers.cloudstack import CloudStackNetwork + + # This work-around for Issue #32743 is no longer needed for libcloud >= + # 1.4.0. However, older versions of libcloud must still be supported with + # this work-around. This work-around can be removed when the required + # minimum version of libcloud is 2.0.0 (See PR #40837 - which is + # implemented in Salt 2018.3.0). + if Version(libcloud.__version__) < Version("1.4.0"): + # See https://github.com/saltstack/salt/issues/32743 + import libcloud.security + + libcloud.security.CA_CERTS_PATH.append("/etc/ssl/certs/YaST-CA.pem") + HAS_LIBS = True +except ImportError: + HAS_LIBS = False + +# Get logging started +log = logging.getLogger(__name__) + +# Redirect CloudStack functions to this module namespace +get_node = namespaced_function(get_node, globals()) +get_size = namespaced_function(get_size, globals()) +get_image = namespaced_function(get_image, globals()) +avail_locations = namespaced_function(avail_locations, globals()) +avail_images = namespaced_function(avail_images, globals()) +avail_sizes = namespaced_function(avail_sizes, globals()) +script = namespaced_function(script, globals()) +list_nodes = namespaced_function(list_nodes, globals()) +list_nodes_full = namespaced_function(list_nodes_full, globals()) +list_nodes_select = namespaced_function(list_nodes_select, globals()) +show_instance = namespaced_function(show_instance, globals()) + +__virtualname__ = "cloudstack" + + +# Only load in this module if the CLOUDSTACK configurations are in place +def __virtual__(): + """ + Set up the libcloud functions and check for CloudStack configurations. + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ("apikey", "secretkey", "host", "path"), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + return config.check_driver_dependencies(__virtualname__, {"libcloud": HAS_LIBS}) + + +def get_conn(): + """ + Return a conn object for the passed VM data + """ + driver = get_driver(Provider.CLOUDSTACK) + + verify_ssl_cert = config.get_cloud_config_value( + "verify_ssl_cert", + get_configured_provider(), + __opts__, + default=True, + search_global=False, + ) + + if verify_ssl_cert is False: + try: + import libcloud.security + + libcloud.security.VERIFY_SSL_CERT = False + except (ImportError, AttributeError): + raise SaltCloudSystemExit( + "Could not disable SSL certificate verification. Not loading module." + ) + + return driver( + key=config.get_cloud_config_value( + "apikey", get_configured_provider(), __opts__, search_global=False + ), + secret=config.get_cloud_config_value( + "secretkey", get_configured_provider(), __opts__, search_global=False + ), + secure=config.get_cloud_config_value( + "secure", + get_configured_provider(), + __opts__, + default=True, + search_global=False, + ), + host=config.get_cloud_config_value( + "host", get_configured_provider(), __opts__, search_global=False + ), + path=config.get_cloud_config_value( + "path", get_configured_provider(), __opts__, search_global=False + ), + port=config.get_cloud_config_value( + "port", + get_configured_provider(), + __opts__, + default=None, + search_global=False, + ), + ) + + +def get_location(conn, vm_): + """ + Return the node location to use + """ + locations = conn.list_locations() + # Default to Dallas if not otherwise set + loc = config.get_cloud_config_value("location", vm_, __opts__, default=2) + for location in locations: + if str(loc) in (str(location.id), str(location.name)): + return location + + +def get_security_groups(conn, vm_): + """ + Return a list of security groups to use, defaulting to ['default'] + """ + securitygroup_enabled = config.get_cloud_config_value( + "securitygroup_enabled", vm_, __opts__, default=True + ) + if securitygroup_enabled: + return config.get_cloud_config_value( + "securitygroup", vm_, __opts__, default=["default"] + ) + else: + return False + + +def get_password(vm_): + """ + Return the password to use + """ + return config.get_cloud_config_value( + "password", + vm_, + __opts__, + default=config.get_cloud_config_value( + "passwd", vm_, __opts__, search_global=False + ), + search_global=False, + ) + + +def get_key(): + """ + Returns the ssh private key for VM access + """ + return config.get_cloud_config_value( + "private_key", get_configured_provider(), __opts__, search_global=False + ) + + +def get_keypair(vm_): + """ + Return the keypair to use + """ + keypair = config.get_cloud_config_value("keypair", vm_, __opts__) + + if keypair: + return keypair + else: + return False + + +def get_ip(data): + """ + Return the IP address of the VM + If the VM has public IP as defined by libcloud module then use it + Otherwise try to extract the private IP and use that one. + """ + try: + ip = data.public_ips[0] + except Exception: # pylint: disable=broad-except + ip = data.private_ips[0] + return ip + + +def get_networkid(vm_): + """ + Return the networkid to use, only valid for Advanced Zone + """ + networkid = config.get_cloud_config_value("networkid", vm_, __opts__) + + if networkid is not None: + return networkid + else: + return False + + +def get_project(conn, vm_): + """ + Return the project to use. + """ + try: + projects = conn.ex_list_projects() + except AttributeError: + # with versions <0.15 of libcloud this is causing an AttributeError. + log.warning( + "Cannot get projects, you may need to update libcloud to 0.15 or later" + ) + return False + projid = config.get_cloud_config_value("projectid", vm_, __opts__) + + if not projid: + return False + + for project in projects: + if str(projid) in (str(project.id), str(project.name)): + return project + + log.warning("Couldn't find project %s in projects", projid) + return False + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "cloudstack", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + sock_dir=__opts__["sock_dir"], + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", vm_["name"]) + conn = get_conn() + # pylint: disable=not-callable + kwargs = { + "name": vm_["name"], + "image": get_image(conn, vm_), + "size": get_size(conn, vm_), + "location": get_location(conn, vm_), + } + # pylint: enable=not-callable + + sg = get_security_groups(conn, vm_) + if sg is not False: + kwargs["ex_security_groups"] = sg + + if get_keypair(vm_) is not False: + kwargs["ex_keyname"] = get_keypair(vm_) + + if get_networkid(vm_) is not False: + kwargs["networkids"] = get_networkid(vm_) + kwargs["networks"] = ( # The only attr that is used is 'id'. + CloudStackNetwork(None, None, None, kwargs["networkids"], None, None), + ) + + if get_project(conn, vm_) is not False: + kwargs["project"] = get_project(conn, vm_) + + event_data = kwargs.copy() + event_data["image"] = kwargs["image"].name + event_data["size"] = kwargs["size"].name + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + sock_dir=__opts__["sock_dir"], + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", + event_data, + ["name", "profile", "provider", "driver", "image", "size"], + ), + }, + transport=__opts__["transport"], + ) + + displayname = cloudstack_displayname(vm_) + if displayname: + kwargs["ex_displayname"] = displayname + else: + kwargs["ex_displayname"] = kwargs["name"] + + volumes = {} + ex_blockdevicemappings = block_device_mappings(vm_) + if ex_blockdevicemappings: + for ex_blockdevicemapping in ex_blockdevicemappings: + if "VirtualName" not in ex_blockdevicemapping: + ex_blockdevicemapping["VirtualName"] = "{}-{}".format( + vm_["name"], len(volumes) + ) + __utils__["cloud.fire_event"]( + "event", + "requesting volume", + "salt/cloud/{}/requesting".format(ex_blockdevicemapping["VirtualName"]), + sock_dir=__opts__["sock_dir"], + args={ + "kwargs": { + "name": ex_blockdevicemapping["VirtualName"], + "device": ex_blockdevicemapping["DeviceName"], + "size": ex_blockdevicemapping["VolumeSize"], + } + }, + ) + try: + volumes[ex_blockdevicemapping["DeviceName"]] = conn.create_volume( + ex_blockdevicemapping["VolumeSize"], + ex_blockdevicemapping["VirtualName"], + ) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating volume %s on CLOUDSTACK\n\n" + "The following exception was thrown by libcloud when trying to " + "requesting a volume: \n%s", + ex_blockdevicemapping["VirtualName"], + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + else: + ex_blockdevicemapping = {} + try: + data = conn.create_node(**kwargs) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on CLOUDSTACK\n\n" + "The following exception was thrown by libcloud when trying to " + "run the initial deployment: \n%s", + vm_["name"], + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + for device_name in volumes: + try: + conn.attach_volume(data, volumes[device_name], device_name) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error attaching volume %s on CLOUDSTACK\n\n" + "The following exception was thrown by libcloud when trying to " + "attach a volume: \n%s", + ex_blockdevicemapping.get("VirtualName", "UNKNOWN"), + exc, + # Show the traceback if the debug logging level is enabled + exc_info=log.isEnabledFor(logging.DEBUG), + ) + return False + + ssh_username = config.get_cloud_config_value( + "ssh_username", vm_, __opts__, default="root" + ) + + vm_["ssh_host"] = get_ip(data) + vm_["password"] = data.extra["password"] + vm_["key_filename"] = get_key() + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + ret.update(data.__dict__) + + if "password" in data.extra: + del data.extra["password"] + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug( + "'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data.__dict__) + ) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + sock_dir=__opts__["sock_dir"], + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + transport=__opts__["transport"], + ) + + return ret + + +def destroy(name, conn=None, call=None): + """ + Delete a single VM, and all of its volumes + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + sock_dir=__opts__["sock_dir"], + args={"name": name}, + ) + + if not conn: + conn = get_conn() # pylint: disable=E0602 + + node = get_node(conn, name) # pylint: disable=not-callable + if node is None: + log.error("Unable to find the VM %s", name) + volumes = conn.list_volumes(node) + if volumes is None: + log.error("Unable to find volumes of the VM %s", name) + # TODO add an option like 'delete_sshkeys' below + for volume in volumes: + if volume.extra["volume_type"] != "DATADISK": + log.info( + "Ignoring volume type %s: %s", volume.extra["volume_type"], volume.name + ) + continue + log.info("Detaching volume: %s", volume.name) + __utils__["cloud.fire_event"]( + "event", + "detaching volume", + f"salt/cloud/{volume.name}/detaching", + sock_dir=__opts__["sock_dir"], + args={"name": volume.name}, + ) + if not conn.detach_volume(volume): + log.error("Failed to Detach volume: %s", volume.name) + return False + log.info("Detached volume: %s", volume.name) + __utils__["cloud.fire_event"]( + "event", + "detached volume", + f"salt/cloud/{volume.name}/detached", + sock_dir=__opts__["sock_dir"], + args={"name": volume.name}, + ) + + log.info("Destroying volume: %s", volume.name) + __utils__["cloud.fire_event"]( + "event", + "destroying volume", + f"salt/cloud/{volume.name}/destroying", + sock_dir=__opts__["sock_dir"], + args={"name": volume.name}, + ) + if not conn.destroy_volume(volume): + log.error("Failed to Destroy volume: %s", volume.name) + return False + log.info("Destroyed volume: %s", volume.name) + __utils__["cloud.fire_event"]( + "event", + "destroyed volume", + f"salt/cloud/{volume.name}/destroyed", + sock_dir=__opts__["sock_dir"], + args={"name": volume.name}, + ) + log.info("Destroying VM: %s", name) + ret = conn.destroy_node(node) + if not ret: + log.error("Failed to Destroy VM: %s", name) + return False + log.info("Destroyed VM: %s", name) + # Fire destroy action + event = salt.utils.event.SaltEvent("master", __opts__["sock_dir"]) + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + sock_dir=__opts__["sock_dir"], + args={"name": name}, + ) + if __opts__["delete_sshkeys"] is True: + salt.utils.cloud.remove_sshkey(node.public_ips[0]) + return True + + +def block_device_mappings(vm_): + """ + Return the block device mapping: + + :: + + [{'DeviceName': '/dev/sdb', 'VirtualName': 'ephemeral0'}, + {'DeviceName': '/dev/sdc', 'VirtualName': 'ephemeral1'}] + """ + return config.get_cloud_config_value( + "block_device_mappings", vm_, __opts__, search_global=True + ) + + +def cloudstack_displayname(vm_): + """ + Return display name of VM: + + :: + "minion1" + """ + return config.get_cloud_config_value( + "cloudstack_displayname", vm_, __opts__, search_global=True + ) diff --git a/salt/cloud/clouds/digitalocean.py b/salt/cloud/clouds/digitalocean.py new file mode 100644 index 000000000000..5ef14b67d12c --- /dev/null +++ b/salt/cloud/clouds/digitalocean.py @@ -0,0 +1,1513 @@ +""" +DigitalOcean Cloud Module +========================= + +The DigitalOcean cloud module is used to control access to the DigitalOcean VPS system. + +Use of this module requires a requires a ``personal_access_token``, an ``ssh_key_file``, +and at least one SSH key name in ``ssh_key_names``. More ``ssh_key_names`` can be added +by separating each key with a comma. The ``personal_access_token`` can be found in the +DigitalOcean web interface in the "Apps & API" section. The SSH key name can be found +under the "SSH Keys" section. + +.. code-block:: yaml + + # Note: This example is for /etc/salt/cloud.providers or any file in the + # /etc/salt/cloud.providers.d/ directory. + + my-digital-ocean-config: + personal_access_token: xxx + ssh_key_file: /path/to/ssh/key/file + ssh_key_names: my-key-name,my-key-name-2 + driver: digitalocean + +:depends: requests +""" + +import decimal +import logging +import os +import pprint +import time + +import salt.config as config +import salt.utils.cloud +import salt.utils.files +import salt.utils.json +import salt.utils.stringutils +from salt.exceptions import ( + SaltCloudConfigError, + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudNotFound, + SaltCloudSystemExit, + SaltInvocationError, +) + +try: + import requests + + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "digitalocean" +__virtual_aliases__ = ("digital_ocean", "do") + + +# Only load in this module if the DIGITALOCEAN configurations are in place +def __virtual__(): + """ + Check for DigitalOcean configurations + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + opts=__opts__, + provider=_get_active_provider_name() or __virtualname__, + aliases=__virtual_aliases__, + required_keys=("personal_access_token",), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + return config.check_driver_dependencies(__virtualname__, {"requests": HAS_REQUESTS}) + + +def avail_locations(call=None): + """ + Return a dict of all available VM locations on the cloud provider with + relevant data + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + items = query(method="regions") + ret = {} + for region in items["regions"]: + ret[region["name"]] = {} + for item in region.keys(): + ret[region["name"]][item] = str(region[item]) + + return ret + + +def avail_images(call=None): + """ + Return a list of the images that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + fetch = True + page = 1 + ret = {} + + while fetch: + items = query(method="images", command="?page=" + str(page) + "&per_page=200") + + for image in items["images"]: + ret[image["name"]] = {} + for item in image.keys(): + ret[image["name"]][item] = image[item] + + page += 1 + try: + fetch = "next" in items["links"]["pages"] + except KeyError: + fetch = False + + return ret + + +def avail_sizes(call=None): + """ + Return a list of the image sizes that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + + items = query(method="sizes", command="?per_page=100") + ret = {} + for size in items["sizes"]: + ret[size["slug"]] = {} + for item in size.keys(): + ret[size["slug"]][item] = str(size[item]) + + return ret + + +def list_nodes(call=None): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + return _list_nodes() + + +def list_nodes_full(call=None, for_output=True): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + return _list_nodes(full=True, for_output=for_output) + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full("function"), + __opts__["query.selection"], + call, + ) + + +def get_image(vm_): + """ + Return the image object to use + """ + images = avail_images() + vm_image = config.get_cloud_config_value( + "image", vm_, __opts__, search_global=False + ) + if not isinstance(vm_image, str): + vm_image = str(vm_image) + + for image in images: + if vm_image in ( + images[image]["name"], + images[image]["slug"], + images[image]["id"], + ): + if images[image]["slug"] is not None: + return images[image]["slug"] + return int(images[image]["id"]) + raise SaltCloudNotFound(f"The specified image, '{vm_image}', could not be found.") + + +def get_size(vm_): + """ + Return the VM's size. Used by create_node(). + """ + sizes = avail_sizes() + vm_size = str( + config.get_cloud_config_value("size", vm_, __opts__, search_global=False) + ) + for size in sizes: + if vm_size.lower() == sizes[size]["slug"]: + return sizes[size]["slug"] + raise SaltCloudNotFound(f"The specified size, '{vm_size}', could not be found.") + + +def get_location(vm_): + """ + Return the VM's location + """ + locations = avail_locations() + vm_location = str( + config.get_cloud_config_value("location", vm_, __opts__, search_global=False) + ) + + for location in locations: + if vm_location in (locations[location]["name"], locations[location]["slug"]): + return locations[location]["slug"] + raise SaltCloudNotFound( + f"The specified location, '{vm_location}', could not be found." + ) + + +def create_node(args): + """ + Create a node + """ + node = query(method="droplets", args=args, http_method="post") + return node + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "digitalocean", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", vm_["name"]) + + kwargs = { + "name": vm_["name"], + "size": get_size(vm_), + "image": get_image(vm_), + "region": get_location(vm_), + "ssh_keys": [], + "tags": [], + } + + # backwards compat + ssh_key_name = config.get_cloud_config_value( + "ssh_key_name", vm_, __opts__, search_global=False + ) + + if ssh_key_name: + kwargs["ssh_keys"].append(get_keyid(ssh_key_name)) + + ssh_key_names = config.get_cloud_config_value( + "ssh_key_names", vm_, __opts__, search_global=False, default=False + ) + + if ssh_key_names: + for key in ssh_key_names.split(","): + kwargs["ssh_keys"].append(get_keyid(key)) + + key_filename = config.get_cloud_config_value( + "ssh_key_file", vm_, __opts__, search_global=False, default=None + ) + + if key_filename is not None and not os.path.isfile(key_filename): + raise SaltCloudConfigError( + f"The defined key_filename '{key_filename}' does not exist" + ) + + if not __opts__.get("ssh_agent", False) and key_filename is None: + raise SaltCloudConfigError( + "The DigitalOcean driver requires an ssh_key_file and an ssh_key_name " + "because it does not supply a root password upon building the server." + ) + + ssh_interface = config.get_cloud_config_value( + "ssh_interface", vm_, __opts__, search_global=False, default="public" + ) + + if ssh_interface in ["private", "public"]: + log.info("ssh_interface: Setting interface for ssh to %s", ssh_interface) + kwargs["ssh_interface"] = ssh_interface + else: + raise SaltCloudConfigError( + "The DigitalOcean driver requires ssh_interface to be defined as 'public'" + " or 'private'." + ) + + vpc_name = config.get_cloud_config_value( + "vpc_name", + vm_, + __opts__, + search_global=False, + default=None, + ) + + if vpc_name is not None: + vpc = _get_vpc_by_name(vpc_name) + if vpc is None: + raise SaltCloudConfigError("Invalid VPC name provided") + else: + kwargs["vpc_uuid"] = vpc[vpc_name]["id"] + else: + private_networking = config.get_cloud_config_value( + "private_networking", + vm_, + __opts__, + search_global=False, + default=None, + ) + if private_networking is not None: + if not isinstance(private_networking, bool): + raise SaltCloudConfigError( + "'private_networking' should be a boolean value." + ) + kwargs["private_networking"] = private_networking + + if not private_networking and ssh_interface == "private": + raise SaltCloudConfigError( + "The DigitalOcean driver requires ssh_interface if defined as 'private' " + "then private_networking should be set as 'True'." + ) + backups_enabled = config.get_cloud_config_value( + "backups_enabled", + vm_, + __opts__, + search_global=False, + default=None, + ) + + if backups_enabled is not None: + if not isinstance(backups_enabled, bool): + raise SaltCloudConfigError("'backups_enabled' should be a boolean value.") + kwargs["backups"] = backups_enabled + + ipv6 = config.get_cloud_config_value( + "ipv6", + vm_, + __opts__, + search_global=False, + default=None, + ) + + if ipv6 is not None: + if not isinstance(ipv6, bool): + raise SaltCloudConfigError("'ipv6' should be a boolean value.") + kwargs["ipv6"] = ipv6 + + monitoring = config.get_cloud_config_value( + "monitoring", + vm_, + __opts__, + search_global=False, + default=None, + ) + + if monitoring is not None: + if not isinstance(monitoring, bool): + raise SaltCloudConfigError("'monitoring' should be a boolean value.") + kwargs["monitoring"] = monitoring + + kwargs["tags"] = config.get_cloud_config_value( + "tags", vm_, __opts__, search_global=False, default=False + ) + + userdata_file = config.get_cloud_config_value( + "userdata_file", vm_, __opts__, search_global=False, default=None + ) + if userdata_file is not None: + try: + with salt.utils.files.fopen(userdata_file, "r") as fp_: + kwargs["user_data"] = salt.utils.cloud.userdata_template( + __opts__, vm_, salt.utils.stringutils.to_unicode(fp_.read()) + ) + except Exception as exc: # pylint: disable=broad-except + log.exception("Failed to read userdata from %s: %s", userdata_file, exc) + + create_dns_record = config.get_cloud_config_value( + "create_dns_record", + vm_, + __opts__, + search_global=False, + default=None, + ) + + if create_dns_record: + log.info("create_dns_record: will attempt to write DNS records") + default_dns_domain = None + dns_domain_name = vm_["name"].split(".") + if len(dns_domain_name) > 2: + log.debug( + "create_dns_record: inferring default dns_hostname, dns_domain from" + " minion name as FQDN" + ) + default_dns_hostname = ".".join(dns_domain_name[:-2]) + default_dns_domain = ".".join(dns_domain_name[-2:]) + else: + log.debug("create_dns_record: can't infer dns_domain from %s", vm_["name"]) + default_dns_hostname = dns_domain_name[0] + + dns_hostname = config.get_cloud_config_value( + "dns_hostname", + vm_, + __opts__, + search_global=False, + default=default_dns_hostname, + ) + dns_domain = config.get_cloud_config_value( + "dns_domain", + vm_, + __opts__, + search_global=False, + default=default_dns_domain, + ) + if dns_hostname and dns_domain: + log.info( + 'create_dns_record: using dns_hostname="%s", dns_domain="%s"', + dns_hostname, + dns_domain, + ) + + def __add_dns_addr__(t, d): + return post_dns_record( + dns_domain=dns_domain, + name=dns_hostname, + record_type=t, + record_data=d, + ) + + log.debug("create_dns_record: %s", __add_dns_addr__) + else: + log.error( + "create_dns_record: could not determine dns_hostname and/or dns_domain" + ) + raise SaltCloudConfigError( + "'create_dns_record' must be a dict specifying \"domain\" " + 'and "hostname" or the minion name must be an FQDN.' + ) + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args=__utils__["cloud.filter_event"]("requesting", kwargs, list(kwargs)), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + ret = create_node(kwargs) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on DIGITALOCEAN\n\n" + "The following exception was thrown when trying to " + "run the initial deployment: %s", + vm_["name"], + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + def __query_node_data(vm_name): + data = show_instance(vm_name, "action") + if not data: + # Trigger an error in the wait_for_ip function + return False + if data["networks"].get("v4"): + for network in data["networks"]["v4"]: + if network["type"] == "public": + return data + return False + + try: + data = salt.utils.cloud.wait_for_ip( + __query_node_data, + update_args=(vm_["name"],), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=10 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + if not vm_.get("ssh_host"): + vm_["ssh_host"] = None + + # add DNS records, set ssh_host, default to first found IP, preferring IPv4 for ssh bootstrap script target + addr_families, dns_arec_types = (("v4", "v6"), ("A", "AAAA")) + arec_map = dict(list(zip(addr_families, dns_arec_types))) + for facing, addr_family, ip_address in [ + (net["type"], family, net["ip_address"]) + for family in addr_families + for net in data["networks"][family] + ]: + log.info('found %s IP%s interface for "%s"', facing, addr_family, ip_address) + dns_rec_type = arec_map[addr_family] + if facing == "public": + if create_dns_record: + __add_dns_addr__(dns_rec_type, ip_address) + if facing == ssh_interface: + if not vm_["ssh_host"]: + vm_["ssh_host"] = ip_address + + if vm_["ssh_host"] is None: + raise SaltCloudSystemExit( + "No suitable IP addresses found for ssh minion bootstrapping: {}".format( + repr(data["networks"]) + ) + ) + + log.debug( + "Found public IP address to use for ssh minion bootstrapping: %s", + vm_["ssh_host"], + ) + + vm_["key_filename"] = key_filename + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + ret.update(data) + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def query( + method="droplets", droplet_id=None, command=None, args=None, http_method="get" +): + """ + Make a web call to DigitalOcean + """ + base_path = str( + config.get_cloud_config_value( + "api_root", + get_configured_provider(), + __opts__, + search_global=False, + default="https://api.digitalocean.com/v2", + ) + ) + # vpcs method doesn't like the / at the end. + if method == "vpcs": + path = f"{base_path}/{method}" + else: + path = f"{base_path}/{method}/" + + if droplet_id: + path += f"{droplet_id}/" + + if command: + path += command + + if not isinstance(args, dict): + args = {} + + personal_access_token = config.get_cloud_config_value( + "personal_access_token", + get_configured_provider(), + __opts__, + search_global=False, + ) + + data = salt.utils.json.dumps(args) + + requester = getattr(requests, http_method) + request = requester( + path, + data=data, + headers={ + "Authorization": "Bearer " + personal_access_token, + "Content-Type": "application/json", + }, + timeout=120, + ) + if request.status_code > 299: + raise SaltCloudSystemExit( + "An error occurred while querying DigitalOcean. HTTP Code: {} " + "Error: '{}'".format( + request.status_code, + # request.read() + request.text, + ) + ) + + log.debug(request.url) + + # success without data + if request.status_code == 204: + return True + + content = request.text + + result = salt.utils.json.loads(content) + if result.get("status", "").lower() == "error": + raise SaltCloudSystemExit(pprint.pformat(result.get("error_message", {}))) + + return result + + +def script(vm_): + """ + Return the script deployment object + """ + deploy_script = salt.utils.cloud.os_script( + config.get_cloud_config_value("script", vm_, __opts__), + vm_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, vm_) + ), + ) + return deploy_script + + +def show_instance(name, call=None): + """ + Show the details from DigitalOcean concerning a droplet + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + node = _get_node(name) + __utils__["cloud.cache_node"](node, _get_active_provider_name(), __opts__) + return node + + +def _get_node(name): + attempts = 10 + while attempts >= 0: + try: + return list_nodes_full(for_output=False)[name] + except KeyError: + attempts -= 1 + log.debug( + "Failed to get the data for node '%s'. Remaining attempts: %s", + name, + attempts, + ) + # Just a little delay between attempts... + time.sleep(0.5) + return {} + + +def list_keypairs(call=None): + """ + Return a dict of all available VM locations on the cloud provider with + relevant data + """ + if call != "function": + log.error("The list_keypairs function must be called with -f or --function.") + return False + + fetch = True + page = 1 + ret = {} + + while fetch: + items = query( + method="account/keys", + command="?page=" + str(page) + "&per_page=100", + ) + + for key_pair in items["ssh_keys"]: + name = key_pair["name"] + if name in ret: + raise SaltCloudSystemExit( + "A duplicate key pair name, '{}', was found in DigitalOcean's " + "key pair list. Please change the key name stored by DigitalOcean. " + "Be sure to adjust the value of 'ssh_key_file' in your cloud " + "profile or provider configuration, if necessary.".format(name) + ) + ret[name] = {} + for item in key_pair.keys(): + ret[name][item] = str(key_pair[item]) + + page += 1 + try: + fetch = "next" in items["links"]["pages"] + except KeyError: + fetch = False + + return ret + + +def show_keypair(kwargs=None, call=None): + """ + Show the details of an SSH keypair + """ + if call != "function": + log.error("The show_keypair function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + if "keyname" not in kwargs: + log.error("A keyname is required.") + return False + + keypairs = list_keypairs(call="function") + keyid = keypairs[kwargs["keyname"]]["id"] + log.debug("Key ID is %s", keyid) + + details = query(method="account/keys", command=keyid) + + return details + + +def import_keypair(kwargs=None, call=None): + """ + Upload public key to cloud provider. + Similar to EC2 import_keypair. + + .. versionadded:: 2016.11.0 + + kwargs + file(mandatory): public key file-name + keyname(mandatory): public key name in the provider + """ + with salt.utils.files.fopen(kwargs["file"], "r") as public_key_filename: + public_key_content = salt.utils.stringutils.to_unicode( + public_key_filename.read() + ) + + digitalocean_kwargs = {"name": kwargs["keyname"], "public_key": public_key_content} + + created_result = create_key(digitalocean_kwargs, call=call) + return created_result + + +def create_key(kwargs=None, call=None): + """ + Upload a public key + """ + if call != "function": + log.error("The create_key function must be called with -f or --function.") + return False + + try: + result = query( + method="account", + command="keys", + args={"name": kwargs["name"], "public_key": kwargs["public_key"]}, + http_method="post", + ) + except KeyError: + log.info("`name` and `public_key` arguments must be specified") + return False + + return result + + +def remove_key(kwargs=None, call=None): + """ + Delete public key + """ + if call != "function": + log.error("The create_key function must be called with -f or --function.") + return False + + try: + result = query( + method="account", command="keys/" + kwargs["id"], http_method="delete" + ) + except KeyError: + log.info("`id` argument must be specified") + return False + + return result + + +def get_keyid(keyname): + """ + Return the ID of the keyname + """ + if not keyname: + return None + keypairs = list_keypairs(call="function") + keyid = keypairs[keyname]["id"] + if keyid: + return keyid + raise SaltCloudNotFound("The specified ssh key could not be found.") + + +def destroy(name, call=None): + """ + Destroy a node. Will check termination protection and warn if enabled. + + CLI Example: + + .. code-block:: bash + + salt-cloud --destroy mymachine + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + data = show_instance(name, call="action") + node = query(method="droplets", droplet_id=data["id"], http_method="delete") + + ## This is all terribly optomistic: + # vm_ = get_vm_config(name=name) + # delete_dns_record = config.get_cloud_config_value( + # 'delete_dns_record', vm_, __opts__, search_global=False, default=None, + # ) + # TODO: when _vm config data can be made available, we should honor the configuration settings, + # but until then, we should assume stale DNS records are bad, and default behavior should be to + # delete them if we can. When this is resolved, also resolve the comments a couple of lines below. + delete_dns_record = True + + if not isinstance(delete_dns_record, bool): + raise SaltCloudConfigError("'delete_dns_record' should be a boolean value.") + # When the "to do" a few lines up is resolved, remove these lines and use the if/else logic below. + log.debug("Deleting DNS records for %s.", name) + destroy_dns_records(name) + + # Until the "to do" from line 754 is taken care of, we don't need this logic. + # if delete_dns_record: + # log.debug('Deleting DNS records for %s.', name) + # destroy_dns_records(name) + # else: + # log.debug('delete_dns_record : %s', delete_dns_record) + # for line in pprint.pformat(dir()).splitlines(): + # log.debug('delete context: %s', line) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return node + + +def post_dns_record(**kwargs): + """ + Creates a DNS record for the given name if the domain is managed with DO. + """ + if "kwargs" in kwargs: # flatten kwargs if called via salt-cloud -f + f_kwargs = kwargs["kwargs"] + del kwargs["kwargs"] + kwargs.update(f_kwargs) + mandatory_kwargs = ("dns_domain", "name", "record_type", "record_data") + for i in mandatory_kwargs: + if kwargs[i]: + pass + else: + error = '{}="{}" ## all mandatory args must be provided: {}'.format( + i, kwargs[i], mandatory_kwargs + ) + raise SaltInvocationError(error) + + domain = query(method="domains", droplet_id=kwargs["dns_domain"]) + + if domain: + result = query( + method="domains", + droplet_id=kwargs["dns_domain"], + command="records", + args={ + "type": kwargs["record_type"], + "name": kwargs["name"], + "data": kwargs["record_data"], + }, + http_method="post", + ) + return result + + return False + + +def destroy_dns_records(fqdn): + """ + Deletes DNS records for the given hostname if the domain is managed with DO. + """ + domain = ".".join(fqdn.split(".")[-2:]) + hostname = ".".join(fqdn.split(".")[:-2]) + # TODO: remove this when the todo on 754 is available + try: + response = query(method="domains", droplet_id=domain, command="records") + except SaltCloudSystemExit: + log.debug("Failed to find domains.") + return False + log.debug("found DNS records: %s", pprint.pformat(response)) + records = response["domain_records"] + + if records: + record_ids = [r["id"] for r in records if r["name"] == hostname] + log.debug("deleting DNS record IDs: %s", record_ids) + for id_ in record_ids: + try: + log.info("deleting DNS record %s", id_) + ret = query( + method="domains", + droplet_id=domain, + command=f"records/{id_}", + http_method="delete", + ) + except SaltCloudSystemExit: + log.error( + "failed to delete DNS domain %s record ID %s.", domain, hostname + ) + log.debug("DNS deletion REST call returned: %s", pprint.pformat(ret)) + + return False + + +def show_pricing(kwargs=None, call=None): + """ + Show pricing for a particular profile. This is only an estimate, based on + unofficial pricing sources. + + .. versionadded:: 2015.8.0 + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f show_pricing my-digitalocean-config profile=my-profile + """ + profile = __opts__["profiles"].get(kwargs["profile"], {}) + if not profile: + return {"Error": "The requested profile was not found"} + + # Make sure the profile belongs to DigitalOcean + provider = profile.get("provider", "0:0") + comps = provider.split(":") + if len(comps) < 2 or comps[1] != "digitalocean": + return {"Error": "The requested profile does not belong to DigitalOcean"} + + raw = {} + ret = {} + sizes = avail_sizes() + ret["per_hour"] = decimal.Decimal(sizes[profile["size"]]["price_hourly"]) + + ret["per_day"] = ret["per_hour"] * 24 + ret["per_week"] = ret["per_day"] * 7 + ret["per_month"] = decimal.Decimal(sizes[profile["size"]]["price_monthly"]) + ret["per_year"] = ret["per_week"] * 52 + + if kwargs.get("raw", False): + ret["_raw"] = raw + + return {profile["profile"]: ret} + + +def list_floating_ips(call=None): + """ + Return a list of the floating ips that are on the provider + + .. versionadded:: 2016.3.0 + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f list_floating_ips my-digitalocean-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_floating_ips function must be called with " + "-f or --function, or with the --list-floating-ips option" + ) + + fetch = True + page = 1 + ret = {} + + while fetch: + items = query( + method="floating_ips", + command="?page=" + str(page) + "&per_page=200", + ) + + for floating_ip in items["floating_ips"]: + ret[floating_ip["ip"]] = {} + for item in floating_ip.keys(): + ret[floating_ip["ip"]][item] = floating_ip[item] + + page += 1 + try: + fetch = "next" in items["links"]["pages"] + except KeyError: + fetch = False + + return ret + + +def show_floating_ip(kwargs=None, call=None): + """ + Show the details of a floating IP + + .. versionadded:: 2016.3.0 + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f show_floating_ip my-digitalocean-config floating_ip='45.55.96.47' + """ + if call != "function": + log.error("The show_floating_ip function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + if "floating_ip" not in kwargs: + log.error("A floating IP is required.") + return False + + floating_ip = kwargs["floating_ip"] + log.debug("Floating ip is %s", floating_ip) + + details = query(method="floating_ips", command=floating_ip) + + return details + + +def create_floating_ip(kwargs=None, call=None): + """ + Create a new floating IP + + .. versionadded:: 2016.3.0 + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f create_floating_ip my-digitalocean-config region='NYC2' + + salt-cloud -f create_floating_ip my-digitalocean-config droplet_id='1234567' + """ + if call != "function": + log.error( + "The create_floating_ip function must be called with -f or --function." + ) + return False + + if not kwargs: + kwargs = {} + + if "droplet_id" in kwargs: + result = query( + method="floating_ips", + args={"droplet_id": kwargs["droplet_id"]}, + http_method="post", + ) + + return result + + elif "region" in kwargs: + result = query( + method="floating_ips", args={"region": kwargs["region"]}, http_method="post" + ) + + return result + + else: + log.error("A droplet_id or region is required.") + return False + + +def delete_floating_ip(kwargs=None, call=None): + """ + Delete a floating IP + + .. versionadded:: 2016.3.0 + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f delete_floating_ip my-digitalocean-config floating_ip='45.55.96.47' + """ + if call != "function": + log.error( + "The delete_floating_ip function must be called with -f or --function." + ) + return False + + if not kwargs: + kwargs = {} + + if "floating_ip" not in kwargs: + log.error("A floating IP is required.") + return False + + floating_ip = kwargs["floating_ip"] + log.debug("Floating ip is %s", kwargs["floating_ip"]) + + result = query(method="floating_ips", command=floating_ip, http_method="delete") + + return result + + +def assign_floating_ip(kwargs=None, call=None): + """ + Assign a floating IP + + .. versionadded:: 2016.3.0 + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f assign_floating_ip my-digitalocean-config droplet_id=1234567 floating_ip='45.55.96.47' + """ + if call != "function": + log.error( + "The assign_floating_ip function must be called with -f or --function." + ) + return False + + if not kwargs: + kwargs = {} + + if "floating_ip" and "droplet_id" not in kwargs: + log.error("A floating IP and droplet_id is required.") + return False + + result = query( + method="floating_ips", + command=kwargs["floating_ip"] + "/actions", + args={"droplet_id": kwargs["droplet_id"], "type": "assign"}, + http_method="post", + ) + + return result + + +def unassign_floating_ip(kwargs=None, call=None): + """ + Unassign a floating IP + + .. versionadded:: 2016.3.0 + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f unassign_floating_ip my-digitalocean-config floating_ip='45.55.96.47' + """ + if call != "function": + log.error( + "The inassign_floating_ip function must be called with -f or --function." + ) + return False + + if not kwargs: + kwargs = {} + + if "floating_ip" not in kwargs: + log.error("A floating IP is required.") + return False + + result = query( + method="floating_ips", + command=kwargs["floating_ip"] + "/actions", + args={"type": "unassign"}, + http_method="post", + ) + + return result + + +def _get_vpc_by_name(name): + """ + Helper function to format and parse vpc data. It's pretty expensive as it + retrieves a list of vpcs and iterates through them till it finds the correct + vpc by name. + """ + fetch = True + page = 1 + ret = {} + + log.debug("Matching vpc name with: %s", name) + while fetch: + items = query(method="vpcs", command=f"?page={str(page)}&per_page=200") + for node in items["vpcs"]: + log.debug("Node returned : %s", node["name"]) + if name == node["name"]: + log.debug("Matched VPC node") + ret[name] = { + "id": node["id"], + "urn": node["urn"], + "name": name, + "description": node["description"], + "region": node["region"], + "ip_range": node["ip_range"], + "default": node["default"], + } + return ret + page += 1 + try: + fetch = "next" in items["links"]["pages"] + except KeyError: + fetch = False + return None + + +def _list_nodes(full=False, for_output=False): + """ + Helper function to format and parse node data. + """ + fetch = True + page = 1 + ret = {} + + while fetch: + items = query(method="droplets", command=f"?page={str(page)}&per_page=200") + for node in items["droplets"]: + name = node["name"] + ret[name] = {} + if full: + ret[name] = _get_full_output(node, for_output=for_output) + else: + public_ips, private_ips = _get_ips(node["networks"]) + ret[name] = { + "id": node["id"], + "image": node["image"]["name"], + "name": name, + "private_ips": private_ips, + "public_ips": public_ips, + "size": node["size_slug"], + "state": str(node["status"]), + } + + page += 1 + try: + fetch = "next" in items["links"]["pages"] + except KeyError: + fetch = False + + return ret + + +def reboot(name, call=None): + """ + Reboot a droplet in DigitalOcean. + + .. versionadded:: 2015.8.8 + + name + The name of the droplet to restart. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot droplet_name + """ + if call != "action": + raise SaltCloudSystemExit( + "The reboot action must be called with -a or --action." + ) + + data = show_instance(name, call="action") + if data.get("status") == "off": + return { + "success": True, + "action": "stop", + "status": "off", + "msg": "Machine is already off.", + } + + ret = query( + droplet_id=data["id"], + command="actions", + args={"type": "reboot"}, + http_method="post", + ) + + return { + "success": True, + "action": ret["action"]["type"], + "state": ret["action"]["status"], + } + + +def start(name, call=None): + """ + Start a droplet in DigitalOcean. + + .. versionadded:: 2015.8.8 + + name + The name of the droplet to start. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start droplet_name + """ + if call != "action": + raise SaltCloudSystemExit( + "The start action must be called with -a or --action." + ) + + data = show_instance(name, call="action") + if data.get("status") == "active": + return { + "success": True, + "action": "start", + "status": "active", + "msg": "Machine is already running.", + } + + ret = query( + droplet_id=data["id"], + command="actions", + args={"type": "power_on"}, + http_method="post", + ) + + return { + "success": True, + "action": ret["action"]["type"], + "state": ret["action"]["status"], + } + + +def stop(name, call=None): + """ + Stop a droplet in DigitalOcean. + + .. versionadded:: 2015.8.8 + + name + The name of the droplet to stop. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop droplet_name + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + data = show_instance(name, call="action") + if data.get("status") == "off": + return { + "success": True, + "action": "stop", + "status": "off", + "msg": "Machine is already off.", + } + + ret = query( + droplet_id=data["id"], + command="actions", + args={"type": "shutdown"}, + http_method="post", + ) + + return { + "success": True, + "action": ret["action"]["type"], + "state": ret["action"]["status"], + } + + +def _get_full_output(node, for_output=False): + """ + Helper function for _list_nodes to loop through all node information. + Returns a dictionary containing the full information of a node. + """ + ret = {} + for item in node.keys(): + value = node[item] + if value is not None and for_output: + value = str(value) + ret[item] = value + return ret + + +def _get_ips(networks): + """ + Helper function for list_nodes. Returns public and private ip lists based on a + given network dictionary. + """ + v4s = networks.get("v4") + v6s = networks.get("v6") + public_ips = [] + private_ips = [] + + if v4s: + for item in v4s: + ip_type = item.get("type") + ip_address = item.get("ip_address") + if ip_type == "public": + public_ips.append(ip_address) + if ip_type == "private": + private_ips.append(ip_address) + + if v6s: + for item in v6s: + ip_type = item.get("type") + ip_address = item.get("ip_address") + if ip_type == "public": + public_ips.append(ip_address) + if ip_type == "private": + private_ips.append(ip_address) + + return public_ips, private_ips diff --git a/salt/cloud/clouds/dimensiondata.py b/salt/cloud/clouds/dimensiondata.py new file mode 100644 index 000000000000..a6503def1f41 --- /dev/null +++ b/salt/cloud/clouds/dimensiondata.py @@ -0,0 +1,616 @@ +""" +Dimension Data Cloud Module +=========================== + +This is a cloud module for the Dimension Data Cloud, +using the existing Libcloud driver for Dimension Data. + +.. code-block:: yaml + + # Note: This example is for /etc/salt/cloud.providers + # or any file in the + # /etc/salt/cloud.providers.d/ directory. + + my-dimensiondata-config: + user_id: my_username + key: myPassword! + region: dd-na + driver: dimensiondata + +:maintainer: Anthony Shaw +:depends: libcloud >= 1.2.1 +""" + +import logging +import pprint +import socket + +import salt.config as config +import salt.utils.cloud +from salt.cloud.libcloudfuncs import * # pylint: disable=redefined-builtin,wildcard-import,unused-wildcard-import +from salt.exceptions import ( + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudSystemExit, +) +from salt.utils.functools import namespaced_function +from salt.utils.versions import Version + +# Import libcloud +try: + import libcloud + from libcloud.compute.base import NodeAuthPassword, NodeDriver, NodeState + from libcloud.compute.providers import get_driver + from libcloud.compute.types import Provider + from libcloud.loadbalancer.base import Member + from libcloud.loadbalancer.providers import get_driver as get_driver_lb + from libcloud.loadbalancer.types import Provider as Provider_lb + + # This work-around for Issue #32743 is no longer needed for libcloud >= + # 1.4.0. However, older versions of libcloud must still be supported with + # this work-around. This work-around can be removed when the required + # minimum version of libcloud is 2.0.0 (See PR #40837 - which is + # implemented in Salt 2018.3.0). + if Version(libcloud.__version__) < Version("1.4.0"): + # See https://github.com/saltstack/salt/issues/32743 + import libcloud.security + + libcloud.security.CA_CERTS_PATH.append("/etc/ssl/certs/YaST-CA.pem") + HAS_LIBCLOUD = True +except ImportError: + HAS_LIBCLOUD = False + + +try: + from netaddr import all_matching_cidrs # pylint: disable=unused-import + + HAS_NETADDR = True +except ImportError: + HAS_NETADDR = False + + +# Some of the libcloud functions need to be in the same namespace as the +# functions defined in the module, so we create new function objects inside +# this module namespace +get_size = namespaced_function(get_size, globals()) +get_image = namespaced_function(get_image, globals()) +avail_locations = namespaced_function(avail_locations, globals()) +avail_images = namespaced_function(avail_images, globals()) +avail_sizes = namespaced_function(avail_sizes, globals()) +script = namespaced_function(script, globals()) +destroy = namespaced_function(destroy, globals()) +reboot = namespaced_function(reboot, globals()) +list_nodes = namespaced_function(list_nodes, globals()) +list_nodes_full = namespaced_function(list_nodes_full, globals()) +list_nodes_select = namespaced_function(list_nodes_select, globals()) +show_instance = namespaced_function(show_instance, globals()) +get_node = namespaced_function(get_node, globals()) + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "dimensiondata" + + +def __virtual__(): + """ + Set up the libcloud functions and check for dimensiondata configurations. + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + for provider, details in __opts__["providers"].items(): + if "dimensiondata" not in details: + continue + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or "dimensiondata", + ("user_id", "key", "region"), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + deps = {"libcloud": HAS_LIBCLOUD, "netaddr": HAS_NETADDR} + return config.check_driver_dependencies(__virtualname__, deps) + + +def _query_node_data(vm_, data): + running = False + try: + node = show_instance(vm_["name"], "action") # pylint: disable=not-callable + running = node["state"] == NodeState.RUNNING + log.debug( + "Loaded node data for %s:\nname: %s\nstate: %s", + vm_["name"], + pprint.pformat(node["name"]), + node["state"], + ) + except Exception as err: # pylint: disable=broad-except + log.error( + "Failed to get nodes list: %s", + err, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + # Trigger a failure in the wait for IP function + return running + + if not running: + # Still not running, trigger another iteration + return + + private = node["private_ips"] + public = node["public_ips"] + + if private and not public: + log.warning( + "Private IPs returned, but not public. Checking for misidentified IPs." + ) + for private_ip in private: + private_ip = preferred_ip(vm_, [private_ip]) + if private_ip is False: + continue + if salt.utils.cloud.is_public_ip(private_ip): + log.warning("%s is a public IP", private_ip) + data.public_ips.append(private_ip) + else: + log.warning("%s is a private IP", private_ip) + if private_ip not in data.private_ips: + data.private_ips.append(private_ip) + + if ssh_interface(vm_) == "private_ips" and data.private_ips: + return data + + if private: + data.private_ips = private + if ssh_interface(vm_) == "private_ips": + return data + + if public: + data.public_ips = public + if ssh_interface(vm_) != "private_ips": + return data + + log.debug("Contents of the node data:") + log.debug(data) + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, _get_active_provider_name() or "dimensiondata", vm_["profile"] + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", vm_["name"]) + conn = get_conn() + + location = conn.ex_get_location_by_id(vm_["location"]) + images = conn.list_images(location=location) + image = [x for x in images if x.id == vm_["image"]][0] + network_domains = conn.ex_list_network_domains(location=location) + try: + network_domain = [ + y for y in network_domains if y.name == vm_["network_domain"] + ][0] + except IndexError: + network_domain = conn.ex_create_network_domain( + location=location, + name=vm_["network_domain"], + plan="ADVANCED", + description="", + ) + + try: + vlan = [ + y + for y in conn.ex_list_vlans( + location=location, network_domain=network_domain + ) + if y.name == vm_["vlan"] + ][0] + except (IndexError, KeyError): + # Use the first VLAN in the network domain + vlan = conn.ex_list_vlans(location=location, network_domain=network_domain)[0] + + kwargs = { + "name": vm_["name"], + "image": image, + "ex_description": vm_["description"], + "ex_network_domain": network_domain, + "ex_vlan": vlan, + "ex_is_started": vm_["is_started"], + } + + event_data = _to_event_data(kwargs) + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "requesting", event_data, list(event_data) + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + # Initial password (excluded from event payload) + initial_password = NodeAuthPassword(vm_["auth"]) + kwargs["auth"] = initial_password + + try: + data = conn.create_node(**kwargs) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on DIMENSIONDATA\n\n" + "The following exception was thrown by libcloud when trying to " + "run the initial deployment: \n%s", + vm_["name"], + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + try: + data = __utils__["cloud.wait_for_ip"]( + _query_node_data, + update_args=(vm_, data), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=25 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=30 + ), + max_failures=config.get_cloud_config_value( + "wait_for_ip_max_failures", vm_, __opts__, default=60 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(vm_["name"]) # pylint: disable=not-callable + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + log.debug("VM is now running") + if ssh_interface(vm_) == "private_ips": + ip_address = preferred_ip(vm_, data.private_ips) + else: + ip_address = preferred_ip(vm_, data.public_ips) + log.debug("Using IP address %s", ip_address) + + if __utils__["cloud.get_salt_interface"](vm_, __opts__) == "private_ips": + salt_ip_address = preferred_ip(vm_, data.private_ips) + log.info("Salt interface set to: %s", salt_ip_address) + else: + salt_ip_address = preferred_ip(vm_, data.public_ips) + log.debug("Salt interface set to: %s", salt_ip_address) + + if not ip_address: + raise SaltCloudSystemExit("No IP addresses could be found.") + + vm_["salt_host"] = salt_ip_address + vm_["ssh_host"] = ip_address + vm_["password"] = vm_["auth"] + + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + ret.update(data.__dict__) + + if "password" in data.extra: + del data.extra["password"] + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug( + "'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data.__dict__) + ) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def create_lb(kwargs=None, call=None): + r""" + Create a load-balancer configuration. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_lb dimensiondata \ + name=dev-lb port=80 protocol=http \ + members=w1,w2,w3 algorithm=ROUND_ROBIN + """ + conn = get_conn() + if call != "function": + raise SaltCloudSystemExit( + "The create_lb function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when creating a health check.") + return False + if "port" not in kwargs: + log.error("A port or port-range must be specified for the load-balancer.") + return False + if "networkdomain" not in kwargs: + log.error("A network domain must be specified for the load-balancer.") + return False + if "members" in kwargs: + members = [] + ip = "" + membersList = kwargs.get("members").split(",") + log.debug("MemberList: %s", membersList) + for member in membersList: + try: + log.debug("Member: %s", member) + node = get_node(conn, member) # pylint: disable=not-callable + log.debug("Node: %s", node) + ip = node.private_ips[0] + except Exception as err: # pylint: disable=broad-except + log.error( + "Failed to get node ip: %s", + err, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + members.append(Member(ip, ip, kwargs["port"])) + else: + members = None + log.debug("Members: %s", members) + + networkdomain = kwargs["networkdomain"] + name = kwargs["name"] + port = kwargs["port"] + protocol = kwargs.get("protocol", None) + algorithm = kwargs.get("algorithm", None) + + lb_conn = get_lb_conn(conn) + network_domains = conn.ex_list_network_domains() + network_domain = [y for y in network_domains if y.name == networkdomain][0] + + log.debug("Network Domain: %s", network_domain.id) + lb_conn.ex_set_current_network_domain(network_domain.id) + + event_data = _to_event_data(kwargs) + + __utils__["cloud.fire_event"]( + "event", + "create load_balancer", + "salt/cloud/loadbalancer/creating", + args=event_data, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + lb = lb_conn.create_balancer(name, port, protocol, algorithm, members) + + event_data = _to_event_data(kwargs) + + __utils__["cloud.fire_event"]( + "event", + "created load_balancer", + "salt/cloud/loadbalancer/created", + args=event_data, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return _expand_balancer(lb) + + +def _expand_balancer(lb): + """ + Convert the libcloud load-balancer object into something more serializable. + """ + ret = {} + ret.update(lb.__dict__) + return ret + + +def preferred_ip(vm_, ips): + """ + Return the preferred Internet protocol. Either 'ipv4' (default) or 'ipv6'. + """ + proto = config.get_cloud_config_value( + "protocol", vm_, __opts__, default="ipv4", search_global=False + ) + family = socket.AF_INET + if proto == "ipv6": + family = socket.AF_INET6 + for ip in ips: + try: + socket.inet_pton(family, ip) + return ip + except Exception: # pylint: disable=broad-except + continue + return False + + +def ssh_interface(vm_): + """ + Return the ssh_interface type to connect to. Either 'public_ips' (default) + or 'private_ips'. + """ + return config.get_cloud_config_value( + "ssh_interface", vm_, __opts__, default="public_ips", search_global=False + ) + + +def stop(name, call=None): + """ + Stop a VM in DimensionData. + + name: + The name of the VM to stop. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop vm_name + """ + conn = get_conn() + node = get_node(conn, name) # pylint: disable=not-callable + log.debug("Node of Cloud VM: %s", node) + + status = conn.ex_shutdown_graceful(node) + log.debug("Status of Cloud VM: %s", status) + + return status + + +def start(name, call=None): + """ + Stop a VM in DimensionData. + + :param str name: + The name of the VM to stop. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop vm_name + """ + + conn = get_conn() + node = get_node(conn, name) # pylint: disable=not-callable + log.debug("Node of Cloud VM: %s", node) + + status = conn.ex_start_node(node) + log.debug("Status of Cloud VM: %s", status) + + return status + + +def get_conn(): + """ + Return a conn object for the passed VM data + """ + vm_ = get_configured_provider() + driver = get_driver(Provider.DIMENSIONDATA) + + region = config.get_cloud_config_value("region", vm_, __opts__) + + user_id = config.get_cloud_config_value("user_id", vm_, __opts__) + key = config.get_cloud_config_value("key", vm_, __opts__) + + if key is not None: + log.debug("DimensionData authenticating using password") + + return driver(user_id, key, region=region) + + +def get_lb_conn(dd_driver=None): + """ + Return a load-balancer conn object + """ + vm_ = get_configured_provider() + + region = config.get_cloud_config_value("region", vm_, __opts__) + + user_id = config.get_cloud_config_value("user_id", vm_, __opts__) + key = config.get_cloud_config_value("key", vm_, __opts__) + if not dd_driver: + raise SaltCloudSystemExit( + "Missing dimensiondata_driver for get_lb_conn method." + ) + return get_driver_lb(Provider_lb.DIMENSIONDATA)(user_id, key, region=region) + + +def _to_event_data(obj): + """ + Convert the specified object into a form that can be serialised by msgpack as event data. + + :param obj: The object to convert. + """ + + if obj is None: + return None + if isinstance(obj, bool): + return obj + if isinstance(obj, int): + return obj + if isinstance(obj, float): + return obj + if isinstance(obj, str): + return obj + if isinstance(obj, bytes): + return obj + if isinstance(obj, dict): + return obj + + if isinstance(obj, NodeDriver): # Special case for NodeDriver (cyclic references) + return obj.name + + if isinstance(obj, list): + return [_to_event_data(item) for item in obj] + + event_data = {} + for attribute_name in dir(obj): + if attribute_name.startswith("_"): + continue + + attribute_value = getattr(obj, attribute_name) + + if callable(attribute_value): # Strip out methods + continue + + event_data[attribute_name] = _to_event_data(attribute_value) + + return event_data diff --git a/salt/cloud/clouds/ec2.py b/salt/cloud/clouds/ec2.py new file mode 100644 index 000000000000..47eb71af3002 --- /dev/null +++ b/salt/cloud/clouds/ec2.py @@ -0,0 +1,5238 @@ +""" +The EC2 Cloud Module +==================== + +The EC2 cloud module is used to interact with the Amazon Elastic Compute Cloud. + +To use the EC2 cloud module, set up the cloud configuration at + ``/etc/salt/cloud.providers`` or ``/etc/salt/cloud.providers.d/ec2.conf``: + +.. code-block:: yaml + + my-ec2-config: + # EC2 API credentials: Access Key ID and Secret Access Key. + # Alternatively, to use IAM Instance Role credentials available via + # EC2 metadata set both id and key to 'use-instance-role-credentials' + id: GKTADJGHEIQSXMKKRBJ08H + key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs + + # If 'role_arn' is specified the above credentials are used to + # to assume to the role. By default, role_arn is set to None. + role_arn: arn:aws:iam::012345678910:role/SomeRoleName + + # The ssh keyname to use + keyname: default + # The amazon security group + securitygroup: ssh_open + # The location of the private key which corresponds to the keyname + private_key: /root/default.pem + + # Be default, service_url is set to amazonaws.com. If you are using this + # driver for something other than Amazon EC2, change it here: + service_url: amazonaws.com + + # The endpoint that is ultimately used is usually formed using the region + # and the service_url. If you would like to override that entirely, you + # can explicitly define the endpoint: + endpoint: myendpoint.example.com:1138/services/Cloud + + # SSH Gateways can be used with this provider. Gateways can be used + # when a salt-master is not on the same private network as the instance + # that is being deployed. + + # Defaults to None + # Required + ssh_gateway: gateway.example.com + + # Defaults to port 22 + # Optional + ssh_gateway_port: 22 + + # Defaults to root + # Optional + ssh_gateway_username: root + + # Default to nc -q0 %h %p + # Optional + ssh_gateway_command: "-W %h:%p" + + # One authentication method is required. If both + # are specified, Private key wins. + + # Private key defaults to None + ssh_gateway_private_key: /path/to/key.pem + + # Password defaults to None + ssh_gateway_password: ExamplePasswordHere + + driver: ec2 + + # Pass userdata to the instance to be created + userdata_file: /etc/salt/my-userdata-file + + # Instance termination protection setting + # Default is disabled + termination_protection: False + +:depends: requests +""" + +import base64 +import binascii +import datetime +import decimal +import hashlib +import hmac +import logging +import os +import pprint +import re +import stat +import time +import urllib.parse +import uuid +import xml.etree.ElementTree as ET +from functools import cmp_to_key + +import salt.config as config +import salt.crypt +import salt.utils.aws as aws +import salt.utils.cloud +import salt.utils.compat +import salt.utils.files +import salt.utils.hashutils +import salt.utils.http as http +import salt.utils.json +import salt.utils.msgpack +import salt.utils.stringutils +import salt.utils.yaml +from salt.exceptions import ( + SaltCloudConfigError, + SaltCloudException, + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudSystemExit, +) + +try: + import requests + + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +# Get logging started +log = logging.getLogger(__name__) + + +EC2_LOCATIONS = { + "ap-northeast-1": "ec2_ap_northeast", + "ap-northeast-2": "ec2_ap_northeast_2", + "ap-southeast-1": "ec2_ap_southeast", + "ap-southeast-2": "ec2_ap_southeast_2", + "eu-west-1": "ec2_eu_west", + "eu-central-1": "ec2_eu_central", + "sa-east-1": "ec2_sa_east", + "us-east-1": "ec2_us_east", + "us-gov-west-1": "ec2_us_gov_west_1", + "us-west-1": "ec2_us_west", + "us-west-2": "ec2_us_west_oregon", +} +DEFAULT_LOCATION = "us-east-1" + +DEFAULT_EC2_API_VERSION = "2016-11-15" + +EC2_RETRY_CODES = [ + "RequestLimitExceeded", + "InsufficientInstanceCapacity", + "InternalError", + "Unavailable", + "InsufficientAddressCapacity", + "InsufficientReservedInstanceCapacity", +] + +JS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.S) + +__virtualname__ = "ec2" + + +# Only load in this module if the EC2 configurations are in place +def __virtual__(): + """ + Set up the libcloud functions and check for EC2 configurations + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("id", "key") + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + deps = { + "requests": HAS_REQUESTS, + "cryptography": salt.crypt.HAS_CRYPTOGRAPHY, + } + return config.check_driver_dependencies(__virtualname__, deps) + + +def _xml_to_dict(xmltree): + """ + Convert an XML tree into a dict + """ + if len(xmltree) < 1: + name = xmltree.tag + if "}" in name: + comps = name.split("}") + name = comps[1] + return {name: xmltree.text} + + xmldict = {} + for item in xmltree: + name = item.tag + if "}" in name: + comps = name.split("}") + name = comps[1] + if name not in xmldict: + if len(item) > 0: + xmldict[name] = _xml_to_dict(item) + else: + xmldict[name] = item.text + else: + if not isinstance(xmldict[name], list): + tempvar = xmldict[name] + xmldict[name] = [] + xmldict[name].append(tempvar) + xmldict[name].append(_xml_to_dict(item)) + return xmldict + + +def optimize_providers(providers): + """ + Return an optimized list of providers. + + We want to reduce the duplication of querying + the same region. + + If a provider is using the same credentials for the same region + the same data will be returned for each provider, thus causing + un-wanted duplicate data and API calls to EC2. + + """ + tmp_providers = {} + optimized_providers = {} + + for name, data in providers.items(): + if "location" not in data: + data["location"] = DEFAULT_LOCATION + + if data["location"] not in tmp_providers: + tmp_providers[data["location"]] = {} + + creds = (data["id"], data["key"]) + if creds not in tmp_providers[data["location"]]: + tmp_providers[data["location"]][creds] = { + "name": name, + "data": data, + } + + for location, tmp_data in tmp_providers.items(): + for creds, data in tmp_data.items(): + _id, _key = creds + _name = data["name"] + _data = data["data"] + if _name not in optimized_providers: + optimized_providers[_name] = _data + + return optimized_providers + + +def sign(key, msg): + return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() + + +def query( + params=None, + setname=None, + requesturl=None, + location=None, + return_url=False, + return_root=False, +): + + provider = get_configured_provider() + service_url = provider.get("service_url", "amazonaws.com") + + # Retrieve access credentials from meta-data, or use provided + access_key_id, secret_access_key, token = aws.creds(provider) + + attempts = 0 + while attempts < aws.AWS_MAX_RETRIES: + params_with_headers = params.copy() + timestamp = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + + if not location: + location = get_location() + + if not requesturl: + endpoint = provider.get("endpoint", f"ec2.{location}.{service_url}") + + requesturl = f"https://{endpoint}/" + endpoint = urllib.parse.urlparse(requesturl).netloc + endpoint_path = urllib.parse.urlparse(requesturl).path + else: + endpoint = urllib.parse.urlparse(requesturl).netloc + endpoint_path = urllib.parse.urlparse(requesturl).path + if endpoint == "": + endpoint_err = ( + "Could not find a valid endpoint in the " + "requesturl: {}. Looking for something like " + "https://some.ec2.endpoint/?args".format(requesturl) + ) + log.error(endpoint_err) + if return_url is True: + return {"error": endpoint_err}, requesturl + return {"error": endpoint_err} + + log.debug("Using EC2 endpoint: %s", endpoint) + # AWS v4 signature + + method = "GET" + region = location + service = "ec2" + canonical_uri = urllib.parse.urlparse(requesturl).path + host = endpoint.strip() + + # Create a date for headers and the credential string + t = datetime.datetime.utcnow() + amz_date = t.strftime("%Y%m%dT%H%M%SZ") # Format date as YYYYMMDD'T'HHMMSS'Z' + datestamp = t.strftime("%Y%m%d") # Date w/o time, used in credential scope + + canonical_headers = "host:" + host + "\n" + "x-amz-date:" + amz_date + "\n" + signed_headers = "host;x-amz-date" + + payload_hash = salt.utils.hashutils.sha256_digest("") + + ec2_api_version = provider.get("ec2_api_version", DEFAULT_EC2_API_VERSION) + + params_with_headers["Version"] = ec2_api_version + + keys = sorted(list(params_with_headers)) + values = map(params_with_headers.get, keys) + querystring = urllib.parse.urlencode(list(zip(keys, values))) + querystring = querystring.replace("+", "%20") + + canonical_request = ( + method + + "\n" + + canonical_uri + + "\n" + + querystring + + "\n" + + canonical_headers + + "\n" + + signed_headers + + "\n" + + payload_hash + ) + + algorithm = "AWS4-HMAC-SHA256" + credential_scope = ( + datestamp + "/" + region + "/" + service + "/" + "aws4_request" + ) + + string_to_sign = ( + algorithm + + "\n" + + amz_date + + "\n" + + credential_scope + + "\n" + + salt.utils.hashutils.sha256_digest(canonical_request) + ) + + kDate = sign(("AWS4" + provider["key"]).encode("utf-8"), datestamp) + kRegion = sign(kDate, region) + kService = sign(kRegion, service) + signing_key = sign(kService, "aws4_request") + + signature = hmac.new( + signing_key, (string_to_sign).encode("utf-8"), hashlib.sha256 + ).hexdigest() + + authorization_header = ( + algorithm + + " " + + "Credential=" + + provider["id"] + + "/" + + credential_scope + + ", " + + "SignedHeaders=" + + signed_headers + + ", " + + "Signature=" + + signature + ) + headers = {"x-amz-date": amz_date, "Authorization": authorization_header} + + log.debug("EC2 Request: %s", requesturl) + log.trace("EC2 Request Parameters: %s", params_with_headers) + try: + result = requests.get( + requesturl, headers=headers, params=params_with_headers, timeout=120 + ) + log.debug( + "EC2 Response Status Code: %s", + # result.getcode() + result.status_code, + ) + log.trace("EC2 Response Text: %s", result.text) + result.raise_for_status() + break + except requests.exceptions.HTTPError as exc: + root = ET.fromstring(exc.response.content) + data = _xml_to_dict(root) + + # check to see if we should retry the query + err_code = data.get("Errors", {}).get("Error", {}).get("Code", "") + if err_code and err_code in EC2_RETRY_CODES: + attempts += 1 + log.error( + "EC2 Response Status Code and Error: [%s %s] %s; " + "Attempts remaining: %s", + exc.response.status_code, + exc, + data, + attempts, + ) + aws.sleep_exponential_backoff(attempts) + continue + + log.error( + "EC2 Response Status Code and Error: [%s %s] %s", + exc.response.status_code, + exc, + data, + ) + if return_url is True: + return {"error": data}, requesturl + return {"error": data} + else: + log.error( + "EC2 Response Status Code and Error: [%s %s] %s", + exc.response.status_code, + exc, + data, + ) + if return_url is True: + return {"error": data}, requesturl + return {"error": data} + + response = result.text + + root = ET.fromstring(response) + items = root[1] + if return_root is True: + items = root + + if setname: + for idx, item in enumerate(root): + comps = item.tag.split("}") + if comps[1] == setname: + items = root[idx] + + ret = [] + for item in items: + ret.append(_xml_to_dict(item)) + + if return_url is True: + return ret, requesturl + + return ret + + +def _wait_for_spot_instance( + update_callback, + update_args=None, + update_kwargs=None, + timeout=10 * 60, + interval=30, + interval_multiplier=1, + max_failures=10, +): + """ + Helper function that waits for a spot instance request to become active + for a specific maximum amount of time. + + :param update_callback: callback function which queries the cloud provider + for spot instance request. It must return None if + the required data, running instance included, is + not available yet. + :param update_args: Arguments to pass to update_callback + :param update_kwargs: Keyword arguments to pass to update_callback + :param timeout: The maximum amount of time(in seconds) to wait for the IP + address. + :param interval: The looping interval, i.e., the amount of time to sleep + before the next iteration. + :param interval_multiplier: Increase the interval by this multiplier after + each request; helps with throttling + :param max_failures: If update_callback returns ``False`` it's considered + query failure. This value is the amount of failures + accepted before giving up. + :returns: The update_callback returned data + :raises: SaltCloudExecutionTimeout + + """ + if update_args is None: + update_args = () + if update_kwargs is None: + update_kwargs = {} + + duration = timeout + while True: + log.debug( + "Waiting for spot instance reservation. Giving up in 00:%02d:%02d", + int(timeout // 60), + int(timeout % 60), + ) + data = update_callback(*update_args, **update_kwargs) + if data is False: + log.debug( + "update_callback has returned False which is considered a " + "failure. Remaining Failures: %s", + max_failures, + ) + max_failures -= 1 + if max_failures <= 0: + raise SaltCloudExecutionFailure( + "Too many failures occurred while waiting for " + "the spot instance reservation to become active." + ) + elif data is not None: + return data + + if timeout < 0: + raise SaltCloudExecutionTimeout( + "Unable to get an active spot instance request for " + "00:{:02d}:{:02d}".format(int(duration // 60), int(duration % 60)) + ) + time.sleep(interval) + timeout -= interval + + if interval_multiplier > 1: + interval *= interval_multiplier + if interval > timeout: + interval = timeout + 1 + log.info("Interval multiplier in effect; interval is now %ss", interval) + + +def avail_sizes(call=None): + """ + Return a dict of all available VM sizes on the cloud provider with + relevant data. Latest version can be found at: + + http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-types.html + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + + sizes = { + "Cluster Compute": { + "cc2.8xlarge": { + "id": "cc2.8xlarge", + "cores": "16 (2 x Intel Xeon E5-2670, eight-core with hyperthread)", + "disk": "3360 GiB (4 x 840 GiB)", + "ram": "60.5 GiB", + }, + "cc1.4xlarge": { + "id": "cc1.4xlarge", + "cores": "8 (2 x Intel Xeon X5570, quad-core with hyperthread)", + "disk": "1690 GiB (2 x 840 GiB)", + "ram": "22.5 GiB", + }, + }, + "Cluster CPU": { + "cg1.4xlarge": { + "id": "cg1.4xlarge", + "cores": ( + "8 (2 x Intel Xeon X5570, quad-core with " + "hyperthread), plus 2 NVIDIA Tesla M2050 GPUs" + ), + "disk": "1680 GiB (2 x 840 GiB)", + "ram": "22.5 GiB", + }, + }, + "Compute Optimized": { + "c4.large": { + "id": "c4.large", + "cores": "2", + "disk": "EBS - 500 Mbps", + "ram": "3.75 GiB", + }, + "c4.xlarge": { + "id": "c4.xlarge", + "cores": "4", + "disk": "EBS - 750 Mbps", + "ram": "7.5 GiB", + }, + "c4.2xlarge": { + "id": "c4.2xlarge", + "cores": "8", + "disk": "EBS - 1000 Mbps", + "ram": "15 GiB", + }, + "c4.4xlarge": { + "id": "c4.4xlarge", + "cores": "16", + "disk": "EBS - 2000 Mbps", + "ram": "30 GiB", + }, + "c4.8xlarge": { + "id": "c4.8xlarge", + "cores": "36", + "disk": "EBS - 4000 Mbps", + "ram": "60 GiB", + }, + "c3.large": { + "id": "c3.large", + "cores": "2", + "disk": "32 GiB (2 x 16 GiB SSD)", + "ram": "3.75 GiB", + }, + "c3.xlarge": { + "id": "c3.xlarge", + "cores": "4", + "disk": "80 GiB (2 x 40 GiB SSD)", + "ram": "7.5 GiB", + }, + "c3.2xlarge": { + "id": "c3.2xlarge", + "cores": "8", + "disk": "160 GiB (2 x 80 GiB SSD)", + "ram": "15 GiB", + }, + "c3.4xlarge": { + "id": "c3.4xlarge", + "cores": "16", + "disk": "320 GiB (2 x 160 GiB SSD)", + "ram": "30 GiB", + }, + "c3.8xlarge": { + "id": "c3.8xlarge", + "cores": "32", + "disk": "640 GiB (2 x 320 GiB SSD)", + "ram": "60 GiB", + }, + }, + "Dense Storage": { + "d2.xlarge": { + "id": "d2.xlarge", + "cores": "4", + "disk": "6 TiB (3 x 2 TiB hard disk drives)", + "ram": "30.5 GiB", + }, + "d2.2xlarge": { + "id": "d2.2xlarge", + "cores": "8", + "disk": "12 TiB (6 x 2 TiB hard disk drives)", + "ram": "61 GiB", + }, + "d2.4xlarge": { + "id": "d2.4xlarge", + "cores": "16", + "disk": "24 TiB (12 x 2 TiB hard disk drives)", + "ram": "122 GiB", + }, + "d2.8xlarge": { + "id": "d2.8xlarge", + "cores": "36", + "disk": "24 TiB (24 x 2 TiB hard disk drives)", + "ram": "244 GiB", + }, + }, + "GPU": { + "g2.2xlarge": { + "id": "g2.2xlarge", + "cores": "8", + "disk": "60 GiB (1 x 60 GiB SSD)", + "ram": "15 GiB", + }, + "g2.8xlarge": { + "id": "g2.8xlarge", + "cores": "32", + "disk": "240 GiB (2 x 120 GiB SSD)", + "ram": "60 GiB", + }, + }, + "GPU Compute": { + "p2.xlarge": { + "id": "p2.xlarge", + "cores": "4", + "disk": "EBS", + "ram": "61 GiB", + }, + "p2.8xlarge": { + "id": "p2.8xlarge", + "cores": "32", + "disk": "EBS", + "ram": "488 GiB", + }, + "p2.16xlarge": { + "id": "p2.16xlarge", + "cores": "64", + "disk": "EBS", + "ram": "732 GiB", + }, + }, + "High I/O": { + "i2.xlarge": { + "id": "i2.xlarge", + "cores": "4", + "disk": "SSD (1 x 800 GiB)", + "ram": "30.5 GiB", + }, + "i2.2xlarge": { + "id": "i2.2xlarge", + "cores": "8", + "disk": "SSD (2 x 800 GiB)", + "ram": "61 GiB", + }, + "i2.4xlarge": { + "id": "i2.4xlarge", + "cores": "16", + "disk": "SSD (4 x 800 GiB)", + "ram": "122 GiB", + }, + "i2.8xlarge": { + "id": "i2.8xlarge", + "cores": "32", + "disk": "SSD (8 x 800 GiB)", + "ram": "244 GiB", + }, + }, + "High Memory": { + "x1.16xlarge": { + "id": "x1.16xlarge", + "cores": "64 (with 5.45 ECUs each)", + "disk": "1920 GiB (1 x 1920 GiB)", + "ram": "976 GiB", + }, + "x1.32xlarge": { + "id": "x1.32xlarge", + "cores": "128 (with 2.73 ECUs each)", + "disk": "3840 GiB (2 x 1920 GiB)", + "ram": "1952 GiB", + }, + "r4.large": { + "id": "r4.large", + "cores": "2 (with 3.45 ECUs each)", + "disk": "EBS", + "ram": "15.25 GiB", + }, + "r4.xlarge": { + "id": "r4.xlarge", + "cores": "4 (with 3.35 ECUs each)", + "disk": "EBS", + "ram": "30.5 GiB", + }, + "r4.2xlarge": { + "id": "r4.2xlarge", + "cores": "8 (with 3.35 ECUs each)", + "disk": "EBS", + "ram": "61 GiB", + }, + "r4.4xlarge": { + "id": "r4.4xlarge", + "cores": "16 (with 3.3 ECUs each)", + "disk": "EBS", + "ram": "122 GiB", + }, + "r4.8xlarge": { + "id": "r4.8xlarge", + "cores": "32 (with 3.1 ECUs each)", + "disk": "EBS", + "ram": "244 GiB", + }, + "r4.16xlarge": { + "id": "r4.16xlarge", + "cores": "64 (with 3.05 ECUs each)", + "disk": "EBS", + "ram": "488 GiB", + }, + "r3.large": { + "id": "r3.large", + "cores": "2 (with 3.25 ECUs each)", + "disk": "32 GiB (1 x 32 GiB SSD)", + "ram": "15 GiB", + }, + "r3.xlarge": { + "id": "r3.xlarge", + "cores": "4 (with 3.25 ECUs each)", + "disk": "80 GiB (1 x 80 GiB SSD)", + "ram": "30.5 GiB", + }, + "r3.2xlarge": { + "id": "r3.2xlarge", + "cores": "8 (with 3.25 ECUs each)", + "disk": "160 GiB (1 x 160 GiB SSD)", + "ram": "61 GiB", + }, + "r3.4xlarge": { + "id": "r3.4xlarge", + "cores": "16 (with 3.25 ECUs each)", + "disk": "320 GiB (1 x 320 GiB SSD)", + "ram": "122 GiB", + }, + "r3.8xlarge": { + "id": "r3.8xlarge", + "cores": "32 (with 3.25 ECUs each)", + "disk": "640 GiB (2 x 320 GiB SSD)", + "ram": "244 GiB", + }, + }, + "High-Memory Cluster": { + "cr1.8xlarge": { + "id": "cr1.8xlarge", + "cores": "16 (2 x Intel Xeon E5-2670, eight-core)", + "disk": "240 GiB (2 x 120 GiB SSD)", + "ram": "244 GiB", + }, + }, + "High Storage": { + "hs1.8xlarge": { + "id": "hs1.8xlarge", + "cores": "16 (8 cores + 8 hyperthreads)", + "disk": "48 TiB (24 x 2 TiB hard disk drives)", + "ram": "117 GiB", + }, + }, + "General Purpose": { + "t2.nano": {"id": "t2.nano", "cores": "1", "disk": "EBS", "ram": "512 MiB"}, + "t2.micro": {"id": "t2.micro", "cores": "1", "disk": "EBS", "ram": "1 GiB"}, + "t2.small": {"id": "t2.small", "cores": "1", "disk": "EBS", "ram": "2 GiB"}, + "t2.medium": { + "id": "t2.medium", + "cores": "2", + "disk": "EBS", + "ram": "4 GiB", + }, + "t2.large": {"id": "t2.large", "cores": "2", "disk": "EBS", "ram": "8 GiB"}, + "t2.xlarge": { + "id": "t2.xlarge", + "cores": "4", + "disk": "EBS", + "ram": "16 GiB", + }, + "t2.2xlarge": { + "id": "t2.2xlarge", + "cores": "8", + "disk": "EBS", + "ram": "32 GiB", + }, + "m4.large": { + "id": "m4.large", + "cores": "2", + "disk": "EBS - 450 Mbps", + "ram": "8 GiB", + }, + "m4.xlarge": { + "id": "m4.xlarge", + "cores": "4", + "disk": "EBS - 750 Mbps", + "ram": "16 GiB", + }, + "m4.2xlarge": { + "id": "m4.2xlarge", + "cores": "8", + "disk": "EBS - 1000 Mbps", + "ram": "32 GiB", + }, + "m4.4xlarge": { + "id": "m4.4xlarge", + "cores": "16", + "disk": "EBS - 2000 Mbps", + "ram": "64 GiB", + }, + "m4.10xlarge": { + "id": "m4.10xlarge", + "cores": "40", + "disk": "EBS - 4000 Mbps", + "ram": "160 GiB", + }, + "m4.16xlarge": { + "id": "m4.16xlarge", + "cores": "64", + "disk": "EBS - 10000 Mbps", + "ram": "256 GiB", + }, + "m3.medium": { + "id": "m3.medium", + "cores": "1", + "disk": "SSD (1 x 4)", + "ram": "3.75 GiB", + }, + "m3.large": { + "id": "m3.large", + "cores": "2", + "disk": "SSD (1 x 32)", + "ram": "7.5 GiB", + }, + "m3.xlarge": { + "id": "m3.xlarge", + "cores": "4", + "disk": "SSD (2 x 40)", + "ram": "15 GiB", + }, + "m3.2xlarge": { + "id": "m3.2xlarge", + "cores": "8", + "disk": "SSD (2 x 80)", + "ram": "30 GiB", + }, + }, + } + return sizes + + +def avail_images(kwargs=None, call=None): + """ + Return a dict of all available VM images on the cloud provider. + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + if not isinstance(kwargs, dict): + kwargs = {} + + if "owner" in kwargs: + owner = kwargs["owner"] + else: + provider = get_configured_provider() + + owner = config.get_cloud_config_value( + "owner", provider, __opts__, default="amazon" + ) + + ret = {} + params = {"Action": "DescribeImages", "Owner": owner} + images = aws.query( + params, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + for image in images: + ret[image["imageId"]] = image + return ret + + +def script(vm_): + """ + Return the script deployment object + """ + return salt.utils.cloud.os_script( + config.get_cloud_config_value("script", vm_, __opts__), + vm_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, vm_) + ), + ) + + +def keyname(vm_): + """ + Return the keyname + """ + return config.get_cloud_config_value("keyname", vm_, __opts__, search_global=False) + + +def securitygroup(vm_): + """ + Return the security group + """ + return config.get_cloud_config_value( + "securitygroup", vm_, __opts__, search_global=False + ) + + +def iam_profile(vm_): + """ + Return the IAM profile. + + The IAM instance profile to associate with the instances. + This is either the Amazon Resource Name (ARN) of the instance profile + or the name of the role. + + Type: String + + Default: None + + Required: No + + Example: arn:aws:iam::111111111111:instance-profile/s3access + + Example: s3access + + """ + return config.get_cloud_config_value( + "iam_profile", vm_, __opts__, search_global=False + ) + + +def ssh_interface(vm_): + """ + Return the ssh_interface type to connect to. Either 'public_ips' (default) + or 'private_ips'. + """ + ret = config.get_cloud_config_value( + "ssh_interface", vm_, __opts__, default="public_ips", search_global=False + ) + if ret not in ("public_ips", "private_ips"): + log.warning( + "Invalid ssh_interface: %s. " + 'Allowed options are ("public_ips", "private_ips"). ' + 'Defaulting to "public_ips".', + ret, + ) + ret = "public_ips" + return ret + + +def get_ssh_gateway_config(vm_): + """ + Return the ssh_gateway configuration. + """ + ssh_gateway = config.get_cloud_config_value( + "ssh_gateway", vm_, __opts__, default=None, search_global=False + ) + + # Check to see if a SSH Gateway will be used. + if not isinstance(ssh_gateway, str): + return None + + # Create dictionary of configuration items + + # ssh_gateway + ssh_gateway_config = {"ssh_gateway": ssh_gateway} + + # ssh_gateway_port + ssh_gateway_config["ssh_gateway_port"] = config.get_cloud_config_value( + "ssh_gateway_port", vm_, __opts__, default=None, search_global=False + ) + + # ssh_gateway_username + ssh_gateway_config["ssh_gateway_user"] = config.get_cloud_config_value( + "ssh_gateway_username", vm_, __opts__, default=None, search_global=False + ) + + # ssh_gateway_private_key + ssh_gateway_config["ssh_gateway_key"] = config.get_cloud_config_value( + "ssh_gateway_private_key", vm_, __opts__, default=None, search_global=False + ) + + # ssh_gateway_password + ssh_gateway_config["ssh_gateway_password"] = config.get_cloud_config_value( + "ssh_gateway_password", vm_, __opts__, default=None, search_global=False + ) + + # ssh_gateway_command + ssh_gateway_config["ssh_gateway_command"] = config.get_cloud_config_value( + "ssh_gateway_command", vm_, __opts__, default=None, search_global=False + ) + + # Check if private key exists + key_filename = ssh_gateway_config["ssh_gateway_key"] + if key_filename is not None and not os.path.isfile(key_filename): + raise SaltCloudConfigError( + "The defined ssh_gateway_private_key '{}' does not exist".format( + key_filename + ) + ) + elif key_filename is None and not ssh_gateway_config["ssh_gateway_password"]: + raise SaltCloudConfigError( + "No authentication method. Please define: " + " ssh_gateway_password or ssh_gateway_private_key" + ) + + return ssh_gateway_config + + +def get_location(vm_=None): + """ + Return the EC2 region to use, in this order: + - CLI parameter + - VM parameter + - Cloud profile setting + """ + return __opts__.get( + "location", + config.get_cloud_config_value( + "location", + vm_ or get_configured_provider(), + __opts__, + default=DEFAULT_LOCATION, + search_global=False, + ), + ) + + +def avail_locations(call=None): + """ + List all available locations + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + ret = {} + + params = {"Action": "DescribeRegions"} + result = aws.query( + params, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + for region in result: + ret[region["regionName"]] = { + "name": region["regionName"], + "endpoint": region["regionEndpoint"], + } + + return ret + + +def get_availability_zone(vm_): + """ + Return the availability zone to use + """ + avz = config.get_cloud_config_value( + "availability_zone", vm_, __opts__, search_global=False + ) + + if avz is None: + return None + + zones = list_availability_zones(vm_) + + # Validate user-specified AZ + if avz not in zones: + raise SaltCloudException( + "The specified availability zone isn't valid in this region: {}\n".format( + avz + ) + ) + + # check specified AZ is available + elif zones[avz] != "available": + raise SaltCloudException( + "The specified availability zone isn't currently available: {}\n".format( + avz + ) + ) + + return avz + + +def get_tenancy(vm_): + """ + Returns the Tenancy to use. + + Can be "dedicated" or "default". Cannot be present for spot instances. + """ + return config.get_cloud_config_value("tenancy", vm_, __opts__, search_global=False) + + +def get_imageid(vm_): + """ + Returns the ImageId to use + """ + image = config.get_cloud_config_value("image", vm_, __opts__, search_global=False) + if image.startswith("ami-"): + return image + # a poor man's cache + if not hasattr(get_imageid, "images"): + get_imageid.images = {} + elif image in get_imageid.images: + return get_imageid.images[image] + params = { + "Action": "DescribeImages", + "Filter.0.Name": "name", + "Filter.0.Value.0": image, + } + + # Query AWS, sort by 'creationDate' and get the last imageId + def _t(x): + return datetime.datetime.strptime(x["creationDate"], "%Y-%m-%dT%H:%M:%S.%fZ") + + image_id = sorted( + aws.query( + params, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ), + key=cmp_to_key(lambda i, j: salt.utils.compat.cmp(_t(i), _t(j))), + )[-1]["imageId"] + get_imageid.images[image] = image_id + return image_id + + +def _get_subnetname_id(subnetname): + """ + Returns the SubnetId of a SubnetName to use + """ + params = {"Action": "DescribeSubnets"} + for subnet in aws.query( + params, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ): + if "tagSet" in subnet: + tags = subnet.get("tagSet", {}).get("item", []) + if not isinstance(tags, list): + tags = [tags] + for tag in tags: + if tag["key"] == "Name" and tag["value"] == subnetname: + log.debug( + "AWS Subnet ID of %s is %s", subnetname, subnet["subnetId"] + ) + return subnet["subnetId"] + return None + + +def get_subnetid(vm_): + """ + Returns the SubnetId to use + """ + subnetid = config.get_cloud_config_value( + "subnetid", vm_, __opts__, search_global=False + ) + if subnetid: + return subnetid + + subnetname = config.get_cloud_config_value( + "subnetname", vm_, __opts__, search_global=False + ) + if subnetname: + return _get_subnetname_id(subnetname) + return None + + +def _get_securitygroupname_id(securitygroupname_list): + """ + Returns the SecurityGroupId of a SecurityGroupName to use + """ + securitygroupid_set = set() + if not isinstance(securitygroupname_list, list): + securitygroupname_list = [securitygroupname_list] + params = {"Action": "DescribeSecurityGroups"} + for sg in aws.query( + params, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ): + if sg["groupName"] in securitygroupname_list: + log.debug( + "AWS SecurityGroup ID of %s is %s", sg["groupName"], sg["groupId"] + ) + securitygroupid_set.add(sg["groupId"]) + return list(securitygroupid_set) + + +def securitygroupid(vm_): + """ + Returns the SecurityGroupId + """ + securitygroupid_set = set() + securitygroupid_list = config.get_cloud_config_value( + "securitygroupid", vm_, __opts__, search_global=False + ) + # If the list is None, then the set will remain empty + # If the list is already a set then calling 'set' on it is a no-op + # If the list is a string, then calling 'set' generates a one-element set + # If the list is anything else, stacktrace + if securitygroupid_list: + securitygroupid_set = securitygroupid_set.union(set(securitygroupid_list)) + + securitygroupname_list = config.get_cloud_config_value( + "securitygroupname", vm_, __opts__, search_global=False + ) + if securitygroupname_list: + if not isinstance(securitygroupname_list, list): + securitygroupname_list = [securitygroupname_list] + params = {"Action": "DescribeSecurityGroups"} + for sg in aws.query( + params, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ): + if sg["groupName"] in securitygroupname_list: + log.debug( + "AWS SecurityGroup ID of %s is %s", sg["groupName"], sg["groupId"] + ) + securitygroupid_set.add(sg["groupId"]) + return list(securitygroupid_set) + + +def get_placementgroup(vm_): + """ + Returns the PlacementGroup to use + """ + return config.get_cloud_config_value( + "placementgroup", vm_, __opts__, search_global=False + ) + + +def get_spot_config(vm_): + """ + Returns the spot instance configuration for the provided vm + """ + return config.get_cloud_config_value( + "spot_config", vm_, __opts__, search_global=False + ) + + +def get_provider(vm_=None): + """ + Extract the provider name from vm + """ + if vm_ is None: + provider = _get_active_provider_name() or "ec2" + else: + provider = vm_.get("provider", "ec2") + + if ":" in provider: + prov_comps = provider.split(":") + provider = prov_comps[0] + return provider + + +def list_availability_zones(vm_=None): + """ + List all availability zones in the current region + """ + ret = {} + + params = { + "Action": "DescribeAvailabilityZones", + "Filter.0.Name": "region-name", + "Filter.0.Value.0": get_location(vm_), + } + result = aws.query( + params, + location=get_location(vm_), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + for zone in result: + ret[zone["zoneName"]] = zone["zoneState"] + + return ret + + +def block_device_mappings(vm_): + """ + Return the block device mapping: + + .. code-block:: python + + [{'DeviceName': '/dev/sdb', 'VirtualName': 'ephemeral0'}, + {'DeviceName': '/dev/sdc', 'VirtualName': 'ephemeral1'}] + """ + return config.get_cloud_config_value( + "block_device_mappings", vm_, __opts__, search_global=True + ) + + +def _request_eip(interface, vm_): + """ + Request and return Elastic IP + """ + params = {"Action": "AllocateAddress"} + params["Domain"] = interface.setdefault("domain", "vpc") + eips = aws.query( + params, + return_root=True, + location=get_location(vm_), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + for eip in eips: + if "allocationId" in eip: + return eip["allocationId"] + return None + + +def _create_eni_if_necessary(interface, vm_): + """ + Create an Elastic Interface if necessary and return a Network Interface Specification + """ + if ( + "NetworkInterfaceId" in interface + and interface["NetworkInterfaceId"] is not None + ): + return { + "DeviceIndex": interface["DeviceIndex"], + "NetworkInterfaceId": interface["NetworkInterfaceId"], + } + + params = {"Action": "DescribeSubnets"} + subnet_query = aws.query( + params, + return_root=True, + location=get_location(vm_), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + if "SecurityGroupId" not in interface and "securitygroupname" in interface: + interface["SecurityGroupId"] = _get_securitygroupname_id( + interface["securitygroupname"] + ) + if "SubnetId" not in interface and "subnetname" in interface: + interface["SubnetId"] = _get_subnetname_id(interface["subnetname"]) + + subnet_id = _get_subnet_id_for_interface(subnet_query, interface) + if not subnet_id: + raise SaltCloudConfigError( + "No such subnet <{}>".format(interface.get("SubnetId")) + ) + params = {"SubnetId": subnet_id} + + for k in "Description", "PrivateIpAddress", "SecondaryPrivateIpAddressCount": + if k in interface: + params[k] = interface[k] + + for k in "PrivateIpAddresses", "SecurityGroupId": + if k in interface: + params.update(_param_from_config(k, interface[k])) + + if "AssociatePublicIpAddress" in interface: + # Associating a public address in a VPC only works when the interface is not + # created beforehand, but as a part of the machine creation request. + for k in ("DeviceIndex", "AssociatePublicIpAddress", "NetworkInterfaceId"): + if k in interface: + params[k] = interface[k] + params["DeleteOnTermination"] = interface.get( + "delete_interface_on_terminate", True + ) + return params + + params["Action"] = "CreateNetworkInterface" + + result = aws.query( + params, + return_root=True, + location=get_location(vm_), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + eni_desc = result[1] + if not eni_desc or not eni_desc.get("networkInterfaceId"): + raise SaltCloudException(f"Failed to create interface: {result}") + + eni_id = eni_desc.get("networkInterfaceId") + log.debug("Created network interface %s inst %s", eni_id, interface["DeviceIndex"]) + + associate_public_ip = interface.get("AssociatePublicIpAddress", False) + if isinstance(associate_public_ip, str): + # Assume id of EIP as value + _associate_eip_with_interface(eni_id, associate_public_ip, vm_=vm_) + + if interface.get("associate_eip"): + _associate_eip_with_interface(eni_id, interface.get("associate_eip"), vm_=vm_) + elif interface.get("allocate_new_eip"): + _new_eip = _request_eip(interface, vm_) + _associate_eip_with_interface(eni_id, _new_eip, vm_=vm_) + elif interface.get("allocate_new_eips"): + addr_list = _list_interface_private_addrs(eni_desc) + eip_list = [] + for idx, addr in enumerate(addr_list): + eip_list.append(_request_eip(interface, vm_)) + for idx, addr in enumerate(addr_list): + _associate_eip_with_interface(eni_id, eip_list[idx], addr, vm_=vm_) + + if "Name" in interface: + tag_params = { + "Action": "CreateTags", + "ResourceId.0": eni_id, + "Tag.0.Key": "Name", + "Tag.0.Value": interface["Name"], + } + tag_response = aws.query( + tag_params, + return_root=True, + location=get_location(vm_), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + if "error" in tag_response: + log.error("Failed to set name of interface {0}") + + return {"DeviceIndex": interface["DeviceIndex"], "NetworkInterfaceId": eni_id} + + +def _get_subnet_id_for_interface(subnet_query, interface): + for subnet_query_result in subnet_query: + if "item" in subnet_query_result: + if isinstance(subnet_query_result["item"], dict): + subnet_id = _get_subnet_from_subnet_query( + subnet_query_result["item"], interface + ) + if subnet_id is not None: + return subnet_id + + else: + for subnet in subnet_query_result["item"]: + subnet_id = _get_subnet_from_subnet_query(subnet, interface) + if subnet_id is not None: + return subnet_id + + +def _get_subnet_from_subnet_query(subnet_query, interface): + if "subnetId" in subnet_query: + if interface.get("SubnetId"): + if subnet_query["subnetId"] == interface["SubnetId"]: + return subnet_query["subnetId"] + else: + return subnet_query["subnetId"] + + +def _list_interface_private_addrs(eni_desc): + """ + Returns a list of all of the private IP addresses attached to a + network interface. The 'primary' address will be listed first. + """ + primary = eni_desc.get("privateIpAddress") + if not primary: + return None + + addresses = [primary] + + lst = eni_desc.get("privateIpAddressesSet", {}).get("item", []) + if not isinstance(lst, list): + return addresses + + for entry in lst: + if entry.get("primary") == "true": + continue + if entry.get("privateIpAddress"): + addresses.append(entry.get("privateIpAddress")) + + return addresses + + +def _modify_eni_properties(eni_id, properties=None, vm_=None): + """ + Change properties of the interface + with id eni_id to the values in properties dict + """ + if not isinstance(properties, dict): + raise SaltCloudException("ENI properties must be a dictionary") + + params = {"Action": "ModifyNetworkInterfaceAttribute", "NetworkInterfaceId": eni_id} + for k, v in properties.items(): + params[k] = v + + result = aws.query( + params, + return_root=True, + location=get_location(vm_), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + if isinstance(result, dict) and result.get("error"): + raise SaltCloudException( + "Could not change interface <{}> attributes <'{}'>".format( + eni_id, properties + ) + ) + else: + return result + + +def _associate_eip_with_interface(eni_id, eip_id, private_ip=None, vm_=None): + """ + Accept the id of a network interface, and the id of an elastic ip + address, and associate the two of them, such that traffic sent to the + elastic ip address will be forwarded (NATted) to this network interface. + + Optionally specify the private (10.x.x.x) IP address that traffic should + be NATted to - useful if you have multiple IP addresses assigned to an + interface. + """ + params = { + "Action": "AssociateAddress", + "NetworkInterfaceId": eni_id, + "AllocationId": eip_id, + } + + if private_ip: + params["PrivateIpAddress"] = private_ip + + result = aws.query( + params, + return_root=True, + location=get_location(vm_), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + if not result[2].get("associationId"): + raise SaltCloudException( + "Could not associate elastic ip address " + "<{}> with network interface <{}>".format(eip_id, eni_id) + ) + + log.debug("Associated ElasticIP address %s with interface %s", eip_id, eni_id) + + return result[2].get("associationId") + + +def _update_enis(interfaces, instance, vm_=None): + config_enis = {} + instance_enis = [] + for interface in interfaces: + if "DeviceIndex" in interface: + if interface["DeviceIndex"] in config_enis: + log.error("Duplicate DeviceIndex in profile. Cannot update ENIs.") + return None + config_enis[str(interface["DeviceIndex"])] = interface + query_enis = instance[0]["instancesSet"]["item"]["networkInterfaceSet"]["item"] + if isinstance(query_enis, list): + for query_eni in query_enis: + instance_enis.append( + (query_eni["networkInterfaceId"], query_eni["attachment"]) + ) + else: + instance_enis.append( + (query_enis["networkInterfaceId"], query_enis["attachment"]) + ) + + for eni_id, eni_data in instance_enis: + delete_on_terminate = True + if "DeleteOnTermination" in config_enis[eni_data["deviceIndex"]]: + delete_on_terminate = config_enis[eni_data["deviceIndex"]][ + "DeleteOnTermination" + ] + elif "delete_interface_on_terminate" in config_enis[eni_data["deviceIndex"]]: + delete_on_terminate = config_enis[eni_data["deviceIndex"]][ + "delete_interface_on_terminate" + ] + + params_attachment = { + "Attachment.AttachmentId": eni_data["attachmentId"], + "Attachment.DeleteOnTermination": delete_on_terminate, + } + set_eni_attachment_attributes = _modify_eni_properties( + eni_id, params_attachment, vm_=vm_ + ) + + if "SourceDestCheck" in config_enis[eni_data["deviceIndex"]]: + params_sourcedest = { + "SourceDestCheck.Value": config_enis[eni_data["deviceIndex"]][ + "SourceDestCheck" + ] + } + set_eni_sourcedest_property = _modify_eni_properties( + eni_id, params_sourcedest, vm_=vm_ + ) + + return None + + +def _param_from_config(key, data): + """ + Return EC2 API parameters based on the given config data. + + Examples: + 1. List of dictionaries + >>> data = [ + ... {'DeviceIndex': 0, 'SubnetId': 'subid0', + ... 'AssociatePublicIpAddress': True}, + ... {'DeviceIndex': 1, + ... 'SubnetId': 'subid1', + ... 'PrivateIpAddress': '192.168.1.128'} + ... ] + >>> _param_from_config('NetworkInterface', data) + ... {'NetworkInterface.0.SubnetId': 'subid0', + ... 'NetworkInterface.0.DeviceIndex': 0, + ... 'NetworkInterface.1.SubnetId': 'subid1', + ... 'NetworkInterface.1.PrivateIpAddress': '192.168.1.128', + ... 'NetworkInterface.0.AssociatePublicIpAddress': 'true', + ... 'NetworkInterface.1.DeviceIndex': 1} + + 2. List of nested dictionaries + >>> data = [ + ... {'DeviceName': '/dev/sdf', + ... 'Ebs': { + ... 'SnapshotId': 'dummy0', + ... 'VolumeSize': 200, + ... 'VolumeType': 'standard'}}, + ... {'DeviceName': '/dev/sdg', + ... 'Ebs': { + ... 'SnapshotId': 'dummy1', + ... 'VolumeSize': 100, + ... 'VolumeType': 'standard'}} + ... ] + >>> _param_from_config('BlockDeviceMapping', data) + ... {'BlockDeviceMapping.0.Ebs.VolumeType': 'standard', + ... 'BlockDeviceMapping.1.Ebs.SnapshotId': 'dummy1', + ... 'BlockDeviceMapping.0.Ebs.VolumeSize': 200, + ... 'BlockDeviceMapping.0.Ebs.SnapshotId': 'dummy0', + ... 'BlockDeviceMapping.1.Ebs.VolumeType': 'standard', + ... 'BlockDeviceMapping.1.DeviceName': '/dev/sdg', + ... 'BlockDeviceMapping.1.Ebs.VolumeSize': 100, + ... 'BlockDeviceMapping.0.DeviceName': '/dev/sdf'} + + 3. Dictionary of dictionaries + >>> data = { 'Arn': 'dummyarn', 'Name': 'Tester' } + >>> _param_from_config('IamInstanceProfile', data) + {'IamInstanceProfile.Arn': 'dummyarn', 'IamInstanceProfile.Name': 'Tester'} + + """ + + param = {} + + if isinstance(data, dict): + for k, v in data.items(): + param.update(_param_from_config(f"{key}.{k}", v)) + + elif isinstance(data, list) or isinstance(data, tuple): + for idx, conf_item in enumerate(data): + prefix = f"{key}.{idx}" + param.update(_param_from_config(prefix, conf_item)) + + else: + if isinstance(data, bool): + # convert boolean True/False to 'true'/'false' + param.update({key: str(data).lower()}) + else: + param.update({key: data}) + + return param + + +def request_instance(vm_=None, call=None): + """ + Put together all of the information necessary to request an instance on EC2, + and then fire off the request the instance. + + Returns data about the instance + """ + if call == "function": + # Technically this function may be called other ways too, but it + # definitely cannot be called with --function. + raise SaltCloudSystemExit( + "The request_instance action must be called with -a or --action." + ) + + location = vm_.get("location", get_location(vm_)) + + # do we launch a regular vm or a spot instance? + # see http://goo.gl/hYZ13f for more information on EC2 API + spot_config = get_spot_config(vm_) + if spot_config is not None: + if "spot_price" not in spot_config: + raise SaltCloudSystemExit( + "Spot instance config for {} requires a spot_price attribute.".format( + vm_["name"] + ) + ) + + params = { + "Action": "RequestSpotInstances", + "InstanceCount": "1", + "Type": spot_config["type"] if "type" in spot_config else "one-time", + "SpotPrice": spot_config["spot_price"], + } + + # All of the necessary launch parameters for a VM when using + # spot instances are the same except for the prefix below + # being tacked on. + spot_prefix = "LaunchSpecification." + + # regular EC2 instance + else: + # WARNING! EXPERIMENTAL! + # This allows more than one instance to be spun up in a single call. + # The first instance will be called by the name provided, but all other + # instances will be nameless (or more specifically, they will use the + # InstanceId as the name). This interface is expected to change, so + # use at your own risk. + min_instance = config.get_cloud_config_value( + "min_instance", vm_, __opts__, search_global=False, default=1 + ) + max_instance = config.get_cloud_config_value( + "max_instance", vm_, __opts__, search_global=False, default=1 + ) + params = { + "Action": "RunInstances", + "MinCount": min_instance, + "MaxCount": max_instance, + } + + # Normal instances should have no prefix. + spot_prefix = "" + + image_id = get_imageid(vm_) + params[spot_prefix + "ImageId"] = image_id + + userdata = None + userdata_file = config.get_cloud_config_value( + "userdata_file", vm_, __opts__, search_global=False, default=None + ) + if userdata_file is None: + userdata = config.get_cloud_config_value( + "userdata", vm_, __opts__, search_global=False, default=None + ) + else: + log.trace("userdata_file: %s", userdata_file) + if os.path.exists(userdata_file): + with salt.utils.files.fopen(userdata_file, "r") as fh_: + userdata = salt.utils.stringutils.to_unicode(fh_.read()) + + userdata = salt.utils.cloud.userdata_template(__opts__, vm_, userdata) + + if userdata is not None: + try: + params[spot_prefix + "UserData"] = base64.b64encode( + salt.utils.stringutils.to_bytes(userdata) + ) + except Exception as exc: # pylint: disable=broad-except + log.exception("Failed to encode userdata: %s", exc) + + vm_size = config.get_cloud_config_value("size", vm_, __opts__, search_global=False) + params[spot_prefix + "InstanceType"] = vm_size + + ex_keyname = keyname(vm_) + if ex_keyname: + params[spot_prefix + "KeyName"] = ex_keyname + + ex_securitygroup = securitygroup(vm_) + if ex_securitygroup: + if not isinstance(ex_securitygroup, list): + params[spot_prefix + "SecurityGroup.1"] = ex_securitygroup + else: + for counter, sg_ in enumerate(ex_securitygroup): + params[spot_prefix + f"SecurityGroup.{counter}"] = sg_ + + ex_iam_profile = iam_profile(vm_) + if ex_iam_profile: + try: + if ex_iam_profile.startswith("arn:aws:iam:"): + params[spot_prefix + "IamInstanceProfile.Arn"] = ex_iam_profile + else: + params[spot_prefix + "IamInstanceProfile.Name"] = ex_iam_profile + except AttributeError: + raise SaltCloudConfigError("'iam_profile' should be a string value.") + + az_ = get_availability_zone(vm_) + if az_ is not None: + params[spot_prefix + "Placement.AvailabilityZone"] = az_ + + tenancy_ = get_tenancy(vm_) + if tenancy_ is not None: + if spot_config is not None: + raise SaltCloudConfigError( + "Spot instance config for {} does not support " + "specifying tenancy.".format(vm_["name"]) + ) + params["Placement.Tenancy"] = tenancy_ + + subnetid_ = get_subnetid(vm_) + if subnetid_ is not None: + params[spot_prefix + "SubnetId"] = subnetid_ + + ex_securitygroupid = securitygroupid(vm_) + if ex_securitygroupid: + if not isinstance(ex_securitygroupid, list): + params[spot_prefix + "SecurityGroupId.1"] = ex_securitygroupid + else: + for counter, sg_ in enumerate(ex_securitygroupid): + params[spot_prefix + f"SecurityGroupId.{counter}"] = sg_ + + placementgroup_ = get_placementgroup(vm_) + if placementgroup_ is not None: + params[spot_prefix + "Placement.GroupName"] = placementgroup_ + + blockdevicemappings_holder = block_device_mappings(vm_) + if blockdevicemappings_holder: + for _bd in blockdevicemappings_holder: + if "tag" in _bd: + _bd.pop("tag") + + ex_blockdevicemappings = blockdevicemappings_holder + if ex_blockdevicemappings: + params.update( + _param_from_config( + spot_prefix + "BlockDeviceMapping", ex_blockdevicemappings + ) + ) + + network_interfaces = config.get_cloud_config_value( + "network_interfaces", vm_, __opts__, search_global=False + ) + + if network_interfaces: + eni_devices = [] + for interface in network_interfaces: + log.debug("Create network interface: %s", interface) + _new_eni = _create_eni_if_necessary(interface, vm_) + eni_devices.append(_new_eni) + params.update(_param_from_config(spot_prefix + "NetworkInterface", eni_devices)) + + set_ebs_optimized = config.get_cloud_config_value( + "ebs_optimized", vm_, __opts__, search_global=False + ) + + if set_ebs_optimized is not None: + if not isinstance(set_ebs_optimized, bool): + raise SaltCloudConfigError("'ebs_optimized' should be a boolean value.") + params[spot_prefix + "EbsOptimized"] = set_ebs_optimized + + set_del_root_vol_on_destroy = config.get_cloud_config_value( + "del_root_vol_on_destroy", vm_, __opts__, search_global=False + ) + + set_termination_protection = config.get_cloud_config_value( + "termination_protection", vm_, __opts__, search_global=False + ) + + if set_termination_protection is not None: + if not isinstance(set_termination_protection, bool): + raise SaltCloudConfigError( + "'termination_protection' should be a boolean value." + ) + params.update( + _param_from_config( + spot_prefix + "DisableApiTermination", set_termination_protection + ) + ) + + if set_del_root_vol_on_destroy and not isinstance( + set_del_root_vol_on_destroy, bool + ): + raise SaltCloudConfigError( + "'del_root_vol_on_destroy' should be a boolean value." + ) + + vm_["set_del_root_vol_on_destroy"] = set_del_root_vol_on_destroy + + if set_del_root_vol_on_destroy: + # first make sure to look up the root device name + # as Ubuntu and CentOS (and most likely other OSs) + # use different device identifiers + + log.info( + "Attempting to look up root device name for image id %s on VM %s", + image_id, + vm_["name"], + ) + + rd_params = {"Action": "DescribeImages", "ImageId.1": image_id} + try: + rd_data = aws.query( + rd_params, + location=get_location(vm_), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + if "error" in rd_data: + return rd_data["error"] + log.debug("EC2 Response: '%s'", rd_data) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error getting root device name for image id %s for VM %s: \n%s", + image_id, + vm_["name"], + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + raise + + # make sure we have a response + if not rd_data: + err_msg = ( + "There was an error querying EC2 for the root device " + "of image id {}. Empty response.".format(image_id) + ) + raise SaltCloudSystemExit(err_msg) + + # pull the root device name from the result and use it when + # launching the new VM + rd_name = None + rd_type = None + if "blockDeviceMapping" in rd_data[0]: + # Some ami instances do not have a root volume. Ignore such cases + if rd_data[0]["blockDeviceMapping"] is not None: + item = rd_data[0]["blockDeviceMapping"]["item"] + if isinstance(item, list): + item = item[0] + rd_name = item["deviceName"] + # Grab the volume type + rd_type = item["ebs"].get("volumeType", None) + + log.info("Found root device name: %s", rd_name) + + if rd_name is not None: + if ex_blockdevicemappings: + dev_list = [dev["DeviceName"] for dev in ex_blockdevicemappings] + else: + dev_list = [] + + if rd_name in dev_list: + # Device already listed, just grab the index + dev_index = dev_list.index(rd_name) + else: + dev_index = len(dev_list) + # Add the device name in since it wasn't already there + params[f"{spot_prefix}BlockDeviceMapping.{dev_index}.DeviceName"] = ( + rd_name + ) + + # Set the termination value + termination_key = "{}BlockDeviceMapping.{}.Ebs.DeleteOnTermination".format( + spot_prefix, dev_index + ) + params[termination_key] = str(set_del_root_vol_on_destroy).lower() + + # Use default volume type if not specified + if ( + ex_blockdevicemappings + and dev_index < len(ex_blockdevicemappings) + and "Ebs.VolumeType" not in ex_blockdevicemappings[dev_index] + ): + type_key = "{}BlockDeviceMapping.{}.Ebs.VolumeType".format( + spot_prefix, dev_index + ) + params[type_key] = rd_type + + set_del_all_vols_on_destroy = config.get_cloud_config_value( + "del_all_vols_on_destroy", vm_, __opts__, search_global=False, default=False + ) + + if set_del_all_vols_on_destroy and not isinstance( + set_del_all_vols_on_destroy, bool + ): + raise SaltCloudConfigError( + "'del_all_vols_on_destroy' should be a boolean value." + ) + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", params, list(params) + ), + "location": location, + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + provider = get_provider(vm_) + + try: + data = aws.query( + params, + "instancesSet", + location=location, + provider=provider, + opts=__opts__, + sigver="4", + ) + if "error" in data: + return data["error"] + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on EC2 when trying to run the initial deployment: \n%s", + vm_["name"], + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + raise + + # if we're using spot instances, we need to wait for the spot request + # to become active before we continue + if spot_config: + sir_id = data[0]["spotInstanceRequestId"] + + vm_["spotRequestId"] = sir_id + + def __query_spot_instance_request(sir_id, location): + params = { + "Action": "DescribeSpotInstanceRequests", + "SpotInstanceRequestId.1": sir_id, + } + data = aws.query( + params, location=location, provider=provider, opts=__opts__, sigver="4" + ) + if not data: + log.error("There was an error while querying EC2. Empty response") + # Trigger a failure in the wait for spot instance method + return False + + if isinstance(data, dict) and "error" in data: + log.warning("There was an error in the query. %s", data["error"]) + # Trigger a failure in the wait for spot instance method + return False + + log.debug("Returned query data: %s", data) + + state = data[0].get("state") + + if state == "active": + return data + + if state == "open": + # Still waiting for an active state + log.info("Spot instance status: %s", data[0]["status"]["message"]) + return None + + if state in ["cancelled", "failed", "closed"]: + # Request will never be active, fail + log.error( + "Spot instance request resulted in state '{0}'. " + "Nothing else we can do here." + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "waiting for spot instance", + "salt/cloud/{}/waiting_for_spot".format(vm_["name"]), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + data = _wait_for_spot_instance( + __query_spot_instance_request, + update_args=(sir_id, location), + timeout=config.get_cloud_config_value( + "wait_for_spot_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_spot_interval", vm_, __opts__, default=30 + ), + interval_multiplier=config.get_cloud_config_value( + "wait_for_spot_interval_multiplier", vm_, __opts__, default=1 + ), + max_failures=config.get_cloud_config_value( + "wait_for_spot_max_failures", vm_, __opts__, default=10 + ), + ) + log.debug("wait_for_spot_instance data %s", data) + + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # Cancel the existing spot instance request + params = { + "Action": "CancelSpotInstanceRequests", + "SpotInstanceRequestId.1": sir_id, + } + data = aws.query( + params, + location=location, + provider=provider, + opts=__opts__, + sigver="4", + ) + + log.debug( + "Canceled spot instance request %s. Data returned: %s", + sir_id, + data, + ) + + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + return data, vm_ + + +def query_instance(vm_=None, call=None): + """ + Query an instance upon creation from the EC2 API + """ + if call == "function": + # Technically this function may be called other ways too, but it + # definitely cannot be called with --function. + raise SaltCloudSystemExit( + "The query_instance action must be called with -a or --action." + ) + + instance_id = vm_["instance_id"] + location = vm_.get("location", get_location(vm_)) + __utils__["cloud.fire_event"]( + "event", + "querying instance", + "salt/cloud/{}/querying".format(vm_["name"]), + args={"instance_id": instance_id}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.debug("The new VM instance_id is %s", instance_id) + + params = {"Action": "DescribeInstances", "InstanceId.1": instance_id} + + provider = get_provider(vm_) + + attempts = 0 + while attempts < aws.AWS_MAX_RETRIES: + data, requesturl = aws.query( + params, # pylint: disable=unbalanced-tuple-unpacking + location=location, + provider=provider, + opts=__opts__, + return_url=True, + sigver="4", + ) + log.debug("The query returned: %s", data) + + if isinstance(data, dict) and "error" in data: + log.warning( + "There was an error in the query. %s attempts remaining: %s", + attempts, + data["error"], + ) + elif isinstance(data, list) and not data: + log.warning( + "Query returned an empty list. %s attempts remaining.", attempts + ) + else: + break + + aws.sleep_exponential_backoff(attempts) + attempts += 1 + continue + else: + raise SaltCloudSystemExit( + "An error occurred while creating VM: {}".format(data["error"]) + ) + + def __query_ip_address(params, url): # pylint: disable=W0613 + data = aws.query( + params, location=location, provider=provider, opts=__opts__, sigver="4" + ) + if not data: + log.error("There was an error while querying EC2. Empty response") + # Trigger a failure in the wait for IP function + return False + + if isinstance(data, dict) and "error" in data: + log.warning("There was an error in the query. %s", data["error"]) + # Trigger a failure in the wait for IP function + return False + + log.debug("Returned query data: %s", data) + + if ssh_interface(vm_) == "public_ips": + if "ipAddress" in data[0]["instancesSet"]["item"]: + return data + else: + log.error("Public IP not detected.") + + if ssh_interface(vm_) == "private_ips": + if "privateIpAddress" in data[0]["instancesSet"]["item"]: + return data + else: + log.error("Private IP not detected.") + + try: + data = salt.utils.cloud.wait_for_ip( + __query_ip_address, + update_args=(params, requesturl), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=10 + ), + interval_multiplier=config.get_cloud_config_value( + "wait_for_ip_interval_multiplier", vm_, __opts__, default=1 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + if "reactor" in vm_ and vm_["reactor"] is True: + __utils__["cloud.fire_event"]( + "event", + "instance queried", + "salt/cloud/{}/query_reactor".format(vm_["name"]), + args={"data": data}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return data + + +def wait_for_instance( + vm_=None, + data=None, + ip_address=None, + display_ssh_output=True, + call=None, +): + """ + Wait for an instance upon creation from the EC2 API, to become available + """ + if call == "function": + # Technically this function may be called other ways too, but it + # definitely cannot be called with --function. + raise SaltCloudSystemExit( + "The wait_for_instance action must be called with -a or --action." + ) + + if vm_ is None: + vm_ = {} + + if data is None: + data = {} + + ssh_gateway_config = vm_.get("gateway", get_ssh_gateway_config(vm_)) + + __utils__["cloud.fire_event"]( + "event", + "waiting for ssh", + "salt/cloud/{}/waiting_for_ssh".format(vm_["name"]), + args={"ip_address": ip_address}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + ssh_connect_timeout = config.get_cloud_config_value( + "ssh_connect_timeout", vm_, __opts__, 900 # 15 minutes + ) + ssh_port = config.get_cloud_config_value("ssh_port", vm_, __opts__, 22) + + if config.get_cloud_config_value("win_installer", vm_, __opts__): + username = config.get_cloud_config_value( + "win_username", vm_, __opts__, default="Administrator" + ) + win_passwd = config.get_cloud_config_value( + "win_password", vm_, __opts__, default="" + ) + win_deploy_auth_retries = config.get_cloud_config_value( + "win_deploy_auth_retries", vm_, __opts__, default=10 + ) + win_deploy_auth_retry_delay = config.get_cloud_config_value( + "win_deploy_auth_retry_delay", vm_, __opts__, default=1 + ) + use_winrm = config.get_cloud_config_value( + "use_winrm", vm_, __opts__, default=False + ) + winrm_verify_ssl = config.get_cloud_config_value( + "winrm_verify_ssl", vm_, __opts__, default=True + ) + + if win_passwd and win_passwd == "auto": + log.debug("Waiting for auto-generated Windows EC2 password") + while True: + password_data = get_password_data( + name=vm_["name"], + kwargs={"key_file": vm_["private_key"]}, + call="action", + ) + win_passwd = password_data.get("password", None) + if win_passwd is None: + log.debug(password_data) + # This wait is so high, because the password is unlikely to + # be generated for at least 4 minutes + time.sleep(60) + else: + logging_data = password_data + + logging_data["password"] = "XXX-REDACTED-XXX" + logging_data["passwordData"] = "XXX-REDACTED-XXX" + log.debug(logging_data) + + vm_["win_password"] = win_passwd + break + + # SMB used whether psexec or winrm + if not salt.utils.cloud.wait_for_port( + ip_address, port=445, timeout=ssh_connect_timeout + ): + raise SaltCloudSystemExit("Failed to connect to remote windows host") + + # If not using winrm keep same psexec behavior + if not use_winrm: + + log.debug("Trying to authenticate via SMB using psexec") + + if not salt.utils.cloud.validate_windows_cred( + ip_address, + username, + win_passwd, + retries=win_deploy_auth_retries, + retry_delay=win_deploy_auth_retry_delay, + ): + raise SaltCloudSystemExit( + "Failed to authenticate against remote windows host (smb)" + ) + + # If using winrm + else: + + # Default HTTPS port can be changed in cloud configuration + winrm_port = config.get_cloud_config_value( + "winrm_port", vm_, __opts__, default=5986 + ) + + # Wait for winrm port to be available + if not salt.utils.cloud.wait_for_port( + ip_address, port=winrm_port, timeout=ssh_connect_timeout + ): + raise SaltCloudSystemExit( + "Failed to connect to remote windows host (winrm)" + ) + + log.debug("Trying to authenticate via Winrm using pywinrm") + + if not salt.utils.cloud.wait_for_winrm( + ip_address, + winrm_port, + username, + win_passwd, + timeout=ssh_connect_timeout, + verify=winrm_verify_ssl, + ): + raise SaltCloudSystemExit( + "Failed to authenticate against remote windows host" + ) + + elif salt.utils.cloud.wait_for_port( + ip_address, + port=ssh_port, + timeout=ssh_connect_timeout, + gateway=ssh_gateway_config, + ): + # If a known_hosts_file is configured, this instance will not be + # accessible until it has a host key. Since this is provided on + # supported instances by cloud-init, and viewable to us only from the + # console output (which may take several minutes to become available, + # we have some more waiting to do here. + known_hosts_file = config.get_cloud_config_value( + "known_hosts_file", vm_, __opts__, default=None + ) + if known_hosts_file: + console = {} + while "output_decoded" not in console: + console = get_console_output( + instance_id=vm_["instance_id"], + call="action", + location=get_location(vm_), + ) + pprint.pprint(console) + time.sleep(5) + output = salt.utils.stringutils.to_unicode(console["output_decoded"]) + comps = output.split("-----BEGIN SSH HOST KEY KEYS-----") + if len(comps) < 2: + # Fail; there are no host keys + return False + + comps = comps[1].split("-----END SSH HOST KEY KEYS-----") + keys = "" + for line in comps[0].splitlines(): + if not line: + continue + keys += f"\n{ip_address} {line}" + + with salt.utils.files.fopen(known_hosts_file, "a") as fp_: + fp_.write(salt.utils.stringutils.to_str(keys)) + fp_.close() + + for user in vm_["usernames"]: + if salt.utils.cloud.wait_for_passwd( + host=ip_address, + port=ssh_port, + username=user, + ssh_timeout=config.get_cloud_config_value( + "wait_for_passwd_timeout", vm_, __opts__, default=1 * 60 + ), + key_filename=vm_["key_filename"], + display_ssh_output=display_ssh_output, + gateway=ssh_gateway_config, + maxtries=config.get_cloud_config_value( + "wait_for_passwd_maxtries", vm_, __opts__, default=15 + ), + known_hosts_file=config.get_cloud_config_value( + "known_hosts_file", vm_, __opts__, default="/dev/null" + ), + ): + __opts__["ssh_username"] = user + vm_["ssh_username"] = user + break + else: + raise SaltCloudSystemExit("Failed to authenticate against remote ssh") + else: + raise SaltCloudSystemExit("Failed to connect to remote ssh") + + if "reactor" in vm_ and vm_["reactor"] is True: + __utils__["cloud.fire_event"]( + "event", + "ssh is available", + "salt/cloud/{}/ssh_ready_reactor".format(vm_["name"]), + args={"ip_address": ip_address}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return vm_ + + +def _validate_key_path_and_mode(key_filename): + if key_filename is None: + raise SaltCloudSystemExit( + "The required 'private_key' configuration setting is missing from the " + "'ec2' driver." + ) + + if not os.path.exists(key_filename): + raise SaltCloudSystemExit( + f"The EC2 key file '{key_filename}' does not exist.\n" + ) + + key_mode = stat.S_IMODE(os.stat(key_filename).st_mode) + if key_mode not in (0o400, 0o600): + raise SaltCloudSystemExit( + "The EC2 key file '{}' needs to be set to mode 0400 or 0600.\n".format( + key_filename + ) + ) + + return True + + +def create(vm_=None, call=None): + """ + Create a single VM from a data dict + """ + if call: + raise SaltCloudSystemExit("You cannot create an instance with -a or -f.") + + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, _get_active_provider_name() or "ec2", vm_["profile"], vm_=vm_ + ) + is False + ): + return False + except AttributeError: + pass + + # Check for private_key and keyfile name for bootstrapping new instances + deploy = config.get_cloud_config_value("deploy", vm_, __opts__, default=True) + win_password = config.get_cloud_config_value( + "win_password", vm_, __opts__, default="" + ) + key_filename = config.get_cloud_config_value( + "private_key", vm_, __opts__, search_global=False, default=None + ) + if deploy: + # The private_key and keyname settings are only needed for bootstrapping + # new instances when deploy is True + _validate_key_path_and_mode(key_filename) + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + __utils__["cloud.cachedir_index_add"]( + vm_["name"], vm_["profile"], "ec2", vm_["driver"] + ) + + vm_["key_filename"] = key_filename + # wait_for_instance requires private_key + vm_["private_key"] = key_filename + + # Get SSH Gateway config early to verify the private_key, + # if used, exists or not. We don't want to deploy an instance + # and not be able to access it via the gateway. + vm_["gateway"] = get_ssh_gateway_config(vm_) + + location = get_location(vm_) + vm_["location"] = location + + log.info("Creating Cloud VM %s in %s", vm_["name"], location) + vm_["usernames"] = salt.utils.cloud.ssh_usernames( + vm_, + __opts__, + default_users=( + "ec2-user", # Amazon Linux, Fedora, RHEL; FreeBSD + "centos", # CentOS AMIs from AWS Marketplace + "ubuntu", # Ubuntu + "admin", # Debian GNU/Linux + "bitnami", # BitNami AMIs + "root", # Last resort, default user on RHEL 5, SUSE + ), + ) + + if "instance_id" in vm_: + # This was probably created via another process, and doesn't have + # things like salt keys created yet, so let's create them now. + if "pub_key" not in vm_ and "priv_key" not in vm_: + log.debug("Generating minion keys for '%s'", vm_["name"]) + vm_["priv_key"], vm_["pub_key"] = salt.utils.cloud.gen_keys( + salt.config.get_cloud_config_value("keysize", vm_, __opts__) + ) + else: + # Put together all of the information required to request the instance, + # and then fire off the request for it + if keyname(vm_) is None: + raise SaltCloudSystemExit( + "The required 'keyname' configuration setting is missing from the " + "'ec2' driver." + ) + + data, vm_ = request_instance(vm_, location) + + # If data is a str, it's an error + if isinstance(data, str): + log.error("Error requesting instance: %s", data) + return {} + + # Pull the instance ID, valid for both spot and normal instances + + # Multiple instances may have been spun up, get all their IDs + vm_["instance_id_list"] = [] + for instance in data: + vm_["instance_id_list"].append(instance["instanceId"]) + + vm_["instance_id"] = vm_["instance_id_list"].pop() + if vm_["instance_id_list"]: + # Multiple instances were spun up, get one now, and queue the rest + queue_instances(vm_["instance_id_list"]) + + # Wait for vital information, such as IP addresses, to be available + # for the new instance + data = query_instance(vm_) + + # Now that the instance is available, tag it appropriately. Should + # mitigate race conditions with tags + tags = config.get_cloud_config_value("tag", vm_, __opts__, {}, search_global=False) + if not isinstance(tags, dict): + raise SaltCloudConfigError("'tag' should be a dict.") + + for value in tags.values(): + if not isinstance(value, str): + raise SaltCloudConfigError( + "'tag' values must be strings. Try quoting the values. " + 'e.g. "2013-09-19T20:09:46Z".' + ) + + tags["Name"] = vm_["name"] + + __utils__["cloud.fire_event"]( + "event", + "setting tags", + "salt/cloud/{}/tagging".format(vm_["name"]), + args={"tags": tags}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + salt.utils.cloud.wait_for_fun( + set_tags, + timeout=30, + name=vm_["name"], + tags=tags, + instance_id=vm_["instance_id"], + call="action", + location=location, + ) + + # Once instance tags are set, tag the spot request if configured + if "spot_config" in vm_ and "tag" in vm_["spot_config"]: + + if not isinstance(vm_["spot_config"]["tag"], dict): + raise SaltCloudConfigError("'tag' should be a dict.") + + for value in vm_["spot_config"]["tag"].values(): + if not isinstance(value, str): + raise SaltCloudConfigError( + "'tag' values must be strings. Try quoting the values. " + 'e.g. "2013-09-19T20:09:46Z".' + ) + + spot_request_tags = {} + + if "spotRequestId" not in vm_: + raise SaltCloudConfigError("Failed to find spotRequestId") + + sir_id = vm_["spotRequestId"] + + spot_request_tags["Name"] = vm_["name"] + + for k, v in vm_["spot_config"]["tag"].items(): + spot_request_tags[k] = v + + __utils__["cloud.fire_event"]( + "event", + "setting tags", + f"salt/cloud/spot_request_{sir_id}/tagging", + args={"tags": spot_request_tags}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + salt.utils.cloud.wait_for_fun( + set_tags, + timeout=30, + name=vm_["name"], + tags=spot_request_tags, + instance_id=sir_id, + call="action", + location=location, + ) + + network_interfaces = config.get_cloud_config_value( + "network_interfaces", vm_, __opts__, search_global=False + ) + + if network_interfaces: + _update_enis(network_interfaces, data, vm_) + + # At this point, the node is created and tagged, and now needs to be + # bootstrapped, once the necessary port is available. + log.info("Created node %s", vm_["name"]) + + instance = data[0]["instancesSet"]["item"] + + # Wait for the necessary port to become available to bootstrap + if ssh_interface(vm_) == "private_ips": + ip_address = instance["privateIpAddress"] + log.info("Salt node data. Private_ip: %s", ip_address) + else: + ip_address = instance["ipAddress"] + log.info("Salt node data. Public_ip: %s", ip_address) + vm_["ssh_host"] = ip_address + + if salt.utils.cloud.get_salt_interface(vm_, __opts__) == "private_ips": + salt_ip_address = instance["privateIpAddress"] + log.info("Salt interface set to: %s", salt_ip_address) + else: + salt_ip_address = instance["ipAddress"] + log.debug("Salt interface set to: %s", salt_ip_address) + vm_["salt_host"] = salt_ip_address + + if deploy: + display_ssh_output = config.get_cloud_config_value( + "display_ssh_output", vm_, __opts__, default=True + ) + + vm_ = wait_for_instance(vm_, data, ip_address, display_ssh_output) + + # The instance is booted and accessible, let's Salt it! + ret = instance.copy() + + # Get ANY defined volumes settings, merging data, in the following order + # 1. VM config + # 2. Profile config + # 3. Global configuration + volumes = config.get_cloud_config_value( + "volumes", vm_, __opts__, search_global=True + ) + if volumes: + __utils__["cloud.fire_event"]( + "event", + "attaching volumes", + "salt/cloud/{}/attaching_volumes".format(vm_["name"]), + args={"volumes": volumes}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Create and attach volumes to node %s", vm_["name"]) + created = create_attach_volumes( + vm_["name"], + { + "volumes": volumes, + "zone": ret["placement"]["availabilityZone"], + "instance_id": ret["instanceId"], + "del_all_vols_on_destroy": vm_.get("del_all_vols_on_destroy", False), + }, + call="action", + ) + ret["Attached Volumes"] = created + + # Associate instance with a ssm document, if present + ssm_document = config.get_cloud_config_value( + "ssm_document", vm_, __opts__, None, search_global=False + ) + if ssm_document: + log.debug("Associating with ssm document: %s", ssm_document) + assoc = ssm_create_association( + vm_["name"], + {"ssm_document": ssm_document}, + instance_id=vm_["instance_id"], + call="action", + ) + if isinstance(assoc, dict) and assoc.get("error", None): + log.error( + "Failed to associate instance %s with ssm document %s", + vm_["instance_id"], + ssm_document, + ) + return {} + + for key, value in __utils__["cloud.bootstrap"](vm_, __opts__).items(): + ret.setdefault(key, value) + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(instance)) + + event_data = { + "name": vm_["name"], + "profile": vm_["profile"], + "provider": vm_["driver"], + "instance_id": vm_["instance_id"], + } + if volumes: + event_data["volumes"] = volumes + if ssm_document: + event_data["ssm_document"] = ssm_document + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]("created", event_data, list(event_data)), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + # Ensure that the latest node data is returned + node = _get_node(instance_id=vm_["instance_id"]) + __utils__["cloud.cache_node"](node, _get_active_provider_name(), __opts__) + ret.update(node) + + # Add any block device tags specified + ex_blockdevicetags = {} + blockdevicemappings_holder = block_device_mappings(vm_) + if blockdevicemappings_holder: + for _bd in blockdevicemappings_holder: + if "tag" in _bd: + ex_blockdevicetags[_bd["DeviceName"]] = _bd["tag"] + + block_device_volume_id_map = {} + + if ex_blockdevicetags: + for _device, _map in ret["blockDeviceMapping"].items(): + bd_items = [] + if isinstance(_map, dict): + bd_items.append(_map) + else: + for mapitem in _map: + bd_items.append(mapitem) + + for blockitem in bd_items: + if ( + blockitem["deviceName"] in ex_blockdevicetags + and "Name" not in ex_blockdevicetags[blockitem["deviceName"]] + ): + ex_blockdevicetags[blockitem["deviceName"]]["Name"] = vm_["name"] + if blockitem["deviceName"] in ex_blockdevicetags: + block_device_volume_id_map[ + blockitem[ret["rootDeviceType"]]["volumeId"] + ] = ex_blockdevicetags[blockitem["deviceName"]] + + if block_device_volume_id_map: + + for volid, tags in block_device_volume_id_map.items(): + __utils__["cloud.fire_event"]( + "event", + "setting tags", + f"salt/cloud/block_volume_{str(volid)}/tagging", + args={"tags": tags}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + __utils__["cloud.wait_for_fun"]( + set_tags, + timeout=30, + name=vm_["name"], + tags=tags, + resource_id=volid, + call="action", + location=location, + ) + + return ret + + +def queue_instances(instances): + """ + Queue a set of instances to be provisioned later. Expects a list. + + Currently this only queries node data, and then places it in the cloud + cache (if configured). If the salt-cloud-reactor is being used, these + instances will be automatically provisioned using that. + + For more information about the salt-cloud-reactor, see: + + https://github.com/saltstack-formulas/salt-cloud-reactor + """ + for instance_id in instances: + node = _get_node(instance_id=instance_id) + __utils__["cloud.cache_node"](node, _get_active_provider_name(), __opts__) + + +def create_attach_volumes(name, kwargs, call=None, wait_to_finish=True): + """ + Create and attach volumes to created node + """ + if call != "action": + raise SaltCloudSystemExit( + "The create_attach_volumes action must be called with -a or --action." + ) + + if "instance_id" not in kwargs: + kwargs["instance_id"] = _get_node(name)["instanceId"] + + if isinstance(kwargs["volumes"], str): + volumes = salt.utils.yaml.safe_load(kwargs["volumes"]) + else: + volumes = kwargs["volumes"] + + ret = [] + for volume in volumes: + created = False + volume_name = "{} on {}".format(volume["device"], name) + + volume_dict = {"volume_name": volume_name, "zone": kwargs["zone"]} + if "volume_id" in volume: + volume_dict["volume_id"] = volume["volume_id"] + elif "snapshot" in volume: + volume_dict["snapshot"] = volume["snapshot"] + elif "size" in volume: + volume_dict["size"] = volume["size"] + else: + raise SaltCloudConfigError( + "Cannot create volume. Please define one of 'volume_id', " + "'snapshot', or 'size'" + ) + + if "tags" in volume: + volume_dict["tags"] = volume["tags"] + if "type" in volume: + volume_dict["type"] = volume["type"] + if "iops" in volume: + volume_dict["iops"] = volume["iops"] + if "encrypted" in volume: + volume_dict["encrypted"] = volume["encrypted"] + if "kmskeyid" in volume: + volume_dict["kmskeyid"] = volume["kmskeyid"] + + if "volume_id" not in volume_dict: + created_volume = create_volume( + volume_dict, call="function", wait_to_finish=wait_to_finish + ) + created = True + if "volumeId" in created_volume: + volume_dict["volume_id"] = created_volume["volumeId"] + + attach = attach_volume( + name, + {"volume_id": volume_dict["volume_id"], "device": volume["device"]}, + instance_id=kwargs["instance_id"], + call="action", + ) + + # Update the delvol parameter for this volume + delvols_on_destroy = kwargs.get("del_all_vols_on_destroy", None) + + if attach and created and delvols_on_destroy is not None: + _toggle_delvol( + instance_id=kwargs["instance_id"], + device=volume["device"], + value=delvols_on_destroy, + ) + + if attach: + msg = "{} attached to {} (aka {}) as device {}".format( + volume_dict["volume_id"], kwargs["instance_id"], name, volume["device"] + ) + log.info(msg) + ret.append(msg) + return ret + + +def stop(name, call=None): + """ + Stop a node + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + log.info("Stopping node %s", name) + + instance_id = _get_node(name)["instanceId"] + + __utils__["cloud.fire_event"]( + "event", + "stopping instance", + f"salt/cloud/{name}/stopping", + args={"name": name, "instance_id": instance_id}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + params = {"Action": "StopInstances", "InstanceId.1": instance_id} + result = aws.query( + params, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + return result + + +def start(name, call=None): + """ + Start a node + """ + if call != "action": + raise SaltCloudSystemExit( + "The start action must be called with -a or --action." + ) + + log.info("Starting node %s", name) + + instance_id = _get_node(name)["instanceId"] + + __utils__["cloud.fire_event"]( + "event", + "starting instance", + f"salt/cloud/{name}/starting", + args={"name": name, "instance_id": instance_id}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + params = {"Action": "StartInstances", "InstanceId.1": instance_id} + result = aws.query( + params, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + return result + + +def set_tags( + name=None, + tags=None, + call=None, + location=None, + instance_id=None, + resource_id=None, + kwargs=None, +): # pylint: disable=W0613 + """ + Set tags for a resource. Normally a VM name or instance_id is passed in, + but a resource_id may be passed instead. If both are passed in, the + instance_id will be used. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a set_tags mymachine tag1=somestuff tag2='Other stuff' + salt-cloud -a set_tags resource_id=vol-3267ab32 tag=somestuff + """ + if kwargs is None: + kwargs = {} + + if location is None: + location = get_location() + + if instance_id is None: + if "resource_id" in kwargs: + resource_id = kwargs["resource_id"] + del kwargs["resource_id"] + + if "instance_id" in kwargs: + instance_id = kwargs["instance_id"] + del kwargs["instance_id"] + + if resource_id is None: + if instance_id is None: + instance_id = _get_node(name=name, instance_id=None, location=location)[ + "instanceId" + ] + else: + instance_id = resource_id + + # This second check is a safety, in case the above still failed to produce + # a usable ID + if instance_id is None: + return {"Error": "A valid instance_id or resource_id was not specified."} + + params = {"Action": "CreateTags", "ResourceId.1": instance_id} + + log.debug("Tags to set for %s: %s", name, tags) + + if kwargs and not tags: + tags = kwargs + + for idx, (tag_k, tag_v) in enumerate(tags.items()): + params[f"Tag.{idx}.Key"] = tag_k + params[f"Tag.{idx}.Value"] = tag_v + + attempts = 0 + while attempts < aws.AWS_MAX_RETRIES: + aws.query( + params, + setname="tagSet", + location=location, + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + settags = get_tags(instance_id=instance_id, call="action", location=location) + + log.debug("Setting the tags returned: %s", settags) + + failed_to_set_tags = False + for tag in settags: + if tag["key"] not in tags: + # We were not setting this tag + continue + + if tag.get("value") is None and tags.get(tag["key"]) == "": + # This is a correctly set tag with no value + continue + + if str(tags.get(tag["key"])) != str(tag["value"]): + # Not set to the proper value!? + log.debug( + "Setting the tag %s returned %s instead of %s", + tag["key"], + tags.get(tag["key"]), + tag["value"], + ) + failed_to_set_tags = True + break + + if failed_to_set_tags: + log.warning("Failed to set tags. Remaining attempts %s", attempts) + attempts += 1 + aws.sleep_exponential_backoff(attempts) + continue + + return settags + + raise SaltCloudSystemExit(f"Failed to set tags on {name}!") + + +def get_tags( + name=None, instance_id=None, call=None, location=None, kwargs=None, resource_id=None +): # pylint: disable=W0613 + """ + Retrieve tags for a resource. Normally a VM name or instance_id is passed + in, but a resource_id may be passed instead. If both are passed in, the + instance_id will be used. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a get_tags mymachine + salt-cloud -a get_tags resource_id=vol-3267ab32 + """ + if location is None: + location = get_location() + + if instance_id is None: + if resource_id is None: + if name: + instance_id = _get_node(name)["instanceId"] + elif "instance_id" in kwargs: + instance_id = kwargs["instance_id"] + elif "resource_id" in kwargs: + instance_id = kwargs["resource_id"] + else: + instance_id = resource_id + + params = { + "Action": "DescribeTags", + "Filter.1.Name": "resource-id", + "Filter.1.Value": instance_id, + } + + return aws.query( + params, + setname="tagSet", + location=location, + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + +def del_tags( + name=None, kwargs=None, call=None, instance_id=None, resource_id=None +): # pylint: disable=W0613 + """ + Delete tags for a resource. Normally a VM name or instance_id is passed in, + but a resource_id may be passed instead. If both are passed in, the + instance_id will be used. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a del_tags mymachine tags=mytag, + salt-cloud -a del_tags mymachine tags=tag1,tag2,tag3 + salt-cloud -a del_tags resource_id=vol-3267ab32 tags=tag1,tag2,tag3 + """ + if kwargs is None: + kwargs = {} + + if "tags" not in kwargs: + raise SaltCloudSystemExit( + "A tag or tags must be specified using tags=list,of,tags" + ) + + if not name and "resource_id" in kwargs: + instance_id = kwargs["resource_id"] + del kwargs["resource_id"] + + if not instance_id: + instance_id = _get_node(name)["instanceId"] + + params = {"Action": "DeleteTags", "ResourceId.1": instance_id} + + for idx, tag in enumerate(kwargs["tags"].split(",")): + params[f"Tag.{idx}.Key"] = tag + + aws.query( + params, + setname="tagSet", + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + if resource_id: + return get_tags(resource_id=resource_id) + else: + return get_tags(instance_id=instance_id) + + +def rename(name, kwargs, call=None): + """ + Properly rename a node. Pass in the new name as "new name". + + CLI Example: + + .. code-block:: bash + + salt-cloud -a rename mymachine newname=yourmachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The rename action must be called with -a or --action." + ) + + log.info("Renaming %s to %s", name, kwargs["newname"]) + + set_tags(name, {"Name": kwargs["newname"]}, call="action") + + salt.utils.cloud.rename_key(__opts__["pki_dir"], name, kwargs["newname"]) + + +def destroy(name, call=None): + """ + Destroy a node. Will check termination protection and warn if enabled. + + CLI Example: + + .. code-block:: bash + + salt-cloud --destroy mymachine + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + node_metadata = _get_node(name) + instance_id = node_metadata["instanceId"] + sir_id = node_metadata.get("spotInstanceRequestId") + protected = show_term_protect( + name=name, instance_id=instance_id, call="action", quiet=True + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name, "instance_id": instance_id}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if protected == "true": + raise SaltCloudSystemExit( + "This instance has been protected from being destroyed. " + "Use the following command to disable protection:\n\n" + "salt-cloud -a disable_term_protect {}".format(name) + ) + + ret = {} + + # Default behavior is to rename EC2 VMs when destroyed + # via salt-cloud, unless explicitly set to False. + rename_on_destroy = config.get_cloud_config_value( + "rename_on_destroy", get_configured_provider(), __opts__, search_global=False + ) + if rename_on_destroy is not False: + newname = f"{name}-DEL{uuid.uuid4().hex}" + rename(name, kwargs={"newname": newname}, call="action") + log.info( + "Machine will be identified as %s until it has been cleaned up.", newname + ) + ret["newname"] = newname + + params = {"Action": "TerminateInstances", "InstanceId.1": instance_id} + + location = get_location() + provider = get_provider() + result = aws.query( + params, location=location, provider=provider, opts=__opts__, sigver="4" + ) + + log.info(result) + ret.update(result[0]) + + # If this instance is part of a spot instance request, we + # need to cancel it as well + if sir_id is not None: + params = { + "Action": "CancelSpotInstanceRequests", + "SpotInstanceRequestId.1": sir_id, + } + result = aws.query( + params, location=location, provider=provider, opts=__opts__, sigver="4" + ) + ret["spotInstance"] = result[0] + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name, "instance_id": instance_id}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + __utils__["cloud.cachedir_index_del"](name) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return ret + + +def reboot(name, call=None): + """ + Reboot a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot mymachine + """ + instance_id = _get_node(name)["instanceId"] + params = {"Action": "RebootInstances", "InstanceId.1": instance_id} + + result = aws.query( + params, + setname="tagSet", + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + if result == []: + log.info("Complete") + + return {"Reboot": "Complete"} + + +def show_image(kwargs, call=None): + """ + Show the details from EC2 concerning an AMI + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_image action must be called with -f or --function." + ) + + params = {"ImageId.1": kwargs["image"], "Action": "DescribeImages"} + result = aws.query( + params, + setname="tagSet", + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + log.info(result) + + return result + + +def show_instance(name=None, instance_id=None, call=None, kwargs=None): + """ + Show the details from EC2 concerning an AMI. + + Can be called as an action (which requires a name): + + .. code-block:: bash + + salt-cloud -a show_instance myinstance + + ...or as a function (which requires either a name or instance_id): + + .. code-block:: bash + + salt-cloud -f show_instance my-ec2 name=myinstance + salt-cloud -f show_instance my-ec2 instance_id=i-d34db33f + """ + if not name and call == "action": + raise SaltCloudSystemExit("The show_instance action requires a name.") + + if call == "function": + name = kwargs.get("name", None) + instance_id = kwargs.get("instance_id", None) + + if not name and not instance_id: + raise SaltCloudSystemExit( + "The show_instance function requires either a name or an instance_id" + ) + + node = _get_node(name=name, instance_id=instance_id) + __utils__["cloud.cache_node"](node, _get_active_provider_name(), __opts__) + return node + + +def _get_node(name=None, instance_id=None, location=None): + if location is None: + location = get_location() + + params = {"Action": "DescribeInstances"} + + if str(name).startswith("i-") and (len(name) == 10 or len(name) == 19): + instance_id = name + + if instance_id: + params["InstanceId.1"] = instance_id + else: + params["Filter.1.Name"] = "tag:Name" + params["Filter.1.Value.1"] = name + + log.trace(params) + + provider = get_provider() + + attempts = 0 + while attempts < aws.AWS_MAX_RETRIES: + try: + instances = aws.query( + params, location=location, provider=provider, opts=__opts__, sigver="4" + ) + instance_info = _extract_instance_info(instances).values() + return next(iter(instance_info)) + except IndexError: + attempts += 1 + log.debug( + "Failed to get the data for node '%s'. Remaining attempts: %s", + instance_id or name, + attempts, + ) + aws.sleep_exponential_backoff(attempts) + return {} + + +def list_nodes_full(location=None, call=None): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + return _list_nodes_full(location or get_location()) + + +def _extract_name_tag(item): + if "tagSet" in item and item["tagSet"] is not None: + tagset = item["tagSet"] + if isinstance(tagset["item"], list): + for tag in tagset["item"]: + if tag["key"] == "Name": + return tag["value"] + return item["instanceId"] + return item["tagSet"]["item"]["value"] + return item["instanceId"] + + +def _extract_instance_info(instances): + """ + Given an instance query, return a dict of all instance data + """ + ret = {} + for instance in instances: + # items could be type dict or list (for stopped EC2 instances) + if isinstance(instance["instancesSet"]["item"], list): + for item in instance["instancesSet"]["item"]: + name = _extract_name_tag(item) + ret[name] = item + ret[name]["name"] = name + ret[name].update( + dict( + id=item["instanceId"], + image=item["imageId"], + size=item["instanceType"], + state=item["instanceState"]["name"], + private_ips=item.get("privateIpAddress", []), + public_ips=item.get("ipAddress", []), + ) + ) + else: + item = instance["instancesSet"]["item"] + name = _extract_name_tag(item) + ret[name] = item + ret[name]["name"] = name + ret[name].update( + dict( + id=item["instanceId"], + image=item["imageId"], + size=item["instanceType"], + state=item["instanceState"]["name"], + private_ips=item.get("privateIpAddress", []), + public_ips=item.get("ipAddress", []), + ) + ) + + return ret + + +def _list_nodes_full(location=None): + """ + Return a list of the VMs that in this location + """ + provider = _get_active_provider_name() or "ec2" + if ":" in provider: + comps = provider.split(":") + provider = comps[0] + + params = {"Action": "DescribeInstances"} + instances = aws.query( + params, location=location, provider=provider, opts=__opts__, sigver="4" + ) + if "error" in instances: + raise SaltCloudSystemExit( + "An error occurred while listing nodes: {}".format( + instances["error"]["Errors"]["Error"]["Message"] + ) + ) + + ret = _extract_instance_info(instances) + + __utils__["cloud.cache_node_list"](ret, provider, __opts__) + return ret + + +def list_nodes_min(location=None, call=None): + """ + Return a list of the VMs that are on the provider. Only a list of VM names, + and their state, is returned. This is the minimum amount of information + needed to check for existing VMs. + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_min function must be called with -f or --function." + ) + + ret = {} + params = {"Action": "DescribeInstances"} + instances = aws.query( + params, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + if "error" in instances: + raise SaltCloudSystemExit( + "An error occurred while listing nodes: {}".format( + instances["error"]["Errors"]["Error"]["Message"] + ) + ) + + for instance in instances: + if isinstance(instance["instancesSet"]["item"], list): + items = instance["instancesSet"]["item"] + else: + items = [instance["instancesSet"]["item"]] + + for item in items: + state = item["instanceState"]["name"] + name = _extract_name_tag(item) + id = item["instanceId"] + ret[name] = {"state": state, "id": id} + return ret + + +def list_nodes(call=None): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + ret = {} + nodes = list_nodes_full(get_location()) + if "error" in nodes: + raise SaltCloudSystemExit( + "An error occurred while listing nodes: {}".format( + nodes["error"]["Errors"]["Error"]["Message"] + ) + ) + for node in nodes: + ret[node] = { + "id": nodes[node]["id"], + "image": nodes[node]["image"], + "name": nodes[node]["name"], + "size": nodes[node]["size"], + "state": nodes[node]["state"], + "private_ips": nodes[node]["private_ips"], + "public_ips": nodes[node]["public_ips"], + } + return ret + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full(get_location()), + __opts__["query.selection"], + call, + ) + + +def show_term_protect(name=None, instance_id=None, call=None, quiet=False): + """ + Show the details from EC2 concerning an instance's termination protection state + + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_term_protect action must be called with -a or --action." + ) + + if not instance_id: + instance_id = _get_node(name)["instanceId"] + params = { + "Action": "DescribeInstanceAttribute", + "InstanceId": instance_id, + "Attribute": "disableApiTermination", + } + result = aws.query( + params, + location=get_location(), + provider=get_provider(), + return_root=True, + opts=__opts__, + sigver="4", + ) + + disable_protect = False + for item in result: + if "value" in item: + disable_protect = item["value"] + break + + log.log( + logging.DEBUG if quiet is True else logging.INFO, + "Termination Protection is %s for %s", + disable_protect == "true" and "enabled" or "disabled", + name, + ) + + return disable_protect + + +def show_detailed_monitoring(name=None, instance_id=None, call=None, quiet=False): + """ + Show the details from EC2 regarding cloudwatch detailed monitoring. + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_detailed_monitoring action must be called with -a or --action." + ) + location = get_location() + if str(name).startswith("i-") and (len(name) == 10 or len(name) == 19): + instance_id = name + + if not name and not instance_id: + raise SaltCloudSystemExit( + "The show_detailed_monitoring action must be provided with a name or" + " instance ID" + ) + matched = _get_node(name=name, instance_id=instance_id, location=location) + log.log( + logging.DEBUG if quiet is True else logging.INFO, + "Detailed Monitoring is %s for %s", + matched["monitoring"], + name, + ) + return matched["monitoring"] + + +def _toggle_term_protect(name, value): + """ + Enable or Disable termination protection on a node + + """ + instance_id = _get_node(name)["instanceId"] + params = { + "Action": "ModifyInstanceAttribute", + "InstanceId": instance_id, + "DisableApiTermination.Value": value, + } + + result = aws.query( + params, + location=get_location(), + provider=get_provider(), + return_root=True, + opts=__opts__, + sigver="4", + ) + + return show_term_protect(name=name, instance_id=instance_id, call="action") + + +def enable_term_protect(name, call=None): + """ + Enable termination protection on a node + + CLI Example: + + .. code-block:: bash + + salt-cloud -a enable_term_protect mymachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The enable_term_protect action must be called with -a or --action." + ) + + return _toggle_term_protect(name, "true") + + +def disable_term_protect(name, call=None): + """ + Disable termination protection on a node + + CLI Example: + + .. code-block:: bash + + salt-cloud -a disable_term_protect mymachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The enable_term_protect action must be called with -a or --action." + ) + + return _toggle_term_protect(name, "false") + + +def disable_detailed_monitoring(name, call=None): + """ + Enable/disable detailed monitoring on a node + """ + if call != "action": + raise SaltCloudSystemExit( + "The enable_term_protect action must be called with -a or --action." + ) + + instance_id = _get_node(name)["instanceId"] + params = {"Action": "UnmonitorInstances", "InstanceId.1": instance_id} + + result = aws.query( + params, + location=get_location(), + provider=get_provider(), + return_root=True, + opts=__opts__, + sigver="4", + ) + + return show_detailed_monitoring(name=name, instance_id=instance_id, call="action") + + +def enable_detailed_monitoring(name, call=None): + """ + Enable/disable detailed monitoring on a node + """ + if call != "action": + raise SaltCloudSystemExit( + "The enable_term_protect action must be called with -a or --action." + ) + + instance_id = _get_node(name)["instanceId"] + params = {"Action": "MonitorInstances", "InstanceId.1": instance_id} + + result = aws.query( + params, + location=get_location(), + provider=get_provider(), + return_root=True, + opts=__opts__, + sigver="4", + ) + + return show_detailed_monitoring(name=name, instance_id=instance_id, call="action") + + +def show_delvol_on_destroy(name, kwargs=None, call=None): + """ + Do not delete all/specified EBS volumes upon instance termination + + CLI Example: + + .. code-block:: bash + + salt-cloud -a show_delvol_on_destroy mymachine + """ + + if call != "action": + raise SaltCloudSystemExit( + "The show_delvol_on_destroy action must be called with -a or --action." + ) + + if not kwargs: + kwargs = {} + + instance_id = kwargs.get("instance_id", None) + device = kwargs.get("device", None) + volume_id = kwargs.get("volume_id", None) + + if instance_id is None: + instance_id = _get_node(name)["instanceId"] + + params = {"Action": "DescribeInstances", "InstanceId.1": instance_id} + + data = aws.query( + params, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + blockmap = data[0]["instancesSet"]["item"]["blockDeviceMapping"] + + if not isinstance(blockmap["item"], list): + blockmap["item"] = [blockmap["item"]] + + items = [] + + for idx, item in enumerate(blockmap["item"]): + device_name = item["deviceName"] + + if device is not None and device != device_name: + continue + + if volume_id is not None and volume_id != item["ebs"]["volumeId"]: + continue + + info = { + "device_name": device_name, + "volume_id": item["ebs"]["volumeId"], + "deleteOnTermination": item["ebs"]["deleteOnTermination"], + } + + items.append(info) + + return items + + +def keepvol_on_destroy(name, kwargs=None, call=None): + """ + Do not delete all/specified EBS volumes upon instance termination + + CLI Example: + + .. code-block:: bash + + salt-cloud -a keepvol_on_destroy mymachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The keepvol_on_destroy action must be called with -a or --action." + ) + + if not kwargs: + kwargs = {} + + device = kwargs.get("device", None) + volume_id = kwargs.get("volume_id", None) + + return _toggle_delvol(name=name, device=device, volume_id=volume_id, value="false") + + +def delvol_on_destroy(name, kwargs=None, call=None): + """ + Delete all/specified EBS volumes upon instance termination + + CLI Example: + + .. code-block:: bash + + salt-cloud -a delvol_on_destroy mymachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The delvol_on_destroy action must be called with -a or --action." + ) + + if not kwargs: + kwargs = {} + + device = kwargs.get("device", None) + volume_id = kwargs.get("volume_id", None) + + return _toggle_delvol(name=name, device=device, volume_id=volume_id, value="true") + + +def _toggle_delvol( + name=None, + instance_id=None, + device=None, + volume_id=None, + value=None, + requesturl=None, +): + + if not instance_id: + instance_id = _get_node(name)["instanceId"] + + if requesturl: + data = aws.query( + requesturl=requesturl, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + else: + params = {"Action": "DescribeInstances", "InstanceId.1": instance_id} + data, requesturl = aws.query( + params, # pylint: disable=unbalanced-tuple-unpacking + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + blockmap = data[0]["instancesSet"]["item"]["blockDeviceMapping"] + + params = {"Action": "ModifyInstanceAttribute", "InstanceId": instance_id} + + if not isinstance(blockmap["item"], list): + blockmap["item"] = [blockmap["item"]] + + for idx, item in enumerate(blockmap["item"]): + device_name = item["deviceName"] + + if device is not None and device != device_name: + continue + if volume_id is not None and volume_id != item["ebs"]["volumeId"]: + continue + + params[f"BlockDeviceMapping.{idx}.DeviceName"] = device_name + params[f"BlockDeviceMapping.{idx}.Ebs.DeleteOnTermination"] = value + + aws.query( + params, + return_root=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + kwargs = {"instance_id": instance_id, "device": device, "volume_id": volume_id} + return show_delvol_on_destroy(name, kwargs, call="action") + + +def register_image(kwargs=None, call=None): + """ + Create an ami from a snapshot + + CLI Example: + + .. code-block:: bash + + salt-cloud -f register_image my-ec2-config ami_name=my_ami description="my description" + root_device_name=/dev/xvda snapshot_id=snap-xxxxxxxx + """ + + if call != "function": + log.error("The create_volume function must be called with -f or --function.") + return False + + if "ami_name" not in kwargs: + log.error("ami_name must be specified to register an image.") + return False + + block_device_mapping = kwargs.get("block_device_mapping", None) + if not block_device_mapping: + if "snapshot_id" not in kwargs: + log.error( + "snapshot_id or block_device_mapping must be specified to register an" + " image." + ) + return False + if "root_device_name" not in kwargs: + log.error( + "root_device_name or block_device_mapping must be specified to register" + " an image." + ) + return False + block_device_mapping = [ + { + "DeviceName": kwargs["root_device_name"], + "Ebs": { + "VolumeType": kwargs.get("volume_type", "gp2"), + "SnapshotId": kwargs["snapshot_id"], + }, + } + ] + + if not isinstance(block_device_mapping, list): + block_device_mapping = [block_device_mapping] + + params = {"Action": "RegisterImage", "Name": kwargs["ami_name"]} + + params.update(_param_from_config("BlockDeviceMapping", block_device_mapping)) + + if "root_device_name" in kwargs: + params["RootDeviceName"] = kwargs["root_device_name"] + + if "description" in kwargs: + params["Description"] = kwargs["description"] + + if "virtualization_type" in kwargs: + params["VirtualizationType"] = kwargs["virtualization_type"] + + if "architecture" in kwargs: + params["Architecture"] = kwargs["architecture"] + + log.debug(params) + + data = aws.query( + params, + return_url=True, + return_root=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + r_data = {} + for d in data[0]: + for k, v in d.items(): + r_data[k] = v + + return r_data + + +def volume_create(**kwargs): + """ + Wrapper around create_volume. + Here just to ensure the compatibility with the cloud module. + """ + return create_volume(kwargs, "function") + + +def _load_params(kwargs): + params = {"Action": "CreateVolume", "AvailabilityZone": kwargs["zone"]} + + if "size" in kwargs: + params["Size"] = kwargs["size"] + + if "snapshot" in kwargs: + params["SnapshotId"] = kwargs["snapshot"] + + if "type" in kwargs: + params["VolumeType"] = kwargs["type"] + + # io1 and io2 types require the iops parameter + if "iops" in kwargs and kwargs.get("type", "standard").lower() in ["io1", "io2"]: + params["Iops"] = kwargs["iops"] + + # You can't set `encrypted` if you pass a snapshot + if "encrypted" in kwargs and "snapshot" not in kwargs: + params["Encrypted"] = kwargs["encrypted"] + if "kmskeyid" in kwargs: + params["KmsKeyId"] = kwargs["kmskeyid"] + + return params + + +def create_volume(kwargs=None, call=None, wait_to_finish=False): + """ + Create a volume. + + zone + The availability zone used to create the volume. Required. String. + + size + The size of the volume, in GiBs. Defaults to ``10``. Integer. + + snapshot + The snapshot-id from which to create the volume. Integer. + + type + The volume type. This can be ``gp2`` for General Purpose SSD, ``io1`` or + ``io2`` for Provisioned IOPS SSD, ``st1`` for Throughput Optimized HDD, + ``sc1`` for Cold HDD, or ``standard`` for Magnetic volumes. String. + + iops + The number of I/O operations per second (IOPS) to provision for the volume, + with a maximum ratio of 50 IOPS/GiB. Only valid for Provisioned IOPS SSD + volumes. Integer. + + This option will only be set if ``type`` is also specified as ``io1`` or + ``io2`` + + encrypted + Specifies whether the volume will be encrypted. Boolean. + + If ``snapshot`` is also given in the list of kwargs, then this value is ignored + since volumes that are created from encrypted snapshots are also automatically + encrypted. + + tags + The tags to apply to the volume during creation. Dictionary. + + call + The ``create_volume`` function must be called with ``-f`` or ``--function``. + String. + + wait_to_finish + Whether or not to wait for the volume to be available. Boolean. Defaults to + ``False``. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f create_volume my-ec2-config zone=us-east-1b + salt-cloud -f create_volume my-ec2-config zone=us-east-1b tags='{"tag1": "val1", "tag2", "val2"}' + """ + if call != "function": + log.error("The create_volume function must be called with -f or --function.") + return False + + if "zone" not in kwargs: + log.error("An availability zone must be specified to create a volume.") + return False + + if "kmskeyid" in kwargs and "encrypted" not in kwargs: + log.error("If a KMS Key ID is specified, encryption must be enabled") + return False + + if kwargs.get("type").lower() in ["io1", "io2"] and "iops" not in kwargs: + log.error("Iops must be specified for types 'io1' and 'io2'") + return False + + if "size" not in kwargs and "snapshot" not in kwargs: + # This number represents GiB + kwargs["size"] = "10" + + params = _load_params(kwargs) + + log.debug(params) + + data = aws.query( + params, + return_url=True, + return_root=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + r_data = {} + for d in data[0]: + for k, v in d.items(): + r_data[k] = v + volume_id = r_data["volumeId"] + + # Allow tags to be set upon creation + if "tags" in kwargs: + if isinstance(kwargs["tags"], str): + tags = salt.utils.yaml.safe_load(kwargs["tags"]) + else: + tags = kwargs["tags"] + + if isinstance(tags, dict): + new_tags = set_tags( + tags=tags, resource_id=volume_id, call="action", location=get_location() + ) + r_data["tags"] = new_tags + + # Waits till volume is available + if wait_to_finish: + salt.utils.cloud.run_func_until_ret_arg( + fun=describe_volumes, + kwargs={"volume_id": volume_id}, + fun_call=call, + argument_being_watched="status", + required_argument_response="available", + ) + + return r_data + + +def __attach_vol_to_instance(params, kws, instance_id): + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + if data[0]: + log.warning( + "Error attaching volume %s to instance %s. Retrying!", + kws["volume_id"], + instance_id, + ) + return False + + return data + + +def attach_volume(name=None, kwargs=None, instance_id=None, call=None): + """ + Attach a volume to an instance + """ + if call != "action": + raise SaltCloudSystemExit( + "The attach_volume action must be called with -a or --action." + ) + + if not kwargs: + kwargs = {} + + if "instance_id" in kwargs: + instance_id = kwargs["instance_id"] + + if name and not instance_id: + instance_id = _get_node(name)["instanceId"] + + if not name and not instance_id: + log.error("Either a name or an instance_id is required.") + return False + + if "volume_id" not in kwargs: + log.error("A volume_id is required.") + return False + + if "device" not in kwargs: + log.error("A device is required (ex. /dev/sdb1).") + return False + + params = { + "Action": "AttachVolume", + "VolumeId": kwargs["volume_id"], + "InstanceId": instance_id, + "Device": kwargs["device"], + } + + log.debug(params) + + vm_ = get_configured_provider() + + data = salt.utils.cloud.wait_for_ip( + __attach_vol_to_instance, + update_args=(params, kwargs, instance_id), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=10 + ), + interval_multiplier=config.get_cloud_config_value( + "wait_for_ip_interval_multiplier", vm_, __opts__, default=1 + ), + ) + + return data + + +def show_volume(kwargs=None, call=None): + """ + Wrapper around describe_volumes. + Here just to keep functionality. + Might be depreciated later. + """ + if not kwargs: + kwargs = {} + + return describe_volumes(kwargs, call) + + +def detach_volume(name=None, kwargs=None, instance_id=None, call=None): + """ + Detach a volume from an instance + """ + if call != "action": + raise SaltCloudSystemExit( + "The detach_volume action must be called with -a or --action." + ) + + if not kwargs: + kwargs = {} + + if "volume_id" not in kwargs: + log.error("A volume_id is required.") + return False + + params = {"Action": "DetachVolume", "VolumeId": kwargs["volume_id"]} + + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + return data + + +def delete_volume(name=None, kwargs=None, instance_id=None, call=None): + """ + Delete a volume + """ + if not kwargs: + kwargs = {} + + if "volume_id" not in kwargs: + log.error("A volume_id is required.") + return False + + params = {"Action": "DeleteVolume", "VolumeId": kwargs["volume_id"]} + + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + return data + + +def volume_list(**kwargs): + """ + Wrapper around describe_volumes. + Here just to ensure the compatibility with the cloud module. + """ + return describe_volumes(kwargs, "function") + + +def describe_volumes(kwargs=None, call=None): + """ + Describe a volume (or volumes) + + volume_id + One or more volume IDs. Multiple IDs must be separated by ",". + + TODO: Add all of the filters. + """ + if call != "function": + log.error("The describe_volumes function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + params = {"Action": "DescribeVolumes"} + + if "volume_id" in kwargs: + volume_id = kwargs["volume_id"].split(",") + for volume_index, volume_id in enumerate(volume_id): + params[f"VolumeId.{volume_index}"] = volume_id + + log.debug(params) + + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + return data + + +def create_keypair(kwargs=None, call=None): + """ + Create an SSH keypair + """ + if call != "function": + log.error("The create_keypair function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + if "keyname" not in kwargs: + log.error("A keyname is required.") + return False + + params = {"Action": "CreateKeyPair", "KeyName": kwargs["keyname"]} + + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + return data + + +def import_keypair(kwargs=None, call=None): + """ + Import an SSH public key. + + .. versionadded:: 2015.8.3 + """ + if call != "function": + log.error("The import_keypair function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + if "keyname" not in kwargs: + log.error("A keyname is required.") + return False + + if "file" not in kwargs: + log.error("A public key file is required.") + return False + + params = {"Action": "ImportKeyPair", "KeyName": kwargs["keyname"]} + + public_key_file = kwargs["file"] + + if os.path.exists(public_key_file): + with salt.utils.files.fopen(public_key_file, "r") as fh_: + public_key = salt.utils.stringutils.to_unicode(fh_.read()) + + if public_key is not None: + params["PublicKeyMaterial"] = base64.b64encode(public_key) + + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + return data + + +def show_keypair(kwargs=None, call=None): + """ + Show the details of an SSH keypair + """ + if call != "function": + log.error("The show_keypair function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + if "keyname" not in kwargs: + log.error("A keyname is required.") + return False + + params = {"Action": "DescribeKeyPairs", "KeyName.1": kwargs["keyname"]} + + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + return data + + +def delete_keypair(kwargs=None, call=None): + """ + Delete an SSH keypair + """ + if call != "function": + log.error("The delete_keypair function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + if "keyname" not in kwargs: + log.error("A keyname is required.") + return False + + params = {"Action": "DeleteKeyPair", "KeyName": kwargs["keyname"]} + + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + return data + + +def create_snapshot(kwargs=None, call=None, wait_to_finish=False): + """ + Create a snapshot. + + volume_id + The ID of the Volume from which to create a snapshot. + + description + The optional description of the snapshot. + + CLI Exampe: + + .. code-block:: bash + + salt-cloud -f create_snapshot my-ec2-config volume_id=vol-351d8826 + salt-cloud -f create_snapshot my-ec2-config volume_id=vol-351d8826 \\ + description="My Snapshot Description" + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_snapshot function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + volume_id = kwargs.get("volume_id", None) + description = kwargs.get("description", "") + + if volume_id is None: + raise SaltCloudSystemExit("A volume_id must be specified to create a snapshot.") + + params = { + "Action": "CreateSnapshot", + "VolumeId": volume_id, + "Description": description, + } + + log.debug(params) + + data = aws.query( + params, + return_url=True, + return_root=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + )[0] + + r_data = {} + for d in data: + for k, v in d.items(): + r_data[k] = v + + if "snapshotId" in r_data: + snapshot_id = r_data["snapshotId"] + + # Waits till volume is available + if wait_to_finish: + salt.utils.cloud.run_func_until_ret_arg( + fun=describe_snapshots, + kwargs={"snapshot_id": snapshot_id}, + fun_call=call, + argument_being_watched="status", + required_argument_response="completed", + ) + + return r_data + + +def delete_snapshot(kwargs=None, call=None): + """ + Delete a snapshot + """ + if call != "function": + log.error("The delete_snapshot function must be called with -f or --function.") + return False + + if "snapshot_id" not in kwargs: + log.error("A snapshot_id must be specified to delete a snapshot.") + return False + + params = {"Action": "DeleteSnapshot"} + + if "snapshot_id" in kwargs: + params["SnapshotId"] = kwargs["snapshot_id"] + + log.debug(params) + + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + return data + + +def copy_snapshot(kwargs=None, call=None): + """ + Copy a snapshot + """ + if call != "function": + log.error("The copy_snapshot function must be called with -f or --function.") + return False + + if "source_region" not in kwargs: + log.error("A source_region must be specified to copy a snapshot.") + return False + + if "source_snapshot_id" not in kwargs: + log.error("A source_snapshot_id must be specified to copy a snapshot.") + return False + + if "description" not in kwargs: + kwargs["description"] = "" + + params = {"Action": "CopySnapshot"} + + if "source_region" in kwargs: + params["SourceRegion"] = kwargs["source_region"] + + if "source_snapshot_id" in kwargs: + params["SourceSnapshotId"] = kwargs["source_snapshot_id"] + + if "description" in kwargs: + params["Description"] = kwargs["description"] + + log.debug(params) + + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + return data + + +def describe_snapshots(kwargs=None, call=None): + """ + Describe a snapshot (or snapshots) + + snapshot_id + One or more snapshot IDs. Multiple IDs must be separated by ",". + + owner + Return the snapshots owned by the specified owner. Valid values + include: self, amazon, . Multiple values must be + separated by ",". + + restorable_by + One or more AWS accounts IDs that can create volumes from the snapshot. + Multiple aws account IDs must be separated by ",". + + TODO: Add all of the filters. + """ + if call != "function": + log.error( + "The describe_snapshot function must be called with -f or --function." + ) + return False + + params = {"Action": "DescribeSnapshots"} + + # The AWS correct way is to use non-plurals like snapshot_id INSTEAD of snapshot_ids. + if "snapshot_ids" in kwargs: + kwargs["snapshot_id"] = kwargs["snapshot_ids"] + + if "snapshot_id" in kwargs: + snapshot_ids = kwargs["snapshot_id"].split(",") + for snapshot_index, snapshot_id in enumerate(snapshot_ids): + params[f"SnapshotId.{snapshot_index}"] = snapshot_id + + if "owner" in kwargs: + owners = kwargs["owner"].split(",") + for owner_index, owner in enumerate(owners): + params[f"Owner.{owner_index}"] = owner + + if "restorable_by" in kwargs: + restorable_bys = kwargs["restorable_by"].split(",") + for restorable_by_index, restorable_by in enumerate(restorable_bys): + params[f"RestorableBy.{restorable_by_index}"] = restorable_by + + log.debug(params) + + data = aws.query( + params, + return_url=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + return data + + +def get_console_output( + name=None, + location=None, + instance_id=None, + call=None, + kwargs=None, +): + """ + Show the console output from the instance. + + By default, returns decoded data, not the Base64-encoded data that is + actually returned from the EC2 API. + """ + if call != "action": + raise SaltCloudSystemExit( + "The get_console_output action must be called with -a or --action." + ) + + if location is None: + location = get_location() + + if not instance_id: + instance_id = _get_node(name)["instanceId"] + + if kwargs is None: + kwargs = {} + + if instance_id is None: + if "instance_id" in kwargs: + instance_id = kwargs["instance_id"] + del kwargs["instance_id"] + + params = {"Action": "GetConsoleOutput", "InstanceId": instance_id} + + ret = {} + data = aws.query( + params, + return_root=True, + location=location, + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + for item in data: + if next(iter(item.keys())) == "output": + ret["output_decoded"] = binascii.a2b_base64(next(iter(item.values()))) + else: + ret[next(iter(item.keys()))] = next(iter(item.values())) + + return ret + + +def get_password_data( + name=None, + kwargs=None, + instance_id=None, + call=None, +): + """ + Return password data for a Windows instance. + + By default only the encrypted password data will be returned. However, if a + key_file is passed in, then a decrypted password will also be returned. + + Note that the key_file references the private key that was used to generate + the keypair associated with this instance. This private key will _not_ be + transmitted to Amazon; it is only used internally inside of Salt Cloud to + decrypt data _after_ it has been received from Amazon. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a get_password_data mymachine + salt-cloud -a get_password_data mymachine key_file=/root/ec2key.pem + + Note: PKCS1_v1_5 was added in PyCrypto 2.5 + """ + if call != "action": + raise SaltCloudSystemExit( + "The get_password_data action must be called with -a or --action." + ) + + if not instance_id: + instance_id = _get_node(name)["instanceId"] + + if kwargs is None: + kwargs = {} + + if instance_id is None: + if "instance_id" in kwargs: + instance_id = kwargs["instance_id"] + del kwargs["instance_id"] + + params = {"Action": "GetPasswordData", "InstanceId": instance_id} + + ret = {} + data = aws.query( + params, + return_root=True, + location=get_location(), + provider=get_provider(), + opts=__opts__, + sigver="4", + ) + + for item in data: + ret[next(iter(item.keys()))] = next(iter(item.values())) + + if not salt.crypt.HAS_CRYPTOGRAPHY: + if "key" in kwargs or "key_file" in kwargs: + log.warning("No crypto library is installed, can not decrypt password") + return ret + + if "key" not in kwargs: + if "key_file" in kwargs: + with salt.utils.files.fopen(kwargs["key_file"], "r") as kf_: + kwargs["key"] = salt.utils.stringutils.to_unicode(kf_.read()) + + if "key" in kwargs: + pwdata = ret.get("passwordData", None) + if pwdata is not None: + rsa_key = kwargs["key"] + pwdata = base64.b64decode(pwdata) + ret["password"] = salt.crypt.pwdata_decrypt(rsa_key, pwdata) + + return ret + + +def update_pricing(kwargs=None, call=None): + """ + Download most recent pricing information from AWS and convert to a local + JSON file. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f update_pricing my-ec2-config + salt-cloud -f update_pricing my-ec2-config type=linux + + .. versionadded:: 2015.8.0 + """ + sources = { + "linux": "https://a0.awsstatic.com/pricing/1/ec2/linux-od.min.js", + "rhel": "https://a0.awsstatic.com/pricing/1/ec2/rhel-od.min.js", + "sles": "https://a0.awsstatic.com/pricing/1/ec2/sles-od.min.js", + "mswin": "https://a0.awsstatic.com/pricing/1/ec2/mswin-od.min.js", + "mswinsql": "https://a0.awsstatic.com/pricing/1/ec2/mswinSQL-od.min.js", + "mswinsqlweb": "https://a0.awsstatic.com/pricing/1/ec2/mswinSQLWeb-od.min.js", + } + + if kwargs is None: + kwargs = {} + + if "type" not in kwargs: + for source in sources: + _parse_pricing(sources[source], source) + else: + _parse_pricing(sources[kwargs["type"]], kwargs["type"]) + + +def _parse_pricing(url, name): + """ + Download and parse an individual pricing file from AWS + + .. versionadded:: 2015.8.0 + """ + price_js = http.query(url, text=True) + + items = [] + current_item = "" + + price_js = re.sub(JS_COMMENT_RE, "", price_js["text"]) + price_js = price_js.strip().rstrip(");").lstrip("callback(") + for keyword in ( + "vers", + "config", + "rate", + "valueColumns", + "currencies", + "instanceTypes", + "type", + "ECU", + "storageGB", + "name", + "vCPU", + "memoryGiB", + "storageGiB", + "USD", + ): + price_js = price_js.replace(keyword, f'"{keyword}"') + + for keyword in ("region", "price", "size"): + price_js = price_js.replace(keyword, f'"{keyword}"') + price_js = price_js.replace(f'"{keyword}"s', f'"{keyword}s"') + + price_js = price_js.replace('""', '"') + + # Turn the data into something that's easier/faster to process + regions = {} + price_json = salt.utils.json.loads(price_js) + for region in price_json["config"]["regions"]: + sizes = {} + for itype in region["instanceTypes"]: + for size in itype["sizes"]: + sizes[size["size"]] = size + regions[region["region"]] = sizes + + outfile = os.path.join(__opts__["cachedir"], f"ec2-pricing-{name}.p") + with salt.utils.files.fopen(outfile, "w") as fho: + salt.utils.msgpack.dump(regions, fho) + + return True + + +def show_pricing(kwargs=None, call=None): + """ + Show pricing for a particular profile. This is only an estimate, based on + unofficial pricing sources. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f show_pricing my-ec2-config profile=my-profile + + If pricing sources have not been cached, they will be downloaded. Once they + have been cached, they will not be updated automatically. To manually update + all prices, use the following command: + + .. code-block:: bash + + salt-cloud -f update_pricing + + .. versionadded:: 2015.8.0 + """ + profile = __opts__["profiles"].get(kwargs["profile"], {}) + if not profile: + return {"Error": "The requested profile was not found"} + + # Make sure the profile belongs to ec2 + provider = profile.get("provider", "0:0") + comps = provider.split(":") + if len(comps) < 2 or comps[1] != "ec2": + return {"Error": "The requested profile does not belong to EC2"} + + image_id = profile.get("image", None) + image_dict = show_image({"image": image_id}, "function") + image_info = image_dict[0] + + # Find out what platform it is + if image_info.get("imageOwnerAlias", "") == "amazon": + if image_info.get("platform", "") == "windows": + image_description = image_info.get("description", "") + if "sql" in image_description.lower(): + if "web" in image_description.lower(): + name = "mswinsqlweb" + else: + name = "mswinsql" + else: + name = "mswin" + elif image_info.get("imageLocation", "").strip().startswith("amazon/suse"): + name = "sles" + else: + name = "linux" + elif image_info.get("imageOwnerId", "") == "309956199498": + name = "rhel" + else: + name = "linux" + + pricefile = os.path.join(__opts__["cachedir"], f"ec2-pricing-{name}.p") + + if not os.path.isfile(pricefile): + update_pricing({"type": name}, "function") + + with salt.utils.files.fopen(pricefile, "r") as fhi: + ec2_price = salt.utils.stringutils.to_unicode(salt.utils.msgpack.load(fhi)) + + region = get_location(profile) + size = profile.get("size", None) + if size is None: + return {"Error": "The requested profile does not contain a size"} + + try: + raw = ec2_price[region][size] + except KeyError: + return { + "Error": ( + "The size ({}) in the requested profile does not have " + "a price associated with it for the {} region".format(size, region) + ) + } + + ret = {} + if kwargs.get("raw", False): + ret["_raw"] = raw + + ret["per_hour"] = 0 + for col in raw.get("valueColumns", []): + ret["per_hour"] += decimal.Decimal(col["prices"].get("USD", 0)) + + ret["per_hour"] = decimal.Decimal(ret["per_hour"]) + ret["per_day"] = ret["per_hour"] * 24 + ret["per_week"] = ret["per_day"] * 7 + ret["per_month"] = ret["per_day"] * 30 + ret["per_year"] = ret["per_week"] * 52 + + return {profile["profile"]: ret} + + +def ssm_create_association(name=None, kwargs=None, instance_id=None, call=None): + """ + Associates the specified SSM document with the specified instance + + http://docs.aws.amazon.com/ssm/latest/APIReference/API_CreateAssociation.html + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a ssm_create_association ec2-instance-name ssm_document=ssm-document-name + """ + + if call != "action": + raise SaltCloudSystemExit( + "The ssm_create_association action must be called with -a or --action." + ) + + if not kwargs: + kwargs = {} + + if "instance_id" in kwargs: + instance_id = kwargs["instance_id"] + + if name and not instance_id: + instance_id = _get_node(name)["instanceId"] + + if not name and not instance_id: + log.error("Either a name or an instance_id is required.") + return False + + if "ssm_document" not in kwargs: + log.error("A ssm_document is required.") + return False + + params = { + "Action": "CreateAssociation", + "InstanceId": instance_id, + "Name": kwargs["ssm_document"], + } + + result = aws.query( + params, + return_root=True, + location=get_location(), + provider=get_provider(), + product="ssm", + opts=__opts__, + sigver="4", + ) + log.info(result) + return result + + +def ssm_describe_association(name=None, kwargs=None, instance_id=None, call=None): + """ + Describes the associations for the specified SSM document or instance. + + http://docs.aws.amazon.com/ssm/latest/APIReference/API_DescribeAssociation.html + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a ssm_describe_association ec2-instance-name ssm_document=ssm-document-name + """ + if call != "action": + raise SaltCloudSystemExit( + "The ssm_describe_association action must be called with -a or --action." + ) + + if not kwargs: + kwargs = {} + + if "instance_id" in kwargs: + instance_id = kwargs["instance_id"] + + if name and not instance_id: + instance_id = _get_node(name)["instanceId"] + + if not name and not instance_id: + log.error("Either a name or an instance_id is required.") + return False + + if "ssm_document" not in kwargs: + log.error("A ssm_document is required.") + return False + + params = { + "Action": "DescribeAssociation", + "InstanceId": instance_id, + "Name": kwargs["ssm_document"], + } + + result = aws.query( + params, + return_root=True, + location=get_location(), + provider=get_provider(), + product="ssm", + opts=__opts__, + sigver="4", + ) + log.info(result) + return result diff --git a/salt/cloud/clouds/gce.py b/salt/cloud/clouds/gce.py new file mode 100644 index 000000000000..f5b16897fa05 --- /dev/null +++ b/salt/cloud/clouds/gce.py @@ -0,0 +1,2590 @@ +""" +Copyright 2013 Google Inc. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Google Compute Engine Module +============================ + +The Google Compute Engine module. This module interfaces with Google Compute +Engine (GCE). To authenticate to GCE, you will need to create a Service Account. +To set up Service Account Authentication, follow the :ref:`gce_setup` instructions. + +Example Provider Configuration +------------------------------ + +.. code-block:: yaml + + my-gce-config: + # The Google Cloud Platform Project ID + project: "my-project-id" + # The Service Account client ID + service_account_email_address: 1234567890@developer.gserviceaccount.com + # The location of the private key (PEM format) + service_account_private_key: /home/erjohnso/PRIVKEY.pem + driver: gce + # Specify whether to use public or private IP for deploy script. + # Valid options are: + # private_ips - The salt-master is also hosted with GCE + # public_ips - The salt-master is hosted outside of GCE + ssh_interface: public_ips + +:maintainer: Eric Johnson +:maintainer: Russell Tolle +:depends: libcloud >= 1.0.0 +""" + +# pylint: disable=function-redefined + +import logging +import os +import pprint +import re +import sys +from ast import literal_eval + +import salt.config as config +import salt.utils.cloud +import salt.utils.files +import salt.utils.http +import salt.utils.msgpack +from salt.cloud.libcloudfuncs import * # pylint: disable=redefined-builtin,wildcard-import,unused-wildcard-import +from salt.exceptions import SaltCloudSystemExit +from salt.utils.functools import namespaced_function +from salt.utils.versions import Version + +# pylint: disable=import-error +LIBCLOUD_IMPORT_ERROR = None +try: + import libcloud + from libcloud.common.google import ResourceInUseError, ResourceNotFoundError + from libcloud.compute.providers import get_driver + from libcloud.compute.types import Provider + from libcloud.loadbalancer.providers import get_driver as get_driver_lb + from libcloud.loadbalancer.types import Provider as Provider_lb + + HAS_LIBCLOUD = True +except ImportError: + LIBCLOUD_IMPORT_ERROR = sys.exc_info() + HAS_LIBCLOUD = False +# pylint: enable=import-error + + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "gce" + +# custom UA +_UA_PRODUCT = "salt-cloud" +_UA_VERSION = "0.2.0" + +# Redirect GCE functions to this module namespace +avail_locations = namespaced_function(avail_locations, globals()) +script = namespaced_function(script, globals()) +destroy = namespaced_function(destroy, globals()) +list_nodes = namespaced_function(list_nodes, globals()) +list_nodes_full = namespaced_function(list_nodes_full, globals()) +list_nodes_select = namespaced_function(list_nodes_select, globals()) + +GCE_VM_NAME_REGEX = re.compile(r"^(?:[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?)$") + + +# Only load in this module if the GCE configurations are in place +def __virtual__(): + """ + Set up the libcloud functions and check for GCE configurations. + """ + if not HAS_LIBCLOUD: + return False, "apache-libcloud is not installed" + + if Version(libcloud.__version__) < Version("2.5.0"): + return False, "The salt-cloud GCE driver requires apache-libcloud>=2.5.0" + + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + for provider, details in __opts__["providers"].items(): + if "gce" not in details: + continue + + parameters = details["gce"] + pathname = os.path.expanduser(parameters["service_account_private_key"]) + # empty pathname will tell libcloud to use instance credentials + if ( + pathname + and salt.utils.cloud.check_key_path_and_mode(provider, pathname) is False + ): + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or "gce", + ("project", "service_account_email_address", "service_account_private_key"), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + if LIBCLOUD_IMPORT_ERROR: + log.error("Failure when importing LibCloud: ", exc_info=LIBCLOUD_IMPORT_ERROR) + log.error( + "Note: The libcloud dependency is called 'apache-libcloud' on PyPi/pip." + ) + return config.check_driver_dependencies(__virtualname__, {"libcloud": HAS_LIBCLOUD}) + + +def get_lb_conn(gce_driver=None): + """ + Return a load-balancer conn object + """ + if not gce_driver: + raise SaltCloudSystemExit("Missing gce_driver for get_lb_conn method.") + return get_driver_lb(Provider_lb.GCE)(gce_driver=gce_driver) + + +def get_conn(): + """ + Return a conn object for the passed VM data + """ + driver = get_driver(Provider.GCE) + provider = get_configured_provider() + project = config.get_cloud_config_value("project", provider, __opts__) + email = config.get_cloud_config_value( + "service_account_email_address", provider, __opts__ + ) + private_key = config.get_cloud_config_value( + "service_account_private_key", provider, __opts__ + ) + gce = driver(email, private_key, project=project) + gce.connection.user_agent_append(f"{_UA_PRODUCT}/{_UA_VERSION}") + return gce + + +def _expand_item(item): + """ + Convert the libcloud object into something more serializable. + """ + ret = {} + ret.update(item.__dict__) + return ret + + +def _expand_node(node): + """ + Convert the libcloud Node object into something more serializable. + """ + ret = {} + ret.update(node.__dict__) + try: + del ret["extra"]["boot_disk"] + except Exception: # pylint: disable=W0703 + pass + zone = ret["extra"]["zone"] + ret["extra"]["zone"] = {} + ret["extra"]["zone"].update(zone.__dict__) + + # Remove unserializable GCENodeDriver objects + if "driver" in ret: + del ret["driver"] + if "driver" in ret["extra"]["zone"]: + del ret["extra"]["zone"]["driver"] + + return ret + + +def _expand_disk(disk): + """ + Convert the libcloud Volume object into something more serializable. + """ + ret = {} + ret.update(disk.__dict__) + zone = ret["extra"]["zone"] + ret["extra"]["zone"] = {} + ret["extra"]["zone"].update(zone.__dict__) + return ret + + +def _expand_address(addy): + """ + Convert the libcloud GCEAddress object into something more serializable. + """ + ret = {} + ret.update(addy.__dict__) + ret["extra"]["zone"] = addy.region.name + return ret + + +def _expand_balancer(lb): + """ + Convert the libcloud load-balancer object into something more serializable. + """ + ret = {} + ret.update(lb.__dict__) + hc = ret["extra"]["healthchecks"] + ret["extra"]["healthchecks"] = [] + for item in hc: + ret["extra"]["healthchecks"].append(_expand_item(item)) + + fwr = ret["extra"]["forwarding_rule"] + tp = ret["extra"]["forwarding_rule"].targetpool + reg = ret["extra"]["forwarding_rule"].region + ret["extra"]["forwarding_rule"] = {} + ret["extra"]["forwarding_rule"].update(fwr.__dict__) + ret["extra"]["forwarding_rule"]["targetpool"] = tp.name + ret["extra"]["forwarding_rule"]["region"] = reg.name + + tp = ret["extra"]["targetpool"] + hc = ret["extra"]["targetpool"].healthchecks + nodes = ret["extra"]["targetpool"].nodes + region = ret["extra"]["targetpool"].region + zones = ret["extra"]["targetpool"].region.zones + + ret["extra"]["targetpool"] = {} + ret["extra"]["targetpool"].update(tp.__dict__) + ret["extra"]["targetpool"]["region"] = _expand_item(region) + ret["extra"]["targetpool"]["nodes"] = [] + for n in nodes: + ret["extra"]["targetpool"]["nodes"].append(_expand_node(n)) + ret["extra"]["targetpool"]["healthchecks"] = [] + for hci in hc: + ret["extra"]["targetpool"]["healthchecks"].append(hci.name) + ret["extra"]["targetpool"]["region"]["zones"] = [] + for z in zones: + ret["extra"]["targetpool"]["region"]["zones"].append(z.name) + return ret + + +def show_instance(vm_name, call=None): + """ + Show the details of the existing instance. + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + conn = get_conn() + node = _expand_node(conn.ex_get_node(vm_name)) + __utils__["cloud.cache_node"](node, _get_active_provider_name(), __opts__) + return node + + +def avail_sizes(conn=None): + """ + Return a dict of available instances sizes (a.k.a machine types) and + convert them to something more serializable. + """ + if not conn: + conn = get_conn() + raw_sizes = conn.list_sizes("all") # get *all* the machine types! + sizes = [] + for size in raw_sizes: + zone = size.extra["zone"] + size.extra["zone"] = {} + size.extra["zone"].update(zone.__dict__) + mtype = {} + mtype.update(size.__dict__) + sizes.append(mtype) + return sizes + + +def avail_images(conn=None): + """ + Return a dict of all available VM images on the cloud provider with + relevant data. + + Note that for GCE, there are custom images within the project, but the + generic images are in other projects. This returns a dict of images in + the project plus images in well-known public projects that provide supported + images, as listed on this page: + https://cloud.google.com/compute/docs/operating-systems/ + + If image names overlap, the image in the current project is used. + """ + if not conn: + conn = get_conn() + + all_images = [] + # The list of public image projects can be found via: + # % gcloud compute images list + # and looking at the "PROJECT" column in the output. + public_image_projects = ( + "centos-cloud", + "coreos-cloud", + "debian-cloud", + "google-containers", + "opensuse-cloud", + "rhel-cloud", + "suse-cloud", + "ubuntu-os-cloud", + "windows-cloud", + ) + for project in public_image_projects: + all_images.extend(conn.list_images(project)) + + # Finally, add the images in this current project last so that it overrides + # any image that also exists in any public project. + all_images.extend(conn.list_images()) + + ret = {} + for img in all_images: + ret[img.name] = {} + for attr in dir(img): + if attr.startswith("_"): + continue + ret[img.name][attr] = getattr(img, attr) + return ret + + +def __get_image(conn, vm_): + """ + The get_image for GCE allows partial name matching and returns a + libcloud object. + """ + img = config.get_cloud_config_value( + "image", vm_, __opts__, default="debian-7", search_global=False + ) + return conn.ex_get_image(img) + + +def __get_location(conn, vm_): + """ + Need to override libcloud to find the zone. + """ + location = config.get_cloud_config_value("location", vm_, __opts__) + return conn.ex_get_zone(location) + + +def __get_size(conn, vm_): + """ + Need to override libcloud to find the machine type in the proper zone. + """ + size = config.get_cloud_config_value( + "size", vm_, __opts__, default="n1-standard-1", search_global=False + ) + return conn.ex_get_size(size, __get_location(conn, vm_)) + + +def __get_labels(vm_): + """ + Get configured labels. + """ + l = config.get_cloud_config_value( + "ex_labels", vm_, __opts__, default="{}", search_global=False + ) + # Consider warning the user that the labels in the cloud profile + # could not be interpreted, bad formatting? + try: + labels = literal_eval(l) + except Exception: # pylint: disable=W0703 + labels = None + if not labels or not isinstance(labels, dict): + labels = None + return labels + + +def __get_tags(vm_): + """ + Get configured tags. + """ + t = config.get_cloud_config_value( + "tags", vm_, __opts__, default="[]", search_global=False + ) + # Consider warning the user that the tags in the cloud profile + # could not be interpreted, bad formatting? + try: + tags = literal_eval(t) + except Exception: # pylint: disable=W0703 + tags = None + if not tags or not isinstance(tags, list): + tags = None + return tags + + +def __get_metadata(vm_): + """ + Get configured metadata and add 'salt-cloud-profile'. + """ + md = config.get_cloud_config_value( + "metadata", vm_, __opts__, default="{}", search_global=False + ) + # Consider warning the user that the metadata in the cloud profile + # could not be interpreted, bad formatting? + try: + metadata = literal_eval(md) + except Exception: # pylint: disable=W0703 + metadata = None + if not metadata or not isinstance(metadata, dict): + metadata = {"items": [{"key": "salt-cloud-profile", "value": vm_["profile"]}]} + else: + metadata["salt-cloud-profile"] = vm_["profile"] + items = [] + for k, v in metadata.items(): + items.append({"key": k, "value": v}) + metadata = {"items": items} + return metadata + + +def __get_host(node, vm_): + """ + Return public IP, private IP, or hostname for the libcloud 'node' object + """ + if __get_ssh_interface(vm_) == "private_ips" or vm_["external_ip"] is None: + ip_address = node.private_ips[0] + log.info("Salt node data. Private_ip: %s", ip_address) + else: + ip_address = node.public_ips[0] + log.info("Salt node data. Public_ip: %s", ip_address) + + if ip_address: + return ip_address + + return node.name + + +def __get_network(conn, vm_): + """ + Return a GCE libcloud network object with matching name + """ + network = config.get_cloud_config_value( + "network", vm_, __opts__, default="default", search_global=False + ) + return conn.ex_get_network(network) + + +def __get_subnetwork(vm_): + """ + Get configured subnetwork. + """ + ex_subnetwork = config.get_cloud_config_value( + "subnetwork", vm_, __opts__, search_global=False + ) + + return ex_subnetwork + + +def __get_region(conn, vm_): + """ + Return a GCE libcloud region object with matching name. + """ + location = __get_location(conn, vm_) + region = "-".join(location.name.split("-")[:2]) + + return conn.ex_get_region(region) + + +def __get_ssh_interface(vm_): + """ + Return the ssh_interface type to connect to. Either 'public_ips' (default) + or 'private_ips'. + """ + return config.get_cloud_config_value( + "ssh_interface", vm_, __opts__, default="public_ips", search_global=False + ) + + +def __create_orget_address(conn, name, region): + """ + Reuse or create a static IP address. + Returns a native GCEAddress construct to use with libcloud. + """ + try: + addy = conn.ex_get_address(name, region) + except ResourceNotFoundError: # pylint: disable=W0703 + addr_kwargs = {"name": name, "region": region} + new_addy = create_address(addr_kwargs, "function") + addy = conn.ex_get_address(new_addy["name"], new_addy["region"]) + + return addy + + +def _parse_allow(allow): + """ + Convert firewall rule allowed user-string to specified REST API format. + """ + # input=> tcp:53,tcp:80,tcp:443,icmp,tcp:4201,udp:53 + # output<= [ + # {"IPProtocol": "tcp", "ports": ["53","80","443","4201"]}, + # {"IPProtocol": "icmp"}, + # {"IPProtocol": "udp", "ports": ["53"]}, + # ] + seen_protos = {} + allow_dict = [] + protocols = allow.split(",") + for p in protocols: + pairs = p.split(":") + if pairs[0].lower() not in ["tcp", "udp", "icmp"]: + raise SaltCloudSystemExit( + f"Unsupported protocol {pairs[0]}. Must be tcp, udp, or icmp." + ) + if len(pairs) == 1 or pairs[0].lower() == "icmp": + seen_protos[pairs[0]] = [] + else: + if pairs[0] not in seen_protos: + seen_protos[pairs[0]] = [pairs[1]] + else: + seen_protos[pairs[0]].append(pairs[1]) + for k in seen_protos: + d = {"IPProtocol": k} + if seen_protos[k]: + d["ports"] = seen_protos[k] + allow_dict.append(d) + log.debug("firewall allowed protocols/ports: %s", allow_dict) + return allow_dict + + +def __get_ssh_credentials(vm_): + """ + Get configured SSH credentials. + """ + ssh_user = config.get_cloud_config_value( + "ssh_username", vm_, __opts__, default=os.getenv("USER") + ) + ssh_key = config.get_cloud_config_value( + "ssh_keyfile", + vm_, + __opts__, + default=os.path.expanduser("~/.ssh/google_compute_engine"), + ) + return ssh_user, ssh_key + + +def create_network(kwargs=None, call=None): + """ + .. versionchanged:: 2017.7.0 + + Create a GCE network. Must specify name and cidr. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_network gce name=mynet cidr=10.10.10.0/24 mode=legacy description=optional + salt-cloud -f create_network gce name=mynet description=optional + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_network function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when creating a network.") + return False + + mode = kwargs.get("mode", "legacy") + cidr = kwargs.get("cidr", None) + if cidr is None and mode == "legacy": + log.error( + "A network CIDR range must be specified when creating a legacy network." + ) + return False + + name = kwargs["name"] + desc = kwargs.get("description", None) + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "creating network", + "salt/cloud/net/creating", + args={"name": name, "cidr": cidr, "description": desc, "mode": mode}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + network = conn.ex_create_network(name, cidr, desc, mode) + + __utils__["cloud.fire_event"]( + "event", + "created network", + "salt/cloud/net/created", + args={"name": name, "cidr": cidr, "description": desc, "mode": mode}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return _expand_item(network) + + +def delete_network(kwargs=None, call=None): + """ + Permanently delete a network. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f delete_network gce name=mynet + """ + if call != "function": + raise SaltCloudSystemExit( + "The delete_network function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when deleting a network.") + return False + + name = kwargs["name"] + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "deleting network", + "salt/cloud/net/deleting", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + result = conn.ex_destroy_network(conn.ex_get_network(name)) + except ResourceNotFoundError as exc: + log.error( + "Nework %s was not found. Exception was: %s", + name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "deleted network", + "salt/cloud/net/deleted", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return result + + +def show_network(kwargs=None, call=None): + """ + Show the details of an existing network. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f show_network gce name=mynet + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_network function must be called with -f or --function." + ) + if not kwargs or "name" not in kwargs: + log.error("Must specify name of network.") + return False + + conn = get_conn() + return _expand_item(conn.ex_get_network(kwargs["name"])) + + +def create_subnetwork(kwargs=None, call=None): + """ + .. versionadded:: 2017.7.0 + + Create a GCE Subnetwork. Must specify name, cidr, network, and region. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_subnetwork gce name=mysubnet network=mynet1 region=us-west1 cidr=10.0.0.0/24 description=optional + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_subnetwork function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("Must specify name of subnet.") + return False + + if "network" not in kwargs: + log.errror("Must specify name of network to create subnet under.") + return False + + if "cidr" not in kwargs: + log.errror("A network CIDR range must be specified when creating a subnet.") + return False + + if "region" not in kwargs: + log.error("A region must be specified when creating a subnetwork.") + return False + + name = kwargs["name"] + cidr = kwargs["cidr"] + network = kwargs["network"] + region = kwargs["region"] + desc = kwargs.get("description", None) + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "create subnetwork", + "salt/cloud/subnet/creating", + args={ + "name": name, + "network": network, + "cidr": cidr, + "region": region, + "description": desc, + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + subnet = conn.ex_create_subnetwork(name, cidr, network, region, desc) + + __utils__["cloud.fire_event"]( + "event", + "created subnetwork", + "salt/cloud/subnet/created", + args={ + "name": name, + "network": network, + "cidr": cidr, + "region": region, + "description": desc, + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return _expand_item(subnet) + + +def delete_subnetwork(kwargs=None, call=None): + """ + .. versionadded:: 2017.7.0 + + Delete a GCE Subnetwork. Must specify name and region. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f delete_subnetwork gce name=mysubnet network=mynet1 region=us-west1 + """ + if call != "function": + raise SaltCloudSystemExit( + "The delete_subnet function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("Must specify name of subnet.") + return False + + if "region" not in kwargs: + log.error("Must specify region of subnet.") + return False + + name = kwargs["name"] + region = kwargs["region"] + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "deleting subnetwork", + "salt/cloud/subnet/deleting", + args={"name": name, "region": region}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + result = conn.ex_destroy_subnetwork(name, region) + except ResourceNotFoundError as exc: + log.error( + "Subnetwork %s was not found. Exception was: %s", + name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "deleted subnetwork", + "salt/cloud/subnet/deleted", + args={"name": name, "region": region}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return result + + +def show_subnetwork(kwargs=None, call=None): + """ + .. versionadded:: 2017.7.0 + + Show details of an existing GCE Subnetwork. Must specify name and region. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f show_subnetwork gce name=mysubnet region=us-west1 + + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_subnetwork function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("Must specify name of subnet.") + return False + + if "region" not in kwargs: + log.error("Must specify region of subnet.") + return False + + name = kwargs["name"] + region = kwargs["region"] + conn = get_conn() + return _expand_item(conn.ex_get_subnetwork(name, region)) + + +def create_fwrule(kwargs=None, call=None): + """ + Create a GCE firewall rule. The 'default' network is used if not specified. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_fwrule gce name=allow-http allow=tcp:80 + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_fwrule function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when creating a firewall rule.") + return False + if "allow" not in kwargs: + log.error('Must use "allow" to specify allowed protocols/ports.') + return False + + name = kwargs["name"] + network_name = kwargs.get("network", "default") + allow = _parse_allow(kwargs["allow"]) + src_range = kwargs.get("src_range", "0.0.0.0/0") + src_tags = kwargs.get("src_tags", None) + dst_tags = kwargs.get("dst_tags", None) + + if src_range: + src_range = src_range.split(",") + if src_tags: + src_tags = src_tags.split(",") + if dst_tags: + dst_tags = dst_tags.split(",") + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "create firewall", + "salt/cloud/firewall/creating", + args={"name": name, "network": network_name, "allow": kwargs["allow"]}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + fwrule = conn.ex_create_firewall( + name, + allow, + network=network_name, + source_ranges=src_range, + source_tags=src_tags, + target_tags=dst_tags, + ) + + __utils__["cloud.fire_event"]( + "event", + "created firewall", + "salt/cloud/firewall/created", + args={"name": name, "network": network_name, "allow": kwargs["allow"]}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return _expand_item(fwrule) + + +def delete_fwrule(kwargs=None, call=None): + """ + Permanently delete a firewall rule. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f delete_fwrule gce name=allow-http + """ + if call != "function": + raise SaltCloudSystemExit( + "The delete_fwrule function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when deleting a firewall rule.") + return False + + name = kwargs["name"] + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "delete firewall", + "salt/cloud/firewall/deleting", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + result = conn.ex_destroy_firewall(conn.ex_get_firewall(name)) + except ResourceNotFoundError as exc: + log.error( + "Rule %s was not found. Exception was: %s", + name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "deleted firewall", + "salt/cloud/firewall/deleted", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return result + + +def show_fwrule(kwargs=None, call=None): + """ + Show the details of an existing firewall rule. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f show_fwrule gce name=allow-http + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_fwrule function must be called with -f or --function." + ) + if not kwargs or "name" not in kwargs: + log.error("Must specify name of network.") + return False + + conn = get_conn() + return _expand_item(conn.ex_get_firewall(kwargs["name"])) + + +def create_hc(kwargs=None, call=None): + """ + Create an HTTP health check configuration. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_hc gce name=hc path=/healthy port=80 + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_hc function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when creating a health check.") + return False + + name = kwargs["name"] + host = kwargs.get("host", None) + path = kwargs.get("path", None) + port = kwargs.get("port", None) + interval = kwargs.get("interval", None) + timeout = kwargs.get("timeout", None) + unhealthy_threshold = kwargs.get("unhealthy_threshold", None) + healthy_threshold = kwargs.get("healthy_threshold", None) + + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "create health_check", + "salt/cloud/healthcheck/creating", + args={ + "name": name, + "host": host, + "path": path, + "port": port, + "interval": interval, + "timeout": timeout, + "unhealthy_threshold": unhealthy_threshold, + "healthy_threshold": healthy_threshold, + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + hc = conn.ex_create_healthcheck( + name, + host=host, + path=path, + port=port, + interval=interval, + timeout=timeout, + unhealthy_threshold=unhealthy_threshold, + healthy_threshold=healthy_threshold, + ) + + __utils__["cloud.fire_event"]( + "event", + "created health_check", + "salt/cloud/healthcheck/created", + args={ + "name": name, + "host": host, + "path": path, + "port": port, + "interval": interval, + "timeout": timeout, + "unhealthy_threshold": unhealthy_threshold, + "healthy_threshold": healthy_threshold, + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return _expand_item(hc) + + +def delete_hc(kwargs=None, call=None): + """ + Permanently delete a health check. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f delete_hc gce name=hc + """ + if call != "function": + raise SaltCloudSystemExit( + "The delete_hc function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when deleting a health check.") + return False + + name = kwargs["name"] + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "delete health_check", + "salt/cloud/healthcheck/deleting", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + result = conn.ex_destroy_healthcheck(conn.ex_get_healthcheck(name)) + except ResourceNotFoundError as exc: + log.error( + "Health check %s was not found. Exception was: %s", + name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "deleted health_check", + "salt/cloud/healthcheck/deleted", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return result + + +def show_hc(kwargs=None, call=None): + """ + Show the details of an existing health check. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f show_hc gce name=hc + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_hc function must be called with -f or --function." + ) + if not kwargs or "name" not in kwargs: + log.error("Must specify name of health check.") + return False + + conn = get_conn() + return _expand_item(conn.ex_get_healthcheck(kwargs["name"])) + + +def create_address(kwargs=None, call=None): + """ + Create a static address in a region. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_address gce name=my-ip region=us-central1 address=IP + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_address function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when creating an address.") + return False + if "region" not in kwargs: + log.error("A region must be specified for the address.") + return False + + name = kwargs["name"] + ex_region = kwargs["region"] + ex_address = kwargs.get("address", None) + kwargs["region"] = {"name": ex_region.name} + + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "create address", + "salt/cloud/address/creating", + args=salt.utils.data.simple_types_filter(kwargs), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + addy = conn.ex_create_address(name, ex_region, ex_address) + + __utils__["cloud.fire_event"]( + "event", + "created address", + "salt/cloud/address/created", + args=salt.utils.data.simple_types_filter(kwargs), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Created GCE Address %s", name) + + return _expand_address(addy) + + +def delete_address(kwargs=None, call=None): + """ + Permanently delete a static address. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f delete_address gce name=my-ip + """ + if call != "function": + raise SaltCloudSystemExit( + "The delete_address function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when deleting an address.") + return False + + if not kwargs or "region" not in kwargs: + log.error("A region must be specified when deleting an address.") + return False + + name = kwargs["name"] + ex_region = kwargs["region"] + + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "delete address", + "salt/cloud/address/deleting", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + result = conn.ex_destroy_address(conn.ex_get_address(name, ex_region)) + except ResourceNotFoundError as exc: + log.error( + "Address %s in region %s was not found. Exception was: %s", + name, + ex_region, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "deleted address", + "salt/cloud/address/deleted", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Deleted GCE Address %s", name) + + return result + + +def show_address(kwargs=None, call=None): + """ + Show the details of an existing static address. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f show_address gce name=mysnapshot region=us-central1 + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_snapshot function must be called with -f or --function." + ) + if not kwargs or "name" not in kwargs: + log.error("Must specify name.") + return False + + if not kwargs or "region" not in kwargs: + log.error("Must specify region.") + return False + + conn = get_conn() + return _expand_address(conn.ex_get_address(kwargs["name"], kwargs["region"])) + + +def create_lb(kwargs=None, call=None): + """ + Create a load-balancer configuration. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_lb gce name=lb region=us-central1 ports=80 + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_lb function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when creating a health check.") + return False + if "ports" not in kwargs: + log.error("A port or port-range must be specified for the load-balancer.") + return False + if "region" not in kwargs: + log.error("A region must be specified for the load-balancer.") + return False + if "members" not in kwargs: + log.error("A comma-separated list of members must be specified.") + return False + + name = kwargs["name"] + ports = kwargs["ports"] + ex_region = kwargs["region"] + members = kwargs.get("members").split(",") + + protocol = kwargs.get("protocol", "tcp") + algorithm = kwargs.get("algorithm", None) + ex_healthchecks = kwargs.get("healthchecks", None) + + # pylint: disable=W0511 + + conn = get_conn() + lb_conn = get_lb_conn(conn) + + ex_address = kwargs.get("address", None) + if ex_address is not None: + ex_address = __create_orget_address(conn, ex_address, ex_region) + + if ex_healthchecks: + ex_healthchecks = ex_healthchecks.split(",") + + __utils__["cloud.fire_event"]( + "event", + "create load_balancer", + "salt/cloud/loadbalancer/creating", + args=kwargs, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + lb = lb_conn.create_balancer( + name, + ports, + protocol, + algorithm, + members, + ex_region=ex_region, + ex_healthchecks=ex_healthchecks, + ex_address=ex_address, + ) + + __utils__["cloud.fire_event"]( + "event", + "created load_balancer", + "salt/cloud/loadbalancer/created", + args=kwargs, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return _expand_balancer(lb) + + +def delete_lb(kwargs=None, call=None): + """ + Permanently delete a load-balancer. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f delete_lb gce name=lb + """ + if call != "function": + raise SaltCloudSystemExit( + "The delete_hc function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when deleting a health check.") + return False + + name = kwargs["name"] + lb_conn = get_lb_conn(get_conn()) + + __utils__["cloud.fire_event"]( + "event", + "delete load_balancer", + "salt/cloud/loadbalancer/deleting", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + result = lb_conn.destroy_balancer(lb_conn.get_balancer(name)) + except ResourceNotFoundError as exc: + log.error( + "Load balancer %s was not found. Exception was: %s", + name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "deleted load_balancer", + "salt/cloud/loadbalancer/deleted", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return result + + +def show_lb(kwargs=None, call=None): + """ + Show the details of an existing load-balancer. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f show_lb gce name=lb + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_lb function must be called with -f or --function." + ) + if not kwargs or "name" not in kwargs: + log.error("Must specify name of load-balancer.") + return False + + lb_conn = get_lb_conn(get_conn()) + return _expand_balancer(lb_conn.get_balancer(kwargs["name"])) + + +def attach_lb(kwargs=None, call=None): + """ + Add an existing node/member to an existing load-balancer configuration. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f attach_lb gce name=lb member=myinstance + """ + if call != "function": + raise SaltCloudSystemExit( + "The attach_lb function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A load-balancer name must be specified.") + return False + if "member" not in kwargs: + log.error("A node name name must be specified.") + return False + + conn = get_conn() + node = conn.ex_get_node(kwargs["member"]) + + lb_conn = get_lb_conn(conn) + lb = lb_conn.get_balancer(kwargs["name"]) + + __utils__["cloud.fire_event"]( + "event", + "attach load_balancer", + "salt/cloud/loadbalancer/attaching", + args=kwargs, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + result = lb_conn.balancer_attach_compute_node(lb, node) + + __utils__["cloud.fire_event"]( + "event", + "attached load_balancer", + "salt/cloud/loadbalancer/attached", + args=kwargs, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return _expand_item(result) + + +def detach_lb(kwargs=None, call=None): + """ + Remove an existing node/member from an existing load-balancer configuration. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f detach_lb gce name=lb member=myinstance + """ + if call != "function": + raise SaltCloudSystemExit( + "The detach_lb function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A load-balancer name must be specified.") + return False + if "member" not in kwargs: + log.error("A node name name must be specified.") + return False + + conn = get_conn() + lb_conn = get_lb_conn(conn) + lb = lb_conn.get_balancer(kwargs["name"]) + + member_list = lb_conn.balancer_list_members(lb) + remove_member = None + for member in member_list: + if member.id == kwargs["member"]: + remove_member = member + break + + if not remove_member: + log.error( + "The specified member %s was not a member of LB %s.", + kwargs["member"], + kwargs["name"], + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "detach load_balancer", + "salt/cloud/loadbalancer/detaching", + args=kwargs, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + result = lb_conn.balancer_detach_member(lb, remove_member) + + __utils__["cloud.fire_event"]( + "event", + "detached load_balancer", + "salt/cloud/loadbalancer/detached", + args=kwargs, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return result + + +def delete_snapshot(kwargs=None, call=None): + """ + Permanently delete a disk snapshot. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f delete_snapshot gce name=disk-snap-1 + """ + if call != "function": + raise SaltCloudSystemExit( + "The delete_snapshot function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when deleting a snapshot.") + return False + + name = kwargs["name"] + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "delete snapshot", + "salt/cloud/snapshot/deleting", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + result = conn.destroy_volume_snapshot(conn.ex_get_snapshot(name)) + except ResourceNotFoundError as exc: + log.error( + "Snapshot %s was not found. Exception was: %s", + name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "deleted snapshot", + "salt/cloud/snapshot/deleted", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return result + + +def delete_disk(kwargs=None, call=None): + """ + Permanently delete a persistent disk. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f delete_disk gce disk_name=pd + """ + if call != "function": + raise SaltCloudSystemExit( + "The delete_disk function must be called with -f or --function." + ) + + if not kwargs or "disk_name" not in kwargs: + log.error("A disk_name must be specified when deleting a disk.") + return False + + conn = get_conn() + + disk = conn.ex_get_volume(kwargs.get("disk_name")) + + __utils__["cloud.fire_event"]( + "event", + "delete disk", + "salt/cloud/disk/deleting", + args={ + "name": disk.name, + "location": disk.extra["zone"].name, + "size": disk.size, + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + result = conn.destroy_volume(disk) + except ResourceInUseError as exc: + log.error( + "Disk %s is in use and must be detached before deleting.\n" + "The following exception was thrown by libcloud:\n%s", + disk.name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "deleted disk", + "salt/cloud/disk/deleted", + args={ + "name": disk.name, + "location": disk.extra["zone"].name, + "size": disk.size, + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return result + + +def create_disk(kwargs=None, call=None): + """ + Create a new persistent disk. Must specify `disk_name` and `location`, + and optionally can specify 'disk_type' as pd-standard or pd-ssd, which + defaults to pd-standard. Can also specify an `image` or `snapshot` but + if neither of those are specified, a `size` (in GB) is required. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_disk gce disk_name=pd size=300 location=us-central1-b + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_disk function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("disk_name", None) + image = kwargs.get("image", None) + location = kwargs.get("location", None) + size = kwargs.get("size", None) + snapshot = kwargs.get("snapshot", None) + disk_type = kwargs.get("type", "pd-standard") + + if location is None: + log.error("A location (zone) must be specified when creating a disk.") + return False + + if name is None: + log.error("A disk_name must be specified when creating a disk.") + return False + + if size is None and image is None and snapshot is None: + log.error("Must specify image, snapshot, or size.") + return False + + conn = get_conn() + + location = conn.ex_get_zone(kwargs["location"]) + use_existing = True + + __utils__["cloud.fire_event"]( + "event", + "create disk", + "salt/cloud/disk/creating", + args={ + "name": name, + "location": location.name, + "image": image, + "snapshot": snapshot, + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + disk = conn.create_volume( + size, name, location, snapshot, image, use_existing, disk_type + ) + + __utils__["cloud.fire_event"]( + "event", + "created disk", + "salt/cloud/disk/created", + args={ + "name": name, + "location": location.name, + "image": image, + "snapshot": snapshot, + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return _expand_disk(disk) + + +def create_snapshot(kwargs=None, call=None): + """ + Create a new disk snapshot. Must specify `name` and `disk_name`. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_snapshot gce name=snap1 disk_name=pd + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_snapshot function must be called with -f or --function." + ) + + if not kwargs or "name" not in kwargs: + log.error("A name must be specified when creating a snapshot.") + return False + + if "disk_name" not in kwargs: + log.error("A disk_name must be specified when creating a snapshot.") + return False + + conn = get_conn() + + name = kwargs.get("name") + disk_name = kwargs.get("disk_name") + + try: + disk = conn.ex_get_volume(disk_name) + except ResourceNotFoundError as exc: + log.error( + "Disk %s was not found. Exception was: %s", + disk_name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + __utils__["cloud.fire_event"]( + "event", + "create snapshot", + "salt/cloud/snapshot/creating", + args={"name": name, "disk_name": disk_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + snapshot = conn.create_volume_snapshot(disk, name) + + __utils__["cloud.fire_event"]( + "event", + "created snapshot", + "salt/cloud/snapshot/created", + args={"name": name, "disk_name": disk_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return _expand_item(snapshot) + + +def show_disk(name=None, kwargs=None, call=None): # pylint: disable=W0613 + """ + Show the details of an existing disk. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a show_disk myinstance disk_name=mydisk + salt-cloud -f show_disk gce disk_name=mydisk + """ + if not kwargs or "disk_name" not in kwargs: + log.error("Must specify disk_name.") + return False + + conn = get_conn() + return _expand_disk(conn.ex_get_volume(kwargs["disk_name"])) + + +def show_snapshot(kwargs=None, call=None): + """ + Show the details of an existing snapshot. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f show_snapshot gce name=mysnapshot + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_snapshot function must be called with -f or --function." + ) + if not kwargs or "name" not in kwargs: + log.error("Must specify name.") + return False + + conn = get_conn() + return _expand_item(conn.ex_get_snapshot(kwargs["name"])) + + +def detach_disk(name=None, kwargs=None, call=None): + """ + Detach a disk from an instance. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a detach_disk myinstance disk_name=mydisk + """ + if call != "action": + raise SaltCloudSystemExit( + "The detach_Disk action must be called with -a or --action." + ) + + if not name: + log.error("Must specify an instance name.") + return False + if not kwargs or "disk_name" not in kwargs: + log.error("Must specify a disk_name to detach.") + return False + + node_name = name + disk_name = kwargs["disk_name"] + + conn = get_conn() + node = conn.ex_get_node(node_name) + disk = conn.ex_get_volume(disk_name) + + __utils__["cloud.fire_event"]( + "event", + "detach disk", + "salt/cloud/disk/detaching", + args={"name": node_name, "disk_name": disk_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + result = conn.detach_volume(disk, node) + + __utils__["cloud.fire_event"]( + "event", + "detached disk", + "salt/cloud/disk/detached", + args={"name": node_name, "disk_name": disk_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return result + + +def attach_disk(name=None, kwargs=None, call=None): + """ + Attach an existing disk to an existing instance. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a attach_disk myinstance disk_name=mydisk mode=READ_WRITE + """ + if call != "action": + raise SaltCloudSystemExit( + "The attach_disk action must be called with -a or --action." + ) + + if not name: + log.error("Must specify an instance name.") + return False + if not kwargs or "disk_name" not in kwargs: + log.error("Must specify a disk_name to attach.") + return False + + node_name = name + disk_name = kwargs["disk_name"] + mode = kwargs.get("mode", "READ_WRITE").upper() + boot = kwargs.get("boot", False) + auto_delete = kwargs.get("auto_delete", False) + if boot and boot.lower() in ["true", "yes", "enabled"]: + boot = True + else: + boot = False + + if mode not in ["READ_WRITE", "READ_ONLY"]: + log.error("Mode must be either READ_ONLY or (default) READ_WRITE.") + return False + + conn = get_conn() + node = conn.ex_get_node(node_name) + disk = conn.ex_get_volume(disk_name) + + __utils__["cloud.fire_event"]( + "event", + "attach disk", + "salt/cloud/disk/attaching", + args={"name": node_name, "disk_name": disk_name, "mode": mode, "boot": boot}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + result = conn.attach_volume( + node, disk, ex_mode=mode, ex_boot=boot, ex_auto_delete=auto_delete + ) + + __utils__["cloud.fire_event"]( + "event", + "attached disk", + "salt/cloud/disk/attached", + args={"name": node_name, "disk_name": disk_name, "mode": mode, "boot": boot}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return result + + +def reboot(vm_name, call=None): + """ + Call GCE 'reset' on the instance. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot myinstance + """ + if call != "action": + raise SaltCloudSystemExit( + "The reboot action must be called with -a or --action." + ) + + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "reboot instance", + f"salt/cloud/{vm_name}/rebooting", + args={"name": vm_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + result = conn.reboot_node(conn.ex_get_node(vm_name)) + + __utils__["cloud.fire_event"]( + "event", + "reboot instance", + f"salt/cloud/{vm_name}/rebooted", + args={"name": vm_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return result + + +def start(vm_name, call=None): + """ + Call GCE 'start on the instance. + + .. versionadded:: 2017.7.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start myinstance + """ + if call != "action": + raise SaltCloudSystemExit( + "The start action must be called with -a or --action." + ) + + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "start instance", + f"salt/cloud/{vm_name}/starting", + args={"name": vm_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + result = conn.ex_start_node(conn.ex_get_node(vm_name)) + + __utils__["cloud.fire_event"]( + "event", + "start instance", + f"salt/cloud/{vm_name}/started", + args={"name": vm_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return result + + +def stop(vm_name, call=None): + """ + Call GCE 'stop' on the instance. + + .. versionadded:: 2017.7.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop myinstance + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + conn = get_conn() + + __utils__["cloud.fire_event"]( + "event", + "stop instance", + f"salt/cloud/{vm_name}/stopping", + args={"name": vm_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + result = conn.ex_stop_node(conn.ex_get_node(vm_name)) + + __utils__["cloud.fire_event"]( + "event", + "stop instance", + f"salt/cloud/{vm_name}/stopped", + args={"name": vm_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return result + + +def destroy(vm_name, call=None): + """ + Call 'destroy' on the instance. Can be called with "-a destroy" or -d + + CLI Example: + + .. code-block:: bash + + salt-cloud -a destroy myinstance1 myinstance2 ... + salt-cloud -d myinstance1 myinstance2 ... + """ + if call and call != "action": + raise SaltCloudSystemExit( + 'The destroy action must be called with -d or "-a destroy".' + ) + + conn = get_conn() + + try: + node = conn.ex_get_node(vm_name) + except Exception as exc: # pylint: disable=W0703 + log.error( + "Could not locate instance %s\n\n" + "The following exception was thrown by libcloud when trying to " + "run the initial deployment: \n%s", + vm_name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + raise SaltCloudSystemExit(f"Could not find instance {vm_name}.") + + __utils__["cloud.fire_event"]( + "event", + "delete instance", + f"salt/cloud/{vm_name}/deleting", + args={"name": vm_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + # Use the instance metadata to see if its salt cloud profile was + # preserved during instance create. If so, use the profile value + # to see if the 'delete_boot_pd' value is set to delete the disk + # along with the instance. + profile = None + if node.extra["metadata"] and "items" in node.extra["metadata"]: + for md in node.extra["metadata"]["items"]: + if md["key"] == "salt-cloud-profile": + profile = md["value"] + vm_ = get_configured_provider() + delete_boot_pd = False + + if ( + profile + and profile in vm_["profiles"] + and "delete_boot_pd" in vm_["profiles"][profile] + ): + delete_boot_pd = vm_["profiles"][profile]["delete_boot_pd"] + + try: + inst_deleted = conn.destroy_node(node) + except Exception as exc: # pylint: disable=W0703 + log.error( + "Could not destroy instance %s\n\n" + "The following exception was thrown by libcloud when trying to " + "run the initial deployment: \n%s", + vm_name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + raise SaltCloudSystemExit(f"Could not destroy instance {vm_name}.") + __utils__["cloud.fire_event"]( + "event", + "delete instance", + f"salt/cloud/{vm_name}/deleted", + args={"name": vm_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if delete_boot_pd: + log.info( + "delete_boot_pd is enabled for the instance profile, " + "attempting to delete disk" + ) + __utils__["cloud.fire_event"]( + "event", + "delete disk", + "salt/cloud/disk/deleting", + args={"name": vm_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + try: + conn.destroy_volume(conn.ex_get_volume(vm_name)) + except Exception as exc: # pylint: disable=W0703 + # Note that we don't raise a SaltCloudSystemExit here in order + # to allow completion of instance deletion. Just log the error + # and keep going. + log.error( + "Could not destroy disk %s\n\n" + "The following exception was thrown by libcloud when trying " + "to run the initial deployment: \n%s", + vm_name, + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + __utils__["cloud.fire_event"]( + "event", + "deleted disk", + "salt/cloud/disk/deleted", + args={"name": vm_name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + vm_name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return inst_deleted + + +def create_attach_volumes(name, kwargs, call=None): + """ + .. versionadded:: 2017.7.0 + + Create and attach multiple volumes to a node. The 'volumes' and 'node' + arguments are required, where 'node' is a libcloud node, and 'volumes' + is a list of maps, where each map contains: + + size + The size of the new disk in GB. Required. + + type + The disk type, either pd-standard or pd-ssd. Optional, defaults to pd-standard. + + image + An image to use for this new disk. Optional. + + snapshot + A snapshot to use for this new disk. Optional. + + auto_delete + An option(bool) to keep or remove the disk upon instance deletion. + Optional, defaults to False. + + Volumes are attached in the order in which they are given, thus on a new + node the first volume will be /dev/sdb, the second /dev/sdc, and so on. + """ + if call != "action": + raise SaltCloudSystemExit( + "The create_attach_volumes action must be called with -a or --action." + ) + + volumes = literal_eval(kwargs["volumes"]) + node = kwargs["node"] + conn = get_conn() + node_data = _expand_node(conn.ex_get_node(node)) + letter = ord("a") - 1 + + for idx, volume in enumerate(volumes): + volume_name = f"{name}-sd{chr(letter + 2 + idx)}" + + volume_dict = { + "disk_name": volume_name, + "location": node_data["extra"]["zone"]["name"], + "size": volume["size"], + "type": volume.get("type", "pd-standard"), + "image": volume.get("image", None), + "snapshot": volume.get("snapshot", None), + "auto_delete": volume.get("auto_delete", False), + } + + create_disk(volume_dict, "function") + attach_disk(name, volume_dict, "action") + + +def request_instance(vm_): + """ + Request a single GCE instance from a data dict. + + .. versionchanged:: 2017.7.0 + """ + if not GCE_VM_NAME_REGEX.match(vm_["name"]): + raise SaltCloudSystemExit( + "VM names must start with a letter, only contain letters, numbers, or" + " dashes and cannot end in a dash." + ) + + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, _get_active_provider_name() or "gce", vm_["profile"], vm_=vm_ + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "create instance", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + conn = get_conn() + + kwargs = { + "name": vm_["name"], + "size": __get_size(conn, vm_), + "image": __get_image(conn, vm_), + "location": __get_location(conn, vm_), + "ex_labels": __get_labels(vm_), + "ex_network": __get_network(conn, vm_), + "ex_subnetwork": __get_subnetwork(vm_), + "ex_tags": __get_tags(vm_), + "ex_metadata": __get_metadata(vm_), + } + external_ip = config.get_cloud_config_value( + "external_ip", vm_, __opts__, default="ephemeral" + ) + + if external_ip.lower() == "ephemeral": + external_ip = "ephemeral" + vm_["external_ip"] = external_ip + elif external_ip == "None": + external_ip = None + vm_["external_ip"] = external_ip + else: + region = __get_region(conn, vm_) + external_ip = __create_orget_address(conn, external_ip, region) + + vm_["external_ip"] = { + "name": external_ip.name, + "address": external_ip.address, + "region": external_ip.region.name, + } + kwargs["external_ip"] = external_ip + + if LIBCLOUD_VERSION_INFO > (0, 15, 1): + + kwargs.update( + { + "ex_disk_type": config.get_cloud_config_value( + "ex_disk_type", vm_, __opts__, default="pd-standard" + ), + "ex_disk_auto_delete": config.get_cloud_config_value( + "ex_disk_auto_delete", vm_, __opts__, default=True + ), + "ex_disks_gce_struct": config.get_cloud_config_value( + "ex_disks_gce_struct", vm_, __opts__, default=None + ), + "ex_service_accounts": config.get_cloud_config_value( + "ex_service_accounts", vm_, __opts__, default=None + ), + "ex_can_ip_forward": config.get_cloud_config_value( + "ip_forwarding", vm_, __opts__, default=False + ), + "ex_preemptible": config.get_cloud_config_value( + "preemptible", vm_, __opts__, default=False + ), + } + ) + if kwargs.get("ex_disk_type") not in ("pd-standard", "pd-ssd"): + raise SaltCloudSystemExit( + "The value of 'ex_disk_type' needs to be one of: " + "'pd-standard', 'pd-ssd'" + ) + + if LIBCLOUD_VERSION_INFO >= (2, 3, 0): + + kwargs.update( + { + "ex_accelerator_type": config.get_cloud_config_value( + "ex_accelerator_type", vm_, __opts__, default=None + ), + "ex_accelerator_count": config.get_cloud_config_value( + "ex_accelerator_count", vm_, __opts__, default=None + ), + } + ) + if kwargs.get("ex_accelerator_type"): + log.warning( + "An accelerator is being attached to this instance, " + "the ex_on_host_maintenance setting is being set to " + "'TERMINATE' as a result" + ) + kwargs.update({"ex_on_host_maintenance": "TERMINATE"}) + + log.info("Creating GCE instance %s in %s", vm_["name"], kwargs["location"].name) + log.debug("Create instance kwargs %s", kwargs) + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "requesting", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + node_data = conn.create_node(**kwargs) + except Exception as exc: # pylint: disable=W0703 + log.error( + "Error creating %s on GCE\n\n" + "The following exception was thrown by libcloud when trying to " + "run the initial deployment: \n%s", + vm_["name"], + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + volumes = config.get_cloud_config_value( + "volumes", vm_, __opts__, search_global=True + ) + + if volumes: + __utils__["cloud.fire_event"]( + "event", + "attaching volumes", + "salt/cloud/{}/attaching_volumes".format(vm_["name"]), + args={"volumes": volumes}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Create and attach volumes to node %s", vm_["name"]) + create_attach_volumes( + vm_["name"], {"volumes": volumes, "node": node_data}, call="action" + ) + + try: + node_dict = show_instance(node_data["name"], "action") + except TypeError: + # node_data is a libcloud Node which is unsubscriptable + node_dict = show_instance(node_data.name, "action") + + return node_dict, node_data + + +def create(vm_=None, call=None): + """ + Create a single GCE instance from a data dict. + """ + if call: + raise SaltCloudSystemExit("You cannot create an instance with -a or -f.") + + node_info = request_instance(vm_) + if isinstance(node_info, bool): + raise SaltCloudSystemExit("There was an error creating the GCE instance.") + node_dict = node_info[0] + node_data = node_info[1] + + ssh_user, ssh_key = __get_ssh_credentials(vm_) + vm_["ssh_host"] = __get_host(node_data, vm_) + vm_["key_filename"] = ssh_key + + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + ret.update(node_dict) + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.trace("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(node_dict)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def update_pricing(kwargs=None, call=None): + """ + Download most recent pricing information from GCE and save locally + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f update_pricing my-gce-config + + .. versionadded:: 2015.8.0 + """ + url = "https://cloudpricingcalculator.appspot.com/static/data/pricelist.json" + price_json = salt.utils.http.query(url, decode=True, decode_type="json") + + outfile = os.path.join(__opts__["cachedir"], "gce-pricing.p") + with salt.utils.files.fopen(outfile, "w") as fho: + salt.utils.msgpack.dump(price_json["dict"], fho) + + return True + + +def show_pricing(kwargs=None, call=None): + """ + Show pricing for a particular profile. This is only an estimate, based on + unofficial pricing sources. + + .. versionadded:: 2015.8.0 + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f show_pricing my-gce-config profile=my-profile + """ + profile = __opts__["profiles"].get(kwargs["profile"], {}) + if not profile: + return {"Error": "The requested profile was not found"} + + # Make sure the profile belongs to DigitalOcean + provider = profile.get("provider", "0:0") + comps = provider.split(":") + if len(comps) < 2 or comps[1] != "gce": + return {"Error": "The requested profile does not belong to GCE"} + + comps = profile.get("location", "us").split("-") + region = comps[0] + + size = "CP-COMPUTEENGINE-VMIMAGE-{}".format(profile["size"].upper()) + pricefile = os.path.join(__opts__["cachedir"], "gce-pricing.p") + if not os.path.exists(pricefile): + update_pricing() + + with salt.utils.files.fopen(pricefile, "r") as fho: + sizes = salt.utils.msgpack.load(fho) + + per_hour = float(sizes["gcp_price_list"][size][region]) + + week1_discount = float(sizes["gcp_price_list"]["sustained_use_tiers"]["0.25"]) + week2_discount = float(sizes["gcp_price_list"]["sustained_use_tiers"]["0.50"]) + week3_discount = float(sizes["gcp_price_list"]["sustained_use_tiers"]["0.75"]) + week4_discount = float(sizes["gcp_price_list"]["sustained_use_tiers"]["1.0"]) + week1 = per_hour * (730 / 4) * week1_discount + week2 = per_hour * (730 / 4) * week2_discount + week3 = per_hour * (730 / 4) * week3_discount + week4 = per_hour * (730 / 4) * week4_discount + + raw = sizes + ret = {} + + ret["per_hour"] = per_hour + ret["per_day"] = ret["per_hour"] * 24 + ret["per_week"] = ret["per_day"] * 7 + ret["per_month"] = week1 + week2 + week3 + week4 + ret["per_year"] = ret["per_month"] * 12 + + if kwargs.get("raw", False): + ret["_raw"] = raw + + return {profile["profile"]: ret} diff --git a/salt/cloud/clouds/gogrid.py b/salt/cloud/clouds/gogrid.py new file mode 100644 index 000000000000..1a28f8374592 --- /dev/null +++ b/salt/cloud/clouds/gogrid.py @@ -0,0 +1,578 @@ +""" +GoGrid Cloud Module +==================== + +The GoGrid cloud module. This module interfaces with the gogrid public cloud +service. To use Salt Cloud with GoGrid log into the GoGrid web interface and +create an api key. Do this by clicking on "My Account" and then going to the +API Keys tab. + +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or +``/etc/salt/cloud.providers.d/gogrid.conf``: + +.. code-block:: yaml + + my-gogrid-config: + # The generated api key to use + apikey: asdff7896asdh789 + # The apikey's shared secret + sharedsecret: saltybacon + driver: gogrid + +.. note:: + + A Note about using Map files with GoGrid: + + Due to limitations in the GoGrid API, instances cannot be provisioned in parallel + with the GoGrid driver. Map files will work with GoGrid, but the ``-P`` + argument should not be used on maps referencing GoGrid instances. + +.. note:: + + A Note about using Map files with GoGrid: + + Due to limitations in the GoGrid API, instances cannot be provisioned in parallel + with the GoGrid driver. Map files will work with GoGrid, but the ``-P`` + argument should not be used on maps referencing GoGrid instances. + +""" + +import logging +import pprint +import time + +import salt.config as config +import salt.utils.cloud +import salt.utils.hashutils +from salt.exceptions import SaltCloudException, SaltCloudSystemExit + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "gogrid" + + +# Only load in this module if the GoGrid configurations are in place +def __virtual__(): + """ + Check for GoGrid configs + """ + if get_configured_provider() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ("apikey", "sharedsecret"), + ) + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "gogrid", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if len(vm_["name"]) > 20: + raise SaltCloudException("VM names must not be longer than 20 characters") + + log.info("Creating Cloud VM %s", vm_["name"]) + image_id = avail_images()[vm_["image"]]["id"] + if "assign_public_ip" in vm_: + host_ip = vm_["assign_public_ip"] + else: + public_ips = list_public_ips() + if not public_ips: + raise SaltCloudException("No more IPs available") + host_ip = next(iter(public_ips)) + + create_kwargs = { + "name": vm_["name"], + "image": image_id, + "ram": vm_["size"], + "ip": host_ip, + } + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", create_kwargs, list(create_kwargs) + ), + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + data = _query("grid", "server/add", args=create_kwargs) + except Exception: # pylint: disable=broad-except + log.error( + "Error creating %s on GOGRID\n\n" + "The following exception was thrown when trying to " + "run the initial deployment:\n", + vm_["name"], + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + ssh_username = config.get_cloud_config_value( + "ssh_username", vm_, __opts__, default="root" + ) + + def wait_for_apipass(): + """ + Wait for the password to become available, via the API + """ + try: + passwords = list_passwords() + return passwords[vm_["name"]][0]["password"] + except KeyError: + pass + time.sleep(5) + return False + + vm_["password"] = salt.utils.cloud.wait_for_fun( + wait_for_apipass, + timeout=config.get_cloud_config_value( + "wait_for_fun_timeout", vm_, __opts__, default=15 * 60 + ), + ) + + vm_["ssh_host"] = host_ip + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + ret.update(data) + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def list_nodes(full=False, call=None): + """ + List of nodes, keeping only a brief listing + + CLI Example: + + .. code-block:: bash + + salt-cloud -Q + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + ret = {} + nodes = list_nodes_full("function") + if full: + return nodes + + for node in nodes: + ret[node] = {} + for item in ("id", "image", "size", "public_ips", "private_ips", "state"): + ret[node][item] = nodes[node][item] + + return ret + + +def list_nodes_full(call=None): + """ + List nodes, with all available information + + CLI Example: + + .. code-block:: bash + + salt-cloud -F + """ + response = _query("grid", "server/list") + + ret = {} + for item in response["list"]: + name = item["name"] + ret[name] = item + + ret[name]["image_info"] = item["image"] + ret[name]["image"] = item["image"]["friendlyName"] + ret[name]["size"] = item["ram"]["name"] + ret[name]["public_ips"] = [item["ip"]["ip"]] + ret[name]["private_ips"] = [] + ret[name]["state_info"] = item["state"] + if "active" in item["state"]["description"]: + ret[name]["state"] = "RUNNING" + + return ret + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + + CLI Example: + + .. code-block:: bash + + salt-cloud -S + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full("function"), + __opts__["query.selection"], + call, + ) + + +def avail_locations(): + """ + Available locations + """ + response = list_common_lookups(kwargs={"lookup": "ip.datacenter"}) + + ret = {} + for item in response["list"]: + name = item["name"] + ret[name] = item + + return ret + + +def avail_sizes(): + """ + Available sizes + """ + response = list_common_lookups(kwargs={"lookup": "server.ram"}) + + ret = {} + for item in response["list"]: + name = item["name"] + ret[name] = item + + return ret + + +def avail_images(): + """ + Available images + """ + response = _query("grid", "image/list") + + ret = {} + for item in response["list"]: + name = item["friendlyName"] + ret[name] = item + + return ret + + +def list_passwords(kwargs=None, call=None): + """ + List all password on the account + + .. versionadded:: 2015.8.0 + """ + response = _query("support", "password/list") + + ret = {} + for item in response["list"]: + if "server" in item: + server = item["server"]["name"] + if server not in ret: + ret[server] = [] + ret[server].append(item) + + return ret + + +def list_public_ips(kwargs=None, call=None): + """ + List all available public IPs. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_public_ips + + To list unavailable (assigned) IPs, use: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_public_ips state=assigned + + .. versionadded:: 2015.8.0 + """ + if kwargs is None: + kwargs = {} + + args = {} + if "state" in kwargs: + if kwargs["state"] == "assigned": + args["ip.state"] = "Assigned" + else: + args["ip.state"] = "Unassigned" + else: + args["ip.state"] = "Unassigned" + + args["ip.type"] = "Public" + + response = _query("grid", "ip/list", args=args) + + ret = {} + for item in response["list"]: + name = item["ip"] + ret[name] = item + + return ret + + +def list_common_lookups(kwargs=None, call=None): + """ + List common lookups for a particular type of item + + .. versionadded:: 2015.8.0 + """ + if kwargs is None: + kwargs = {} + + args = {} + if "lookup" in kwargs: + args["lookup"] = kwargs["lookup"] + + response = _query("common", "lookup/list", args=args) + + return response + + +def destroy(name, call=None): + """ + Destroy a machine by name + + CLI Example: + + .. code-block:: bash + + salt-cloud -d vm_name + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + response = _query("grid", "server/delete", args={"name": name}) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return response + + +def reboot(name, call=None): + """ + Reboot a machine by name + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot vm_name + + .. versionadded:: 2015.8.0 + """ + return _query("grid", "server/power", args={"name": name, "power": "restart"}) + + +def stop(name, call=None): + """ + Stop a machine by name + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop vm_name + + .. versionadded:: 2015.8.0 + """ + return _query("grid", "server/power", args={"name": name, "power": "stop"}) + + +def start(name, call=None): + """ + Start a machine by name + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start vm_name + + .. versionadded:: 2015.8.0 + """ + return _query("grid", "server/power", args={"name": name, "power": "start"}) + + +def show_instance(name, call=None): + """ + Start a machine by name + + CLI Example: + + .. code-block:: bash + + salt-cloud -a show_instance vm_name + + .. versionadded:: 2015.8.0 + """ + response = _query("grid", "server/get", args={"name": name}) + ret = {} + for item in response["list"]: + name = item["name"] + ret[name] = item + + ret[name]["image_info"] = item["image"] + ret[name]["image"] = item["image"]["friendlyName"] + ret[name]["size"] = item["ram"]["name"] + ret[name]["public_ips"] = [item["ip"]["ip"]] + ret[name]["private_ips"] = [] + ret[name]["state_info"] = item["state"] + if "active" in item["state"]["description"]: + ret[name]["state"] = "RUNNING" + return ret + + +def _query( + action=None, command=None, args=None, method="GET", header_dict=None, data=None +): + """ + Make a web call to GoGrid + + .. versionadded:: 2015.8.0 + """ + vm_ = get_configured_provider() + apikey = config.get_cloud_config_value("apikey", vm_, __opts__, search_global=False) + sharedsecret = config.get_cloud_config_value( + "sharedsecret", vm_, __opts__, search_global=False + ) + + path = "https://api.gogrid.com/api/" + + if action: + path += action + + if command: + path += f"/{command}" + + log.debug("GoGrid URL: %s", path) + + if not isinstance(args, dict): + args = {} + + epoch = str(int(time.time())) + hashtext = "".join((apikey, sharedsecret, epoch)) + args["sig"] = salt.utils.hashutils.md5_digest(hashtext) + args["format"] = "json" + args["v"] = "1.0" + args["api_key"] = apikey + + if header_dict is None: + header_dict = {} + + if method != "POST": + header_dict["Accept"] = "application/json" + + decode = True + if method == "DELETE": + decode = False + + return_content = None + result = salt.utils.http.query( + path, + method, + params=args, + data=data, + header_dict=header_dict, + decode=decode, + decode_type="json", + text=True, + status=True, + opts=__opts__, + ) + log.debug("GoGrid Response Status Code: %s", result["status"]) + + return result["dict"] diff --git a/salt/cloud/clouds/hetzner.py b/salt/cloud/clouds/hetzner.py new file mode 100644 index 000000000000..f8f34ad6d852 --- /dev/null +++ b/salt/cloud/clouds/hetzner.py @@ -0,0 +1,664 @@ +""" +Hetzner Cloud Module +==================== + +The Hetzner cloud module is used to control access to the hetzner cloud. +https://docs.hetzner.cloud/ + +:depends: hcloud >= 1.10 + +Use of this module requires the ``key`` parameter to be set. + +.. code-block:: yaml + + my-hetzner-cloud-config: + key: + driver: hetzner + +""" + +# pylint: disable=invalid-name,function-redefined + + +import logging +import time + +import salt.config as config +from salt.exceptions import SaltCloudException, SaltCloudSystemExit + +# hcloud module will be needed +# pylint: disable=import-error +try: + import hcloud + + HAS_HCLOUD = True +except ImportError: + HAS_HCLOUD = False + + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "hetzner" + + +def __virtual__(): + """ + Check for hetzner configurations + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ("key",), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + return config.check_driver_dependencies( + _get_active_provider_name() or __virtualname__, + {"hcloud": HAS_HCLOUD}, + ) + + +def _object_to_dict(obj, attrs): + return {attr: getattr(obj, attr) for attr in attrs} + + +def _datacenter_to_dict(datacenter): + return { + "name": datacenter.name, + "location": datacenter.location.name, + } + + +def _public_network_to_dict(net): + return { + "ipv4": getattr(net.ipv4, "ip", None), + "ipv6": getattr(net.ipv6, "ip", None), + } + + +def _private_network_to_dict(net): + return { + "ip": getattr(net, "ip", None), + } + + +def _connect_client(): + provider = get_configured_provider() + return hcloud.Client(provider["key"]) + + +def avail_locations(call=None): + """ + Return a dictionary of available locations + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_locations function must be called with -f or --function" + ) + + client = _connect_client() + locations = {} + for loc in client.locations.get_all(): + locations[loc.name] = _object_to_dict(loc, loc.model.__slots__) + return locations + + +def avail_images(call=None): + """ + Return a dictionary of available images + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with -f or --function" + ) + + client = _connect_client() + images = {} + for image in client.images.get_all(): + images[image.name] = _object_to_dict(image, image.model.__slots__) + return images + + +def avail_sizes(call=None): + """ + Return a dictionary of available VM sizes + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with -f or --function" + ) + + client = _connect_client() + sizes = {} + for size in client.server_types.get_all(): + sizes[size.name] = _object_to_dict(size, size.model.__slots__) + return sizes + + +def list_ssh_keys(call=None): + """ + Return a dictionary of available SSH keys configured in the current project + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_ssh_keys function must be called with -f or --function" + ) + + client = _connect_client() + ssh_keys = {} + for key in client.ssh_keys.get_all(): + ssh_keys[key.name] = _object_to_dict(key, key.model.__slots__) + return ssh_keys + + +def list_nodes_full(call=None): + """ + Return a dictionary of existing VMs in the current project, containing full details per VM + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function" + ) + + client = _connect_client() + nodes = {} + for node in client.servers.get_all(): + nodes[node.name] = { + "id": node.id, + "name": node.name, + "image": node.image.name, + "size": node.server_type.name, + "state": node.status, + "public_ips": _public_network_to_dict(node.public_net), + "private_ips": list(map(_private_network_to_dict, node.private_net)), + "labels": node.labels, + "created": str(node.created), + "datacenter": _datacenter_to_dict(node.datacenter), + "volumes": [vol.name for vol in node.volumes], + } + return nodes + + +def list_nodes(call=None): + """ + Return a dictionary of existing VMs in the current project, containing basic details of each VM + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function" + ) + + ret = {} + + nodes = list_nodes_full() + for node in nodes: + ret[node] = {"name": node} + for prop in ("id", "image", "size", "state", "private_ips", "public_ips"): + ret[node][prop] = nodes[node].get(prop) + return ret + + +def wait_until(name, state, timeout=300): + """ + Wait until a specific state has been reached on a node + """ + start_time = time.time() + node = show_instance(name, call="action") + while True: + if node["state"] == state: + return True + time.sleep(1) + if time.time() - start_time > timeout: + return False + node = show_instance(name, call="action") + + +def show_instance(name, call=None): + """ + Return the details of a specific VM + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance function must be called with -a or --action." + ) + + try: + node = list_nodes_full("function")[name] + except KeyError: + log.debug("Failed to get data for node '%s'", name) + node = {} + + __utils__["cloud.cache_node"]( + node, + _get_active_provider_name() or __virtualname__, + __opts__, + ) + + return node + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_.get("profile") + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + client = _connect_client() + + name = config.get_cloud_config_value( + "name", + vm_, + __opts__, + search_global=False, + ) + if not name: + raise SaltCloudException("Missing server name") + + # Get the required configuration + server_type = client.server_types.get_by_name( + config.get_cloud_config_value( + "size", + vm_, + __opts__, + search_global=False, + ) + ) + if server_type is None: + raise SaltCloudException("The server size is not supported") + + image = client.images.get_by_name( + config.get_cloud_config_value( + "image", + vm_, + __opts__, + search_global=False, + ) + ) + if image is None: + raise SaltCloudException("The server image is not supported") + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", + vm_, + ["name", "profile", "provider", "driver"], + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + # Get the ssh_keys + ssh_keys = config.get_cloud_config_value( + "ssh_keys", vm_, __opts__, search_global=False + ) + + if ssh_keys: + names, ssh_keys = ssh_keys[:], [] + for n in names: + ssh_key = client.ssh_keys.get_by_name(n) + if ssh_key is None: + log.error("Invalid ssh key %s.", n) + else: + ssh_keys.append(ssh_key) + + # Get the location + location = config.get_cloud_config_value( + "location", + vm_, + __opts__, + search_global=False, + ) + if location: + location = client.locations.get_by_name(location) + + if location is None: + raise SaltCloudException("The server location is not supported") + + # Get the datacenter + datacenter = config.get_cloud_config_value( + "datacenter", + vm_, + __opts__, + search_global=False, + ) + if datacenter: + datacenter = client.datacenters.get_by_name(datacenter) + + if datacenter is None: + raise SaltCloudException("The server datacenter is not supported") + + # Get the volumes + volumes = config.get_cloud_config_value( + "volumes", + vm_, + __opts__, + search_global=False, + ) + if volumes: + volumes = [vol for vol in client.volumes.get_all() if vol in volumes] + + # Get the networks + networks = config.get_cloud_config_value( + "networks", + vm_, + __opts__, + search_global=False, + ) + if networks: + networks = [vol for vol in client.networks.get_all() if vol in networks] + + # Create the machine + response = client.servers.create( + name=name, + server_type=server_type, + image=image, + ssh_keys=ssh_keys, + volumes=volumes, + networks=networks, + location=location, + datacenter=datacenter, + user_data=config.get_cloud_config_value( + "user_data", + vm_, + __opts__, + search_global=False, + ), + labels=config.get_cloud_config_value( + "labels", + vm_, + __opts__, + search_global=False, + ), + automount=config.get_cloud_config_value( + "automount", + vm_, + __opts__, + search_global=False, + ), + ) + + # Bootstrap if ssh keys are configured + server = response.server + vm_.update( + { + "ssh_host": server.public_net.ipv4.ip or server.public_net.ipv6.ip, + "ssh_password": response.root_password, + "key_filename": config.get_cloud_config_value( + "private_key", vm_, __opts__, search_global=False, default=None + ), + } + ) + + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + log.info("Created Cloud VM '%s'", vm_["name"]) + ret["created"] = True + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", + vm_, + ["name", "profile", "provider", "driver"], + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def start(name, call=None, wait=True): + """ + Start a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start mymachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The start action must be called with -a or --action." + ) + + client = _connect_client() + server = client.servers.get_by_name(name) + if server is None: + return f"Instance {name} doesn't exist." + + server.power_on() + if wait and not wait_until(name, "running"): + return f"Instance {name} doesn't start." + + __utils__["cloud.fire_event"]( + "event", + "started instance", + f"salt/cloud/{name}/started", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return {"Started": f"{name} was started."} + + +def stop(name, call=None, wait=True): + """ + Stop a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop mymachine + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + client = _connect_client() + server = client.servers.get_by_name(name) + if server is None: + return f"Instance {name} doesn't exist." + + server.power_off() + if wait and not wait_until(name, "off"): + return f"Instance {name} doesn't stop." + + __utils__["cloud.fire_event"]( + "event", + "stopped instance", + f"salt/cloud/{name}/stopped", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return {"Stopped": f"{name} was stopped."} + + +def reboot(name, call=None, wait=True): + """ + Reboot a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot mymachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The reboot action must be called with -a or --action." + ) + + client = _connect_client() + server = client.servers.get_by_name(name) + if server is None: + return f"Instance {name} doesn't exist." + + server.reboot() + + if wait and not wait_until(name, "running"): + return f"Instance {name} doesn't start." + + return {"Rebooted": f"{name} was rebooted."} + + +def destroy(name, call=None): + """ + Destroy a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud --destroy mymachine + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + client = _connect_client() + server = client.servers.get_by_name(name) + if server is None: + return f"Instance {name} doesn't exist." + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + node = show_instance(name, call="action") + if node["state"] == "running": + stop(name, call="action", wait=False) + if not wait_until(name, "off"): + return {"Error": f"Unable to destroy {name}, command timed out"} + + server.delete() + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, + _get_active_provider_name().split(":")[0], + __opts__, + ) + + return {"Destroyed": f"{name} was destroyed."} + + +def resize(name, kwargs, call=None): + """ + Resize a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a resize mymachine size=... + """ + if call != "action": + raise SaltCloudSystemExit( + "The resize action must be called with -a or --action." + ) + + client = _connect_client() + server = client.servers.get_by_name(name) + if server is None: + return f"Instance {name} doesn't exist." + + # Check the configuration + size = kwargs.get("size", None) + if size is None: + raise SaltCloudException("The new size is required") + + server_type = client.server_types.get_by_name(size) + if server_type is None: + raise SaltCloudException("The server size is not supported") + + __utils__["cloud.fire_event"]( + "event", + "resizing instance", + f"salt/cloud/{name}/resizing", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + node = show_instance(name, call="action") + if node["state"] == "running": + stop(name, call="action", wait=False) + if not wait_until(name, "off"): + return {"Error": f"Unable to resize {name}, command timed out"} + + server.change_type(server_type, kwargs.get("upgrade_disk", False)) + + __utils__["cloud.fire_event"]( + "event", + "resizing instance", + f"salt/cloud/{name}/resized", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return {"Resized": f"{name} was resized."} diff --git a/salt/cloud/clouds/joyent.py b/salt/cloud/clouds/joyent.py new file mode 100644 index 000000000000..403e2ffab92e --- /dev/null +++ b/salt/cloud/clouds/joyent.py @@ -0,0 +1,1218 @@ +""" +Joyent Cloud Module +=================== + +The Joyent Cloud module is used to interact with the Joyent cloud system. + +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or +``/etc/salt/cloud.providers.d/joyent.conf``: + +.. code-block:: yaml + + my-joyent-config: + driver: joyent + # The Joyent login user + user: fred + # The Joyent user's password + password: saltybacon + # The location of the ssh private key that can log into the new VM + private_key: /root/mykey.pem + # The name of the private key + keyname: mykey + +When creating your profiles for the joyent cloud, add the location attribute to +the profile, this will automatically get picked up when performing tasks +associated with that vm. An example profile might look like: + +.. code-block:: yaml + + joyent_512: + provider: my-joyent-config + size: g4-highcpu-512M + image: centos-6 + location: us-east-1 + +This driver can also be used with the Joyent SmartDataCenter project. More +details can be found at: + +.. _`SmartDataCenter`: https://github.com/joyent/sdc + +Using SDC requires that an api_host_suffix is set. The default value for this is +`.api.joyentcloud.com`. All characters, including the leading `.`, should be +included: + +.. code-block:: yaml + + api_host_suffix: .api.myhostname.com + +:depends: PyCrypto +""" + +import base64 +import datetime +import http.client +import inspect +import logging +import os +import pprint + +import salt.config as config +import salt.utils.cloud +import salt.utils.files +import salt.utils.http +import salt.utils.json +import salt.utils.yaml +from salt.exceptions import ( + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudNotFound, + SaltCloudSystemExit, +) + +try: + from M2Crypto import EVP + + HAS_REQUIRED_CRYPTO = True + HAS_M2 = True +except ImportError: + HAS_M2 = False + try: + from Cryptodome.Hash import SHA256 + from Cryptodome.Signature import PKCS1_v1_5 + + HAS_REQUIRED_CRYPTO = True + except ImportError: + try: + from Crypto.Hash import SHA256 # nosec + from Crypto.Signature import PKCS1_v1_5 # nosec + + HAS_REQUIRED_CRYPTO = True + except ImportError: + HAS_REQUIRED_CRYPTO = False + + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "joyent" + +JOYENT_API_HOST_SUFFIX = ".api.joyentcloud.com" +JOYENT_API_VERSION = "~7.2" + +JOYENT_LOCATIONS = { + "us-east-1": "North Virginia, USA", + "us-west-1": "Bay Area, California, USA", + "us-sw-1": "Las Vegas, Nevada, USA", + "eu-ams-1": "Amsterdam, Netherlands", +} +DEFAULT_LOCATION = "us-east-1" + +# joyent no longer reports on all data centers, so setting this value to true +# causes the list_nodes function to get information on machines from all +# data centers +POLL_ALL_LOCATIONS = True + +VALID_RESPONSE_CODES = [ + http.client.OK, + http.client.ACCEPTED, + http.client.CREATED, + http.client.NO_CONTENT, +] + + +# Only load in this module if the Joyent configurations are in place +def __virtual__(): + """ + Check for Joyent configs + """ + if HAS_REQUIRED_CRYPTO is False: + return False, "Either PyCrypto or Cryptodome needs to be installed." + if get_configured_provider() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("user", "password") + ) + + +def get_image(vm_): + """ + Return the image object to use + """ + images = avail_images() + + vm_image = config.get_cloud_config_value("image", vm_, __opts__) + + if vm_image and str(vm_image) in images: + images[vm_image]["name"] = images[vm_image]["id"] + return images[vm_image] + + raise SaltCloudNotFound(f"The specified image, '{vm_image}', could not be found.") + + +def get_size(vm_): + """ + Return the VM's size object + """ + sizes = avail_sizes() + vm_size = config.get_cloud_config_value("size", vm_, __opts__) + if not vm_size: + raise SaltCloudNotFound("No size specified for this VM.") + + if vm_size and str(vm_size) in sizes: + return sizes[vm_size] + + raise SaltCloudNotFound(f"The specified size, '{vm_size}', could not be found.") + + +def query_instance(vm_=None, call=None): + """ + Query an instance upon creation from the Joyent API + """ + if isinstance(vm_, str) and call == "action": + vm_ = {"name": vm_, "provider": "joyent"} + + if call == "function": + # Technically this function may be called other ways too, but it + # definitely cannot be called with --function. + raise SaltCloudSystemExit( + "The query_instance action must be called with -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "querying instance", + "salt/cloud/{}/querying".format(vm_["name"]), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + def _query_ip_address(): + data = show_instance(vm_["name"], call="action") + if not data: + log.error("There was an error while querying Joyent. Empty response") + # Trigger a failure in the wait for IP function + return False + + if isinstance(data, dict) and "error" in data: + log.warning("There was an error in the query %s", data.get("error")) + # Trigger a failure in the wait for IP function + return False + + log.debug("Returned query data: %s", data) + + if "primaryIp" in data[1]: + # Wait for SSH to be fully configured on the remote side + if data[1]["state"] == "running": + return data[1]["primaryIp"] + return None + + try: + data = salt.utils.cloud.wait_for_ip( + _query_ip_address, + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=10 + ), + interval_multiplier=config.get_cloud_config_value( + "wait_for_ip_interval_multiplier", vm_, __opts__, default=1 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # destroy(vm_['name']) + pass + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + return data + + +def create(vm_): + """ + Create a single VM from a data dict + + CLI Example: + + .. code-block:: bash + + salt-cloud -p profile_name vm_name + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "joyent", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + key_filename = config.get_cloud_config_value( + "private_key", vm_, __opts__, search_global=False, default=None + ) + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info( + "Creating Cloud VM %s in %s", vm_["name"], vm_.get("location", DEFAULT_LOCATION) + ) + + # added . for fqdn hostnames + salt.utils.cloud.check_name(vm_["name"], "a-zA-Z0-9-.") + kwargs = { + "name": vm_["name"], + "image": get_image(vm_), + "size": get_size(vm_), + "location": vm_.get("location", DEFAULT_LOCATION), + } + # Let's not assign a default here; only assign a network value if + # one is explicitly configured + if "networks" in vm_: + kwargs["networks"] = vm_.get("networks") + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", kwargs, list(kwargs) + ), + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + data = create_node(**kwargs) + if data == {}: + log.error("Error creating %s on JOYENT", vm_["name"]) + return False + + query_instance(vm_) + data = show_instance(vm_["name"], call="action") + + vm_["key_filename"] = key_filename + vm_["ssh_host"] = data[1]["primaryIp"] + + __utils__["cloud.bootstrap"](vm_, __opts__) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return data[1] + + +def create_node(**kwargs): + """ + convenience function to make the rest api call for node creation. + """ + name = kwargs["name"] + size = kwargs["size"] + image = kwargs["image"] + location = kwargs["location"] + networks = kwargs.get("networks") + tag = kwargs.get("tag") + locality = kwargs.get("locality") + metadata = kwargs.get("metadata") + firewall_enabled = kwargs.get("firewall_enabled") + + create_data = { + "name": name, + "package": size["name"], + "image": image["name"], + } + if networks is not None: + create_data["networks"] = networks + + if locality is not None: + create_data["locality"] = locality + + if metadata is not None: + for key, value in metadata.items(): + create_data[f"metadata.{key}"] = value + + if tag is not None: + for key, value in tag.items(): + create_data[f"tag.{key}"] = value + + if firewall_enabled is not None: + create_data["firewall_enabled"] = firewall_enabled + + data = salt.utils.json.dumps(create_data) + + ret = query(command="my/machines", data=data, method="POST", location=location) + if ret[0] in VALID_RESPONSE_CODES: + return ret[1] + else: + log.error("Failed to create node %s: %s", name, ret[1]) + + return {} + + +def destroy(name, call=None): + """ + destroy a machine by name + + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: array of booleans , true if successfully stopped and true if + successfully removed + + CLI Example: + + .. code-block:: bash + + salt-cloud -d vm_name + + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + node = get_node(name) + ret = query( + command="my/machines/{}".format(node["id"]), + location=node["location"], + method="DELETE", + ) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return ret[0] in VALID_RESPONSE_CODES + + +def reboot(name, call=None): + """ + reboot a machine by name + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: true if successful + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot vm_name + """ + node = get_node(name) + ret = take_action( + name=name, + call=call, + method="POST", + command="my/machines/{}".format(node["id"]), + location=node["location"], + data={"action": "reboot"}, + ) + return ret[0] in VALID_RESPONSE_CODES + + +def stop(name, call=None): + """ + stop a machine by name + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: true if successful + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop vm_name + """ + node = get_node(name) + ret = take_action( + name=name, + call=call, + method="POST", + command="my/machines/{}".format(node["id"]), + location=node["location"], + data={"action": "stop"}, + ) + return ret[0] in VALID_RESPONSE_CODES + + +def start(name, call=None): + """ + start a machine by name + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: true if successful + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start vm_name + """ + node = get_node(name) + ret = take_action( + name=name, + call=call, + method="POST", + command="my/machines/{}".format(node["id"]), + location=node["location"], + data={"action": "start"}, + ) + return ret[0] in VALID_RESPONSE_CODES + + +def take_action( + name=None, + call=None, + command=None, + data=None, + method="GET", + location=DEFAULT_LOCATION, +): + """ + take action call used by start,stop, reboot + :param name: name given to the machine + :param call: call value in this case is 'action' + :command: api path + :data: any data to be passed to the api, must be in json format + :method: GET,POST,or DELETE + :location: data center to execute the command on + :return: true if successful + """ + caller = inspect.stack()[1][3] + + if call != "action": + raise SaltCloudSystemExit("This action must be called with -a or --action.") + + if data: + data = salt.utils.json.dumps(data) + + ret = [] + try: + + ret = query(command=command, data=data, method=method, location=location) + log.info("Success %s for node %s", caller, name) + except Exception as exc: # pylint: disable=broad-except + if "InvalidState" in str(exc): + ret = [200, {}] + else: + log.error( + "Failed to invoke %s node %s: %s", + caller, + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + ret = [100, {}] + + return ret + + +def ssh_interface(vm_): + """ + Return the ssh_interface type to connect to. Either 'public_ips' (default) + or 'private_ips'. + """ + return config.get_cloud_config_value( + "ssh_interface", vm_, __opts__, default="public_ips", search_global=False + ) + + +def get_location(vm_=None): + """ + Return the joyent data center to use, in this order: + - CLI parameter + - VM parameter + - Cloud profile setting + """ + return __opts__.get( + "location", + config.get_cloud_config_value( + "location", + vm_ or get_configured_provider(), + __opts__, + default=DEFAULT_LOCATION, + search_global=False, + ), + ) + + +def avail_locations(call=None): + """ + List all available locations + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + ret = {} + for key in JOYENT_LOCATIONS: + ret[key] = {"name": key, "region": JOYENT_LOCATIONS[key]} + + # this can be enabled when the bug in the joyent get data centers call is + # corrected, currently only the European dc (new api) returns the correct + # values + # ret = {} + # rcode, datacenters = query( + # command='my/datacenters', location=DEFAULT_LOCATION, method='GET' + # ) + # if rcode in VALID_RESPONSE_CODES and isinstance(datacenters, dict): + # for key in datacenters: + # ret[key] = { + # 'name': key, + # 'url': datacenters[key] + # } + return ret + + +def has_method(obj, method_name): + """ + Find if the provided object has a specific method + """ + if method_name in dir(obj): + return True + + log.error("Method '%s' not yet supported!", method_name) + return False + + +def key_list(items=None): + """ + convert list to dictionary using the key as the identifier + :param items: array to iterate over + :return: dictionary + """ + if items is None: + items = [] + + ret = {} + if items and isinstance(items, list): + for item in items: + if "name" in item: + # added for consistency with old code + if "id" not in item: + item["id"] = item["name"] + ret[item["name"]] = item + return ret + + +def get_node(name): + """ + gets the node from the full node list by name + :param name: name of the vm + :return: node object + """ + nodes = list_nodes() + if name in nodes: + return nodes[name] + return None + + +def show_instance(name, call=None): + """ + get details about a machine + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: machine information + + CLI Example: + + .. code-block:: bash + + salt-cloud -a show_instance vm_name + """ + node = get_node(name) + ret = query( + command="my/machines/{}".format(node["id"]), + location=node["location"], + method="GET", + ) + + return ret + + +def _old_libcloud_node_state(id_): + """ + Libcloud supported node states + """ + states_int = { + 0: "RUNNING", + 1: "REBOOTING", + 2: "TERMINATED", + 3: "PENDING", + 4: "UNKNOWN", + 5: "STOPPED", + 6: "SUSPENDED", + 7: "ERROR", + 8: "PAUSED", + } + states_str = { + "running": "RUNNING", + "rebooting": "REBOOTING", + "starting": "STARTING", + "terminated": "TERMINATED", + "pending": "PENDING", + "unknown": "UNKNOWN", + "stopping": "STOPPING", + "stopped": "STOPPED", + "suspended": "SUSPENDED", + "error": "ERROR", + "paused": "PAUSED", + "reconfiguring": "RECONFIGURING", + } + return states_str[id_] if isinstance(id_, str) else states_int[id_] + + +def joyent_node_state(id_): + """ + Convert joyent returned state to state common to other data center return + values for consistency + + :param id_: joyent state value + :return: state value + """ + states = { + "running": 0, + "stopped": 2, + "stopping": 2, + "provisioning": 3, + "deleted": 2, + "unknown": 4, + } + + if id_ not in states: + id_ = "unknown" + + return _old_libcloud_node_state(states[id_]) + + +def reformat_node(item=None, full=False): + """ + Reformat the returned data from joyent, determine public/private IPs and + strip out fields if necessary to provide either full or brief content. + + :param item: node dictionary + :param full: full or brief output + :return: dict + """ + desired_keys = [ + "id", + "name", + "state", + "public_ips", + "private_ips", + "size", + "image", + "location", + ] + item["private_ips"] = [] + item["public_ips"] = [] + if "ips" in item: + for ip in item["ips"]: + if salt.utils.cloud.is_public_ip(ip): + item["public_ips"].append(ip) + else: + item["private_ips"].append(ip) + + # add any undefined desired keys + for key in desired_keys: + if key not in item: + item[key] = None + + # remove all the extra key value pairs to provide a brief listing + to_del = [] + if not full: + for key in item.keys(): # iterate over a copy of the keys + if key not in desired_keys: + to_del.append(key) + + for key in to_del: + del item[key] + + if "state" in item: + item["state"] = joyent_node_state(item["state"]) + + return item + + +def list_nodes(full=False, call=None): + """ + list of nodes, keeping only a brief listing + + CLI Example: + + .. code-block:: bash + + salt-cloud -Q + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + ret = {} + if POLL_ALL_LOCATIONS: + for location in JOYENT_LOCATIONS: + result = query(command="my/machines", location=location, method="GET") + if result[0] in VALID_RESPONSE_CODES: + nodes = result[1] + for node in nodes: + if "name" in node: + node["location"] = location + ret[node["name"]] = reformat_node(item=node, full=full) + else: + log.error("Invalid response when listing Joyent nodes: %s", result[1]) + + else: + location = get_location() + result = query(command="my/machines", location=location, method="GET") + nodes = result[1] + for node in nodes: + if "name" in node: + node["location"] = location + ret[node["name"]] = reformat_node(item=node, full=full) + return ret + + +def list_nodes_full(call=None): + """ + list of nodes, maintaining all content provided from joyent listings + + CLI Example: + + .. code-block:: bash + + salt-cloud -F + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + return list_nodes(full=True) + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full("function"), + __opts__["query.selection"], + call, + ) + + +def _get_proto(): + """ + Checks configuration to see whether the user has SSL turned on. Default is: + + .. code-block:: yaml + + use_ssl: True + """ + use_ssl = config.get_cloud_config_value( + "use_ssl", + get_configured_provider(), + __opts__, + search_global=False, + default=True, + ) + if use_ssl is True: + return "https" + return "http" + + +def avail_images(call=None): + """ + Get list of available images + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-images + + Can use a custom URL for images. Default is: + + .. code-block:: yaml + + image_url: images.joyent.com/images + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + user = config.get_cloud_config_value( + "user", get_configured_provider(), __opts__, search_global=False + ) + + img_url = config.get_cloud_config_value( + "image_url", + get_configured_provider(), + __opts__, + search_global=False, + default=f"{DEFAULT_LOCATION}{JOYENT_API_HOST_SUFFIX}/{user}/images", + ) + + if not img_url.startswith("http://") and not img_url.startswith("https://"): + img_url = f"{_get_proto()}://{img_url}" + + rcode, data = query(command="my/images", method="GET") + log.debug(data) + + ret = {} + for image in data: + ret[image["name"]] = image + return ret + + +def avail_sizes(call=None): + """ + get list of available packages + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-sizes + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + + rcode, items = query(command="my/packages") + if rcode not in VALID_RESPONSE_CODES: + return {} + return key_list(items=items) + + +def list_keys(kwargs=None, call=None): + """ + List the keys available + """ + if call != "function": + log.error("The list_keys function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + ret = {} + rcode, data = query(command="my/keys", method="GET") + for pair in data: + ret[pair["name"]] = pair["key"] + return {"keys": ret} + + +def show_key(kwargs=None, call=None): + """ + List the keys available + """ + if call != "function": + log.error("The list_keys function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + if "keyname" not in kwargs: + log.error("A keyname is required.") + return False + + rcode, data = query( + command="my/keys/{}".format(kwargs["keyname"]), + method="GET", + ) + return {"keys": {data["name"]: data["key"]}} + + +def import_key(kwargs=None, call=None): + """ + List the keys available + + CLI Example: + + .. code-block:: bash + + salt-cloud -f import_key joyent keyname=mykey keyfile=/tmp/mykey.pub + """ + if call != "function": + log.error("The import_key function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + if "keyname" not in kwargs: + log.error("A keyname is required.") + return False + + if "keyfile" not in kwargs: + log.error("The location of the SSH keyfile is required.") + return False + + if not os.path.isfile(kwargs["keyfile"]): + log.error("The specified keyfile (%s) does not exist.", kwargs["keyfile"]) + return False + + with salt.utils.files.fopen(kwargs["keyfile"], "r") as fp_: + kwargs["key"] = salt.utils.stringutils.to_unicode(fp_.read()) + + send_data = {"name": kwargs["keyname"], "key": kwargs["key"]} + kwargs["data"] = salt.utils.json.dumps(send_data) + + rcode, data = query( + command="my/keys", + method="POST", + data=kwargs["data"], + ) + log.debug(pprint.pformat(data)) + return {"keys": {data["name"]: data["key"]}} + + +def delete_key(kwargs=None, call=None): + """ + List the keys available + + CLI Example: + + .. code-block:: bash + + salt-cloud -f delete_key joyent keyname=mykey + """ + if call != "function": + log.error("The delete_keys function must be called with -f or --function.") + return False + + if not kwargs: + kwargs = {} + + if "keyname" not in kwargs: + log.error("A keyname is required.") + return False + + rcode, data = query( + command="my/keys/{}".format(kwargs["keyname"]), + method="DELETE", + ) + return data + + +def get_location_path( + location=DEFAULT_LOCATION, api_host_suffix=JOYENT_API_HOST_SUFFIX +): + """ + create url from location variable + :param location: joyent data center location + :return: url + """ + return f"{_get_proto()}://{location}{api_host_suffix}" + + +def query(action=None, command=None, args=None, method="GET", location=None, data=None): + """ + Make a web call to Joyent + """ + user = config.get_cloud_config_value( + "user", get_configured_provider(), __opts__, search_global=False + ) + + if not user: + log.error( + "username is required for Joyent API requests. Please set one in your" + " provider configuration" + ) + + password = config.get_cloud_config_value( + "password", get_configured_provider(), __opts__, search_global=False + ) + + verify_ssl = config.get_cloud_config_value( + "verify_ssl", + get_configured_provider(), + __opts__, + search_global=False, + default=True, + ) + + ssh_keyfile = config.get_cloud_config_value( + "private_key", + get_configured_provider(), + __opts__, + search_global=False, + default=True, + ) + + if not ssh_keyfile: + log.error( + "ssh_keyfile is required for Joyent API requests. Please set one in your" + " provider configuration" + ) + + ssh_keyname = config.get_cloud_config_value( + "keyname", + get_configured_provider(), + __opts__, + search_global=False, + default=True, + ) + + if not ssh_keyname: + log.error( + "ssh_keyname is required for Joyent API requests. Please set one in your" + " provider configuration" + ) + + if not location: + location = get_location() + + api_host_suffix = config.get_cloud_config_value( + "api_host_suffix", + get_configured_provider(), + __opts__, + search_global=False, + default=JOYENT_API_HOST_SUFFIX, + ) + + path = get_location_path(location=location, api_host_suffix=api_host_suffix) + + if action: + path += action + + if command: + path += f"/{command}" + + log.debug("User: '%s' on PATH: %s", user, path) + + if (not user) or (not ssh_keyfile) or (not ssh_keyname) or (not location): + return None + + timenow = datetime.datetime.utcnow() + timestamp = timenow.strftime("%a, %d %b %Y %H:%M:%S %Z").strip() + rsa_key = salt.crypt.get_rsa_key(ssh_keyfile, None) + if HAS_M2: + md = EVP.MessageDigest("sha256") + md.update(timestamp.encode(__salt_system_encoding__)) + digest = md.final() + signed = rsa_key.sign(digest, algo="sha256") + else: + rsa_ = PKCS1_v1_5.new(rsa_key) # pylint: disable=used-before-assignment + hash_ = SHA256.new() # pylint: disable=used-before-assignment + hash_.update(timestamp.encode(__salt_system_encoding__)) + signed = rsa_.sign(hash_) + signed = base64.b64encode(signed) + user_arr = user.split("/") + if len(user_arr) == 1: + keyid = f"/{user_arr[0]}/keys/{ssh_keyname}" + elif len(user_arr) == 2: + keyid = f"/{user_arr[0]}/users/{user_arr[1]}/keys/{ssh_keyname}" + else: + log.error("Malformed user string") + + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "X-Api-Version": JOYENT_API_VERSION, + "Date": timestamp, + "Authorization": 'Signature keyId="{}",algorithm="rsa-sha256" {}'.format( + keyid, signed.decode(__salt_system_encoding__) + ), + } + + if not isinstance(args, dict): + args = {} + + # post form data + if not data: + data = salt.utils.json.dumps({}) + + return_content = None + result = salt.utils.http.query( + path, + method, + params=args, + header_dict=headers, + data=data, + decode=False, + text=True, + status=True, + headers=True, + verify_ssl=verify_ssl, + opts=__opts__, + ) + log.debug("Joyent Response Status Code: %s", result["status"]) + if "headers" not in result: + return [result["status"], result["error"]] + + if "Content-Length" in result["headers"]: + content = result["text"] + return_content = salt.utils.yaml.safe_load(content) + + return [result["status"], return_content] diff --git a/salt/cloud/clouds/libvirt.py b/salt/cloud/clouds/libvirt.py new file mode 100644 index 000000000000..366e1871e1f6 --- /dev/null +++ b/salt/cloud/clouds/libvirt.py @@ -0,0 +1,741 @@ +""" +Libvirt Cloud Module +==================== + +Example provider: + +.. code-block:: yaml + + # A provider maps to a libvirt instance + my-libvirt-config: + driver: libvirt + # url: "qemu+ssh://user@remotekvm/system?socket=/var/run/libvirt/libvirt-sock" + url: qemu:///system + +Example profile: + +.. code-block:: yaml + + base-itest: + # points back at provider configuration e.g. the libvirt daemon to talk to + provider: my-libvirt-config + base_domain: base-image + # ip_source = [ ip-learning | qemu-agent ] + ip_source: ip-learning + # clone_strategy = [ quick | full ] + clone_strategy: quick + ssh_username: vagrant + # has_ssh_agent: True + password: vagrant + # if /tmp is mounted noexec do workaround + deploy_command: sh /tmp/.saltcloud/deploy.sh + # -F makes the bootstrap script overwrite existing config + # which make reprovisioning a box work + script_args: -F + grains: + sushi: more tasty + # point at the another master at another port + minion: + master: 192.168.16.1 + master_port: 5506 + +Tested on: +- Fedora 26 (libvirt 3.2.1, qemu 2.9.1) +- Fedora 25 (libvirt 1.3.3.2, qemu 2.6.1) +- Fedora 23 (libvirt 1.2.18, qemu 2.4.1) +- Centos 7 (libvirt 1.2.17, qemu 1.5.3) + +""" + +# TODO: look at event descriptions here: +# https://docs.saltproject.io/en/latest/topics/cloud/reactor.html +# TODO: support reboot? salt-cloud -a reboot vm1 vm2 vm2 +# TODO: by using metadata tags in the libvirt XML we could make provider only +# manage domains that we actually created + +import logging +import os +import uuid +from xml.etree import ElementTree + +import salt.config as config +import salt.utils.cloud +from salt.exceptions import ( + SaltCloudConfigError, + SaltCloudExecutionFailure, + SaltCloudNotFound, + SaltCloudSystemExit, +) + +try: + import libvirt # pylint: disable=import-error + + # pylint: disable=no-name-in-module + from libvirt import libvirtError + + # pylint: enable=no-name-in-module + + HAS_LIBVIRT = True +except ImportError: + HAS_LIBVIRT = False + + +VIRT_STATE_NAME_MAP = { + 0: "running", + 1: "running", + 2: "running", + 3: "paused", + 4: "shutdown", + 5: "shutdown", + 6: "crashed", +} + +IP_LEARNING_XML = """ + + """ + +__virtualname__ = "libvirt" + +# Set up logging +log = logging.getLogger(__name__) + + +def libvirt_error_handler(ctx, error): # pylint: disable=unused-argument + """ + Redirect stderr prints from libvirt to salt logging. + """ + log.debug("libvirt error %s", error) + + +if HAS_LIBVIRT: + libvirt.registerErrorHandler(f=libvirt_error_handler, ctx=None) + + +def __virtual__(): + """ + This function determines whether or not + to make this cloud module available upon execution. + Most often, it uses get_configured_provider() to determine + if the necessary configuration has been set up. + It may also check for necessary imports decide whether to load the module. + In most cases, it will return a True or False value. + If the name of the driver used does not match the filename, + then that name should be returned instead of True. + + @return True|False|str + """ + if not HAS_LIBVIRT: + return False, "Unable to locate or import python libvirt library." + + if get_configured_provider() is False: + return False, "The 'libvirt' provider is not configured." + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("url",) + ) + + +def __get_conn(url): + # This has only been tested on kvm and xen, it needs to be expanded to + # support all vm layers supported by libvirt + try: + conn = libvirt.open(url) + except Exception: # pylint: disable=broad-except + raise SaltCloudExecutionFailure( + "Sorry, {} failed to open a connection to the hypervisor " + "software at {}".format(__grains__["fqdn"], url) + ) + return conn + + +def list_nodes(call=None): + """ + Return a list of the VMs + + id (str) + image (str) + size (str) + state (str) + private_ips (list) + public_ips (list) + + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + providers = __opts__.get("providers", {}) + + ret = {} + providers_to_check = [ + _f for _f in [cfg.get("libvirt") for cfg in providers.values()] if _f + ] + for provider in providers_to_check: + conn = __get_conn(provider["url"]) + domains = conn.listAllDomains() + for domain in domains: + data = { + "id": domain.UUIDString(), + "image": "", + "size": "", + "state": VIRT_STATE_NAME_MAP[domain.state()[0]], + "private_ips": [], + "public_ips": get_domain_ips( + domain, libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE + ), + } + # TODO: Annoyingly name is not guaranteed to be unique, but the id will not work in other places + ret[domain.name()] = data + + return ret + + +def list_nodes_full(call=None): + """ + Because this module is not specific to any cloud providers, there will be + no nodes to list. + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + return list_nodes(call) + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_select function must be called with -f or --function." + ) + + selection = __opts__.get("query.selection") + + if not selection: + raise SaltCloudSystemExit("query.selection not found in /etc/salt/cloud") + + # TODO: somewhat doubt the implementation of cloud.list_nodes_select + return salt.utils.cloud.list_nodes_select( + list_nodes_full(), + selection, + call, + ) + + +def to_ip_addr_type(addr_type): + if addr_type == libvirt.VIR_IP_ADDR_TYPE_IPV4: + return "ipv4" + elif addr_type == libvirt.VIR_IP_ADDR_TYPE_IPV6: + return "ipv6" + + +def get_domain_ips(domain, ip_source): + ips = [] + state = domain.state(0) + if state[0] != libvirt.VIR_DOMAIN_RUNNING: + return ips + try: + addresses = domain.interfaceAddresses(ip_source, 0) + except libvirt.libvirtError as error: + log.info("Exception polling address %s", error) + return ips + + for name, val in addresses.items(): + if val["addrs"]: + for addr in val["addrs"]: + tp = to_ip_addr_type(addr["type"]) + log.info("Found address %s", addr) + if tp == "ipv4": + ips.append(addr["addr"]) + return ips + + +def get_domain_ip(domain, idx, ip_source, skip_loopback=True): + ips = get_domain_ips(domain, ip_source) + + if skip_loopback: + ips = [ip for ip in ips if not ip.startswith("127.")] + + if not ips or len(ips) <= idx: + return None + + return ips[idx] + + +def create(vm_): + """ + Provision a single machine + """ + clone_strategy = vm_.get("clone_strategy") or "full" + + if clone_strategy not in ("quick", "full"): + raise SaltCloudSystemExit( + "'clone_strategy' must be one of quick or full. Got '{}'".format( + clone_strategy + ) + ) + + ip_source = vm_.get("ip_source") or "ip-learning" + + if ip_source not in ("ip-learning", "qemu-agent"): + raise SaltCloudSystemExit( + "'ip_source' must be one of qemu-agent or ip-learning. Got '{}'".format( + ip_source + ) + ) + + validate_xml = ( + vm_.get("validate_xml") if vm_.get("validate_xml") is not None else True + ) + + log.info( + "Cloning '%s' with strategy '%s' validate_xml='%s'", + vm_["name"], + clone_strategy, + validate_xml, + ) + + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, _get_active_provider_name() or "libvirt", vm_["profile"] + ) + is False + ): + return False + except AttributeError: + pass + + # TODO: check name qemu/libvirt will choke on some characters (like '/')? + name = vm_["name"] + + __utils__["cloud.fire_event"]( + "event", + "starting create", + f"salt/cloud/{name}/creating", + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + key_filename = config.get_cloud_config_value( + "private_key", vm_, __opts__, search_global=False, default=None + ) + if key_filename is not None and not os.path.isfile(key_filename): + raise SaltCloudConfigError( + f"The defined key_filename '{key_filename}' does not exist" + ) + vm_["key_filename"] = key_filename + # wait_for_instance requires private_key + vm_["private_key"] = key_filename + + cleanup = [] + try: + # clone the vm + base = vm_["base_domain"] + conn = __get_conn(vm_["url"]) + + try: + # for idempotency the salt-bootstrap needs -F argument + # script_args: -F + clone_domain = conn.lookupByName(name) + except libvirtError as e: + domain = conn.lookupByName(base) + # TODO: ensure base is shut down before cloning + xml = domain.XMLDesc(0) + + kwargs = { + "name": name, + "base_domain": base, + } + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + f"salt/cloud/{name}/requesting", + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", kwargs, list(kwargs) + ), + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.debug("Source machine XML '%s'", xml) + + domain_xml = ElementTree.fromstring(xml) + domain_xml.find("./name").text = name + if domain_xml.find("./description") is None: + description_elem = ElementTree.Element("description") + domain_xml.insert(0, description_elem) + description = domain_xml.find("./description") + description.text = f"Cloned from {base}" + domain_xml.remove(domain_xml.find("./uuid")) + + for iface_xml in domain_xml.findall("./devices/interface"): + iface_xml.remove(iface_xml.find("./mac")) + # enable IP learning, this might be a default behaviour... + # Don't always enable since it can cause problems through libvirt-4.5 + if ( + ip_source == "ip-learning" + and iface_xml.find( + "./filterref/parameter[@name='CTRL_IP_LEARNING']" + ) + is None + ): + iface_xml.append(ElementTree.fromstring(IP_LEARNING_XML)) + + # If a qemu agent is defined we need to fix the path to its socket + # + # + # + #
+ # + for agent_xml in domain_xml.findall("""./devices/channel[@type='unix']"""): + # is org.qemu.guest_agent.0 an option? + if ( + agent_xml.find( + """./target[@type='virtio'][@name='org.qemu.guest_agent.0']""" + ) + is not None + ): + source_element = agent_xml.find("""./source[@mode='bind']""") + # see if there is a path element that needs rewriting + if source_element and "path" in source_element.attrib: + path = source_element.attrib["path"] + new_path = path.replace(f"/domain-{base}/", f"/domain-{name}/") + log.debug("Rewriting agent socket path to %s", new_path) + source_element.attrib["path"] = new_path + + for disk in domain_xml.findall( + """./devices/disk[@device='disk'][@type='file']""" + ): + # print "Disk: ", ElementTree.tostring(disk) + # check if we can clone + driver = disk.find("./driver[@name='qemu']") + if driver is None: + # Err on the safe side + raise SaltCloudExecutionFailure( + "Non qemu driver disk encountered bailing out." + ) + disk_type = driver.attrib.get("type") + log.info("disk attributes %s", disk.attrib) + if disk_type == "qcow2": + source = disk.find("./source").attrib["file"] + pool, volume = find_pool_and_volume(conn, source) + if clone_strategy == "quick": + new_volume = pool.createXML( + create_volume_with_backing_store_xml(volume), 0 + ) + else: + new_volume = pool.createXMLFrom( + create_volume_xml(volume), volume, 0 + ) + cleanup.append({"what": "volume", "item": new_volume}) + + disk.find("./source").attrib["file"] = new_volume.path() + elif disk_type == "raw": + source = disk.find("./source").attrib["file"] + pool, volume = find_pool_and_volume(conn, source) + # TODO: more control on the cloned disk type + new_volume = pool.createXMLFrom( + create_volume_xml(volume), volume, 0 + ) + cleanup.append({"what": "volume", "item": new_volume}) + + disk.find("./source").attrib["file"] = new_volume.path() + else: + raise SaltCloudExecutionFailure( + f"Disk type '{disk_type}' not supported" + ) + + clone_xml = salt.utils.stringutils.to_str(ElementTree.tostring(domain_xml)) + log.debug("Clone XML '%s'", clone_xml) + + validate_flags = libvirt.VIR_DOMAIN_DEFINE_VALIDATE if validate_xml else 0 + clone_domain = conn.defineXMLFlags(clone_xml, validate_flags) + + cleanup.append({"what": "domain", "item": clone_domain}) + clone_domain.createWithFlags(libvirt.VIR_DOMAIN_START_FORCE_BOOT) + + log.debug("VM '%s'", vm_) + + if ip_source == "qemu-agent": + ip_source = libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT + elif ip_source == "ip-learning": + ip_source = libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE + + address = salt.utils.cloud.wait_for_ip( + get_domain_ip, + update_args=(clone_domain, 0, ip_source), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=10 + ), + interval_multiplier=config.get_cloud_config_value( + "wait_for_ip_interval_multiplier", vm_, __opts__, default=1 + ), + ) + + log.info("Address = %s", address) + + vm_["ssh_host"] = address + + # the bootstrap script needs to be installed first in /etc/salt/cloud.deploy.d/ + # salt-cloud -u is your friend + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + f"salt/cloud/{name}/created", + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + except Exception: # pylint: disable=broad-except + do_cleanup(cleanup) + # throw the root cause after cleanup + raise + + +def do_cleanup(cleanup): + """ + Clean up clone domain leftovers as much as possible. + + Extra robust clean up in order to deal with some small changes in libvirt + behavior over time. Passed in volumes and domains are deleted, any errors + are ignored. Used when cloning/provisioning a domain fails. + + :param cleanup: list containing dictionaries with two keys: 'what' and 'item'. + If 'what' is domain the 'item' is a libvirt domain object. + If 'what' is volume then the item is a libvirt volume object. + + Returns: + none + + .. versionadded:: 2017.7.3 + """ + log.info("Cleaning up after exception") + for leftover in cleanup: + what = leftover["what"] + item = leftover["item"] + if what == "domain": + log.info("Cleaning up %s %s", what, item.name()) + try: + item.destroy() + log.debug("%s %s forced off", what, item.name()) + except libvirtError: + pass + try: + item.undefineFlags( + libvirt.VIR_DOMAIN_UNDEFINE_MANAGED_SAVE + + libvirt.VIR_DOMAIN_UNDEFINE_SNAPSHOTS_METADATA + + libvirt.VIR_DOMAIN_UNDEFINE_NVRAM + ) + log.debug("%s %s undefined", what, item.name()) + except libvirtError: + pass + if what == "volume": + try: + item.delete() + log.debug("%s %s cleaned up", what, item.name()) + except libvirtError: + pass + + +def destroy(name, call=None): + """ + This function irreversibly destroys a virtual machine on the cloud provider. + Before doing so, it should fire an event on the Salt event bus. + + The tag for this event is `salt/cloud//destroying`. + Once the virtual machine has been destroyed, another event is fired. + The tag for that event is `salt/cloud//destroyed`. + + Dependencies: + list_nodes + + @param name: + @type name: str + @param call: + @type call: + @return: True if all went well, otherwise an error message + @rtype: bool|str + """ + log.info("Attempting to delete instance %s", name) + + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + found = [] + + providers = __opts__.get("providers", {}) + providers_to_check = [ + _f for _f in [cfg.get("libvirt") for cfg in providers.values()] if _f + ] + for provider in providers_to_check: + conn = __get_conn(provider["url"]) + log.info("looking at %s", provider["url"]) + try: + domain = conn.lookupByName(name) + found.append({"domain": domain, "conn": conn}) + except libvirtError: + pass + + if not found: + return f"{name} doesn't exist and can't be deleted" + + if len(found) > 1: + return f"{name} doesn't identify a unique machine leaving things" + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + destroy_domain(found[0]["conn"], found[0]["domain"]) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + +def destroy_domain(conn, domain): + log.info("Destroying domain %s", domain.name()) + try: + domain.destroy() + except libvirtError: + pass + volumes = get_domain_volumes(conn, domain) + for volume in volumes: + log.debug("Removing volume %s", volume.name()) + volume.delete() + + log.debug("Undefining domain %s", domain.name()) + domain.undefineFlags( + libvirt.VIR_DOMAIN_UNDEFINE_MANAGED_SAVE + + libvirt.VIR_DOMAIN_UNDEFINE_SNAPSHOTS_METADATA + + libvirt.VIR_DOMAIN_UNDEFINE_NVRAM + ) + + +def create_volume_xml(volume): + template = """ + n + c + 0 + + p + + 1.1 + + + """ + volume_xml = ElementTree.fromstring(template) + # TODO: generate name + volume_xml.find("name").text = generate_new_name(volume.name()) + log.debug("Volume: %s", dir(volume)) + volume_xml.find("capacity").text = str(volume.info()[1]) + volume_xml.find("./target/path").text = volume.path() + xml_string = salt.utils.stringutils.to_str(ElementTree.tostring(volume_xml)) + log.debug("Creating %s", xml_string) + return xml_string + + +def create_volume_with_backing_store_xml(volume): + template = """ + n + c + 0 + + + 1.1 + + + + p + + + """ + volume_xml = ElementTree.fromstring(template) + # TODO: generate name + volume_xml.find("name").text = generate_new_name(volume.name()) + log.debug("volume: %s", dir(volume)) + volume_xml.find("capacity").text = str(volume.info()[1]) + volume_xml.find("./backingStore/path").text = volume.path() + xml_string = salt.utils.stringutils.to_str(ElementTree.tostring(volume_xml)) + log.debug("Creating %s", xml_string) + return xml_string + + +def find_pool_and_volume(conn, path): + # active and persistent storage pools + # TODO: should we filter on type? + for sp in conn.listAllStoragePools(2 + 4): + for v in sp.listAllVolumes(): + if v.path() == path: + return sp, v + raise SaltCloudNotFound(f"Could not find volume for path {path}") + + +def generate_new_name(orig_name): + if "." not in orig_name: + return f"{orig_name}-{uuid.uuid1()}" + + name, ext = orig_name.rsplit(".", 1) + return f"{name}-{uuid.uuid1()}.{ext}" + + +def get_domain_volumes(conn, domain): + volumes = [] + xml = ElementTree.fromstring(domain.XMLDesc(0)) + for disk in xml.findall("""./devices/disk[@device='disk'][@type='file']"""): + if disk.find("./driver[@name='qemu'][@type='qcow2']") is not None: + source = disk.find("./source").attrib["file"] + try: + pool, volume = find_pool_and_volume(conn, source) + volumes.append(volume) + except libvirtError: + log.warning("Disk not found '%s'", source) + return volumes diff --git a/salt/cloud/clouds/linode.py b/salt/cloud/clouds/linode.py new file mode 100644 index 000000000000..616ef18a21c0 --- /dev/null +++ b/salt/cloud/clouds/linode.py @@ -0,0 +1,1605 @@ +r""" +The Linode Cloud Module +======================= + +The Linode cloud module is used to interact with the Linode Cloud. + +Provider +-------- + +The following provider parameters are supported: + +- **apikey**: (required) The key to use to authenticate with the Linode API. +- **password**: (required) The default password to set on new VMs. Must be 8 characters with at least one lowercase, uppercase, and numeric. +- **poll_interval**: (optional) The rate of time in milliseconds to poll the Linode API for changes. Defaults to ``500``. +- **ratelimit_sleep**: (optional) The time in seconds to wait before retrying after a ratelimit has been enforced. Defaults to ``0``. + +.. note:: + + APIv3 usage has been removed in favor of APIv4. To move to APIv4 now, + See the full migration guide + here https://docs.saltproject.io/en/latest/topics/cloud/linode.html#migrating-to-apiv4. + +Set up the provider configuration at ``/etc/salt/cloud.providers`` or ``/etc/salt/cloud.providers.d/linode.conf``: + +.. code-block:: yaml + + my-linode-provider: + driver: linode + apikey: f4ZsmwtB1c7f85Jdu43RgXVDFlNjuJaeIYV8QMftTqKScEB2vSosFSr... + password: F00barbazverylongp@ssword + +Profile +------- + +The following profile parameters are supported: + +- **size**: (required) The size of the VM. This should be a Linode instance type ID (i.e. ``g6-standard-2``). Run ``salt-cloud -f avail_sizes my-linode-provider`` for options. +- **location**: (required) The location of the VM. This should be a Linode region (e.g. ``us-east``). Run ``salt-cloud -f avail_locations my-linode-provider`` for options. +- **image**: (required) The image to deploy the boot disk from. This should be an image ID (e.g. ``linode/ubuntu22.04``); official images start with ``linode/``. Run ``salt-cloud -f avail_images my-linode-provider`` for more options. +- **password**: (\*required) The default password for the VM. Must be provided at the profile or provider level. +- **assign_private_ip**: (optional) Whether or not to assign a private IP to the VM. Defaults to ``False``. +- **backups_enabled**: (optional) Whether or not to enable the backup for this VM. Backup can be configured in your Linode account Defaults to ``False``. +- **ssh_interface**: (optional) The interface with which to connect over SSH. Valid options are ``private_ips`` or ``public_ips``. Defaults to ``public_ips``. +- **ssh_pubkey**: (optional) The public key to authorize for SSH with the VM. +- **swap**: (optional) The amount of disk space to allocate for the swap partition. Defaults to ``256``. +- **clonefrom**: (optional) The name of the Linode to clone from. + +Set up a profile configuration in ``/etc/salt/cloud.profiles.d/``: + +.. code-block:: yaml + + my-linode-profile: + # a minimal configuration + provider: my-linode-provider + size: g6-standard-1 + image: linode/ubuntu22.04 + location: us-east + + my-linode-profile-advanced: + # an advanced configuration + provider: my-linode-provider + size: g6-standard-3 + image: linode/ubuntu22.04 + location: eu-west + password: bogus123X + assign_private_ip: true + ssh_interface: private_ips + ssh_pubkey: ssh-rsa AAAAB3NzaC1yc2EAAAADAQAB... + swap_size: 512 + +Migrating to APIv4 +------------------ + +You will need to generate a new token for your account. See https://www.linode.com/docs/products/tools/api/get-started/#create-an-api-token + +There are a few changes to note: +- There has been a general move from label references to ID references. The profile configuration parameters ``location``, ``size``, and ``image`` have moved from being label based references to IDs. See the profile section for more information. In addition to these inputs being changed, ``avail_sizes``, ``avail_locations``, and ``avail_images`` now output options sorted by ID instead of label. +- The ``disk_size`` profile configuration parameter has been deprecated and will not be taken into account when creating new VMs while targeting APIv4. + +:maintainer: Linode Developer Tools and Experience Team +:depends: requests +""" + +import datetime +import json +import logging +import pprint +import re +import time +from abc import ABC, abstractmethod +from pathlib import Path + +import salt.config as config +from salt._compat import ipaddress +from salt.exceptions import SaltCloudException, SaltCloudNotFound, SaltCloudSystemExit + +try: + import requests + + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +# Get logging started +log = logging.getLogger(__name__) + +# The epoch of the last time a query was made +LASTCALL = int(time.mktime(datetime.datetime.now().timetuple())) + +__virtualname__ = "linode" + + +# Only load in this module if the Linode configurations are in place +def __virtual__(): + """ + Check for Linode configs. + """ + if get_configured_provider() is False: + return False + + if _get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def _get_backup_enabled(vm_): + """ + Return True if a backup is set to enabled + """ + return config.get_cloud_config_value( + "backups_enabled", + vm_, + __opts__, + default=False, + ) + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ("apikey", "password"), + ) + + +def _get_dependencies(): + """ + Warn if dependencies aren't met. + """ + deps = {"requests": HAS_REQUESTS} + return config.check_driver_dependencies(__virtualname__, deps) + + +def _get_api_key(): + """ + Returned the configured Linode API key. + """ + val = config.get_cloud_config_value( + "api_key", + get_configured_provider(), + __opts__, + search_global=False, + default=config.get_cloud_config_value( + "apikey", get_configured_provider(), __opts__, search_global=False + ), + ) + return val + + +def _get_ratelimit_sleep(): + """ + Return the configured time to wait before retrying after a ratelimit has been enforced. + """ + return config.get_cloud_config_value( + "ratelimit_sleep", + get_configured_provider(), + __opts__, + search_global=False, + default=0, + ) + + +def _get_poll_interval(): + """ + Return the configured interval in milliseconds to poll the Linode API for changes at. + """ + return config.get_cloud_config_value( + "poll_interval", + get_configured_provider(), + __opts__, + search_global=False, + default=500, + ) + + +def _get_password(vm_): + r""" + Return the password to use for a VM. + + vm\_ + The configuration to obtain the password from. + """ + return config.get_cloud_config_value( + "password", + vm_, + __opts__, + default=config.get_cloud_config_value( + "passwd", vm_, __opts__, search_global=False + ), + search_global=False, + ) + + +def _get_private_ip(vm_): + """ + Return True if a private ip address is requested + """ + return config.get_cloud_config_value( + "assign_private_ip", vm_, __opts__, default=False + ) + + +def _get_ssh_key_files(vm_): + """ + Return the configured file paths of the SSH keys. + """ + return config.get_cloud_config_value( + "ssh_key_files", vm_, __opts__, search_global=False, default=[] + ) + + +def _get_ssh_key(vm_): + r""" + Return the SSH pubkey. + + vm\_ + The configuration to obtain the public key from. + """ + return config.get_cloud_config_value( + "ssh_pubkey", vm_, __opts__, search_global=False + ) + + +def _get_swap_size(vm_): + r""" + Returns the amount of swap space to be used in MB. + + vm\_ + The VM profile to obtain the swap size from. + """ + return config.get_cloud_config_value("swap", vm_, __opts__, default=256) + + +def _get_ssh_keys(vm_): + """ + Return all SSH keys from ``ssh_pubkey`` and ``ssh_key_files``. + """ + ssh_keys = set() + + raw_pub_key = _get_ssh_key(vm_) + if raw_pub_key is not None: + ssh_keys.add(raw_pub_key) + + key_files = _get_ssh_key_files(vm_) + for file in map(lambda file: Path(file).resolve(), key_files): + if not (file.exists() or file.is_file()): + raise SaltCloudSystemExit(f"Invalid SSH key file: {str(file)}") + ssh_keys.add(file.read_text()) + + return list(ssh_keys) + + +def _get_ssh_interface(vm_): + """ + Return the ssh_interface type to connect to. Either 'public_ips' (default) + or 'private_ips'. + """ + return config.get_cloud_config_value( + "ssh_interface", vm_, __opts__, default="public_ips", search_global=False + ) + + +def _validate_name(name): + """ + Checks if the provided name fits Linode's labeling parameters. + + .. versionadded:: 2015.5.6 + + name + The VM name to validate + """ + name = str(name) + name_length = len(name) + regex = re.compile(r"^[a-zA-Z0-9][A-Za-z0-9_-]*[a-zA-Z0-9]$") + + if name_length < 3 or name_length > 48: + ret = False + elif not re.match(regex, name): + ret = False + else: + ret = True + + if ret is False: + log.warning( + "A Linode label may only contain ASCII letters or numbers, dashes, and " + "underscores, must begin and end with letters or numbers, and be at least " + "three characters in length." + ) + + return ret + + +class LinodeAPI(ABC): + @abstractmethod + def avail_images(self): + """avail_images implementation""" + + @abstractmethod + def avail_locations(self): + """avail_locations implementation""" + + @abstractmethod + def avail_sizes(self): + """avail_sizes implementation""" + + @abstractmethod + def boot(self, name=None, kwargs=None): + """boot implementation""" + + @abstractmethod + def clone(self, kwargs=None): + """clone implementation""" + + @abstractmethod + def create_config(self, kwargs=None): + """create_config implementation""" + + @abstractmethod + def create(self, vm_): + """create implementation""" + + @abstractmethod + def destroy(self, name): + """destroy implementation""" + + @abstractmethod + def get_config_id(self, kwargs=None): + """get_config_id implementation""" + + @abstractmethod + def list_nodes(self): + """list_nodes implementation""" + + @abstractmethod + def list_nodes_full(self): + """list_nodes_full implementation""" + + @abstractmethod + def list_nodes_min(self): + """list_nodes_min implementation""" + + @abstractmethod + def reboot(self, name): + """reboot implementation""" + + @abstractmethod + def show_instance(self, name): + """show_instance implementation""" + + @abstractmethod + def show_pricing(self, kwargs=None): + """show_pricing implementation""" + + @abstractmethod + def start(self, name): + """start implementation""" + + @abstractmethod + def stop(self, name): + """stop implementation""" + + @abstractmethod + def _get_linode_by_name(self, name): + """_get_linode_by_name implementation""" + + @abstractmethod + def _get_linode_by_id(self, linode_id): + """_get_linode_by_id implementation""" + + def get_linode(self, kwargs=None): + name = kwargs.get("name", None) + linode_id = kwargs.get("linode_id", None) + + if linode_id is not None: + return self._get_linode_by_id(linode_id) + elif name is not None: + return self._get_linode_by_name(name) + + raise SaltCloudSystemExit( + "The get_linode function requires either a 'name' or a 'linode_id'." + ) + + def list_nodes_select(self, call): + return __utils__["cloud.list_nodes_select"]( + self.list_nodes_full(), + __opts__["query.selection"], + call, + ) + + +class LinodeAPIv4(LinodeAPI): + @classmethod + def get_api_instance(cls): + if not hasattr(cls, "api_instance"): + cls.api_instance = cls() + return cls.api_instance + + def _query(self, path, method="GET", data=None, headers=None): + """ + Make a call to the Linode API. + """ + api_key = _get_api_key() + ratelimit_sleep = _get_ratelimit_sleep() + + if headers is None: + headers = {} + headers["Authorization"] = f"Bearer {api_key}" + headers["Content-Type"] = "application/json" + headers["User-Agent"] = "salt-cloud-linode" + + url = f"https://api.linode.com/v4{path}" + + decode = method != "DELETE" + result = None + + log.debug("Linode API request: %s %s", method, url) + + if data is not None: + log.trace("Linode API request body: %s", data) + + attempt = 0 + while True: + try: + result = requests.request( + method, url, json=data, headers=headers, timeout=120 + ) + + log.debug("Linode API response status code: %d", result.status_code) + log.trace("Linode API response body: %s", result.text) + result.raise_for_status() + break + except requests.exceptions.HTTPError as exc: + err_response = exc.response + err_data = self._get_response_json(err_response) + status_code = err_response.status_code + + if status_code == 429: + log.debug( + "received rate limit; retrying in %d seconds", ratelimit_sleep + ) + time.sleep(ratelimit_sleep) + continue + + if err_data is not None: + # Build an error from the response JSON + if "error" in err_data: + raise SaltCloudSystemExit( + "Linode API reported error: {}".format(err_data["error"]) + ) + elif "errors" in err_data: + api_errors = err_data["errors"] + + # Build Salt exception + errors = [] + for error in err_data["errors"]: + if "field" in error: + errors.append( + "field '{}': {}".format( + error.get("field"), error.get("reason") + ) + ) + else: + errors.append(error.get("reason")) + + raise SaltCloudSystemExit( + "Linode API reported error(s): {}".format(", ".join(errors)) + ) + + # If the response is not valid JSON or the error was not included, propagate the + # human readable status representation. + raise SaltCloudSystemExit( + f"Linode API error occurred: {err_response.reason}" + ) + if decode: + return self._get_response_json(result) + + return result + + def avail_images(self): + response = self._query(path="/images") + ret = {} + for image in response["data"]: + ret[image["id"]] = image + return ret + + def avail_locations(self): + response = self._query(path="/regions") + ret = {} + for region in response["data"]: + ret[region["id"]] = region + return ret + + def avail_sizes(self): + response = self._query(path="/linode/types") + ret = {} + for instance_type in response["data"]: + ret[instance_type["id"]] = instance_type + return ret + + def set_backup_schedule(self, label, linode_id, day, window, auto_enable=False): + instance = self.get_linode(kwargs={"linode_id": linode_id, "name": label}) + linode_id = instance.get("id", None) + + if auto_enable: + backups = instance.get("backups") + if backups and not backups.get("enabled"): + self._query( + f"/linode/instances/{linode_id}/backups/enable", + method="POST", + ) + + self._query( + f"/linode/instances/{linode_id}", + method="PUT", + data={"backups": {"schedule": {"day": day, "window": window}}}, + ) + + def boot(self, name=None, kwargs=None): + instance = self.get_linode( + kwargs={"linode_id": kwargs.get("linode_id", None), "name": name} + ) + config_id = kwargs.get("config_id", None) + check_running = kwargs.get("check_running", True) + linode_id = instance.get("id", None) + name = instance.get("label", None) + + if check_running: + if instance["status"] == "running": + raise SaltCloudSystemExit( + "Cannot boot Linode {0} ({1}). " + "Linode {0} is already running.".format(name, linode_id) + ) + + self._query( + f"/linode/instances/{linode_id}/boot", + method="POST", + data={"config_id": config_id}, + ) + + self._wait_for_linode_status(linode_id, "running") + return True + + def clone(self, kwargs=None): + linode_id = kwargs.get("linode_id", None) + location = kwargs.get("location", None) + size = kwargs.get("size", None) + + for item in [linode_id, location, size]: + if item is None: + raise SaltCloudSystemExit( + "The clone function requires a 'linode_id', 'location'," + "and 'size' to be provided." + ) + + return self._query( + f"/linode/instances/{linode_id}/clone", + method="POST", + data={"region": location, "type": size}, + ) + + def create_config(self, kwargs=None): + name = kwargs.get("name", None) + linode_id = kwargs.get("linode_id", None) + root_disk_id = kwargs.get("root_disk_id", None) + swap_disk_id = kwargs.get("swap_disk_id", None) + data_disk_id = kwargs.get("data_disk_id", None) + + if not name and not linode_id: + raise SaltCloudSystemExit( + "The create_config function requires either a 'name' or 'linode_id'" + ) + + required_params = [name, linode_id, root_disk_id, swap_disk_id] + for item in required_params: + if item is None: + raise SaltCloudSystemExit( + "The create_config functions requires a 'name', 'linode_id', " + "'root_disk_id', and 'swap_disk_id'." + ) + + devices = { + "sda": {"disk_id": int(root_disk_id)}, + "sdb": {"disk_id": int(data_disk_id)} if data_disk_id is not None else None, + "sdc": {"disk_id": int(swap_disk_id)}, + } + + return self._query( + f"/linode/instances/{linode_id}/configs", + method="POST", + data={"label": name, "devices": devices}, + ) + + def create(self, vm_): + name = vm_["name"] + + if not _validate_name(name): + return False + + __utils__["cloud.fire_event"]( + "event", + "starting create", + f"salt/cloud/{name}/creating", + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", name) + + result = None + + pub_ssh_keys = _get_ssh_keys(vm_) + ssh_interface = _get_ssh_interface(vm_) + use_private_ip = ssh_interface == "private_ips" + assign_private_ip = _get_private_ip(vm_) or use_private_ip + password = _get_password(vm_) + swap_size = _get_swap_size(vm_) + backups_enabled = _get_backup_enabled(vm_) + + clonefrom_name = vm_.get("clonefrom", None) + instance_type = vm_.get("size", None) + image = vm_.get("image", None) + should_clone = True if clonefrom_name else False + + if should_clone: + # clone into new linode + clone_linode = self.get_linode(kwargs={"name": clonefrom_name}) + result = clone( + { + "linode_id": clone_linode["id"], + "location": clone_linode["region"], + "size": clone_linode["type"], + } + ) + + # create private IP if needed + if assign_private_ip: + self._query( + "/networking/ips", + method="POST", + data={"type": "ipv4", "public": False, "linode_id": result["id"]}, + ) + else: + # create new linode + result = self._query( + "/linode/instances", + method="POST", + data={ + "backups_enabled": backups_enabled, + "label": name, + "type": instance_type, + "region": vm_.get("location", None), + "private_ip": assign_private_ip, + "booted": True, + "root_pass": password, + "authorized_keys": pub_ssh_keys, + "image": image, + "swap_size": swap_size, + }, + ) + + linode_id = result.get("id", None) + + # wait for linode to be created + self._wait_for_event("linode_create", "linode", linode_id, "finished") + log.debug("linode '%s' has been created", name) + + if should_clone: + self.boot(kwargs={"linode_id": linode_id}) + + # wait for linode to finish booting + self._wait_for_linode_status(linode_id, "running") + + public_ips, private_ips = self._get_ips(linode_id) + + data = {} + data["id"] = linode_id + data["name"] = result["label"] + data["size"] = result["type"] + data["state"] = result["status"] + data["ipv4"] = result["ipv4"] + data["ipv6"] = result["ipv6"] + data["public_ips"] = public_ips + data["private_ips"] = private_ips + + if use_private_ip: + vm_["ssh_host"] = private_ips[0] + else: + vm_["ssh_host"] = public_ips[0] + + # Send event that the instance has booted. + __utils__["cloud.fire_event"]( + "event", + "waiting for ssh", + f"salt/cloud/{name}/waiting_for_ssh", + sock_dir=__opts__["sock_dir"], + args={"ip_address": vm_["ssh_host"]}, + transport=__opts__["transport"], + ) + + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + ret.update(data) + + log.info("Created Cloud VM '%s'", name) + log.debug("'%s' VM creation details:\n%s", name, pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + f"salt/cloud/{name}/created", + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + def destroy(self, name): + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + instance = self._get_linode_by_name(name) + linode_id = instance.get("id", None) + + self._query(f"/linode/instances/{linode_id}", method="DELETE") + + def get_config_id(self, kwargs=None): + name = kwargs.get("name", None) + linode_id = kwargs.get("linode_id", None) + + if name is None and linode_id is None: + raise SaltCloudSystemExit( + "The get_config_id function requires either a 'name' or a 'linode_id' " + "to be provided." + ) + + if linode_id is None: + linode_id = self.get_linode(kwargs=kwargs).get("id", None) + + response = self._query(f"/linode/instances/{linode_id}/configs") + configs = response.get("data", []) + + return {"config_id": configs[0]["id"]} + + def list_nodes_min(self): + result = self._query("/linode/instances") + instances = result.get("data", []) + + ret = {} + for instance in instances: + name = instance["label"] + ret[name] = {"id": instance["id"], "state": instance["status"]} + + return ret + + def list_nodes_full(self): + return self._list_linodes(full=True) + + def list_nodes(self): + return self._list_linodes() + + def reboot(self, name): + instance = self._get_linode_by_name(name) + linode_id = instance.get("id", None) + + self._query(f"/linode/instances/{linode_id}/reboot", method="POST") + return self._wait_for_linode_status(linode_id, "running") + + def show_instance(self, name): + instance = self._get_linode_by_name(name) + linode_id = instance.get("id", None) + public_ips, private_ips = self._get_ips(linode_id) + + return { + "id": instance["id"], + "image": instance["image"], + "name": instance["label"], + "size": instance["type"], + "state": instance["status"], + "public_ips": public_ips, + "private_ips": private_ips, + } + + def show_pricing(self, kwargs=None): + profile = __opts__["profiles"].get(kwargs["profile"], {}) + if not profile: + raise SaltCloudNotFound("The requested profile was not found.") + + # Make sure the profile belongs to Linode + provider = profile.get("provider", "0:0") + comps = provider.split(":") + if len(comps) < 2 or comps[1] != "linode": + raise SaltCloudException("The requested profile does not belong to Linode.") + + instance_type = self._get_linode_type(profile["size"]) + pricing = instance_type.get("price", {}) + + per_hour = pricing["hourly"] + per_day = per_hour * 24 + per_week = per_day * 7 + per_month = pricing["monthly"] + per_year = per_month * 12 + + return { + profile["profile"]: { + "per_hour": per_hour, + "per_day": per_day, + "per_week": per_week, + "per_month": per_month, + "per_year": per_year, + } + } + + def start(self, name): + instance = self._get_linode_by_name(name) + linode_id = instance.get("id", None) + + if instance["status"] == "running": + return { + "success": True, + "action": "start", + "state": "Running", + "msg": "Machine already running", + } + + self._query(f"/linode/instances/{linode_id}/boot", method="POST") + + self._wait_for_linode_status(linode_id, "running") + return { + "success": True, + "state": "Running", + "action": "start", + } + + def stop(self, name): + instance = self._get_linode_by_name(name) + linode_id = instance.get("id", None) + + if instance["status"] == "offline": + return { + "success": True, + "action": "stop", + "state": "Stopped", + "msg": "Machine already stopped", + } + + self._query(f"/linode/instances/{linode_id}/shutdown", method="POST") + + self._wait_for_linode_status(linode_id, "offline") + return {"success": True, "state": "Stopped", "action": "stop"} + + def _get_linode_by_id(self, linode_id): + return self._query(f"/linode/instances/{linode_id}") + + def _get_linode_by_name(self, name): + result = self._query("/linode/instances") + instances = result.get("data", []) + + for instance in instances: + if instance["label"] == name: + return instance + + raise SaltCloudNotFound(f"The specified name, {name}, could not be found.") + + def _list_linodes(self, full=False): + result = self._query("/linode/instances") + instances = result.get("data", []) + + ret = {} + for instance in instances: + node = {} + node["id"] = instance["id"] + node["image"] = instance["image"] + node["name"] = instance["label"] + node["size"] = instance["type"] + node["state"] = instance["status"] + + public_ips, private_ips = self._get_ips(node["id"]) + node["public_ips"] = public_ips + node["private_ips"] = private_ips + + if full: + node["extra"] = instance + + ret[instance["label"]] = node + + return ret + + def _get_linode_type(self, linode_type): + return self._query(f"/linode/types/{linode_type}") + + def _get_ips(self, linode_id): + instance = self._get_linode_by_id(linode_id) + public = [] + private = [] + + for addr in instance.get("ipv4", []): + if ipaddress.ip_address(addr).is_private: + private.append(addr) + else: + public.append(addr) + + return (public, private) + + def _poll( + self, + description, + getter, + condition, + timeout=None, + poll_interval=None, + ): + """ + Return true in handler to signal complete. + """ + if poll_interval is None: + poll_interval = _get_poll_interval() + + if timeout is None: + timeout = 120 + + times = (timeout * 1000) / poll_interval + curr = 0 + + while True: + curr += 1 + result = getter() + if condition(result): + return True + elif curr <= times: + time.sleep(poll_interval / 1000) + log.info("retrying: polling for %s...", description) + else: + raise SaltCloudException(f"timed out: polling for {description}") + + def _wait_for_entity_status( + self, getter, status, entity_name="item", identifier="some", timeout=None + ): + return self._poll( + f"{entity_name} (id={identifier}) status to be '{status}'", + getter, + lambda item: item.get("status") == status, + timeout=timeout, + ) + + def _wait_for_linode_status(self, linode_id, status, timeout=None): + return self._wait_for_entity_status( + lambda: self._get_linode_by_id(linode_id), + status, + entity_name="linode", + identifier=linode_id, + timeout=timeout, + ) + + def _check_event_status(self, event, desired_status): + status = event.get("status") + action = event.get("action") + entity = event.get("entity") + if status == "failed": + raise SaltCloudSystemExit( + "event {} for {} (id={}) failed".format( + action, entity["type"], entity["id"] + ) + ) + return status == desired_status + + def _wait_for_event(self, action, entity, entity_id, status, timeout=None): + event_filter = { + "+order_by": "created", + "+order": "desc", + "seen": False, + "action": action, + "entity.id": entity_id, + "entity.type": entity, + } + last_event = None + + def condition(event): + return self._check_event_status(event, status) + + while True: + if last_event is not None: + event_filter["+gt"] = last_event + filter_json = json.dumps(event_filter, separators=(",", ":")) + result = self._query("/account/events", headers={"X-Filter": filter_json}) + events = result.get("data", []) + + if len(events) == 0: + break + + for event in events: + event_id = event.get("id") + event_entity = event.get("entity", None) + last_event = event_id + if not event_entity: + continue + + if not ( + event_entity["type"] == entity + and event_entity["id"] == entity_id + and event.get("action") == action + ): + continue + + if condition(event): + return True + + return self._poll( + f"event {event_id} to be '{status}'", + lambda: self._query(f"/account/events/{event_id}"), + condition, + timeout=timeout, + ) + + return False + + def _get_response_json(self, response): + json = None + try: + json = response.json() + except ValueError: + pass + return json + + +def avail_images(call=None): + """ + Return available Linode images. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-images my-linode-config + salt-cloud -f avail_images my-linode-config + """ + if call == "action": + raise SaltCloudException( + "The avail_images function must be called with -f or --function." + ) + return LinodeAPIv4.get_api_instance().avail_images() + + +def avail_locations(call=None): + """ + Return available Linode datacenter locations. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-locations my-linode-config + salt-cloud -f avail_locations my-linode-config + """ + if call == "action": + raise SaltCloudException( + "The avail_locations function must be called with -f or --function." + ) + return LinodeAPIv4.get_api_instance().avail_locations() + + +def avail_sizes(call=None): + """ + Return available Linode sizes. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-sizes my-linode-config + salt-cloud -f avail_sizes my-linode-config + """ + if call == "action": + raise SaltCloudException( + "The avail_locations function must be called with -f or --function." + ) + return LinodeAPIv4.get_api_instance().avail_sizes() + + +def set_backup_schedule(name=None, kwargs=None, call=None): + """ + Set the backup schedule for a Linode. + + name + The name (label) of the Linode. Can be used instead of + ``linode_id``. + + linode_id + The ID of the Linode instance to set the backup schedule for. + If provided, will be used as an alternative to ``name`` and + reduces the number of API calls to Linode by one. Will be + preferred over ``name``. + + auto_enable + If ``True``, automatically enable the backup feature for the Linode + if it wasn't already enabled. Optional parameter, default to ``False``. + + day + Possible values: + ``Sunday``, ``Monday``, ``Tuesday``, ``Wednesday``, + ``Thursday``, ``Friday``, ``Saturday`` + + The day of the week that your Linode's weekly Backup is taken. + If not set manually, a day will be chosen for you. Backups are + taken every day, but backups taken on this day are preferred + when selecting backups to retain for a longer period. + + If not set manually, then when backups are initially enabled, + this may come back as ``Scheduling`` until the day is automatically + selected. + + window + Possible values: + ``W0``, ``W2``, ``W4``, ``W6``, ``W8``, ``W10``, + ``W12``, ``W14``, ``W16``, ``W18``, ``W20``, ``W22`` + + The window in which your backups will be taken, in UTC. A backups + window is a two-hour span of time in which the backup may occur. + + For example, ``W10`` indicates that your backups should be taken + between 10:00 and 12:00. If you do not choose a backup window, one + will be selected for you automatically. + + If not set manually, when backups are initially enabled this may come + back as ``Scheduling`` until the window is automatically selected. + + Can be called as an action (which requires a name): + + .. code-block:: bash + + salt-cloud -a set_backup_schedule my-linode-instance day=Monday window=W20 auto_enable=True + + ...or as a function (which requires either a name or linode_id): + + .. code-block:: bash + + salt-cloud -f set_backup_schedule my-linode-provider name=my-linode-instance day=Monday window=W20 auto_enable=True + salt-cloud -f set_backup_schedule my-linode-provider linode_id=1225876 day=Monday window=W20 auto_enable=True + """ + if name is None and call == "action": + raise SaltCloudSystemExit( + "The set_backup_schedule backup schedule " + "action requires the name of the Linode.", + ) + + if kwargs is None: + kwargs = {} + + if call == "function": + name = kwargs.get("name", None) + linode_id = kwargs.get("linode_id") + + auto_enable = str(kwargs.get("auto_enable")).lower() == "true" + + if name is None and linode_id is None: + raise SaltCloudSystemExit( + "The set_backup_schedule function requires " + "either a 'name' or a 'linode_id'." + ) + + return LinodeAPIv4.get_api_instance().set_backup_schedule( + day=kwargs.get("day"), + window=kwargs.get("window"), + label=name, + linode_id=linode_id, + auto_enable=auto_enable, + ) + + +def boot(name=None, kwargs=None, call=None): + """ + Boot a Linode. + + name + The name of the Linode to boot. Can be used instead of ``linode_id``. + + linode_id + The ID of the Linode to boot. If provided, will be used as an + alternative to ``name`` and reduces the number of API calls to + Linode by one. Will be preferred over ``name``. + + config_id + The ID of the Config to boot. Required. + + check_running + Defaults to True. If set to False, overrides the call to check if + the VM is running before calling the linode.boot API call. Change + ``check_running`` to True is useful during the boot call in the + create function, since the new VM will not be running yet. + + Can be called as an action (which requires a name): + + .. code-block:: bash + + salt-cloud -a boot my-instance config_id=10 + + ...or as a function (which requires either a name or linode_id): + + .. code-block:: bash + + salt-cloud -f boot my-linode-config name=my-instance config_id=10 + salt-cloud -f boot my-linode-config linode_id=1225876 config_id=10 + """ + if name is None and call == "action": + raise SaltCloudSystemExit("The boot action requires a 'name'.") + + linode_id = kwargs.get("linode_id", None) + config_id = kwargs.get("config_id", None) + + if call == "function": + name = kwargs.get("name", None) + + if name is None and linode_id is None: + raise SaltCloudSystemExit( + "The boot function requires either a 'name' or a 'linode_id'." + ) + + return LinodeAPIv4.get_api_instance().boot(name=name, kwargs=kwargs) + + +def clone(kwargs=None, call=None): + """ + Clone a Linode. + + linode_id + The ID of the Linode to clone. Required. + + location + The location of the new Linode. Required. + + size + The size of the new Linode (must be greater than or equal to the clone source). Required. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f clone my-linode-config linode_id=1234567 location=us-central size=g6-standard-1 + """ + if call == "action": + raise SaltCloudSystemExit( + "The clone function must be called with -f or --function." + ) + + return LinodeAPIv4.get_api_instance().clone(kwargs=kwargs) + + +def create(vm_): + """ + Create a single Linode VM. + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "linode", + vm_["profile"], + vm_=vm_, + ) + ) is False: + return False + except AttributeError: + pass + + return LinodeAPIv4.get_api_instance().create(vm_) + + +def create_config(kwargs=None, call=None): + """ + Creates a Linode Configuration Profile. + + name + The name of the VM to create the config for. + + linode_id + The ID of the Linode to create the configuration for. + + root_disk_id + The Root Disk ID to be used for this config. + + swap_disk_id + The Swap Disk ID to be used for this config. + + data_disk_id + The Data Disk ID to be used for this config. + + .. versionadded:: 2016.3.0 + + kernel_id + The ID of the kernel to use for this configuration profile. + """ + if call == "action": + raise SaltCloudSystemExit( + "The create_config function must be called with -f or --function." + ) + return LinodeAPIv4.get_api_instance().create_config(kwargs=kwargs) + + +def destroy(name, call=None): + """ + Destroys a Linode by name. + + name + The name of VM to be be destroyed. + + CLI Example: + + .. code-block:: bash + + salt-cloud -d vm_name + """ + if call == "function": + raise SaltCloudException( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + return LinodeAPIv4.get_api_instance().destroy(name) + + +def get_config_id(kwargs=None, call=None): + """ + Returns a config_id for a given linode. + + .. versionadded:: 2015.8.0 + + name + The name of the Linode for which to get the config_id. Can be used instead + of ``linode_id``. + + linode_id + The ID of the Linode for which to get the config_id. Can be used instead + of ``name``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_config_id my-linode-config name=my-linode + salt-cloud -f get_config_id my-linode-config linode_id=1234567 + """ + if call == "action": + raise SaltCloudException( + "The get_config_id function must be called with -f or --function." + ) + return LinodeAPIv4.get_api_instance().get_config_id(kwargs=kwargs) + + +def get_linode(kwargs=None, call=None): + """ + Returns data for a single named Linode. + + name + The name of the Linode for which to get data. Can be used instead + ``linode_id``. Note this will induce an additional API call + compared to using ``linode_id``. + + linode_id + The ID of the Linode for which to get data. Can be used instead of + ``name``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_linode my-linode-config name=my-instance + salt-cloud -f get_linode my-linode-config linode_id=1234567 + """ + if call == "action": + raise SaltCloudSystemExit( + "The get_linode function must be called with -f or --function." + ) + return LinodeAPIv4.get_api_instance().get_linode(kwargs=kwargs) + + +def list_nodes(call=None): + """ + Returns a list of linodes, keeping only a brief listing. + + CLI Example: + + .. code-block:: bash + + salt-cloud -Q + salt-cloud --query + salt-cloud -f list_nodes my-linode-config + + .. note:: + + The ``image`` label only displays information about the VM's distribution vendor, + such as "Debian" or "RHEL" and does not display the actual image name. This is + due to a limitation of the Linode API. + """ + if call == "action": + raise SaltCloudException( + "The list_nodes function must be called with -f or --function." + ) + return LinodeAPIv4.get_api_instance().list_nodes() + + +def list_nodes_full(call=None): + """ + List linodes, with all available information. + + CLI Example: + + .. code-block:: bash + + salt-cloud -F + salt-cloud --full-query + salt-cloud -f list_nodes_full my-linode-config + + .. note:: + + The ``image`` label only displays information about the VM's distribution vendor, + such as "Debian" or "RHEL" and does not display the actual image name. This is + due to a limitation of the Linode API. + """ + if call == "action": + raise SaltCloudException( + "The list_nodes_full function must be called with -f or --function." + ) + return LinodeAPIv4.get_api_instance().list_nodes_full() + + +def list_nodes_min(call=None): + """ + Return a list of the VMs that are on the provider. Only a list of VM names and + their state is returned. This is the minimum amount of information needed to + check for existing VMs. + + .. versionadded:: 2015.8.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_nodes_min my-linode-config + salt-cloud --function list_nodes_min my-linode-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_min function must be called with -f or --function." + ) + return LinodeAPIv4.get_api_instance().list_nodes_min() + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields. + """ + return LinodeAPIv4.get_api_instance().list_nodes_select(call) + + +def reboot(name, call=None): + """ + Reboot a linode. + + .. versionadded:: 2015.8.0 + + name + The name of the VM to reboot. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot vm_name + """ + if call != "action": + raise SaltCloudException( + "The show_instance action must be called with -a or --action." + ) + return LinodeAPIv4.get_api_instance().reboot(name) + + +def show_instance(name, call=None): + """ + Displays details about a particular Linode VM. Either a name or a linode_id must + be provided. + + .. versionadded:: 2015.8.0 + + name + The name of the VM for which to display details. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a show_instance vm_name + + .. note:: + + The ``image`` label only displays information about the VM's distribution vendor, + such as "Debian" or "RHEL" and does not display the actual image name. This is + due to a limitation of the Linode API. + """ + if call != "action": + raise SaltCloudException( + "The show_instance action must be called with -a or --action." + ) + return LinodeAPIv4.get_api_instance().show_instance(name) + + +def show_pricing(kwargs=None, call=None): + """ + Show pricing for a particular profile. This is only an estimate, based on + unofficial pricing sources. + + .. versionadded:: 2015.8.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f show_pricing my-linode-config profile=my-linode-profile + """ + if call != "function": + raise SaltCloudException( + "The show_instance action must be called with -f or --function." + ) + return LinodeAPIv4.get_api_instance().show_pricing(kwargs=kwargs) + + +def start(name, call=None): + """ + Start a VM in Linode. + + name + The name of the VM to start. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop vm_name + """ + if call != "action": + raise SaltCloudException("The start action must be called with -a or --action.") + return LinodeAPIv4.get_api_instance().start(name) + + +def stop(name, call=None): + """ + Stop a VM in Linode. + + name + The name of the VM to stop. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop vm_name + """ + if call != "action": + raise SaltCloudException("The stop action must be called with -a or --action.") + return LinodeAPIv4.get_api_instance().stop(name) diff --git a/salt/cloud/clouds/lxc.py b/salt/cloud/clouds/lxc.py new file mode 100644 index 000000000000..ced89e587bbd --- /dev/null +++ b/salt/cloud/clouds/lxc.py @@ -0,0 +1,549 @@ +""" +Install Salt on an LXC Container +================================ + +.. versionadded:: 2014.7.0 + +Please read :ref:`core config documentation `. +""" + +import copy +import logging +import os +import pprint +import time + +import salt.client +import salt.config as config +import salt.runner +import salt.utils.cloud +import salt.utils.json +from salt.exceptions import SaltCloudSystemExit + +log = logging.getLogger(__name__) + +__FUN_TIMEOUT = { + "cmd.run": 60 * 60, + "test.ping": 10, + "lxc.info": 40, + "lxc.list": 300, + "lxc.templates": 100, + "grains.items": 100, +} +__CACHED_CALLS = {} +__CACHED_FUNS = { + "test.ping": 3 * 60, # cache ping for 3 minutes + "lxc.list": 2, # cache lxc.list for 2 seconds +} + + +def __virtual__(): + """ + Needs no special configuration + """ + return True + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def _get_grain_id(id_): + if not get_configured_provider(): + return + infos = get_configured_provider() + return "salt.cloud.lxc.{}.{}".format(infos["target"], id_) + + +def _minion_opts(cfg="minion"): + if "conf_file" in __opts__: + default_dir = os.path.dirname(__opts__["conf_file"]) + else: + default_dir = (__opts__["config_dir"],) + cfg = os.environ.get("SALT_MINION_CONFIG", os.path.join(default_dir, cfg)) + opts = config.minion_config(cfg) + return opts + + +def _master_opts(cfg="master"): + if "conf_file" in __opts__: + default_dir = os.path.dirname(__opts__["conf_file"]) + else: + default_dir = (__opts__["config_dir"],) + cfg = os.environ.get("SALT_MASTER_CONFIG", os.path.join(default_dir, cfg)) + opts = config.master_config(cfg) + opts["output"] = "quiet" + return opts + + +def _client(): + return salt.client.get_local_client(mopts=_master_opts()) + + +def _runner(): + # opts = _master_opts() + # opts['output'] = 'quiet' + return salt.runner.RunnerClient(_master_opts()) + + +def _salt(fun, *args, **kw): + """Execute a salt function on a specific minion + + Special kwargs: + + salt_target + target to exec things on + salt_timeout + timeout for jobs + salt_job_poll + poll interval to wait for job finish result + """ + try: + poll = kw.pop("salt_job_poll") + except KeyError: + poll = 0.1 + try: + target = kw.pop("salt_target") + except KeyError: + target = None + try: + timeout = int(kw.pop("salt_timeout")) + except (KeyError, ValueError): + # try to has some low timeouts for very basic commands + timeout = __FUN_TIMEOUT.get( + fun, 900 # wait up to 15 minutes for the default timeout + ) + try: + kwargs = kw.pop("kwargs") + except KeyError: + kwargs = {} + if not target: + infos = get_configured_provider() + if not infos: + return + target = infos["target"] + laps = time.time() + cache = False + if fun in __CACHED_FUNS: + cache = True + laps = laps // __CACHED_FUNS[fun] + try: + sargs = salt.utils.json.dumps(args) + except TypeError: + sargs = "" + try: + skw = salt.utils.json.dumps(kw) + except TypeError: + skw = "" + try: + skwargs = salt.utils.json.dumps(kwargs) + except TypeError: + skwargs = "" + cache_key = (laps, target, fun, sargs, skw, skwargs) + if not cache or (cache and (cache_key not in __CACHED_CALLS)): + with _client() as conn: + runner = _runner() + rkwargs = kwargs.copy() + rkwargs["timeout"] = timeout + rkwargs.setdefault("tgt_type", "list") + kwargs.setdefault("tgt_type", "list") + ping_retries = 0 + # the target(s) have environ one minute to respond + # we call 60 ping request, this prevent us + # from blindly send commands to unmatched minions + ping_max_retries = 60 + ping = True + # do not check ping... if we are pinguing + if fun == "test.ping": + ping_retries = ping_max_retries + 1 + # be sure that the executors are alive + while ping_retries <= ping_max_retries: + try: + if ping_retries > 0: + time.sleep(1) + pings = conn.cmd(tgt=target, timeout=10, fun="test.ping") + values = list(pings.values()) + if not values: + ping = False + for v in values: + if v is not True: + ping = False + if not ping: + raise ValueError("Unreachable") + break + except Exception: # pylint: disable=broad-except + ping = False + ping_retries += 1 + log.error("%s unreachable, retrying", target) + if not ping: + raise SaltCloudSystemExit(f"Target {target} unreachable") + jid = conn.cmd_async(tgt=target, fun=fun, arg=args, kwarg=kw, **rkwargs) + cret = conn.cmd( + tgt=target, fun="saltutil.find_job", arg=[jid], timeout=10, **kwargs + ) + running = bool(cret.get(target, False)) + endto = time.time() + timeout + while running: + rkwargs = { + "tgt": target, + "fun": "saltutil.find_job", + "arg": [jid], + "timeout": 10, + } + cret = conn.cmd(**rkwargs) + running = bool(cret.get(target, False)) + if not running: + break + if running and (time.time() > endto): + raise Exception( + "Timeout {}s for {} is elapsed".format( + timeout, pprint.pformat(rkwargs) + ) + ) + time.sleep(poll) + # timeout for the master to return data about a specific job + wait_for_res = float({"test.ping": "5"}.get(fun, "120")) + while wait_for_res: + wait_for_res -= 0.5 + cret = runner.cmd("jobs.lookup_jid", [jid, {"__kwarg__": True}]) + if target in cret: + ret = cret[target] + break + # recent changes + elif "data" in cret and "outputter" in cret: + ret = cret["data"] + break + # special case, some answers may be crafted + # to handle the unresponsivness of a specific command + # which is also meaningful, e.g. a minion not yet provisioned + if fun in ["test.ping"] and not wait_for_res: + ret = {"test.ping": False}.get(fun, False) + time.sleep(0.5) + try: + if "is not available." in ret: + raise SaltCloudSystemExit(f"module/function {fun} is not available") + except SaltCloudSystemExit: # pylint: disable=try-except-raise + raise + except TypeError: + pass + if cache: + __CACHED_CALLS[cache_key] = ret + elif cache and cache_key in __CACHED_CALLS: + ret = __CACHED_CALLS[cache_key] + return ret + + +def avail_images(): + return _salt("lxc.templates") + + +def list_nodes(conn=None, call=None): + hide = False + names = __opts__.get("names", []) + profiles = __opts__.get("profiles", {}) + profile = __opts__.get("profile", __opts__.get("internal_lxc_profile", [])) + destroy_opt = __opts__.get("destroy", False) + action = __opts__.get("action", "") + for opt in ["full_query", "select_query", "query"]: + if __opts__.get(opt, False): + call = "full" + if destroy_opt: + call = "full" + if action and not call: + call = "action" + if profile and names and not destroy_opt: + hide = True + if not get_configured_provider(): + return + + path = None + if profile and profile in profiles: + path = profiles[profile].get("path", None) + lxclist = _salt("lxc.list", extra=True, path=path) + nodes = {} + for state, lxcs in lxclist.items(): + for lxcc, linfos in lxcs.items(): + info = { + "id": lxcc, + "name": lxcc, # required for cloud cache + "image": None, + "size": linfos["size"], + "state": state.lower(), + "public_ips": linfos["public_ips"], + "private_ips": linfos["private_ips"], + } + # in creation mode, we need to go inside the create method + # so we hide the running vm from being seen as already installed + # do not also mask half configured nodes which are explicitly asked + # to be acted on, on the command line + if (call in ["full"] or not hide) and ( + (lxcc in names and call in ["action"]) or call in ["full"] + ): + nodes[lxcc] = { + "id": lxcc, + "name": lxcc, # required for cloud cache + "image": None, + "size": linfos["size"], + "state": state.lower(), + "public_ips": linfos["public_ips"], + "private_ips": linfos["private_ips"], + } + else: + nodes[lxcc] = {"id": lxcc, "state": state.lower()} + return nodes + + +def list_nodes_full(conn=None, call=None): + if not get_configured_provider(): + return + if not call: + call = "action" + return list_nodes(conn=conn, call=call) + + +def show_instance(name, call=None): + """ + Show the details from the provider concerning an instance + """ + + if not get_configured_provider(): + return + if not call: + call = "action" + nodes = list_nodes_full(call=call) + __utils__["cloud.cache_node"](nodes[name], _get_active_provider_name(), __opts__) + return nodes[name] + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + if not call: + call = "select" + if not get_configured_provider(): + return + info = ["id", "name", "image", "size", "state", "public_ips", "private_ips"] + return salt.utils.cloud.list_nodes_select( + list_nodes_full(call="action"), __opts__.get("query.selection", info), call + ) + + +def _checkpoint(ret): + sret = """ +id: {name} +last message: {comment}""".format( + **ret + ) + keys = list(ret["changes"].items()) + keys.sort() + for ch, comment in keys: + sret += "\n {}:\n {}".format(ch, comment.replace("\n", "\n ")) + if not ret["result"]: + if "changes" in ret: + del ret["changes"] + raise SaltCloudSystemExit(sret) + log.info(sret) + return sret + + +def destroy(vm_, call=None): + """Destroy a lxc container""" + destroy_opt = __opts__.get("destroy", False) + profiles = __opts__.get("profiles", {}) + profile = __opts__.get("profile", __opts__.get("internal_lxc_profile", [])) + path = None + if profile and profile in profiles: + path = profiles[profile].get("path", None) + action = __opts__.get("action", "") + if action != "destroy" and not destroy_opt: + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + if not get_configured_provider(): + return + ret = {"comment": f"{vm_} was not found", "result": False} + if _salt("lxc.info", vm_, path=path): + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{vm_}/destroying", + args={"name": vm_, "instance_id": vm_}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + cret = _salt("lxc.destroy", vm_, stop=True, path=path) + ret["result"] = cret["result"] + if ret["result"]: + ret["comment"] = f"{vm_} was destroyed" + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{vm_}/destroyed", + args={"name": vm_, "instance_id": vm_}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + vm_, _get_active_provider_name().split(":")[0], __opts__ + ) + return ret + + +def create(vm_, call=None): + """Create an lxc Container. + This function is idempotent and will try to either provision + or finish the provision of an lxc container. + + NOTE: Most of the initialization code has been moved and merged + with the lxc runner and lxc.init functions + """ + prov = get_configured_provider(vm_) + if not prov: + return + # we cant use profile as a configuration key as it conflicts + # with salt cloud internals + profile = vm_.get("lxc_profile", vm_.get("container_profile", None)) + + event_data = vm_.copy() + event_data["profile"] = profile + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", event_data, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + ret = {"name": vm_["name"], "changes": {}, "result": True, "comment": ""} + if "pub_key" not in vm_ and "priv_key" not in vm_: + log.debug("Generating minion keys for %s", vm_["name"]) + vm_["priv_key"], vm_["pub_key"] = salt.utils.cloud.gen_keys( + salt.config.get_cloud_config_value("keysize", vm_, __opts__) + ) + # get the minion key pair to distribute back to the container + kwarg = copy.deepcopy(vm_) + kwarg["host"] = prov["target"] + kwarg["profile"] = profile + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "requesting", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + cret = _runner().cmd("lxc.cloud_init", [vm_["name"]], kwarg=kwarg) + ret["runner_return"] = cret + ret["result"] = cret["result"] + if not ret["result"]: + ret["Error"] = "Error while creating {},".format(vm_["name"]) + else: + ret["changes"]["created"] = "created" + + # When using cloud states to manage LXC containers + # __opts__['profile'] is not implicitly reset between operations + # on different containers. However list_nodes will hide container + # if profile is set in opts assuming that it have to be created. + # But in cloud state we do want to check at first if it really + # exists hence the need to remove profile from global opts once + # current container is created. + if "profile" in __opts__: + __opts__["internal_lxc_profile"] = __opts__["profile"] + del __opts__["profile"] + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def get_provider(name): + data = None + if name in __opts__["providers"]: + data = __opts__["providers"][name] + if "lxc" in data: + data = data["lxc"] + else: + data = None + return data + + +def get_configured_provider(vm_=None): + """ + Return the contextual provider of None if no configured + one can be found. + """ + if vm_ is None: + vm_ = {} + dalias, driver = _get_active_provider_name().split(":") + data = None + tgt = "unknown" + img_provider = __opts__.get("list_images", "") + arg_providers = __opts__.get("names", []) + matched = False + # --list-images level + if img_provider: + tgt = f"provider: {img_provider}" + if dalias == img_provider: + data = get_provider(img_provider) + matched = True + # providers are set in configuration + if not data and "profile" not in __opts__ and arg_providers: + for name in arg_providers: + tgt = f"provider: {name}" + if dalias == name: + data = get_provider(name) + if data: + matched = True + break + # -p is providen, get the uplinked provider + elif "profile" in __opts__: + curprof = __opts__["profile"] + profs = __opts__["profiles"] + tgt = f"profile: {curprof}" + if ( + curprof in profs + and profs[curprof]["provider"] == _get_active_provider_name() + ): + prov, cdriver = profs[curprof]["provider"].split(":") + tgt += f" provider: {prov}" + data = get_provider(prov) + matched = True + # fallback if we have only __active_provider_name__ + if (__opts__.get("destroy", False) and not data) or ( + not matched and _get_active_provider_name() + ): + data = __opts__.get("providers", {}).get(dalias, {}).get(driver, {}) + # in all cases, verify that the linked saltmaster is alive. + if data: + ret = _salt("test.ping", salt_target=data["target"]) + if ret: + return data + else: + log.error( + "Configured provider %s minion: %s is unreachable", + _get_active_provider_name(), + data["target"], + ) + return False diff --git a/salt/cloud/clouds/oneandone.py b/salt/cloud/clouds/oneandone.py new file mode 100644 index 000000000000..6028e4f53e09 --- /dev/null +++ b/salt/cloud/clouds/oneandone.py @@ -0,0 +1,901 @@ +""" +1&1 Cloud Server Module +======================= + +The 1&1 SaltStack cloud module allows a 1&1 server to be automatically deployed +and bootstrapped with Salt. It also has functions to create block storages and +ssh keys. + +:depends: 1and1 >= 1.2.0 + +The module requires the 1&1 api_token to be provided. The server should also +be assigned a public LAN, a private LAN, or both along with SSH key pairs. + +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or +``/etc/salt/cloud.providers.d/oneandone.conf``: + +.. code-block:: yaml + + my-oneandone-config: + driver: oneandone + # The 1&1 api token + api_token: + # SSH private key filename + ssh_private_key: /path/to/private_key + # SSH public key filename + ssh_public_key: /path/to/public_key + +.. code-block:: yaml + + my-oneandone-profile: + provider: my-oneandone-config + # Either provide fixed_instance_size_id or vcore, cores_per_processor, ram, and hdds. + # Size of the ID desired for the server + fixed_instance_size: S + # Total amount of processors + vcore: 2 + # Number of cores per processor + cores_per_processor: 2 + # RAM memory size in GB + ram: 4 + # Hard disks + hdds: + - + is_main: true + size: 20 + - + is_main: false + size: 20 + # ID of the appliance image that will be installed on server + appliance_id: + # ID of the datacenter where the server will be created + datacenter_id: + # Description of the server + description: My server description + # Password of the server. Password must contain more than 8 characters + # using uppercase letters, numbers and other special symbols. + password: P4$$w0rD + # Power on server after creation - default True + power_on: true + # Firewall policy ID. If it is not provided, the server will assign + # the best firewall policy, creating a new one if necessary. + # If the parameter is sent with a 0 value, the server will be created with all ports blocked. + firewall_policy_id: + # IP address ID + ip_id: + # Load balancer ID + load_balancer_id: + # Monitoring policy ID + monitoring_policy_id: + +Set ``deploy`` to False if Salt should not be installed on the node. + +.. code-block:: yaml + + my-oneandone-profile: + deploy: False + +Create an SSH key + +.. code-block:: bash + + sudo salt-cloud -f create_ssh_key my-oneandone-config name='SaltTest' description='SaltTestDescription' + +Create a block storage + +.. code-block:: bash + + sudo salt-cloud -f create_block_storage my-oneandone-config name='SaltTest2' + description='SaltTestDescription' size=50 datacenter_id='5091F6D8CBFEF9C26ACE957C652D5D49' + +""" + +import logging +import os +import pprint +import time + +import salt.config as config +import salt.utils.cloud +import salt.utils.files +import salt.utils.stringutils +from salt.exceptions import ( + SaltCloudConfigError, + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudNotFound, + SaltCloudSystemExit, +) + +try: + # pylint: disable=no-name-in-module + from oneandone.client import BlockStorage, Hdd, OneAndOneService, Server, SshKey + + # pylint: enable=no-name-in-module + + HAS_ONEANDONE = True +except ImportError: + HAS_ONEANDONE = False + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "oneandone" + + +# Only load in this module if the 1&1 configurations are in place +def __virtual__(): + """ + Check for 1&1 configurations. + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("api_token",) + ) + + +def get_dependencies(): + """ + Warn if dependencies are not met. + """ + return config.check_driver_dependencies( + __virtualname__, {"oneandone": HAS_ONEANDONE} + ) + + +def get_conn(): + """ + Return a conn object for the passed VM data + """ + return OneAndOneService( + api_token=config.get_cloud_config_value( + "api_token", get_configured_provider(), __opts__, search_global=False + ) + ) + + +def get_size(vm_): + """ + Return the VM's size object + """ + vm_size = config.get_cloud_config_value( + "fixed_instance_size", vm_, __opts__, default=None, search_global=False + ) + sizes = avail_sizes() + + if not vm_size: + size = next((item for item in sizes if item["name"] == "S"), None) + return size + + size = next( + (item for item in sizes if item["name"] == vm_size or item["id"] == vm_size), + None, + ) + if size: + return size + + raise SaltCloudNotFound(f"The specified size, '{vm_size}', could not be found.") + + +def get_image(vm_): + """ + Return the image object to use + """ + vm_image = config.get_cloud_config_value("image", vm_, __opts__).encode( + "ascii", "salt-cloud-force-ascii" + ) + + images = avail_images() + for key, value in images.items(): + if vm_image and vm_image in (images[key]["id"], images[key]["name"]): + return images[key] + + raise SaltCloudNotFound(f"The specified image, '{vm_image}', could not be found.") + + +def avail_locations(conn=None, call=None): + """ + List available locations/datacenters for 1&1 + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + datacenters = [] + + if not conn: + conn = get_conn() + + for datacenter in conn.list_datacenters(): + datacenters.append({datacenter["country_code"]: datacenter}) + + return {"Locations": datacenters} + + +def create_block_storage(kwargs=None, call=None): + """ + Create a block storage + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + conn = get_conn() + + # Assemble the composite block storage object. + block_storage = _get_block_storage(kwargs) + + data = conn.create_block_storage(block_storage=block_storage) + + return {"BlockStorage": data} + + +def _get_block_storage(kwargs): + """ + Construct a block storage instance from passed arguments + """ + if kwargs is None: + kwargs = {} + + block_storage_name = kwargs.get("name", None) + block_storage_size = kwargs.get("size", None) + block_storage_description = kwargs.get("description", None) + datacenter_id = kwargs.get("datacenter_id", None) + server_id = kwargs.get("server_id", None) + + block_storage = BlockStorage(name=block_storage_name, size=block_storage_size) + + if block_storage_description: + block_storage.description = block_storage_description + + if datacenter_id: + block_storage.datacenter_id = datacenter_id + + if server_id: + block_storage.server_id = server_id + + return block_storage + + +def _get_ssh_key(kwargs): + """ + Construct an SshKey instance from passed arguments + """ + ssh_key_name = kwargs.get("name", None) + ssh_key_description = kwargs.get("description", None) + public_key = kwargs.get("public_key", None) + + return SshKey( + name=ssh_key_name, description=ssh_key_description, public_key=public_key + ) + + +def create_ssh_key(kwargs=None, call=None): + """ + Create an ssh key + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + conn = get_conn() + + # Assemble the composite SshKey object. + ssh_key = _get_ssh_key(kwargs) + + data = conn.create_ssh_key(ssh_key=ssh_key) + + return {"SshKey": data} + + +def avail_images(conn=None, call=None): + """ + Return a list of the server appliances that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + if not conn: + conn = get_conn() + + ret = {} + + for appliance in conn.list_appliances(): + ret[appliance["name"]] = appliance + + return ret + + +def avail_sizes(call=None): + """ + Return a dict of all available VM sizes on the cloud provider with + relevant data. + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + + conn = get_conn() + + sizes = conn.fixed_server_flavors() + + return sizes + + +def script(vm_): + """ + Return the script deployment object + """ + return salt.utils.cloud.os_script( + config.get_cloud_config_value("script", vm_, __opts__), + vm_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, vm_) + ), + ) + + +def list_nodes(conn=None, call=None): + """ + Return a list of VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + if not conn: + conn = get_conn() + + ret = {} + nodes = conn.list_servers() + + for node in nodes: + public_ips = [] + private_ips = [] + ret = {} + + size = node.get("hardware").get("fixed_instance_size_id", "Custom size") + + if node.get("private_networks"): + for private_ip in node["private_networks"]: + private_ips.append(private_ip) + + if node.get("ips"): + for public_ip in node["ips"]: + public_ips.append(public_ip["ip"]) + + server = { + "id": node["id"], + "image": node["image"]["id"], + "size": size, + "state": node["status"]["state"], + "private_ips": private_ips, + "public_ips": public_ips, + } + ret[node["name"]] = server + + return ret + + +def list_nodes_full(conn=None, call=None): + """ + Return a list of the VMs that are on the provider, with all fields + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + if not conn: + conn = get_conn() + + ret = {} + nodes = conn.list_servers() + + for node in nodes: + ret[node["name"]] = node + + return ret + + +def list_nodes_select(conn=None, call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + if not conn: + conn = get_conn() + + return salt.utils.cloud.list_nodes_select( + list_nodes_full(conn, "function"), + __opts__["query.selection"], + call, + ) + + +def show_instance(name, call=None): + """ + Show the details from the provider concerning an instance + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + nodes = list_nodes_full() + __utils__["cloud.cache_node"](nodes[name], _get_active_provider_name(), __opts__) + return nodes[name] + + +def _get_server(vm_): + """ + Construct server instance from cloud profile config + """ + description = config.get_cloud_config_value( + "description", vm_, __opts__, default=None, search_global=False + ) + + ssh_key = load_public_key(vm_) + + vcore = None + cores_per_processor = None + ram = None + fixed_instance_size_id = None + + if "fixed_instance_size" in vm_: + fixed_instance_size = get_size(vm_) + fixed_instance_size_id = fixed_instance_size["id"] + elif vm_["vcore"] and vm_["cores_per_processor"] and vm_["ram"] and vm_["hdds"]: + vcore = config.get_cloud_config_value( + "vcore", vm_, __opts__, default=None, search_global=False + ) + cores_per_processor = config.get_cloud_config_value( + "cores_per_processor", vm_, __opts__, default=None, search_global=False + ) + ram = config.get_cloud_config_value( + "ram", vm_, __opts__, default=None, search_global=False + ) + else: + raise SaltCloudConfigError( + "'fixed_instance_size' or 'vcore'," + "'cores_per_processor', 'ram', and 'hdds'" + "must be provided." + ) + + appliance_id = config.get_cloud_config_value( + "appliance_id", vm_, __opts__, default=None, search_global=False + ) + + password = config.get_cloud_config_value( + "password", vm_, __opts__, default=None, search_global=False + ) + + firewall_policy_id = config.get_cloud_config_value( + "firewall_policy_id", vm_, __opts__, default=None, search_global=False + ) + + ip_id = config.get_cloud_config_value( + "ip_id", vm_, __opts__, default=None, search_global=False + ) + + load_balancer_id = config.get_cloud_config_value( + "load_balancer_id", vm_, __opts__, default=None, search_global=False + ) + + monitoring_policy_id = config.get_cloud_config_value( + "monitoring_policy_id", vm_, __opts__, default=None, search_global=False + ) + + datacenter_id = config.get_cloud_config_value( + "datacenter_id", vm_, __opts__, default=None, search_global=False + ) + + private_network_id = config.get_cloud_config_value( + "private_network_id", vm_, __opts__, default=None, search_global=False + ) + + power_on = config.get_cloud_config_value( + "power_on", vm_, __opts__, default=True, search_global=False + ) + + public_key = config.get_cloud_config_value( + "public_key_ids", vm_, __opts__, default=True, search_global=False + ) + + # Contruct server object + return Server( + name=vm_["name"], + description=description, + fixed_instance_size_id=fixed_instance_size_id, + vcore=vcore, + cores_per_processor=cores_per_processor, + ram=ram, + appliance_id=appliance_id, + password=password, + power_on=power_on, + firewall_policy_id=firewall_policy_id, + ip_id=ip_id, + load_balancer_id=load_balancer_id, + monitoring_policy_id=monitoring_policy_id, + datacenter_id=datacenter_id, + rsa_key=ssh_key, + private_network_id=private_network_id, + public_key=public_key, + ) + + +def _get_hdds(vm_): + """ + Construct VM hdds from cloud profile config + """ + _hdds = config.get_cloud_config_value( + "hdds", vm_, __opts__, default=None, search_global=False + ) + + hdds = [] + + for hdd in _hdds: + hdds.append(Hdd(size=hdd["size"], is_main=hdd["is_main"])) + + return hdds + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, (_get_active_provider_name() or "oneandone"), vm_["profile"] + ) + is False + ): + return False + except AttributeError: + pass + + data = None + conn = get_conn() + hdds = [] + + # Assemble the composite server object. + server = _get_server(vm_) + + if not bool(server.specs["hardware"]["fixed_instance_size_id"]): + # Assemble the hdds object. + hdds = _get_hdds(vm_) + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args={"name": vm_["name"]}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + data = conn.create_server(server=server, hdds=hdds) + + _wait_for_completion(conn, get_wait_timeout(vm_), data["id"]) + except Exception as exc: # pylint: disable=W0703 + log.error( + "Error creating %s on 1and1\n\n" + "The following exception was thrown by the 1and1 library " + "when trying to run the initial deployment: \n%s", + vm_["name"], + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + vm_["server_id"] = data["id"] + password = data["first_password"] + + def __query_node_data(vm_, data): + """ + Query node data until node becomes available. + """ + running = False + try: + data = show_instance(vm_["name"], "action") + if not data: + return False + log.debug( + "Loaded node data for %s:\nname: %s\nstate: %s", + vm_["name"], + pprint.pformat(data["name"]), + data["status"]["state"], + ) + except Exception as err: # pylint: disable=broad-except + log.error( + "Failed to get nodes list: %s", + err, + # Show the trackback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + # Trigger a failure in the wait for IP function + return False + + running = data["status"]["state"].lower() == "powered_on" + if not running: + # Still not running, trigger another iteration + return + + vm_["ssh_host"] = data["ips"][0]["ip"] + + return data + + try: + data = salt.utils.cloud.wait_for_ip( + __query_node_data, + update_args=(vm_, data), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=10 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc.message)) + + log.debug("VM is now running") + log.info("Created Cloud VM %s", vm_) + log.debug("%s VM creation details:\n%s", vm_, pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args={ + "name": vm_["name"], + "profile": vm_["profile"], + "provider": vm_["driver"], + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if "ssh_host" in vm_: + vm_["password"] = password + vm_["key_filename"] = get_key_filename(vm_) + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + ret.update(data) + return ret + else: + raise SaltCloudSystemExit("A valid IP address was not found.") + + +def destroy(name, call=None): + """ + destroy a server by name + + :param name: name given to the server + :param call: call value in this case is 'action' + :return: array of booleans , true if successfully stopped and true if + successfully removed + + CLI Example: + + .. code-block:: bash + + salt-cloud -d vm_name + + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + conn = get_conn() + node = get_node(conn, name) + + conn.delete_server(server_id=node["id"]) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return True + + +def reboot(name, call=None): + """ + reboot a server by name + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: true if successful + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot vm_name + """ + conn = get_conn() + node = get_node(conn, name) + + conn.modify_server_status(server_id=node["id"], action="REBOOT") + + return True + + +def stop(name, call=None): + """ + stop a server by name + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: true if successful + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop vm_name + """ + conn = get_conn() + node = get_node(conn, name) + + conn.stop_server(server_id=node["id"]) + + return True + + +def start(name, call=None): + """ + start a server by name + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: true if successful + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start vm_name + """ + conn = get_conn() + node = get_node(conn, name) + + conn.start_server(server_id=node["id"]) + + return True + + +def get_node(conn, name): + """ + Return a node for the named VM + """ + for node in conn.list_servers(per_page=1000): + if node["name"] == name: + return node + + +def get_key_filename(vm_): + """ + Check SSH private key file and return absolute path if exists. + """ + key_filename = config.get_cloud_config_value( + "ssh_private_key", vm_, __opts__, search_global=False, default=None + ) + if key_filename is not None: + key_filename = os.path.expanduser(key_filename) + if not os.path.isfile(key_filename): + raise SaltCloudConfigError( + f"The defined ssh_private_key '{key_filename}' does not exist" + ) + + return key_filename + + +def load_public_key(vm_): + """ + Load the public key file if exists. + """ + public_key_filename = config.get_cloud_config_value( + "ssh_public_key", vm_, __opts__, search_global=False, default=None + ) + if public_key_filename is not None: + public_key_filename = os.path.expanduser(public_key_filename) + if not os.path.isfile(public_key_filename): + raise SaltCloudConfigError( + "The defined ssh_public_key '{}' does not exist".format( + public_key_filename + ) + ) + + with salt.utils.files.fopen(public_key_filename, "r") as public_key: + key = salt.utils.stringutils.to_unicode(public_key.read().replace("\n", "")) + + return key + + +def get_wait_timeout(vm_): + """ + Return the wait_for_timeout for resource provisioning. + """ + return config.get_cloud_config_value( + "wait_for_timeout", vm_, __opts__, default=15 * 60, search_global=False + ) + + +def _wait_for_completion(conn, wait_timeout, server_id): + """ + Poll request status until resource is provisioned. + """ + wait_timeout = time.time() + wait_timeout + while wait_timeout > time.time(): + time.sleep(5) + + server = conn.get_server(server_id) + server_state = server["status"]["state"].lower() + + if server_state == "powered_on": + return + elif server_state == "failed": + raise Exception(f"Server creation failed for {server_id}") + elif server_state in ("active", "enabled", "deploying", "configuring"): + continue + else: + raise Exception(f"Unknown server state {server_state}") + raise Exception(f"Timed out waiting for server create completion for {server_id}") diff --git a/salt/cloud/clouds/opennebula.py b/salt/cloud/clouds/opennebula.py new file mode 100644 index 000000000000..f3274a26b5aa --- /dev/null +++ b/salt/cloud/clouds/opennebula.py @@ -0,0 +1,4562 @@ +""" +OpenNebula Cloud Module +======================= + +The OpenNebula cloud module is used to control access to an OpenNebula cloud. + +.. versionadded:: 2014.7.0 + +:depends: lxml +:depends: OpenNebula installation running version ``4.14`` or later. + +Use of this module requires the ``xml_rpc``, ``user``, and ``password`` +parameters to be set. + +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or +``/etc/salt/cloud.providers.d/opennebula.conf``: + +.. code-block:: yaml + + my-opennebula-config: + xml_rpc: http://localhost:2633/RPC2 + user: oneadmin + password: JHGhgsayu32jsa + driver: opennebula + +This driver supports accessing new VM instances via DNS entry instead +of IP address. To enable this feature, in the provider or profile file +add `fqdn_base` with a value matching the base of your fully-qualified +domain name. Example: + +.. code-block:: yaml + + my-opennebula-config: + [...] + fqdn_base: + [...] + +The driver will prepend the hostname to the fqdn_base and do a DNS lookup +to find the IP of the new VM. + +.. note: + + Whenever ``data`` is provided as a kwarg to a function and the + attribute=value syntax is used, the entire ``data`` value must be + wrapped in single or double quotes. If the value given in the + attribute=value data string contains multiple words, double quotes + *must* be used for the value while the entire data string should + be encapsulated in single quotes. Failing to do so will result in + an error. Example: + +.. code-block:: bash + + salt-cloud -f image_allocate opennebula datastore_name=default \\ + data='NAME="My New Image" DESCRIPTION="Description of the image." \\ + PATH=/home/one_user/images/image_name.img' + salt-cloud -f secgroup_allocate opennebula \\ + data="Name = test RULE = [PROTOCOL = TCP, RULE_TYPE = inbound, \\ + RANGE = 1000:2000]" + +""" + +import logging +import os +import pprint +import time + +import salt.config as config +import salt.utils.data +import salt.utils.files +from salt.exceptions import ( + SaltCloudConfigError, + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudNotFound, + SaltCloudSystemExit, +) + +try: + import xmlrpc.client # nosec + + from lxml import etree + + HAS_XML_LIBS = True +except ImportError: + HAS_XML_LIBS = False + + +log = logging.getLogger(__name__) + +__virtualname__ = "opennebula" + + +def __virtual__(): + """ + Check for OpenNebula configs. + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ("xml_rpc", "user", "password"), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + return config.check_driver_dependencies(__virtualname__, {"lmxl": HAS_XML_LIBS}) + + +def avail_images(call=None): + """ + Return available OpenNebula images. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-images opennebula + salt-cloud --function avail_images opennebula + salt-cloud -f avail_images opennebula + + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + + image_pool = server.one.imagepool.info(auth, -2, -1, -1)[1] + + images = {} + for image in _get_xml(image_pool): + images[image.find("NAME").text] = _xml_to_dict(image) + + return images + + +def avail_locations(call=None): + """ + Return available OpenNebula locations. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-locations opennebula + salt-cloud --function avail_locations opennebula + salt-cloud -f avail_locations opennebula + + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + host_pool = server.one.hostpool.info(auth)[1] + + locations = {} + for host in _get_xml(host_pool): + locations[host.find("NAME").text] = _xml_to_dict(host) + + return locations + + +def avail_sizes(call=None): + """ + Because sizes are built into templates with OpenNebula, there will be no sizes to + return here. + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option." + ) + + log.warning( + "Because sizes are built into templates with OpenNebula, there are no sizes " + "to return." + ) + + return {} + + +def list_clusters(call=None): + """ + Returns a list of clusters in OpenNebula. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_clusters opennebula + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_clusters function must be called with -f or --function." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + cluster_pool = server.one.clusterpool.info(auth)[1] + + clusters = {} + for cluster in _get_xml(cluster_pool): + clusters[cluster.find("NAME").text] = _xml_to_dict(cluster) + + return clusters + + +def list_datastores(call=None): + """ + Returns a list of data stores on OpenNebula. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_datastores opennebula + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_datastores function must be called with -f or --function." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + datastore_pool = server.one.datastorepool.info(auth)[1] + + datastores = {} + for datastore in _get_xml(datastore_pool): + datastores[datastore.find("NAME").text] = _xml_to_dict(datastore) + + return datastores + + +def list_hosts(call=None): + """ + Returns a list of hosts on OpenNebula. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_hosts opennebula + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_hosts function must be called with -f or --function." + ) + + return avail_locations() + + +def list_nodes(call=None): + """ + Return a list of VMs on OpenNebula. + + CLI Example: + + .. code-block:: bash + + salt-cloud -Q + salt-cloud --query + salt-cloud --function list_nodes opennebula + salt-cloud -f list_nodes opennebula + + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + return _list_nodes(full=False) + + +def list_nodes_full(call=None): + """ + Return a list of the VMs on OpenNebula. + + CLI Example: + + .. code-block:: bash + + salt-cloud -F + salt-cloud --full-query + salt-cloud --function list_nodes_full opennebula + salt-cloud -f list_nodes_full opennebula + + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + return _list_nodes(full=True) + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields. + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + return __utils__["cloud.list_nodes_select"]( + list_nodes_full("function"), + __opts__["query.selection"], + call, + ) + + +def list_security_groups(call=None): + """ + Lists all security groups available to the user and the user's groups. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_security_groups opennebula + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_security_groups function must be called with -f or --function." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + secgroup_pool = server.one.secgrouppool.info(auth, -2, -1, -1)[1] + + groups = {} + for group in _get_xml(secgroup_pool): + groups[group.find("NAME").text] = _xml_to_dict(group) + + return groups + + +def list_templates(call=None): + """ + Lists all templates available to the user and the user's groups. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_templates opennebula + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_templates function must be called with -f or --function." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + template_pool = server.one.templatepool.info(auth, -2, -1, -1)[1] + + templates = {} + for template in _get_xml(template_pool): + templates[template.find("NAME").text] = _xml_to_dict(template) + + return templates + + +def list_vns(call=None): + """ + Lists all virtual networks available to the user and the user's groups. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_vns opennebula + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_vns function must be called with -f or --function." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vn_pool = server.one.vnpool.info(auth, -2, -1, -1)[1] + + vns = {} + for v_network in _get_xml(vn_pool): + vns[v_network.find("NAME").text] = _xml_to_dict(v_network) + + return vns + + +def reboot(name, call=None): + """ + Reboot a VM. + + .. versionadded:: 2016.3.0 + + name + The name of the VM to reboot. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot my-vm + """ + if call != "action": + raise SaltCloudSystemExit( + "The start action must be called with -a or --action." + ) + + log.info("Rebooting node %s", name) + + return vm_action(name, kwargs={"action": "reboot"}, call=call) + + +def start(name, call=None): + """ + Start a VM. + + .. versionadded:: 2016.3.0 + + name + The name of the VM to start. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start my-vm + """ + if call != "action": + raise SaltCloudSystemExit( + "The start action must be called with -a or --action." + ) + + log.info("Starting node %s", name) + + return vm_action(name, kwargs={"action": "resume"}, call=call) + + +def stop(name, call=None): + """ + Stop a VM. + + .. versionadded:: 2016.3.0 + + name + The name of the VM to stop. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop my-vm + """ + if call != "action": + raise SaltCloudSystemExit( + "The start action must be called with -a or --action." + ) + + log.info("Stopping node %s", name) + + return vm_action(name, kwargs={"action": "stop"}, call=call) + + +def get_one_version(kwargs=None, call=None): + """ + Returns the OpenNebula version. + + .. versionadded:: 2016.3.5 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_one_version one_provider_name + """ + + if call == "action": + raise SaltCloudSystemExit( + "The get_cluster_id function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + + return server.one.system.version(auth)[1] + + +def get_cluster_id(kwargs=None, call=None): + """ + Returns a cluster's ID from the given cluster name. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_cluster_id opennebula name=my-cluster-name + """ + if call == "action": + raise SaltCloudSystemExit( + "The get_cluster_id function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + if name is None: + raise SaltCloudSystemExit("The get_cluster_id function requires a name.") + + try: + ret = list_clusters()[name]["id"] + except KeyError: + raise SaltCloudSystemExit(f"The cluster '{name}' could not be found") + + return ret + + +def get_datastore_id(kwargs=None, call=None): + """ + Returns a data store's ID from the given data store name. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_datastore_id opennebula name=my-datastore-name + """ + if call == "action": + raise SaltCloudSystemExit( + "The get_datastore_id function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + if name is None: + raise SaltCloudSystemExit("The get_datastore_id function requires a name.") + + try: + ret = list_datastores()[name]["id"] + except KeyError: + raise SaltCloudSystemExit(f"The datastore '{name}' could not be found.") + + return ret + + +def get_host_id(kwargs=None, call=None): + """ + Returns a host's ID from the given host name. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_host_id opennebula name=my-host-name + """ + if call == "action": + raise SaltCloudSystemExit( + "The get_host_id function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + if name is None: + raise SaltCloudSystemExit("The get_host_id function requires a name.") + + try: + ret = avail_locations()[name]["id"] + except KeyError: + raise SaltCloudSystemExit(f"The host '{name}' could not be found") + + return ret + + +def get_image(vm_): + r""" + Return the image object to use. + + vm\_ + The VM dictionary for which to obtain an image. + """ + images = avail_images() + vm_image = str( + config.get_cloud_config_value("image", vm_, __opts__, search_global=False) + ) + for image in images: + if vm_image in (images[image]["name"], images[image]["id"]): + return images[image]["id"] + raise SaltCloudNotFound(f"The specified image, '{vm_image}', could not be found.") + + +def get_image_id(kwargs=None, call=None): + """ + Returns an image's ID from the given image name. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_image_id opennebula name=my-image-name + """ + if call == "action": + raise SaltCloudSystemExit( + "The get_image_id function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + if name is None: + raise SaltCloudSystemExit("The get_image_id function requires a name.") + + try: + ret = avail_images()[name]["id"] + except KeyError: + raise SaltCloudSystemExit(f"The image '{name}' could not be found") + + return ret + + +def get_location(vm_): + r""" + Return the VM's location. + + vm\_ + The VM dictionary for which to obtain a location. + """ + locations = avail_locations() + vm_location = str( + config.get_cloud_config_value("location", vm_, __opts__, search_global=False) + ) + + if vm_location == "None": + return None + + for location in locations: + if vm_location in (locations[location]["name"], locations[location]["id"]): + return locations[location]["id"] + raise SaltCloudNotFound( + f"The specified location, '{vm_location}', could not be found." + ) + + +def get_secgroup_id(kwargs=None, call=None): + """ + Returns a security group's ID from the given security group name. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_secgroup_id opennebula name=my-secgroup-name + """ + if call == "action": + raise SaltCloudSystemExit( + "The get_secgroup_id function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + if name is None: + raise SaltCloudSystemExit("The get_secgroup_id function requires a 'name'.") + + try: + ret = list_security_groups()[name]["id"] + except KeyError: + raise SaltCloudSystemExit(f"The security group '{name}' could not be found.") + + return ret + + +def get_template_image(kwargs=None, call=None): + """ + Returns a template's image from the given template name. + + .. versionadded:: 2018.3.0 + + .. code-block:: bash + + salt-cloud -f get_template_image opennebula name=my-template-name + """ + if call == "action": + raise SaltCloudSystemExit( + "The get_template_image function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + if name is None: + raise SaltCloudSystemExit("The get_template_image function requires a 'name'.") + + try: + ret = list_templates()[name]["template"]["disk"]["image"] + except KeyError: + raise SaltCloudSystemExit( + f"The image for template '{name}' could not be found." + ) + + return ret + + +def get_template_id(kwargs=None, call=None): + """ + Returns a template's ID from the given template name. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_template_id opennebula name=my-template-name + """ + if call == "action": + raise SaltCloudSystemExit( + "The get_template_id function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + if name is None: + raise SaltCloudSystemExit("The get_template_id function requires a 'name'.") + + try: + ret = list_templates()[name]["id"] + except KeyError: + raise SaltCloudSystemExit(f"The template '{name}' could not be found.") + + return ret + + +def get_template(vm_): + r""" + Return the template id for a VM. + + .. versionadded:: 2016.11.0 + + vm\_ + The VM dictionary for which to obtain a template. + """ + + vm_template = str( + config.get_cloud_config_value("template", vm_, __opts__, search_global=False) + ) + try: + return list_templates()[vm_template]["id"] + except KeyError: + raise SaltCloudNotFound( + f"The specified template, '{vm_template}', could not be found." + ) + + +def get_vm_id(kwargs=None, call=None): + """ + Returns a virtual machine's ID from the given virtual machine's name. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_vm_id opennebula name=my-vm + """ + if call == "action": + raise SaltCloudSystemExit( + "The get_vm_id function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + if name is None: + raise SaltCloudSystemExit("The get_vm_id function requires a name.") + + try: + ret = list_nodes()[name]["id"] + except KeyError: + raise SaltCloudSystemExit(f"The VM '{name}' could not be found.") + + return ret + + +def get_vn_id(kwargs=None, call=None): + """ + Returns a virtual network's ID from the given virtual network's name. + + .. versionadded:: 2016.3.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_vn_id opennebula name=my-vn-name + """ + if call == "action": + raise SaltCloudSystemExit( + "The get_vn_id function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + if name is None: + raise SaltCloudSystemExit("The get_vn_id function requires a name.") + + try: + ret = list_vns()[name]["id"] + except KeyError: + raise SaltCloudSystemExit(f"The VN '{name}' could not be found.") + + return ret + + +def _get_device_template(disk, disk_info, template=None): + """ + Returns the template format to create a disk in open nebula + + .. versionadded:: 2018.3.0 + + """ + + def _require_disk_opts(*args): + for arg in args: + if arg not in disk_info: + raise SaltCloudSystemExit(f"The disk {disk} requires a {arg} argument") + + _require_disk_opts("disk_type", "size") + + size = disk_info["size"] + disk_type = disk_info["disk_type"] + + if disk_type == "clone": + if "image" in disk_info: + clone_image = disk_info["image"] + else: + clone_image = get_template_image(kwargs={"name": template}) + + clone_image_id = get_image_id(kwargs={"name": clone_image}) + temp = "DISK=[IMAGE={}, IMAGE_ID={}, CLONE=YES, SIZE={}]".format( + clone_image, clone_image_id, size + ) + return temp + + if disk_type == "volatile": + _require_disk_opts("type") + v_type = disk_info["type"] + temp = f"DISK=[TYPE={v_type}, SIZE={size}]" + + if v_type == "fs": + _require_disk_opts("format") + format = disk_info["format"] + temp = f"DISK=[TYPE={v_type}, SIZE={size}, FORMAT={format}]" + return temp + # TODO add persistant disk_type + + +def create(vm_): + r""" + Create a single VM from a data dict. + + vm\_ + The dictionary use to create a VM. + + Optional vm\_ dict options for overwriting template: + + region_id + Optional - OpenNebula Zone ID + + memory + Optional - In MB + + cpu + Optional - Percent of host CPU to allocate + + vcpu + Optional - Amount of vCPUs to allocate + + CLI Example: + + .. code-block:: bash + + salt-cloud -p my-opennebula-profile vm_name + + salt-cloud -p my-opennebula-profile vm_name memory=16384 cpu=2.5 vcpu=16 + + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, _get_active_provider_name() or "opennebula", vm_["profile"] + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", vm_["name"]) + kwargs = { + "name": vm_["name"], + "template_id": get_template(vm_), + "region_id": get_location(vm_), + } + if "template" in vm_: + kwargs["image_id"] = get_template_id({"name": vm_["template"]}) + + private_networking = config.get_cloud_config_value( + "private_networking", vm_, __opts__, search_global=False, default=None + ) + kwargs["private_networking"] = "true" if private_networking else "false" + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", kwargs, list(kwargs) + ), + }, + sock_dir=__opts__["sock_dir"], + ) + + template = [] + if kwargs.get("region_id"): + template.append('SCHED_REQUIREMENTS="ID={}"'.format(kwargs.get("region_id"))) + if vm_.get("memory"): + template.append("MEMORY={}".format(vm_.get("memory"))) + if vm_.get("cpu"): + template.append("CPU={}".format(vm_.get("cpu"))) + if vm_.get("vcpu"): + template.append("VCPU={}".format(vm_.get("vcpu"))) + if vm_.get("disk"): + get_disks = vm_.get("disk") + template_name = vm_["image"] + for disk in get_disks: + template.append( + _get_device_template(disk, get_disks[disk], template=template_name) + ) + if "CLONE" not in str(template): + raise SaltCloudSystemExit( + "Missing an image disk to clone. Must define a clone disk alongside all" + " other disk definitions." + ) + + template_args = "\n".join(template) + + try: + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + cret = server.one.template.instantiate( + auth, int(kwargs["template_id"]), kwargs["name"], False, template_args + ) + if not cret[0]: + log.error( + "Error creating %s on OpenNebula\n\n" + "The following error was returned when trying to " + "instantiate the template: %s", + vm_["name"], + cret[1], + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on OpenNebula\n\n" + "The following exception was thrown when trying to " + "run the initial deployment: %s", + vm_["name"], + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + fqdn = vm_.get("fqdn_base") + if fqdn is not None: + fqdn = "{}.{}".format(vm_["name"], fqdn) + + def __query_node_data(vm_name): + node_data = show_instance(vm_name, call="action") + if not node_data: + # Trigger an error in the wait_for_ip function + return False + if node_data["state"] == "7": + return False + if node_data["lcm_state"] == "3": + return node_data + + try: + data = __utils__["cloud.wait_for_ip"]( + __query_node_data, + update_args=(vm_["name"],), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=2 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + key_filename = config.get_cloud_config_value( + "private_key", vm_, __opts__, search_global=False, default=None + ) + if key_filename is not None and not os.path.isfile(key_filename): + raise SaltCloudConfigError( + f"The defined key_filename '{key_filename}' does not exist" + ) + + if fqdn: + vm_["ssh_host"] = fqdn + private_ip = "0.0.0.0" + else: + try: + private_ip = data["private_ips"][0] + except KeyError: + try: + private_ip = data["template"]["nic"]["ip"] + except KeyError: + # if IPv6 is used try this as last resort + # OpenNebula does not yet show ULA address here so take global + private_ip = data["template"]["nic"]["ip6_global"] + + vm_["ssh_host"] = private_ip + + ssh_username = config.get_cloud_config_value( + "ssh_username", vm_, __opts__, default="root" + ) + + vm_["username"] = ssh_username + vm_["key_filename"] = key_filename + + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + ret["id"] = data["id"] + ret["image"] = vm_["image"] + ret["name"] = vm_["name"] + ret["size"] = data["template"]["memory"] + ret["state"] = data["state"] + ret["private_ips"] = private_ip + ret["public_ips"] = [] + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + ) + + return ret + + +def destroy(name, call=None): + """ + Destroy a node. Will check termination protection and warn if enabled. + + name + The name of the vm to be destroyed. + + CLI Example: + + .. code-block:: bash + + salt-cloud --destroy vm_name + salt-cloud -d vm_name + salt-cloud --action destroy vm_name + salt-cloud -a destroy vm_name + + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + + data = show_instance(name, call="action") + node = server.one.vm.action(auth, "delete", int(data["id"])) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + data = { + "action": "vm.delete", + "deleted": node[0], + "node_id": node[1], + "error_code": node[2], + } + + return data + + +def image_allocate(call=None, kwargs=None): + """ + Allocates a new image in OpenNebula. + + .. versionadded:: 2016.3.0 + + path + The path to a file containing the template of the image to allocate. + Syntax within the file can be the usual attribute=value or XML. Can be + used instead of ``data``. + + data + The data containing the template of the image to allocate. Syntax can be the + usual attribute=value or XML. Can be used instead of ``path``. + + datastore_id + The ID of the data-store to be used for the new image. Can be used instead + of ``datastore_name``. + + datastore_name + The name of the data-store to be used for the new image. Can be used instead of + ``datastore_id``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f image_allocate opennebula path=/path/to/image_file.txt datastore_id=1 + salt-cloud -f image_allocate opennebula datastore_name=default \\ + data='NAME="Ubuntu 14.04" PATH="/home/one_user/images/ubuntu_desktop.img" \\ + DESCRIPTION="Ubuntu 14.04 for development."' + """ + if call != "function": + raise SaltCloudSystemExit( + "The image_allocate function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + path = kwargs.get("path", None) + data = kwargs.get("data", None) + datastore_id = kwargs.get("datastore_id", None) + datastore_name = kwargs.get("datastore_name", None) + + if datastore_id: + if datastore_name: + log.warning( + "Both a 'datastore_id' and a 'datastore_name' were provided. " + "'datastore_id' will take precedence." + ) + elif datastore_name: + datastore_id = get_datastore_id(kwargs={"name": datastore_name}) + else: + raise SaltCloudSystemExit( + "The image_allocate function requires either a 'datastore_id' or a " + "'datastore_name' to be provided." + ) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The image_allocate function requires either a file 'path' or 'data' " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.image.allocate(auth, data, int(datastore_id)) + + ret = { + "action": "image.allocate", + "allocated": response[0], + "image_id": response[1], + "error_code": response[2], + } + + return ret + + +def image_clone(call=None, kwargs=None): + """ + Clones an existing image. + + .. versionadded:: 2016.3.0 + + name + The name of the new image. + + image_id + The ID of the image to be cloned. Can be used instead of ``image_name``. + + image_name + The name of the image to be cloned. Can be used instead of ``image_id``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f image_clone opennebula name=my-new-image image_id=10 + salt-cloud -f image_clone opennebula name=my-new-image image_name=my-image-to-clone + """ + if call != "function": + raise SaltCloudSystemExit( + "The image_clone function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + image_id = kwargs.get("image_id", None) + image_name = kwargs.get("image_name", None) + + if name is None: + raise SaltCloudSystemExit( + "The image_clone function requires a 'name' to be provided." + ) + + if image_id: + if image_name: + log.warning( + "Both the 'image_id' and 'image_name' arguments were provided. " + "'image_id' will take precedence." + ) + elif image_name: + image_id = get_image_id(kwargs={"name": image_name}) + else: + raise SaltCloudSystemExit( + "The image_clone function requires either an 'image_id' or an " + "'image_name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.image.clone(auth, int(image_id), name) + + data = { + "action": "image.clone", + "cloned": response[0], + "cloned_image_id": response[1], + "cloned_image_name": name, + "error_code": response[2], + } + + return data + + +def image_delete(call=None, kwargs=None): + """ + Deletes the given image from OpenNebula. Either a name or an image_id must + be supplied. + + .. versionadded:: 2016.3.0 + + name + The name of the image to delete. Can be used instead of ``image_id``. + + image_id + The ID of the image to delete. Can be used instead of ``name``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f image_delete opennebula name=my-image + salt-cloud --function image_delete opennebula image_id=100 + """ + if call != "function": + raise SaltCloudSystemExit( + "The image_delete function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + image_id = kwargs.get("image_id", None) + + if image_id: + if name: + log.warning( + "Both the 'image_id' and 'name' arguments were provided. " + "'image_id' will take precedence." + ) + elif name: + image_id = get_image_id(kwargs={"name": name}) + else: + raise SaltCloudSystemExit( + "The image_delete function requires either an 'image_id' or a " + "'name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.image.delete(auth, int(image_id)) + + data = { + "action": "image.delete", + "deleted": response[0], + "image_id": response[1], + "error_code": response[2], + } + + return data + + +def image_info(call=None, kwargs=None): + """ + Retrieves information for a given image. Either a name or an image_id must be + supplied. + + .. versionadded:: 2016.3.0 + + name + The name of the image for which to gather information. Can be used instead + of ``image_id``. + + image_id + The ID of the image for which to gather information. Can be used instead of + ``name``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f image_info opennebula name=my-image + salt-cloud --function image_info opennebula image_id=5 + """ + if call != "function": + raise SaltCloudSystemExit( + "The image_info function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + image_id = kwargs.get("image_id", None) + + if image_id: + if name: + log.warning( + "Both the 'image_id' and 'name' arguments were provided. " + "'image_id' will take precedence." + ) + elif name: + image_id = get_image_id(kwargs={"name": name}) + else: + raise SaltCloudSystemExit( + "The image_info function requires either a 'name or an 'image_id' " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + + info = {} + response = server.one.image.info(auth, int(image_id))[1] + tree = _get_xml(response) + info[tree.find("NAME").text] = _xml_to_dict(tree) + + return info + + +def image_persistent(call=None, kwargs=None): + """ + Sets the Image as persistent or not persistent. + + .. versionadded:: 2016.3.0 + + name + The name of the image to set. Can be used instead of ``image_id``. + + image_id + The ID of the image to set. Can be used instead of ``name``. + + persist + A boolean value to set the image as persistent or not. Set to true + for persistent, false for non-persistent. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f image_persistent opennebula name=my-image persist=True + salt-cloud --function image_persistent opennebula image_id=5 persist=False + """ + if call != "function": + raise SaltCloudSystemExit( + "The image_persistent function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + persist = kwargs.get("persist", None) + image_id = kwargs.get("image_id", None) + + if persist is None: + raise SaltCloudSystemExit( + "The image_persistent function requires 'persist' to be set to 'True' " + "or 'False'." + ) + + if image_id: + if name: + log.warning( + "Both the 'image_id' and 'name' arguments were provided. " + "'image_id' will take precedence." + ) + elif name: + image_id = get_image_id(kwargs={"name": name}) + else: + raise SaltCloudSystemExit( + "The image_persistent function requires either a 'name' or an " + "'image_id' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.image.persistent( + auth, int(image_id), salt.utils.data.is_true(persist) + ) + + data = { + "action": "image.persistent", + "response": response[0], + "image_id": response[1], + "error_code": response[2], + } + + return data + + +def image_snapshot_delete(call=None, kwargs=None): + """ + Deletes a snapshot from the image. + + .. versionadded:: 2016.3.0 + + image_id + The ID of the image from which to delete the snapshot. Can be used instead of + ``image_name``. + + image_name + The name of the image from which to delete the snapshot. Can be used instead + of ``image_id``. + + snapshot_id + The ID of the snapshot to delete. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f image_snapshot_delete vm_id=106 snapshot_id=45 + salt-cloud -f image_snapshot_delete vm_name=my-vm snapshot_id=111 + """ + if call != "function": + raise SaltCloudSystemExit( + "The image_snapshot_delete function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + image_id = kwargs.get("image_id", None) + image_name = kwargs.get("image_name", None) + snapshot_id = kwargs.get("snapshot_id", None) + + if snapshot_id is None: + raise SaltCloudSystemExit( + "The image_snapshot_delete function requires a 'snapshot_id' to be" + " provided." + ) + + if image_id: + if image_name: + log.warning( + "Both the 'image_id' and 'image_name' arguments were provided. " + "'image_id' will take precedence." + ) + elif image_name: + image_id = get_image_id(kwargs={"name": image_name}) + else: + raise SaltCloudSystemExit( + "The image_snapshot_delete function requires either an 'image_id' " + "or a 'image_name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.image.snapshotdelete(auth, int(image_id), int(snapshot_id)) + + data = { + "action": "image.snapshotdelete", + "deleted": response[0], + "snapshot_id": response[1], + "error_code": response[2], + } + + return data + + +def image_snapshot_revert(call=None, kwargs=None): + """ + Reverts an image state to a previous snapshot. + + .. versionadded:: 2016.3.0 + + image_id + The ID of the image to revert. Can be used instead of ``image_name``. + + image_name + The name of the image to revert. Can be used instead of ``image_id``. + + snapshot_id + The ID of the snapshot to which the image will be reverted. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f image_snapshot_revert vm_id=106 snapshot_id=45 + salt-cloud -f image_snapshot_revert vm_name=my-vm snapshot_id=120 + """ + if call != "function": + raise SaltCloudSystemExit( + "The image_snapshot_revert function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + image_id = kwargs.get("image_id", None) + image_name = kwargs.get("image_name", None) + snapshot_id = kwargs.get("snapshot_id", None) + + if snapshot_id is None: + raise SaltCloudSystemExit( + "The image_snapshot_revert function requires a 'snapshot_id' to be" + " provided." + ) + + if image_id: + if image_name: + log.warning( + "Both the 'image_id' and 'image_name' arguments were provided. " + "'image_id' will take precedence." + ) + elif image_name: + image_id = get_image_id(kwargs={"name": image_name}) + else: + raise SaltCloudSystemExit( + "The image_snapshot_revert function requires either an 'image_id' or " + "an 'image_name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.image.snapshotrevert(auth, int(image_id), int(snapshot_id)) + + data = { + "action": "image.snapshotrevert", + "reverted": response[0], + "snapshot_id": response[1], + "error_code": response[2], + } + + return data + + +def image_snapshot_flatten(call=None, kwargs=None): + """ + Flattens the snapshot of an image and discards others. + + .. versionadded:: 2016.3.0 + + image_id + The ID of the image. Can be used instead of ``image_name``. + + image_name + The name of the image. Can be used instead of ``image_id``. + + snapshot_id + The ID of the snapshot to flatten. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f image_snapshot_flatten vm_id=106 snapshot_id=45 + salt-cloud -f image_snapshot_flatten vm_name=my-vm snapshot_id=45 + """ + if call != "function": + raise SaltCloudSystemExit( + "The image_snapshot_flatten function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + image_id = kwargs.get("image_id", None) + image_name = kwargs.get("image_name", None) + snapshot_id = kwargs.get("snapshot_id", None) + + if snapshot_id is None: + raise SaltCloudSystemExit( + "The image_stanpshot_flatten function requires a 'snapshot_id' " + "to be provided." + ) + + if image_id: + if image_name: + log.warning( + "Both the 'image_id' and 'image_name' arguments were provided. " + "'image_id' will take precedence." + ) + elif image_name: + image_id = get_image_id(kwargs={"name": image_name}) + else: + raise SaltCloudSystemExit( + "The image_snapshot_flatten function requires either an " + "'image_id' or an 'image_name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.image.snapshotflatten(auth, int(image_id), int(snapshot_id)) + + data = { + "action": "image.snapshotflatten", + "flattened": response[0], + "snapshot_id": response[1], + "error_code": response[2], + } + + return data + + +def image_update(call=None, kwargs=None): + """ + Replaces the image template contents. + + .. versionadded:: 2016.3.0 + + image_id + The ID of the image to update. Can be used instead of ``image_name``. + + image_name + The name of the image to update. Can be used instead of ``image_id``. + + path + The path to a file containing the template of the image. Syntax within the + file can be the usual attribute=value or XML. Can be used instead of ``data``. + + data + Contains the template of the image. Syntax can be the usual attribute=value + or XML. Can be used instead of ``path``. + + update_type + There are two ways to update an image: ``replace`` the whole template + or ``merge`` the new template with the existing one. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f image_update opennebula image_id=0 file=/path/to/image_update_file.txt update_type=replace + salt-cloud -f image_update opennebula image_name="Ubuntu 14.04" update_type=merge \\ + data='NAME="Ubuntu Dev" PATH="/home/one_user/images/ubuntu_desktop.img" \\ + DESCRIPTION = "Ubuntu 14.04 for development."' + """ + if call != "function": + raise SaltCloudSystemExit( + "The image_allocate function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + image_id = kwargs.get("image_id", None) + image_name = kwargs.get("image_name", None) + path = kwargs.get("path", None) + data = kwargs.get("data", None) + update_type = kwargs.get("update_type", None) + update_args = ["replace", "merge"] + + if update_type is None: + raise SaltCloudSystemExit( + "The image_update function requires an 'update_type' to be provided." + ) + + if update_type == update_args[0]: + update_number = 0 + elif update_type == update_args[1]: + update_number = 1 + else: + raise SaltCloudSystemExit( + "The update_type argument must be either {} or {}.".format( + update_args[0], update_args[1] + ) + ) + + if image_id: + if image_name: + log.warning( + "Both the 'image_id' and 'image_name' arguments were provided. " + "'image_id' will take precedence." + ) + elif image_name: + image_id = get_image_id(kwargs={"name": image_name}) + else: + raise SaltCloudSystemExit( + "The image_update function requires either an 'image_id' or an " + "'image_name' to be provided." + ) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The image_update function requires either 'data' or a file 'path' " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.image.update(auth, int(image_id), data, int(update_number)) + + ret = { + "action": "image.update", + "updated": response[0], + "image_id": response[1], + "error_code": response[2], + } + + return ret + + +def show_instance(name, call=None): + """ + Show the details from OpenNebula concerning a named VM. + + name + The name of the VM for which to display details. + + call + Type of call to use with this function such as ``function``. + + CLI Example: + + .. code-block:: bash + + salt-cloud --action show_instance vm_name + salt-cloud -a show_instance vm_name + + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + node = _get_node(name) + __utils__["cloud.cache_node"](node, _get_active_provider_name(), __opts__) + + return node + + +def secgroup_allocate(call=None, kwargs=None): + """ + Allocates a new security group in OpenNebula. + + .. versionadded:: 2016.3.0 + + path + The path to a file containing the template of the security group. Syntax + within the file can be the usual attribute=value or XML. Can be used + instead of ``data``. + + data + The template data of the security group. Syntax can be the usual + attribute=value or XML. Can be used instead of ``path``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f secgroup_allocate opennebula path=/path/to/secgroup_file.txt + salt-cloud -f secgroup_allocate opennebula \\ + data="NAME = test RULE = [PROTOCOL = TCP, RULE_TYPE = inbound, \\ + RANGE = 1000:2000]" + """ + if call != "function": + raise SaltCloudSystemExit( + "The secgroup_allocate function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + path = kwargs.get("path", None) + data = kwargs.get("data", None) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The secgroup_allocate function requires either 'data' or a file " + "'path' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.secgroup.allocate(auth, data) + + ret = { + "action": "secgroup.allocate", + "allocated": response[0], + "secgroup_id": response[1], + "error_code": response[2], + } + + return ret + + +def secgroup_clone(call=None, kwargs=None): + """ + Clones an existing security group. + + .. versionadded:: 2016.3.0 + + name + The name of the new template. + + secgroup_id + The ID of the security group to be cloned. Can be used instead of + ``secgroup_name``. + + secgroup_name + The name of the security group to be cloned. Can be used instead of + ``secgroup_id``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f secgroup_clone opennebula name=my-cloned-secgroup secgroup_id=0 + salt-cloud -f secgroup_clone opennebula name=my-cloned-secgroup secgroup_name=my-secgroup + """ + if call != "function": + raise SaltCloudSystemExit( + "The secgroup_clone function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + secgroup_id = kwargs.get("secgroup_id", None) + secgroup_name = kwargs.get("secgroup_name", None) + + if name is None: + raise SaltCloudSystemExit( + "The secgroup_clone function requires a 'name' to be provided." + ) + + if secgroup_id: + if secgroup_name: + log.warning( + "Both the 'secgroup_id' and 'secgroup_name' arguments were provided. " + "'secgroup_id' will take precedence." + ) + elif secgroup_name: + secgroup_id = get_secgroup_id(kwargs={"name": secgroup_name}) + else: + raise SaltCloudSystemExit( + "The secgroup_clone function requires either a 'secgroup_id' or a " + "'secgroup_name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.secgroup.clone(auth, int(secgroup_id), name) + + data = { + "action": "secgroup.clone", + "cloned": response[0], + "cloned_secgroup_id": response[1], + "cloned_secgroup_name": name, + "error_code": response[2], + } + + return data + + +def secgroup_delete(call=None, kwargs=None): + """ + Deletes the given security group from OpenNebula. Either a name or a secgroup_id + must be supplied. + + .. versionadded:: 2016.3.0 + + name + The name of the security group to delete. Can be used instead of + ``secgroup_id``. + + secgroup_id + The ID of the security group to delete. Can be used instead of ``name``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f secgroup_delete opennebula name=my-secgroup + salt-cloud --function secgroup_delete opennebula secgroup_id=100 + """ + if call != "function": + raise SaltCloudSystemExit( + "The secgroup_delete function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + secgroup_id = kwargs.get("secgroup_id", None) + + if secgroup_id: + if name: + log.warning( + "Both the 'secgroup_id' and 'name' arguments were provided. " + "'secgroup_id' will take precedence." + ) + elif name: + secgroup_id = get_secgroup_id(kwargs={"name": name}) + else: + raise SaltCloudSystemExit( + "The secgroup_delete function requires either a 'name' or a " + "'secgroup_id' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.secgroup.delete(auth, int(secgroup_id)) + + data = { + "action": "secgroup.delete", + "deleted": response[0], + "secgroup_id": response[1], + "error_code": response[2], + } + + return data + + +def secgroup_info(call=None, kwargs=None): + """ + Retrieves information for the given security group. Either a name or a + secgroup_id must be supplied. + + .. versionadded:: 2016.3.0 + + name + The name of the security group for which to gather information. Can be + used instead of ``secgroup_id``. + + secgroup_id + The ID of the security group for which to gather information. Can be + used instead of ``name``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f secgroup_info opennebula name=my-secgroup + salt-cloud --function secgroup_info opennebula secgroup_id=5 + """ + if call != "function": + raise SaltCloudSystemExit( + "The secgroup_info function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + secgroup_id = kwargs.get("secgroup_id", None) + + if secgroup_id: + if name: + log.warning( + "Both the 'secgroup_id' and 'name' arguments were provided. " + "'secgroup_id' will take precedence." + ) + elif name: + secgroup_id = get_secgroup_id(kwargs={"name": name}) + else: + raise SaltCloudSystemExit( + "The secgroup_info function requires either a name or a secgroup_id " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + + info = {} + response = server.one.secgroup.info(auth, int(secgroup_id))[1] + tree = _get_xml(response) + info[tree.find("NAME").text] = _xml_to_dict(tree) + + return info + + +def secgroup_update(call=None, kwargs=None): + """ + Replaces the security group template contents. + + .. versionadded:: 2016.3.0 + + secgroup_id + The ID of the security group to update. Can be used instead of + ``secgroup_name``. + + secgroup_name + The name of the security group to update. Can be used instead of + ``secgroup_id``. + + path + The path to a file containing the template of the security group. Syntax + within the file can be the usual attribute=value or XML. Can be used instead + of ``data``. + + data + The template data of the security group. Syntax can be the usual attribute=value + or XML. Can be used instead of ``path``. + + update_type + There are two ways to update a security group: ``replace`` the whole template + or ``merge`` the new template with the existing one. + + CLI Example: + + .. code-block:: bash + + salt-cloud --function secgroup_update opennebula secgroup_id=100 \\ + path=/path/to/secgroup_update_file.txt \\ + update_type=replace + salt-cloud -f secgroup_update opennebula secgroup_name=my-secgroup update_type=merge \\ + data="Name = test RULE = [PROTOCOL = TCP, RULE_TYPE = inbound, RANGE = 1000:2000]" + """ + if call != "function": + raise SaltCloudSystemExit( + "The secgroup_allocate function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + secgroup_id = kwargs.get("secgroup_id", None) + secgroup_name = kwargs.get("secgroup_name", None) + path = kwargs.get("path", None) + data = kwargs.get("data", None) + update_type = kwargs.get("update_type", None) + update_args = ["replace", "merge"] + + if update_type is None: + raise SaltCloudSystemExit( + "The secgroup_update function requires an 'update_type' to be provided." + ) + + if update_type == update_args[0]: + update_number = 0 + elif update_type == update_args[1]: + update_number = 1 + else: + raise SaltCloudSystemExit( + "The update_type argument must be either {} or {}.".format( + update_args[0], update_args[1] + ) + ) + + if secgroup_id: + if secgroup_name: + log.warning( + "Both the 'secgroup_id' and 'secgroup_name' arguments were provided. " + "'secgroup_id' will take precedence." + ) + elif secgroup_name: + secgroup_id = get_secgroup_id(kwargs={"name": secgroup_name}) + else: + raise SaltCloudSystemExit( + "The secgroup_update function requires either a 'secgroup_id' or a " + "'secgroup_name' to be provided." + ) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The secgroup_update function requires either 'data' or a file 'path' " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.secgroup.update( + auth, int(secgroup_id), data, int(update_number) + ) + + ret = { + "action": "secgroup.update", + "updated": response[0], + "secgroup_id": response[1], + "error_code": response[2], + } + + return ret + + +def template_allocate(call=None, kwargs=None): + """ + Allocates a new template in OpenNebula. + + .. versionadded:: 2016.3.0 + + path + The path to a file containing the elements of the template to be allocated. + Syntax within the file can be the usual attribute=value or XML. Can be used + instead of ``data``. + + data + Contains the elements of the template to be allocated. Syntax can be the usual + attribute=value or XML. Can be used instead of ``path``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f template_allocate opennebula path=/path/to/template_file.txt + salt-cloud -f template_allocate opennebula \\ + data='CPU="1.0" DISK=[IMAGE="Ubuntu-14.04"] GRAPHICS=[LISTEN="0.0.0.0",TYPE="vnc"] \\ + MEMORY="1024" NETWORK="yes" NIC=[NETWORK="192net",NETWORK_UNAME="oneadmin"] \\ + OS=[ARCH="x86_64"] SUNSTONE_CAPACITY_SELECT="YES" SUNSTONE_NETWORK_SELECT="YES" \\ + VCPU="1"' + """ + if call != "function": + raise SaltCloudSystemExit( + "The template_allocate function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + path = kwargs.get("path", None) + data = kwargs.get("data", None) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The template_allocate function requires either 'data' or a file " + "'path' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.template.allocate(auth, data) + + ret = { + "action": "template.allocate", + "allocated": response[0], + "template_id": response[1], + "error_code": response[2], + } + + return ret + + +def template_clone(call=None, kwargs=None): + """ + Clones an existing virtual machine template. + + .. versionadded:: 2016.3.0 + + name + The name of the new template. + + template_id + The ID of the template to be cloned. Can be used instead of ``template_name``. + + template_name + The name of the template to be cloned. Can be used instead of ``template_id``. + + clone_images + Optional, defaults to False. Indicates if the images attached to the template should be cloned as well. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f template_clone opennebula name=my-new-template template_id=0 + salt-cloud -f template_clone opennebula name=my-new-template template_name=my-template + """ + if call != "function": + raise SaltCloudSystemExit( + "The template_clone function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + template_id = kwargs.get("template_id", None) + template_name = kwargs.get("template_name", None) + clone_images = kwargs.get("clone_images", False) + + if name is None: + raise SaltCloudSystemExit( + "The template_clone function requires a name to be provided." + ) + + if template_id: + if template_name: + log.warning( + "Both the 'template_id' and 'template_name' arguments were provided. " + "'template_id' will take precedence." + ) + elif template_name: + template_id = get_template_id(kwargs={"name": template_name}) + else: + raise SaltCloudSystemExit( + "The template_clone function requires either a 'template_id' " + "or a 'template_name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + + response = server.one.template.clone(auth, int(template_id), name, clone_images) + + data = { + "action": "template.clone", + "cloned": response[0], + "cloned_template_id": response[1], + "cloned_template_name": name, + "error_code": response[2], + } + + return data + + +def template_delete(call=None, kwargs=None): + """ + Deletes the given template from OpenNebula. Either a name or a template_id must + be supplied. + + .. versionadded:: 2016.3.0 + + name + The name of the template to delete. Can be used instead of ``template_id``. + + template_id + The ID of the template to delete. Can be used instead of ``name``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f template_delete opennebula name=my-template + salt-cloud --function template_delete opennebula template_id=5 + """ + if call != "function": + raise SaltCloudSystemExit( + "The template_delete function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + template_id = kwargs.get("template_id", None) + + if template_id: + if name: + log.warning( + "Both the 'template_id' and 'name' arguments were provided. " + "'template_id' will take precedence." + ) + elif name: + template_id = get_template_id(kwargs={"name": name}) + else: + raise SaltCloudSystemExit( + "The template_delete function requires either a 'name' or a 'template_id' " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.template.delete(auth, int(template_id)) + + data = { + "action": "template.delete", + "deleted": response[0], + "template_id": response[1], + "error_code": response[2], + } + + return data + + +def template_instantiate(call=None, kwargs=None): + """ + Instantiates a new virtual machine from a template. + + .. versionadded:: 2016.3.0 + + .. note:: + ``template_instantiate`` creates a VM on OpenNebula from a template, but it + does not install Salt on the new VM. Use the ``create`` function for that + functionality: ``salt-cloud -p opennebula-profile vm-name``. + + vm_name + Name for the new VM instance. + + template_id + The ID of the template from which the VM will be created. Can be used instead + of ``template_name``. + + template_name + The name of the template from which the VM will be created. Can be used instead + of ``template_id``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f template_instantiate opennebula vm_name=my-new-vm template_id=0 + + """ + if call != "function": + raise SaltCloudSystemExit( + "The template_instantiate function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + vm_name = kwargs.get("vm_name", None) + template_id = kwargs.get("template_id", None) + template_name = kwargs.get("template_name", None) + + if vm_name is None: + raise SaltCloudSystemExit( + "The template_instantiate function requires a 'vm_name' to be provided." + ) + + if template_id: + if template_name: + log.warning( + "Both the 'template_id' and 'template_name' arguments were provided. " + "'template_id' will take precedence." + ) + elif template_name: + template_id = get_template_id(kwargs={"name": template_name}) + else: + raise SaltCloudSystemExit( + "The template_instantiate function requires either a 'template_id' " + "or a 'template_name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.template.instantiate(auth, int(template_id), vm_name) + + data = { + "action": "template.instantiate", + "instantiated": response[0], + "instantiated_vm_id": response[1], + "vm_name": vm_name, + "error_code": response[2], + } + + return data + + +def template_update(call=None, kwargs=None): + """ + Replaces the template contents. + + .. versionadded:: 2016.3.0 + + template_id + The ID of the template to update. Can be used instead of ``template_name``. + + template_name + The name of the template to update. Can be used instead of ``template_id``. + + path + The path to a file containing the elements of the template to be updated. + Syntax within the file can be the usual attribute=value or XML. Can be + used instead of ``data``. + + data + Contains the elements of the template to be updated. Syntax can be the + usual attribute=value or XML. Can be used instead of ``path``. + + update_type + There are two ways to update a template: ``replace`` the whole template + or ``merge`` the new template with the existing one. + + CLI Example: + + .. code-block:: bash + + salt-cloud --function template_update opennebula template_id=1 update_type=replace \\ + path=/path/to/template_update_file.txt + salt-cloud -f template_update opennebula template_name=my-template update_type=merge \\ + data='CPU="1.0" DISK=[IMAGE="Ubuntu-14.04"] GRAPHICS=[LISTEN="0.0.0.0",TYPE="vnc"] \\ + MEMORY="1024" NETWORK="yes" NIC=[NETWORK="192net",NETWORK_UNAME="oneadmin"] \\ + OS=[ARCH="x86_64"] SUNSTONE_CAPACITY_SELECT="YES" SUNSTONE_NETWORK_SELECT="YES" \\ + VCPU="1"' + """ + if call != "function": + raise SaltCloudSystemExit( + "The template_update function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + template_id = kwargs.get("template_id", None) + template_name = kwargs.get("template_name", None) + path = kwargs.get("path", None) + data = kwargs.get("data", None) + update_type = kwargs.get("update_type", None) + update_args = ["replace", "merge"] + + if update_type is None: + raise SaltCloudSystemExit( + "The template_update function requires an 'update_type' to be provided." + ) + + if update_type == update_args[0]: + update_number = 0 + elif update_type == update_args[1]: + update_number = 1 + else: + raise SaltCloudSystemExit( + "The update_type argument must be either {} or {}.".format( + update_args[0], update_args[1] + ) + ) + + if template_id: + if template_name: + log.warning( + "Both the 'template_id' and 'template_name' arguments were provided. " + "'template_id' will take precedence." + ) + elif template_name: + template_id = get_template_id(kwargs={"name": template_name}) + else: + raise SaltCloudSystemExit( + "The template_update function requires either a 'template_id' " + "or a 'template_name' to be provided." + ) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The template_update function requires either 'data' or a file " + "'path' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.template.update( + auth, int(template_id), data, int(update_number) + ) + + ret = { + "action": "template.update", + "updated": response[0], + "template_id": response[1], + "error_code": response[2], + } + + return ret + + +def vm_action(name, kwargs=None, call=None): + """ + Submits an action to be performed on a given virtual machine. + + .. versionadded:: 2016.3.0 + + name + The name of the VM to action. + + action + The action to be performed on the VM. Available options include: + - boot + - delete + - delete-recreate + - hold + - poweroff + - poweroff-hard + - reboot + - reboot-hard + - release + - resched + - resume + - shutdown + - shutdown-hard + - stop + - suspend + - undeploy + - undeploy-hard + - unresched + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_action my-vm action='release' + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_action function must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + action = kwargs.get("action", None) + if action is None: + raise SaltCloudSystemExit( + "The vm_action function must have an 'action' provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.action(auth, action, vm_id) + + data = { + "action": "vm.action." + str(action), + "actioned": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_allocate(call=None, kwargs=None): + """ + Allocates a new virtual machine in OpenNebula. + + .. versionadded:: 2016.3.0 + + path + The path to a file defining the template of the VM to allocate. + Syntax within the file can be the usual attribute=value or XML. + Can be used instead of ``data``. + + data + Contains the template definitions of the VM to allocate. Syntax can + be the usual attribute=value or XML. Can be used instead of ``path``. + + hold + If this parameter is set to ``True``, the VM will be created in + the ``HOLD`` state. If not set, the VM is created in the ``PENDING`` + state. Default is ``False``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f vm_allocate path=/path/to/vm_template.txt + salt-cloud --function vm_allocate path=/path/to/vm_template.txt hold=True + """ + if call != "function": + raise SaltCloudSystemExit( + "The vm_allocate function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + path = kwargs.get("path", None) + data = kwargs.get("data", None) + hold = kwargs.get("hold", False) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The vm_allocate function requires either 'data' or a file 'path' " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.vm.allocate(auth, data, salt.utils.data.is_true(hold)) + + ret = { + "action": "vm.allocate", + "allocated": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return ret + + +def vm_attach(name, kwargs=None, call=None): + """ + Attaches a new disk to the given virtual machine. + + .. versionadded:: 2016.3.0 + + name + The name of the VM for which to attach the new disk. + + path + The path to a file containing a single disk vector attribute. + Syntax within the file can be the usual attribute=value or XML. + Can be used instead of ``data``. + + data + Contains the data needed to attach a single disk vector attribute. + Syntax can be the usual attribute=value or XML. Can be used instead + of ``path``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_attach my-vm path=/path/to/disk_file.txt + salt-cloud -a vm_attach my-vm data="DISK=[DISK_ID=1]" + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_attach action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + path = kwargs.get("path", None) + data = kwargs.get("data", None) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The vm_attach function requires either 'data' or a file " + "'path' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.attach(auth, vm_id, data) + + ret = { + "action": "vm.attach", + "attached": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return ret + + +def vm_attach_nic(name, kwargs=None, call=None): + """ + Attaches a new network interface to the given virtual machine. + + .. versionadded:: 2016.3.0 + + name + The name of the VM for which to attach the new network interface. + + path + The path to a file containing a single NIC vector attribute. + Syntax within the file can be the usual attribute=value or XML. Can + be used instead of ``data``. + + data + Contains the single NIC vector attribute to attach to the VM. + Syntax can be the usual attribute=value or XML. Can be used instead + of ``path``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_attach_nic my-vm path=/path/to/nic_file.txt + salt-cloud -a vm_attach_nic my-vm data="NIC=[NETWORK_ID=1]" + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_attach_nic action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + path = kwargs.get("path", None) + data = kwargs.get("data", None) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The vm_attach_nic function requires either 'data' or a file " + "'path' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.attachnic(auth, vm_id, data) + + ret = { + "action": "vm.attachnic", + "nic_attached": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return ret + + +def vm_deploy(name, kwargs=None, call=None): + """ + Initiates the instance of the given VM on the target host. + + .. versionadded:: 2016.3.0 + + name + The name of the VM to deploy. + + host_id + The ID of the target host where the VM will be deployed. Can be used instead + of ``host_name``. + + host_name + The name of the target host where the VM will be deployed. Can be used instead + of ``host_id``. + + capacity_maintained + True to enforce the Host capacity is not over-committed. This parameter is only + acknowledged for users in the ``oneadmin`` group. Host capacity will be always + enforced for regular users. + + datastore_id + The ID of the target system data-store where the VM will be deployed. Optional + and can be used instead of ``datastore_name``. If neither ``datastore_id`` nor + ``datastore_name`` are set, OpenNebula will choose the data-store. + + datastore_name + The name of the target system data-store where the VM will be deployed. Optional, + and can be used instead of ``datastore_id``. If neither ``datastore_id`` nor + ``datastore_name`` are set, OpenNebula will choose the data-store. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_deploy my-vm host_id=0 + salt-cloud -a vm_deploy my-vm host_id=1 capacity_maintained=False + salt-cloud -a vm_deploy my-vm host_name=host01 datastore_id=1 + salt-cloud -a vm_deploy my-vm host_name=host01 datastore_name=default + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_deploy action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + host_id = kwargs.get("host_id", None) + host_name = kwargs.get("host_name", None) + capacity_maintained = kwargs.get("capacity_maintained", True) + datastore_id = kwargs.get("datastore_id", None) + datastore_name = kwargs.get("datastore_name", None) + + if host_id: + if host_name: + log.warning( + "Both the 'host_id' and 'host_name' arguments were provided. " + "'host_id' will take precedence." + ) + elif host_name: + host_id = get_host_id(kwargs={"name": host_name}) + else: + raise SaltCloudSystemExit( + "The vm_deploy function requires a 'host_id' or a 'host_name' " + "to be provided." + ) + + if datastore_id: + if datastore_name: + log.warning( + "Both the 'datastore_id' and 'datastore_name' arguments were provided. " + "'datastore_id' will take precedence." + ) + elif datastore_name: + datastore_id = get_datastore_id(kwargs={"name": datastore_name}) + else: + datastore_id = "-1" + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = get_vm_id(kwargs={"name": name}) + response = server.one.vm.deploy( + auth, + int(vm_id), + int(host_id), + salt.utils.data.is_true(capacity_maintained), + int(datastore_id), + ) + + data = { + "action": "vm.deploy", + "deployed": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_detach(name, kwargs=None, call=None): + """ + Detaches a disk from a virtual machine. + + .. versionadded:: 2016.3.0 + + name + The name of the VM from which to detach the disk. + + disk_id + The ID of the disk to detach. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_detach my-vm disk_id=1 + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_detach action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + disk_id = kwargs.get("disk_id", None) + if disk_id is None: + raise SaltCloudSystemExit( + "The vm_detach function requires a 'disk_id' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.detach(auth, vm_id, int(disk_id)) + + data = { + "action": "vm.detach", + "detached": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_detach_nic(name, kwargs=None, call=None): + """ + Detaches a disk from a virtual machine. + + .. versionadded:: 2016.3.0 + + name + The name of the VM from which to detach the network interface. + + nic_id + The ID of the nic to detach. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_detach_nic my-vm nic_id=1 + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_detach_nic action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + nic_id = kwargs.get("nic_id", None) + if nic_id is None: + raise SaltCloudSystemExit( + "The vm_detach_nic function requires a 'nic_id' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.detachnic(auth, vm_id, int(nic_id)) + + data = { + "action": "vm.detachnic", + "nic_detached": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_disk_save(name, kwargs=None, call=None): + """ + Sets the disk to be saved in the given image. + + .. versionadded:: 2016.3.0 + + name + The name of the VM containing the disk to save. + + disk_id + The ID of the disk to save. + + image_name + The name of the new image where the disk will be saved. + + image_type + The type for the new image. If not set, then the default ``ONED`` Configuration + will be used. Other valid types include: OS, CDROM, DATABLOCK, KERNEL, RAMDISK, + and CONTEXT. + + snapshot_id + The ID of the snapshot to export. If not set, the current image state will be + used. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_disk_save my-vm disk_id=1 image_name=my-new-image + salt-cloud -a vm_disk_save my-vm disk_id=1 image_name=my-new-image image_type=CONTEXT snapshot_id=10 + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_disk_save action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + disk_id = kwargs.get("disk_id", None) + image_name = kwargs.get("image_name", None) + image_type = kwargs.get("image_type", "") + snapshot_id = int(kwargs.get("snapshot_id", "-1")) + + if disk_id is None or image_name is None: + raise SaltCloudSystemExit( + "The vm_disk_save function requires a 'disk_id' and an 'image_name' " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.disksave( + auth, vm_id, int(disk_id), image_name, image_type, snapshot_id + ) + + data = { + "action": "vm.disksave", + "saved": response[0], + "image_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_disk_snapshot_create(name, kwargs=None, call=None): + """ + Takes a new snapshot of the disk image. + + .. versionadded:: 2016.3.0 + + name + The name of the VM of which to take the snapshot. + + disk_id + The ID of the disk to save. + + description + The description for the snapshot. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_disk_snapshot_create my-vm disk_id=0 description="My Snapshot Description" + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_disk_snapshot_create action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + disk_id = kwargs.get("disk_id", None) + description = kwargs.get("description", None) + + if disk_id is None or description is None: + raise SaltCloudSystemExit( + "The vm_disk_snapshot_create function requires a 'disk_id' and a" + " 'description' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.disksnapshotcreate(auth, vm_id, int(disk_id), description) + + data = { + "action": "vm.disksnapshotcreate", + "created": response[0], + "snapshot_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_disk_snapshot_delete(name, kwargs=None, call=None): + """ + Deletes a disk snapshot based on the given VM and the disk_id. + + .. versionadded:: 2016.3.0 + + name + The name of the VM containing the snapshot to delete. + + disk_id + The ID of the disk to save. + + snapshot_id + The ID of the snapshot to be deleted. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_disk_snapshot_delete my-vm disk_id=0 snapshot_id=6 + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_disk_snapshot_delete action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + disk_id = kwargs.get("disk_id", None) + snapshot_id = kwargs.get("snapshot_id", None) + + if disk_id is None or snapshot_id is None: + raise SaltCloudSystemExit( + "The vm_disk_snapshot_create function requires a 'disk_id' and a" + " 'snapshot_id' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.disksnapshotdelete( + auth, vm_id, int(disk_id), int(snapshot_id) + ) + + data = { + "action": "vm.disksnapshotdelete", + "deleted": response[0], + "snapshot_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_disk_snapshot_revert(name, kwargs=None, call=None): + """ + Reverts a disk state to a previously taken snapshot. + + .. versionadded:: 2016.3.0 + + name + The name of the VM containing the snapshot. + + disk_id + The ID of the disk to revert its state. + + snapshot_id + The ID of the snapshot to which the snapshot should be reverted. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_disk_snapshot_revert my-vm disk_id=0 snapshot_id=6 + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_disk_snapshot_revert action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + disk_id = kwargs.get("disk_id", None) + snapshot_id = kwargs.get("snapshot_id", None) + + if disk_id is None or snapshot_id is None: + raise SaltCloudSystemExit( + "The vm_disk_snapshot_revert function requires a 'disk_id' and a" + " 'snapshot_id' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.disksnapshotrevert( + auth, vm_id, int(disk_id), int(snapshot_id) + ) + + data = { + "action": "vm.disksnapshotrevert", + "deleted": response[0], + "snapshot_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_info(name, call=None): + """ + Retrieves information for a given virtual machine. A VM name must be supplied. + + .. versionadded:: 2016.3.0 + + name + The name of the VM for which to gather information. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_info my-vm + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_info action must be called with -a or --action." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.info(auth, vm_id) + + if response[0] is False: + return response[1] + else: + info = {} + tree = _get_xml(response[1]) + info[tree.find("NAME").text] = _xml_to_dict(tree) + return info + + +def vm_migrate(name, kwargs=None, call=None): + """ + Migrates the specified virtual machine to the specified target host. + + .. versionadded:: 2016.3.0 + + name + The name of the VM to migrate. + + host_id + The ID of the host to which the VM will be migrated. Can be used instead + of ``host_name``. + + host_name + The name of the host to which the VM will be migrated. Can be used instead + of ``host_id``. + + live_migration + If set to ``True``, a live-migration will be performed. Default is ``False``. + + capacity_maintained + True to enforce the Host capacity is not over-committed. This parameter is only + acknowledged for users in the ``oneadmin`` group. Host capacity will be always + enforced for regular users. + + datastore_id + The target system data-store ID where the VM will be migrated. Can be used + instead of ``datastore_name``. + + datastore_name + The name of the data-store target system where the VM will be migrated. Can be + used instead of ``datastore_id``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_migrate my-vm host_id=0 datastore_id=1 + salt-cloud -a vm_migrate my-vm host_id=0 datastore_id=1 live_migration=True + salt-cloud -a vm_migrate my-vm host_name=host01 datastore_name=default + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_migrate action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + host_id = kwargs.get("host_id", None) + host_name = kwargs.get("host_name", None) + live_migration = kwargs.get("live_migration", False) + capacity_maintained = kwargs.get("capacity_maintained", True) + datastore_id = kwargs.get("datastore_id", None) + datastore_name = kwargs.get("datastore_name", None) + + if datastore_id: + if datastore_name: + log.warning( + "Both the 'datastore_id' and 'datastore_name' arguments were provided. " + "'datastore_id' will take precedence." + ) + elif datastore_name: + datastore_id = get_datastore_id(kwargs={"name": datastore_name}) + else: + raise SaltCloudSystemExit( + "The vm_migrate function requires either a 'datastore_id' or a " + "'datastore_name' to be provided." + ) + + if host_id: + if host_name: + log.warning( + "Both the 'host_id' and 'host_name' arguments were provided. " + "'host_id' will take precedence." + ) + elif host_name: + host_id = get_host_id(kwargs={"name": host_name}) + else: + raise SaltCloudSystemExit( + "The vm_migrate function requires either a 'host_id' " + "or a 'host_name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.migrate( + auth, + vm_id, + int(host_id), + salt.utils.data.is_true(live_migration), + salt.utils.data.is_true(capacity_maintained), + int(datastore_id), + ) + + data = { + "action": "vm.migrate", + "migrated": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_monitoring(name, call=None): + """ + Returns the monitoring records for a given virtual machine. A VM name must be + supplied. + + The monitoring information returned is a list of VM elements. Each VM element + contains the complete dictionary of the VM with the updated information returned + by the poll action. + + .. versionadded:: 2016.3.0 + + name + The name of the VM for which to gather monitoring records. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_monitoring my-vm + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_monitoring action must be called with -a or --action." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.monitoring(auth, vm_id) + + if response[0] is False: + log.error( + "There was an error retrieving the specified VM's monitoring information." + ) + return {} + else: + info = {} + for vm_ in _get_xml(response[1]): + info[vm_.find("ID").text] = _xml_to_dict(vm_) + return info + + +def vm_resize(name, kwargs=None, call=None): + """ + Changes the capacity of the virtual machine. + + .. versionadded:: 2016.3.0 + + name + The name of the VM to resize. + + path + The path to a file containing new capacity elements CPU, VCPU, MEMORY. If one + of them is not present, or its value is 0, the VM will not be re-sized. Syntax + within the file can be the usual attribute=value or XML. Can be used instead + of ``data``. + + data + Contains the new capacity elements CPU, VCPU, and MEMORY. If one of them is not + present, or its value is 0, the VM will not be re-sized. Can be used instead of + ``path``. + + capacity_maintained + True to enforce the Host capacity is not over-committed. This parameter is only + acknowledged for users in the ``oneadmin`` group. Host capacity will be always + enforced for regular users. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_resize my-vm path=/path/to/capacity_template.txt + salt-cloud -a vm_resize my-vm path=/path/to/capacity_template.txt capacity_maintained=False + salt-cloud -a vm_resize my-vm data="CPU=1 VCPU=1 MEMORY=1024" + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_resize action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + path = kwargs.get("path", None) + data = kwargs.get("data", None) + capacity_maintained = kwargs.get("capacity_maintained", True) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The vm_resize function requires either 'data' or a file 'path' " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.resize( + auth, vm_id, data, salt.utils.data.is_true(capacity_maintained) + ) + + ret = { + "action": "vm.resize", + "resized": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return ret + + +def vm_snapshot_create(vm_name, kwargs=None, call=None): + """ + Creates a new virtual machine snapshot from the provided VM. + + .. versionadded:: 2016.3.0 + + vm_name + The name of the VM from which to create the snapshot. + + snapshot_name + The name of the snapshot to be created. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_snapshot_create my-vm snapshot_name=my-new-snapshot + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_snapshot_create action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + snapshot_name = kwargs.get("snapshot_name", None) + if snapshot_name is None: + raise SaltCloudSystemExit( + "The vm_snapshot_create function requires a 'snapshot_name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": vm_name})) + response = server.one.vm.snapshotcreate(auth, vm_id, snapshot_name) + + data = { + "action": "vm.snapshotcreate", + "snapshot_created": response[0], + "snapshot_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_snapshot_delete(vm_name, kwargs=None, call=None): + """ + Deletes a virtual machine snapshot from the provided VM. + + .. versionadded:: 2016.3.0 + + vm_name + The name of the VM from which to delete the snapshot. + + snapshot_id + The ID of the snapshot to be deleted. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_snapshot_delete my-vm snapshot_id=8 + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_snapshot_delete action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + snapshot_id = kwargs.get("snapshot_id", None) + if snapshot_id is None: + raise SaltCloudSystemExit( + "The vm_snapshot_delete function requires a 'snapshot_id' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": vm_name})) + response = server.one.vm.snapshotdelete(auth, vm_id, int(snapshot_id)) + + data = { + "action": "vm.snapshotdelete", + "snapshot_deleted": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_snapshot_revert(vm_name, kwargs=None, call=None): + """ + Reverts a virtual machine to a snapshot + + .. versionadded:: 2016.3.0 + + vm_name + The name of the VM to revert. + + snapshot_id + The snapshot ID. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_snapshot_revert my-vm snapshot_id=42 + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_snapshot_revert action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + snapshot_id = kwargs.get("snapshot_id", None) + if snapshot_id is None: + raise SaltCloudSystemExit( + "The vm_snapshot_revert function requires a 'snapshot_id' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": vm_name})) + response = server.one.vm.snapshotrevert(auth, vm_id, int(snapshot_id)) + + data = { + "action": "vm.snapshotrevert", + "snapshot_reverted": response[0], + "vm_id": response[1], + "error_code": response[2], + } + + return data + + +def vm_update(name, kwargs=None, call=None): + """ + Replaces the user template contents. + + .. versionadded:: 2016.3.0 + + name + The name of the VM to update. + + path + The path to a file containing new user template contents. Syntax within the + file can be the usual attribute=value or XML. Can be used instead of ``data``. + + data + Contains the new user template contents. Syntax can be the usual attribute=value + or XML. Can be used instead of ``path``. + + update_type + There are two ways to update a VM: ``replace`` the whole template + or ``merge`` the new template with the existing one. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a vm_update my-vm path=/path/to/user_template_file.txt update_type='replace' + """ + if call != "action": + raise SaltCloudSystemExit( + "The vm_update action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + path = kwargs.get("path", None) + data = kwargs.get("data", None) + update_type = kwargs.get("update_type", None) + update_args = ["replace", "merge"] + + if update_type is None: + raise SaltCloudSystemExit( + "The vm_update function requires an 'update_type' to be provided." + ) + + if update_type == update_args[0]: + update_number = 0 + elif update_type == update_args[1]: + update_number = 1 + else: + raise SaltCloudSystemExit( + "The update_type argument must be either {} or {}.".format( + update_args[0], update_args[1] + ) + ) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The vm_update function requires either 'data' or a file 'path' " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + vm_id = int(get_vm_id(kwargs={"name": name})) + response = server.one.vm.update(auth, vm_id, data, int(update_number)) + + ret = { + "action": "vm.update", + "updated": response[0], + "resource_id": response[1], + "error_code": response[2], + } + + return ret + + +def vn_add_ar(call=None, kwargs=None): + """ + Adds address ranges to a given virtual network. + + .. versionadded:: 2016.3.0 + + vn_id + The ID of the virtual network to add the address range. Can be used + instead of ``vn_name``. + + vn_name + The name of the virtual network to add the address range. Can be used + instead of ``vn_id``. + + path + The path to a file containing the template of the address range to add. + Syntax within the file can be the usual attribute=value or XML. Can be + used instead of ``data``. + + data + Contains the template of the address range to add. Syntax can be the + usual attribute=value or XML. Can be used instead of ``path``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f vn_add_ar opennebula vn_id=3 path=/path/to/address_range.txt + salt-cloud -f vn_add_ar opennebula vn_name=my-vn \\ + data="AR=[TYPE=IP4, IP=192.168.0.5, SIZE=10]" + """ + if call != "function": + raise SaltCloudSystemExit( + "The vn_add_ar function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + vn_id = kwargs.get("vn_id", None) + vn_name = kwargs.get("vn_name", None) + path = kwargs.get("path", None) + data = kwargs.get("data", None) + + if vn_id: + if vn_name: + log.warning( + "Both the 'vn_id' and 'vn_name' arguments were provided. " + "'vn_id' will take precedence." + ) + elif vn_name: + vn_id = get_vn_id(kwargs={"name": vn_name}) + else: + raise SaltCloudSystemExit( + "The vn_add_ar function requires a 'vn_id' and a 'vn_name' to be provided." + ) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The vn_add_ar function requires either 'data' or a file 'path' " + "to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.vn.add_ar(auth, int(vn_id), data) + + ret = { + "action": "vn.add_ar", + "address_range_added": response[0], + "resource_id": response[1], + "error_code": response[2], + } + + return ret + + +def vn_allocate(call=None, kwargs=None): + """ + Allocates a new virtual network in OpenNebula. + + .. versionadded:: 2016.3.0 + + path + The path to a file containing the template of the virtual network to allocate. + Syntax within the file can be the usual attribute=value or XML. Can be used + instead of ``data``. + + data + Contains the template of the virtual network to allocate. Syntax can be the + usual attribute=value or XML. Can be used instead of ``path``. + + cluster_id + The ID of the cluster for which to add the new virtual network. Can be used + instead of ``cluster_name``. If neither ``cluster_id`` nor ``cluster_name`` + are provided, the virtual network won’t be added to any cluster. + + cluster_name + The name of the cluster for which to add the new virtual network. Can be used + instead of ``cluster_id``. If neither ``cluster_name`` nor ``cluster_id`` are + provided, the virtual network won't be added to any cluster. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f vn_allocate opennebula path=/path/to/vn_file.txt + """ + if call != "function": + raise SaltCloudSystemExit( + "The vn_allocate function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + cluster_id = kwargs.get("cluster_id", None) + cluster_name = kwargs.get("cluster_name", None) + path = kwargs.get("path", None) + data = kwargs.get("data", None) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The vn_allocate function requires either 'data' or a file 'path' " + "to be provided." + ) + + if cluster_id: + if cluster_name: + log.warning( + "Both the 'cluster_id' and 'cluster_name' arguments were provided. " + "'cluster_id' will take precedence." + ) + elif cluster_name: + cluster_id = get_cluster_id(kwargs={"name": cluster_name}) + else: + cluster_id = "-1" + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.vn.allocate(auth, data, int(cluster_id)) + + ret = { + "action": "vn.allocate", + "allocated": response[0], + "vn_id": response[1], + "error_code": response[2], + } + + return ret + + +def vn_delete(call=None, kwargs=None): + """ + Deletes the given virtual network from OpenNebula. Either a name or a vn_id must + be supplied. + + .. versionadded:: 2016.3.0 + + name + The name of the virtual network to delete. Can be used instead of ``vn_id``. + + vn_id + The ID of the virtual network to delete. Can be used instead of ``name``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f vn_delete opennebula name=my-virtual-network + salt-cloud --function vn_delete opennebula vn_id=3 + """ + if call != "function": + raise SaltCloudSystemExit( + "The vn_delete function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + vn_id = kwargs.get("vn_id", None) + + if vn_id: + if name: + log.warning( + "Both the 'vn_id' and 'name' arguments were provided. " + "'vn_id' will take precedence." + ) + elif name: + vn_id = get_vn_id(kwargs={"name": name}) + else: + raise SaltCloudSystemExit( + "The vn_delete function requires a 'name' or a 'vn_id' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.vn.delete(auth, int(vn_id)) + + data = { + "action": "vn.delete", + "deleted": response[0], + "vn_id": response[1], + "error_code": response[2], + } + + return data + + +def vn_free_ar(call=None, kwargs=None): + """ + Frees a reserved address range from a virtual network. + + .. versionadded:: 2016.3.0 + + vn_id + The ID of the virtual network from which to free an address range. + Can be used instead of ``vn_name``. + + vn_name + The name of the virtual network from which to free an address range. + Can be used instead of ``vn_id``. + + ar_id + The ID of the address range to free. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f vn_free_ar opennebula vn_id=3 ar_id=1 + salt-cloud -f vn_free_ar opennebula vn_name=my-vn ar_id=1 + """ + if call != "function": + raise SaltCloudSystemExit( + "The vn_free_ar function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + vn_id = kwargs.get("vn_id", None) + vn_name = kwargs.get("vn_name", None) + ar_id = kwargs.get("ar_id", None) + + if ar_id is None: + raise SaltCloudSystemExit( + "The vn_free_ar function requires an 'rn_id' to be provided." + ) + + if vn_id: + if vn_name: + log.warning( + "Both the 'vn_id' and 'vn_name' arguments were provided. " + "'vn_id' will take precedence." + ) + elif vn_name: + vn_id = get_vn_id(kwargs={"name": vn_name}) + else: + raise SaltCloudSystemExit( + "The vn_free_ar function requires a 'vn_id' or a 'vn_name' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.vn.free_ar(auth, int(vn_id), int(ar_id)) + + data = { + "action": "vn.free_ar", + "ar_freed": response[0], + "resource_id": response[1], + "error_code": response[2], + } + + return data + + +def vn_hold(call=None, kwargs=None): + """ + Holds a virtual network lease as used. + + .. versionadded:: 2016.3.0 + + vn_id + The ID of the virtual network from which to hold the lease. Can be used + instead of ``vn_name``. + + vn_name + The name of the virtual network from which to hold the lease. Can be used + instead of ``vn_id``. + + path + The path to a file defining the template of the lease to hold. + Syntax within the file can be the usual attribute=value or XML. Can be + used instead of ``data``. + + data + Contains the template of the lease to hold. Syntax can be the usual + attribute=value or XML. Can be used instead of ``path``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f vn_hold opennebula vn_id=3 path=/path/to/vn_hold_file.txt + salt-cloud -f vn_hold opennebula vn_name=my-vn data="LEASES=[IP=192.168.0.5]" + """ + if call != "function": + raise SaltCloudSystemExit( + "The vn_hold function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + vn_id = kwargs.get("vn_id", None) + vn_name = kwargs.get("vn_name", None) + path = kwargs.get("path", None) + data = kwargs.get("data", None) + + if vn_id: + if vn_name: + log.warning( + "Both the 'vn_id' and 'vn_name' arguments were provided. " + "'vn_id' will take precedence." + ) + elif vn_name: + vn_id = get_vn_id(kwargs={"name": vn_name}) + else: + raise SaltCloudSystemExit( + "The vn_hold function requires a 'vn_id' or a 'vn_name' to be provided." + ) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The vn_hold function requires either 'data' or a 'path' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.vn.hold(auth, int(vn_id), data) + + ret = { + "action": "vn.hold", + "held": response[0], + "resource_id": response[1], + "error_code": response[2], + } + + return ret + + +def vn_info(call=None, kwargs=None): + """ + Retrieves information for the virtual network. + + .. versionadded:: 2016.3.0 + + name + The name of the virtual network for which to gather information. Can be + used instead of ``vn_id``. + + vn_id + The ID of the virtual network for which to gather information. Can be + used instead of ``name``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f vn_info opennebula vn_id=3 + salt-cloud --function vn_info opennebula name=public + """ + if call != "function": + raise SaltCloudSystemExit( + "The vn_info function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + name = kwargs.get("name", None) + vn_id = kwargs.get("vn_id", None) + + if vn_id: + if name: + log.warning( + "Both the 'vn_id' and 'name' arguments were provided. " + "'vn_id' will take precedence." + ) + elif name: + vn_id = get_vn_id(kwargs={"name": name}) + else: + raise SaltCloudSystemExit( + "The vn_info function requires either a 'name' or a 'vn_id' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.vn.info(auth, int(vn_id)) + + if response[0] is False: + return response[1] + else: + info = {} + tree = _get_xml(response[1]) + info[tree.find("NAME").text] = _xml_to_dict(tree) + return info + + +def vn_release(call=None, kwargs=None): + """ + Releases a virtual network lease that was previously on hold. + + .. versionadded:: 2016.3.0 + + vn_id + The ID of the virtual network from which to release the lease. Can be + used instead of ``vn_name``. + + vn_name + The name of the virtual network from which to release the lease. + Can be used instead of ``vn_id``. + + path + The path to a file defining the template of the lease to release. + Syntax within the file can be the usual attribute=value or XML. Can be + used instead of ``data``. + + data + Contains the template defining the lease to release. Syntax can be the + usual attribute=value or XML. Can be used instead of ``path``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f vn_release opennebula vn_id=3 path=/path/to/vn_release_file.txt + salt-cloud =f vn_release opennebula vn_name=my-vn data="LEASES=[IP=192.168.0.5]" + """ + if call != "function": + raise SaltCloudSystemExit( + "The vn_reserve function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + vn_id = kwargs.get("vn_id", None) + vn_name = kwargs.get("vn_name", None) + path = kwargs.get("path", None) + data = kwargs.get("data", None) + + if vn_id: + if vn_name: + log.warning( + "Both the 'vn_id' and 'vn_name' arguments were provided. " + "'vn_id' will take precedence." + ) + elif vn_name: + vn_id = get_vn_id(kwargs={"name": vn_name}) + else: + raise SaltCloudSystemExit( + "The vn_release function requires a 'vn_id' or a 'vn_name' to be provided." + ) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The vn_release function requires either 'data' or a 'path' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.vn.release(auth, int(vn_id), data) + + ret = { + "action": "vn.release", + "released": response[0], + "resource_id": response[1], + "error_code": response[2], + } + + return ret + + +def vn_reserve(call=None, kwargs=None): + """ + Reserve network addresses. + + .. versionadded:: 2016.3.0 + + vn_id + The ID of the virtual network from which to reserve addresses. Can be used + instead of vn_name. + + vn_name + The name of the virtual network from which to reserve addresses. Can be + used instead of vn_id. + + path + The path to a file defining the template of the address reservation. + Syntax within the file can be the usual attribute=value or XML. Can be used + instead of ``data``. + + data + Contains the template defining the address reservation. Syntax can be the + usual attribute=value or XML. Data provided must be wrapped in double + quotes. Can be used instead of ``path``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f vn_reserve opennebula vn_id=3 path=/path/to/vn_reserve_file.txt + salt-cloud -f vn_reserve opennebula vn_name=my-vn data="SIZE=10 AR_ID=8 NETWORK_ID=1" + """ + if call != "function": + raise SaltCloudSystemExit( + "The vn_reserve function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + vn_id = kwargs.get("vn_id", None) + vn_name = kwargs.get("vn_name", None) + path = kwargs.get("path", None) + data = kwargs.get("data", None) + + if vn_id: + if vn_name: + log.warning( + "Both the 'vn_id' and 'vn_name' arguments were provided. " + "'vn_id' will take precedence." + ) + elif vn_name: + vn_id = get_vn_id(kwargs={"name": vn_name}) + else: + raise SaltCloudSystemExit( + "The vn_reserve function requires a 'vn_id' or a 'vn_name' to be provided." + ) + + if data: + if path: + log.warning( + "Both the 'data' and 'path' arguments were provided. " + "'data' will take precedence." + ) + elif path: + with salt.utils.files.fopen(path, mode="r") as rfh: + data = rfh.read() + else: + raise SaltCloudSystemExit( + "The vn_reserve function requires a 'path' to be provided." + ) + + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + response = server.one.vn.reserve(auth, int(vn_id), data) + + ret = { + "action": "vn.reserve", + "reserved": response[0], + "resource_id": response[1], + "error_code": response[2], + } + + return ret + + +# Helper Functions + + +def _get_node(name): + """ + Helper function that returns all information about a named node. + + name + The name of the node for which to get information. + """ + attempts = 10 + + while attempts >= 0: + try: + return list_nodes_full()[name] + except KeyError: + attempts -= 1 + log.debug( + "Failed to get the data for node '%s'. Remaining attempts: %s", + name, + attempts, + ) + + # Just a little delay between attempts... + time.sleep(0.5) + + return {} + + +def _get_xml(xml_str): + """ + Intrepret the data coming from opennebula and raise if it's not XML. + """ + try: + xml_data = etree.XML(xml_str) + # XMLSyntaxError seems to be only available from lxml, but that is the xml + # library loaded by this module + except etree.XMLSyntaxError as err: + # opennebula returned invalid XML, which could be an error message, so + # log it + raise SaltCloudSystemExit(f"opennebula returned: {xml_str}") + return xml_data + + +def _get_xml_rpc(): + """ + Uses the OpenNebula cloud provider configurations to connect to the + OpenNebula API. + + Returns the server connection created as well as the user and password + values from the cloud provider config file used to make the connection. + """ + vm_ = get_configured_provider() + + xml_rpc = config.get_cloud_config_value( + "xml_rpc", vm_, __opts__, search_global=False + ) + + user = config.get_cloud_config_value("user", vm_, __opts__, search_global=False) + + password = config.get_cloud_config_value( + "password", vm_, __opts__, search_global=False + ) + + server = xmlrpc.client.ServerProxy(xml_rpc) + + return server, user, password + + +def _list_nodes(full=False): + """ + Helper function for the list_* query functions - Constructs the + appropriate dictionaries to return from the API query. + + full + If performing a full query, such as in list_nodes_full, change + this parameter to ``True``. + """ + server, user, password = _get_xml_rpc() + auth = ":".join([user, password]) + + vm_pool = server.one.vmpool.info(auth, -2, -1, -1, -1)[1] + + vms = {} + for vm in _get_xml(vm_pool): + name = vm.find("NAME").text + vms[name] = {} + + cpu_size = vm.find("TEMPLATE").find("CPU").text + memory_size = vm.find("TEMPLATE").find("MEMORY").text + + private_ips = [] + for nic in vm.find("TEMPLATE").findall("NIC"): + try: + private_ips.append(nic.find("IP").text) + except Exception: # pylint: disable=broad-except + pass + + vms[name]["id"] = vm.find("ID").text + if "TEMPLATE_ID" in vm.find("TEMPLATE"): + vms[name]["image"] = vm.find("TEMPLATE").find("TEMPLATE_ID").text + vms[name]["name"] = name + vms[name]["size"] = {"cpu": cpu_size, "memory": memory_size} + vms[name]["state"] = vm.find("STATE").text + vms[name]["private_ips"] = private_ips + vms[name]["public_ips"] = [] + + if full: + vms[vm.find("NAME").text] = _xml_to_dict(vm) + + return vms + + +def _xml_to_dict(xml): + """ + Helper function to covert xml into a data dictionary. + + xml + The xml data to convert. + """ + dicts = {} + for item in xml: + key = item.tag.lower() + idx = 1 + while key in dicts: + key += str(idx) + idx += 1 + if item.text is None: + dicts[key] = _xml_to_dict(item) + else: + dicts[key] = item.text + + return dicts diff --git a/salt/cloud/clouds/openstack.py b/salt/cloud/clouds/openstack.py new file mode 100644 index 000000000000..8dfbf1c341c4 --- /dev/null +++ b/salt/cloud/clouds/openstack.py @@ -0,0 +1,922 @@ +""" +Openstack Cloud Driver +====================== + +:depends: `shade>=1.19.0 `_ + +OpenStack is an open source project that is in use by a number a cloud +providers, each of which have their own ways of using it. + +This OpenStack driver uses a the shade python module which is managed by the +OpenStack Infra team. This module is written to handle all the different +versions of different OpenStack tools for salt, so most commands are just passed +over to the module to handle everything. + +Provider +-------- + +There are two ways to configure providers for this driver. The first one is to +just let shade handle everything, and configure using os-client-config_ and +setting up `/etc/openstack/clouds.yml`. + +.. code-block:: yaml + + clouds: + democloud: + region_name: RegionOne + auth: + username: 'demo' + password: secret + project_name: 'demo' + auth_url: 'http://openstack/identity' + +And then this can be referenced in the salt provider based on the `democloud` +name. + +.. code-block:: yaml + + myopenstack: + driver: openstack + cloud: democloud + region_name: RegionOne + +This allows for just using one configuration for salt-cloud and for any other +openstack tools which are all using `/etc/openstack/clouds.yml` + +The other method allows for specifying everything in the provider config, +instead of using the extra configuration file. This will allow for passing +salt-cloud configs only through pillars for minions without having to write a +clouds.yml file on each minion.abs + +.. code-block:: yaml + + myopenstack: + driver: openstack + region_name: RegionOne + auth: + username: 'demo' + password: secret + project_name: 'demo' + user_domain_name: default, + project_domain_name: default, + auth_url: 'http://openstack/identity' + +Or if you need to use a profile to setup some extra stuff, it can be passed as a +`profile` to use any of the vendor_ config options. + +.. code-block:: yaml + + myrackspace: + driver: openstack + profile: rackspace + auth: + username: rackusername + api_key: myapikey + region_name: ORD + auth_type: rackspace_apikey + +And this will pull in the profile for rackspace and setup all the correct +options for the auth_url and different api versions for services. + + +Profile +------- + +Most of the options for building servers are just passed on to the +create_server_ function from shade. + +The salt specific ones are: + + - ssh_key_file: The path to the ssh key that should be used to login to the machine to bootstrap it + - ssh_key_file: The name of the keypair in openstack + - userdata_template: The renderer to use if the userdata is a file that is templated. Default: False + - ssh_interface: The interface to use to login for bootstrapping: public_ips, private_ips, floating_ips, fixed_ips + - ignore_cidr: Specify a CIDR range of unreachable private addresses for salt to ignore when connecting + +.. code-block:: yaml + + centos: + provider: myopenstack + image: CentOS 7 + size: ds1G + ssh_key_name: mykey + ssh_key_file: /root/.ssh/id_rsa + +This is the minimum setup required. + +If metadata is set to make sure that the host has finished setting up the +`wait_for_metadata` can be set. + +.. code-block:: yaml + + centos: + provider: myopenstack + image: CentOS 7 + size: ds1G + ssh_key_name: mykey + ssh_key_file: /root/.ssh/id_rsa + meta: + build_config: rack_user_only + wait_for_metadata: + rax_service_level_automation: Complete + rackconnect_automation_status: DEPLOYED + +If your OpenStack instances only have private IP addresses and a CIDR range of +private addresses are not reachable from the salt-master, you may set your +preference to have Salt ignore it: + +.. code-block:: yaml + + my-openstack-config: + ignore_cidr: 192.168.0.0/16 + +Anything else from the create_server_ docs can be passed through here. + +- **image**: Image dict, name or ID to boot with. image is required + unless boot_volume is given. +- **flavor**: Flavor dict, name or ID to boot onto. +- **auto_ip**: Whether to take actions to find a routable IP for + the server. (defaults to True) +- **ips**: List of IPs to attach to the server (defaults to None) +- **ip_pool**: Name of the network or floating IP pool to get an + address from. (defaults to None) +- **root_volume**: Name or ID of a volume to boot from + (defaults to None - deprecated, use boot_volume) +- **boot_volume**: Name or ID of a volume to boot from + (defaults to None) +- **terminate_volume**: If booting from a volume, whether it should + be deleted when the server is destroyed. + (defaults to False) +- **volumes**: (optional) A list of volumes to attach to the server +- **meta**: (optional) A dict of arbitrary key/value metadata to + store for this server. Both keys and values must be + <=255 characters. +- **files**: (optional, deprecated) A dict of files to overwrite + on the server upon boot. Keys are file names (i.e. + ``/etc/passwd``) and values + are the file contents (either as a string or as a + file-like object). A maximum of five entries is allowed, + and each file must be 10k or less. +- **reservation_id**: a UUID for the set of servers being requested. +- **min_count**: (optional extension) The minimum number of + servers to launch. +- **max_count**: (optional extension) The maximum number of + servers to launch. +- **security_groups**: A list of security group names +- **userdata**: user data to pass to be exposed by the metadata + server this can be a file type object as well or a + string. +- **key_name**: (optional extension) name of previously created + keypair to inject into the instance. +- **availability_zone**: Name of the availability zone for instance + placement. +- **block_device_mapping**: (optional) A list of dictionaries representing + legacy block device mappings for this server. See + `documentation `_ + for details. +- **block_device_mapping_v2**: (optional) A list of dictionaries representing + block device mappings for this server. See + `v2 documentation `_ + for details. +- **nics**: (optional extension) an ordered list of nics to be + added to this server, with information about + connected networks, fixed IPs, port etc. +- **scheduler_hints**: (optional extension) arbitrary key-value pairs + specified by the client to help boot an instance +- **config_drive**: (optional extension) value for config drive + either boolean, or volume-id +- **disk_config**: (optional extension) control how the disk is + partitioned when the server is created. possible + values are 'AUTO' or 'MANUAL'. +- **admin_pass**: (optional extension) add a user supplied admin + password. +- **timeout**: (optional) Seconds to wait, defaults to 60. + See the ``wait`` parameter. +- **reuse_ips**: (optional) Whether to attempt to reuse pre-existing + floating ips should a floating IP be + needed (defaults to True) +- **network**: (optional) Network dict or name or ID to attach the + server to. Mutually exclusive with the nics parameter. + Can also be be a list of network names or IDs or + network dicts. +- **boot_from_volume**: Whether to boot from volume. 'boot_volume' + implies True, but boot_from_volume=True with + no boot_volume is valid and will create a + volume from the image and use that. +- **volume_size**: When booting an image from volume, how big should + the created volume be? Defaults to 50. +- **nat_destination**: Which network should a created floating IP + be attached to, if it's not possible to + infer from the cloud's configuration. + (Optional, defaults to None) +- **group**: ServerGroup dict, name or id to boot the server in. + If a group is provided in both scheduler_hints and in + the group param, the group param will win. + (Optional, defaults to None) + +.. note:: + + If there is anything added, that is not in this list, it can be added to an `extras` + dictionary for the profile, and that will be to the create_server function. + +.. _create_server: https://docs.openstack.org/shade/latest/user/usage.html#shade.OpenStackCloud.create_server +.. _vendor: https://docs.openstack.org/os-client-config/latest/user/vendor-support.html +.. _os-client-config: https://docs.openstack.org/os-client-config/latest/user/configuration.html#config-files +""" + +import copy +import logging +import os +import pprint +import socket + +import salt.config as config +from salt.exceptions import ( + SaltCloudConfigError, + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudSystemExit, +) +from salt.utils.versions import Version + +try: + import os_client_config + import shade + import shade.exc + import shade.openstackcloud + + HAS_SHADE = ( + Version(shade.__version__) >= Version("1.19.0"), + "Please install newer version of shade: >= 1.19.0", + ) +except ImportError: + HAS_SHADE = (False, "Install pypi module shade >= 1.19.0") + + +log = logging.getLogger(__name__) +__virtualname__ = "openstack" + + +def __virtual__(): + """ + Check for OpenStack dependencies + """ + if get_configured_provider() is False: + return False + if get_dependencies() is False: + return HAS_SHADE + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + provider = config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ("auth", "region_name"), + ) + if provider: + return provider + + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ("cloud", "region_name"), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + if not HAS_SHADE: + log.warning('"shade" not found') + return False + elif hasattr(HAS_SHADE, "__len__") and not HAS_SHADE[0]: + log.warning(HAS_SHADE[1]) + return False + deps = {"shade": HAS_SHADE[0], "os_client_config": HAS_SHADE[0]} + return config.check_driver_dependencies(__virtualname__, deps) + + +def preferred_ip(vm_, ips): + """ + Return either an 'ipv4' (default) or 'ipv6' address depending on 'protocol' option. + The list of 'ipv4' IPs is filtered by ignore_cidr() to remove any unreachable private addresses. + """ + proto = config.get_cloud_config_value( + "protocol", vm_, __opts__, default="ipv4", search_global=False + ) + + family = socket.AF_INET + if proto == "ipv6": + family = socket.AF_INET6 + for ip in ips: + ignore_ip = ignore_cidr(vm_, ip) + if ignore_ip: + continue + try: + socket.inet_pton(family, ip) + return ip + except Exception: # pylint: disable=broad-except + continue + return False + + +def ignore_cidr(vm_, ip): + """ + Return True if we are to ignore the specified IP. + """ + from ipaddress import ip_address, ip_network + + cidrs = config.get_cloud_config_value( + "ignore_cidr", vm_, __opts__, default=[], search_global=False + ) + if cidrs and isinstance(cidrs, str): + cidrs = [cidrs] + for cidr in cidrs or []: + if ip_address(ip) in ip_network(cidr): + log.warning("IP %r found within %r; ignoring it.", ip, cidr) + return True + + return False + + +def ssh_interface(vm_): + """ + Return the ssh_interface type to connect to. Either 'public_ips' (default) + or 'private_ips'. + """ + return config.get_cloud_config_value( + "ssh_interface", vm_, __opts__, default="public_ips", search_global=False + ) + + +def get_conn(): + """ + Return a conn object for the passed VM data + """ + if _get_active_provider_name() in __context__: + return __context__[_get_active_provider_name()] + vm_ = get_configured_provider() + profile = vm_.pop("profile", None) + if profile is not None: + vm_ = __utils__["dictupdate.update"]( + os_client_config.vendors.get_profile(profile), vm_ + ) + conn = shade.openstackcloud.OpenStackCloud(cloud_config=None, **vm_) + if _get_active_provider_name() is not None: + __context__[_get_active_provider_name()] = conn + return conn + + +def list_nodes(conn=None, call=None): + """ + Return a list of VMs + + CLI Example + + .. code-block:: bash + + salt-cloud -f list_nodes myopenstack + + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + ret = {} + for node, info in list_nodes_full(conn=conn).items(): + for key in ( + "id", + "name", + "size", + "state", + "private_ips", + "public_ips", + "floating_ips", + "fixed_ips", + "image", + ): + ret.setdefault(node, {}).setdefault(key, info.get(key)) + + return ret + + +def list_nodes_min(conn=None, call=None): + """ + Return a list of VMs with minimal information + + CLI Example + + .. code-block:: bash + + salt-cloud -f list_nodes_min myopenstack + + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_min function must be called with -f or --function." + ) + if conn is None: + conn = get_conn() + ret = {} + for node in conn.list_servers(bare=True): + ret[node.name] = {"id": node.id, "state": node.status} + return ret + + +def _get_ips(node, addr_type="public"): + ret = [] + for _, interface in node.addresses.items(): + for addr in interface: + if addr_type in ("floating", "fixed") and addr_type == addr.get( + "OS-EXT-IPS:type" + ): + ret.append(addr["addr"]) + elif addr_type == "public" and __utils__["cloud.is_public_ip"]( + addr["addr"] + ): + ret.append(addr["addr"]) + elif addr_type == "private" and not __utils__["cloud.is_public_ip"]( + addr["addr"] + ): + ret.append(addr["addr"]) + return ret + + +def list_nodes_full(conn=None, call=None): + """ + Return a list of VMs with all the information about them + + CLI Example + + .. code-block:: bash + + salt-cloud -f list_nodes_full myopenstack + + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + if conn is None: + conn = get_conn() + ret = {} + for node in conn.list_servers(detailed=True): + ret[node.name] = dict(node) + ret[node.name]["id"] = node.id + ret[node.name]["name"] = node.name + ret[node.name]["size"] = node.flavor.name + ret[node.name]["state"] = node.status + ret[node.name]["private_ips"] = _get_ips(node, "private") + ret[node.name]["public_ips"] = _get_ips(node, "public") + ret[node.name]["floating_ips"] = _get_ips(node, "floating") + ret[node.name]["fixed_ips"] = _get_ips(node, "fixed") + if isinstance(node.image, str): + ret[node.name]["image"] = node.image + else: + ret[node.name]["image"] = getattr( + conn.get_image(node.image.id), "name", node.image.id + ) + return ret + + +def list_nodes_select(conn=None, call=None): + """ + Return a list of VMs with the fields from `query.selection` + + CLI Example + + .. code-block:: bash + + salt-cloud -f list_nodes_full myopenstack + + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_select function must be called with -f or --function." + ) + return __utils__["cloud.list_nodes_select"]( + list_nodes(conn, "function"), __opts__["query.selection"], call + ) + + +def show_instance(name, conn=None, call=None): + """ + Get VM on this OpenStack account + + name + + name of the instance + + CLI Example + + .. code-block:: bash + + salt-cloud -a show_instance myserver + + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + if conn is None: + conn = get_conn() + + node = conn.get_server(name, bare=True) + ret = dict(node) + ret["id"] = node.id + ret["name"] = node.name + ret["size"] = conn.get_flavor(node.flavor.id).name + ret["state"] = node.status + ret["private_ips"] = _get_ips(node, "private") + ret["public_ips"] = _get_ips(node, "public") + ret["floating_ips"] = _get_ips(node, "floating") + ret["fixed_ips"] = _get_ips(node, "fixed") + if isinstance(node.image, str): + ret["image"] = node.image + else: + ret["image"] = getattr(conn.get_image(node.image.id), "name", node.image.id) + return ret + + +def avail_images(conn=None, call=None): + """ + List available images for OpenStack + + CLI Example + + .. code-block:: bash + + salt-cloud -f avail_images myopenstack + salt-cloud --list-images myopenstack + + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + if conn is None: + conn = get_conn() + return conn.list_images() + + +def avail_sizes(conn=None, call=None): + """ + List available sizes for OpenStack + + CLI Example + + .. code-block:: bash + + salt-cloud -f avail_sizes myopenstack + salt-cloud --list-sizes myopenstack + + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + if conn is None: + conn = get_conn() + return conn.list_flavors() + + +def list_networks(conn=None, call=None): + """ + List networks for OpenStack + + CLI Example + + .. code-block:: bash + + salt-cloud -f list_networks myopenstack + + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_networks function must be called with -f or --function" + ) + if conn is None: + conn = get_conn() + return conn.list_networks() + + +def list_subnets(conn=None, call=None, kwargs=None): + """ + List subnets in a virtual network + + network + network to list subnets of + + .. code-block:: bash + + salt-cloud -f list_subnets myopenstack network=salt-net + + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_subnets function must be called with -f or --function." + ) + if conn is None: + conn = get_conn() + if kwargs is None or (isinstance(kwargs, dict) and "network" not in kwargs): + raise SaltCloudSystemExit("A `network` must be specified") + return conn.list_subnets(filters={"network": kwargs["network"]}) + + +def _clean_create_kwargs(**kwargs): + """ + Sanitize kwargs to be sent to create_server + """ + VALID_OPTS = { + "name": (str,), + "image": (str,), + "flavor": (str,), + "auto_ip": bool, + "ips": list, + "ip_pool": (str,), + "root_volume": (str,), + "boot_volume": (str,), + "terminate_volume": bool, + "volumes": list, + "meta": dict, + "files": dict, + "reservation_id": (str,), + "security_groups": list, + "key_name": (str,), + "availability_zone": (str,), + "block_device_mapping": list, + "block_device_mapping_v2": list, + "nics": list, + "scheduler_hints": dict, + "config_drive": bool, + "disk_config": (str,), # AUTO or MANUAL + "admin_pass": (str,), + "wait": bool, + "timeout": int, + "reuse_ips": bool, + "network": (dict, list), + "boot_from_volume": bool, + "volume_size": int, + "nat_destination": (str,), + "group": (str,), + "userdata": (str,), + } + extra = kwargs.pop("extra", {}) + for key, value in kwargs.copy().items(): + if key in VALID_OPTS: + if isinstance(value, VALID_OPTS[key]): + continue + log.error("Error %s: %s is not of type %s", key, value, VALID_OPTS[key]) + kwargs.pop(key) + return __utils__["dictupdate.update"](kwargs, extra) + + +def request_instance(vm_, conn=None, call=None): + """ + Request an instance to be built + """ + if call == "function": + # Technically this function may be called other ways too, but it + # definitely cannot be called with --function. + raise SaltCloudSystemExit( + "The request_instance action must be called with -a or --action." + ) + kwargs = copy.deepcopy(vm_) + log.info("Creating Cloud VM %s", vm_["name"]) + __utils__["cloud.check_name"](vm_["name"], "a-zA-Z0-9._-") + if conn is None: + conn = get_conn() + userdata = config.get_cloud_config_value( + "userdata", vm_, __opts__, search_global=False, default=None + ) + if userdata is not None and os.path.isfile(userdata): + try: + with __utils__["files.fopen"](userdata, "r") as fp_: + kwargs["userdata"] = __utils__["cloud.userdata_template"]( + __opts__, vm_, fp_.read() + ) + except Exception as exc: # pylint: disable=broad-except + log.exception("Failed to read userdata from %s: %s", userdata, exc) + if "size" in kwargs: + kwargs["flavor"] = kwargs.pop("size") + kwargs["key_name"] = config.get_cloud_config_value( + "ssh_key_name", vm_, __opts__, search_global=False, default=None + ) + kwargs["wait"] = True + try: + conn.create_server(**_clean_create_kwargs(**kwargs)) + except shade.exc.OpenStackCloudException as exc: + log.error("Error creating server %s: %s", vm_["name"], exc) + destroy(vm_["name"], conn=conn, call="action") + raise SaltCloudSystemExit(str(exc)) + + return show_instance(vm_["name"], conn=conn, call="action") + + +def create(vm_): + """ + Create a single VM from a data dict + """ + deploy = config.get_cloud_config_value("deploy", vm_, __opts__) + key_filename = config.get_cloud_config_value( + "ssh_key_file", vm_, __opts__, search_global=False, default=None + ) + if key_filename is not None and not os.path.isfile(key_filename): + raise SaltCloudConfigError( + f"The defined ssh_key_file '{key_filename}' does not exist" + ) + + vm_["key_filename"] = key_filename + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + conn = get_conn() + + if "instance_id" in vm_: + # This was probably created via another process, and doesn't have + # things like salt keys created yet, so let's create them now. + if "pub_key" not in vm_ and "priv_key" not in vm_: + log.debug("Generating minion keys for '%s'", vm_["name"]) + vm_["priv_key"], vm_["pub_key"] = __utils__["cloud.gen_keys"]( + config.get_cloud_config_value("keysize", vm_, __opts__) + ) + else: + # Put together all of the information required to request the instance, + # and then fire off the request for it + request_instance(conn=conn, call="action", vm_=vm_) + data = show_instance(vm_.get("instance_id", vm_["name"]), conn=conn, call="action") + log.debug("VM is now running") + + def __query_node(vm_): + data = show_instance(vm_["name"], conn=conn, call="action") + if "wait_for_metadata" in vm_: + for key, value in vm_.get("wait_for_metadata", {}).items(): + log.debug("Waiting for metadata: %s=%s", key, value) + if data["metadata"].get(key, None) != value: + log.debug( + "Metadata is not ready: %s=%s", key, data["metadata"].get(key) + ) + return False + return preferred_ip(vm_, data[ssh_interface(vm_)]) + + try: + ip_address = __utils__["cloud.wait_for_fun"](__query_node, vm_=vm_) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + log.debug("Using IP address %s", ip_address) + + salt_interface = __utils__["cloud.get_salt_interface"](vm_, __opts__) + salt_ip_address = preferred_ip(vm_, data[salt_interface]) + log.debug("Salt interface set to: %s", salt_ip_address) + + if not ip_address: + raise SaltCloudSystemExit("A valid IP address was not found") + + vm_["ssh_host"] = ip_address + vm_["salt_host"] = salt_ip_address + + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + ret.update(data) + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data)) + + event_data = { + "name": vm_["name"], + "profile": vm_["profile"], + "provider": vm_["driver"], + "instance_id": data["id"], + "floating_ips": data["floating_ips"], + "fixed_ips": data["fixed_ips"], + "private_ips": data["private_ips"], + "public_ips": data["public_ips"], + } + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]("created", event_data, list(event_data)), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + __utils__["cloud.cachedir_index_add"]( + vm_["name"], vm_["profile"], "nova", vm_["driver"] + ) + return ret + + +def destroy(name, conn=None, call=None): + """ + Delete a single VM + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if not conn: + conn = get_conn() + node = show_instance(name, conn=conn, call="action") + log.info("Destroying VM: %s", name) + ret = conn.delete_server(name) + if ret: + log.info("Destroyed VM: %s", name) + # Fire destroy action + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + if __opts__.get("delete_sshkeys", False) is True: + __utils__["cloud.remove_sshkey"]( + getattr(node, __opts__.get("ssh_interface", "public_ips"))[0] + ) + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + __utils__["cloud.cachedir_index_del"](name) + return True + + log.error("Failed to Destroy VM: %s", name) + return False + + +def call(conn=None, call=None, kwargs=None): + """ + Call function from shade. + + func + + function to call from shade.openstackcloud library + + CLI Example + + .. code-block:: bash + + salt-cloud -f call myopenstack func=list_images + t sujksalt-cloud -f call myopenstack func=create_network name=mysubnet + """ + if call == "action": + raise SaltCloudSystemExit( + "The call function must be called with -f or --function." + ) + + if "func" not in kwargs: + raise SaltCloudSystemExit("No `func` argument passed") + + if conn is None: + conn = get_conn() + + func = kwargs.pop("func") + for key, value in kwargs.items(): + try: + kwargs[key] = __utils__["json.loads"](value) + except ValueError: + continue + try: + return getattr(conn, func)(**kwargs) + except shade.exc.OpenStackCloudException as exc: + log.error("Error running %s: %s", func, exc) + raise SaltCloudSystemExit(str(exc)) diff --git a/salt/cloud/clouds/packet.py b/salt/cloud/clouds/packet.py new file mode 100644 index 000000000000..0e1b65bed0c9 --- /dev/null +++ b/salt/cloud/clouds/packet.py @@ -0,0 +1,623 @@ +""" +Packet Cloud Module Using Packet's Python API Client +==================================================== + +The Packet cloud module is used to control access to the Packet VPS system. + +Use of this module only requires the ``token`` parameter. + +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or ``/etc/salt/cloud.providers.d/packet.conf``: + +The Packet profile requires ``size``, ``image``, ``location``, ``project_id`` + +Optional profile parameters: + +- ``storage_size`` - min value is 10, defines Gigabytes of storage that will be attached to device. +- ``storage_tier`` - storage_1 - Standard Plan, storage_2 - Performance Plan +- ``snapshot_count`` - int +- ``snapshot_frequency`` - string - possible values: + + - 1min + - 15min + - 1hour + - 1day + - 1week + - 1month + - 1year + +This driver requires Packet's client library: https://pypi.python.org/pypi/packet-python + +.. code-block:: yaml + + packet-provider: + minion: + master: 192.168.50.10 + driver: packet + token: ewr23rdf35wC8oNjJrhmHa87rjSXzJyi + private_key: /root/.ssh/id_rsa + + packet-profile: + provider: packet-provider + size: baremetal_0 + image: ubuntu_16_04_image + location: ewr1 + project_id: a64d000b-d47c-4d26-9870-46aac43010a6 + storage_size: 10 + storage_tier: storage_1 + storage_snapshot_count: 1 + storage_snapshot_frequency: 15min +""" + +import logging +import pprint +import time + +import salt.config as config +import salt.utils.cloud +from salt.cloud.libcloudfuncs import get_image, get_size, script, show_instance +from salt.exceptions import SaltCloudException, SaltCloudSystemExit +from salt.utils.functools import namespaced_function + +try: + import packet + + HAS_PACKET = True +except ImportError: + HAS_PACKET = False + + +get_size = namespaced_function(get_size, globals()) +get_image = namespaced_function(get_image, globals()) + +script = namespaced_function(script, globals()) + +show_instance = namespaced_function(show_instance, globals()) + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "packet" + + +# Only load this module if the Packet configuration is in place. +def __virtual__(): + """ + Check for Packet configs. + """ + if HAS_PACKET is False: + return False, "The packet python library is not installed" + if get_configured_provider() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("token",) + ) + + +def avail_images(call=None): + """ + Return available Packet os images. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-images packet-provider + salt-cloud -f avail_images packet-provider + """ + if call == "action": + raise SaltCloudException( + "The avail_images function must be called with -f or --function." + ) + + ret = {} + + vm_ = get_configured_provider() + manager = packet.Manager(auth_token=vm_["token"]) + + ret = {} + + for os_system in manager.list_operating_systems(): + ret[os_system.name] = os_system.__dict__ + + return ret + + +def avail_locations(call=None): + """ + Return available Packet datacenter locations. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-locations packet-provider + salt-cloud -f avail_locations packet-provider + """ + if call == "action": + raise SaltCloudException( + "The avail_locations function must be called with -f or --function." + ) + + vm_ = get_configured_provider() + manager = packet.Manager(auth_token=vm_["token"]) + + ret = {} + + for facility in manager.list_facilities(): + ret[facility.name] = facility.__dict__ + + return ret + + +def avail_sizes(call=None): + """ + Return available Packet sizes. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-sizes packet-provider + salt-cloud -f avail_sizes packet-provider + """ + if call == "action": + raise SaltCloudException( + "The avail_locations function must be called with -f or --function." + ) + + vm_ = get_configured_provider() + + manager = packet.Manager(auth_token=vm_["token"]) + + ret = {} + + for plan in manager.list_plans(): + ret[plan.name] = plan.__dict__ + + return ret + + +def avail_projects(call=None): + """ + Return available Packet projects. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f avail_projects packet-provider + """ + if call == "action": + raise SaltCloudException( + "The avail_projects function must be called with -f or --function." + ) + + vm_ = get_configured_provider() + manager = packet.Manager(auth_token=vm_["token"]) + + ret = {} + + for project in manager.list_projects(): + ret[project.name] = project.__dict__ + + return ret + + +def _wait_for_status(status_type, object_id, status=None, timeout=500, quiet=True): + """ + Wait for a certain status from Packet. + status_type + device or volume + object_id + The ID of the Packet device or volume to wait on. Required. + status + The status to wait for. + timeout + The amount of time to wait for a status to update. + quiet + Log status updates to debug logs when False. Otherwise, logs to info. + """ + if status is None: + status = "ok" + + interval = 5 + iterations = int(timeout / interval) + + vm_ = get_configured_provider() + manager = packet.Manager(auth_token=vm_["token"]) + + for i in range(0, iterations): + get_object = getattr(manager, f"get_{status_type}") + obj = get_object(object_id) + + if obj.state == status: + return obj + + time.sleep(interval) + log.log( + logging.INFO if not quiet else logging.DEBUG, + "Status for Packet %s is '%s', waiting for '%s'.", + object_id, + obj.state, + status, + ) + + return obj + + +def is_profile_configured(vm_): + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + + alias, driver = _get_active_provider_name().split(":") + + profile_data = __opts__["providers"][alias][driver]["profiles"][vm_["profile"]] + + if profile_data.get("storage_size") or profile_data.get("storage_tier"): + required_keys = ["storage_size", "storage_tier"] + + for key in required_keys: + if profile_data.get(key) is None: + log.error( + "both storage_size and storage_tier required for " + "profile %s. Please check your profile configuration", + vm_["profile"], + ) + return False + + locations = avail_locations() + + for location in locations.values(): + if location["code"] == profile_data["location"]: + if "storage" not in location["features"]: + log.error( + "Chosen location %s for profile %s does not " + "support storage feature. Please check your " + "profile configuration", + location["code"], + vm_["profile"], + ) + return False + + if profile_data.get("storage_snapshot_count") or profile_data.get( + "storage_snapshot_frequency" + ): + required_keys = ["storage_size", "storage_tier"] + + for key in required_keys: + if profile_data.get(key) is None: + log.error( + "both storage_snapshot_count and " + "storage_snapshot_frequency required for profile " + "%s. Please check your profile configuration", + vm_["profile"], + ) + return False + + except AttributeError: + pass + + return True + + +def create(vm_): + """ + Create a single Packet VM. + """ + name = vm_["name"] + + if not is_profile_configured(vm_): + return False + + __utils__["cloud.fire_event"]( + "event", + "starting create", + f"salt/cloud/{name}/creating", + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Packet VM %s", name) + + manager = packet.Manager(auth_token=vm_["token"]) + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "requesting", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + device = manager.create_device( + project_id=vm_["project_id"], + hostname=name, + plan=vm_["size"], + facility=vm_["location"], + operating_system=vm_["image"], + ) + + device = _wait_for_status("device", device.id, status="active") + + if device.state != "active": + log.error( + "Error creating %s on PACKET\n\nwhile waiting for initial ready status", + name, + exc_info_on_loglevel=logging.DEBUG, + ) + + # Define which ssh_interface to use + ssh_interface = _get_ssh_interface(vm_) + + # Pass the correct IP address to the bootstrap ssh_host key + if ssh_interface == "private_ips": + for ip in device.ip_addresses: + if ip["public"] is False: + vm_["ssh_host"] = ip["address"] + break + else: + for ip in device.ip_addresses: + if ip["public"] is True: + vm_["ssh_host"] = ip["address"] + break + + key_filename = config.get_cloud_config_value( + "private_key", vm_, __opts__, search_global=False, default=None + ) + + vm_["key_filename"] = key_filename + + vm_["private_key"] = key_filename + + # Bootstrap! + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + ret.update({"device": device.__dict__}) + + if vm_.get("storage_tier") and vm_.get("storage_size"): + # create storage and attach it to device + + volume = manager.create_volume( + vm_["project_id"], + f"{name}_storage", + vm_.get("storage_tier"), + vm_.get("storage_size"), + vm_.get("location"), + snapshot_count=vm_.get("storage_snapshot_count", 0), + snapshot_frequency=vm_.get("storage_snapshot_frequency"), + ) + + volume.attach(device.id) + + volume = _wait_for_status("volume", volume.id, status="active") + + if volume.state != "active": + log.error( + "Error creating %s on PACKET\n\nwhile waiting for initial ready status", + name, + exc_info_on_loglevel=logging.DEBUG, + ) + + ret.update({"volume": volume.__dict__}) + + log.info("Created Cloud VM '%s'", name) + + log.debug("'%s' VM creation details:\n%s", name, pprint.pformat(device.__dict__)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + f"salt/cloud/{name}/created", + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def list_nodes_full(call=None): + """ + List devices, with all available information. + + CLI Example: + + .. code-block:: bash + + salt-cloud -F + salt-cloud --full-query + salt-cloud -f list_nodes_full packet-provider + + .. + """ + if call == "action": + raise SaltCloudException( + "The list_nodes_full function must be called with -f or --function." + ) + + ret = {} + + for device in get_devices_by_token(): + ret[device.hostname] = device.__dict__ + + return ret + + +def list_nodes_min(call=None): + """ + Return a list of the VMs that are on the provider. Only a list of VM names and + their state is returned. This is the minimum amount of information needed to + check for existing VMs. + + .. versionadded:: 2015.8.0 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_nodes_min packet-provider + salt-cloud --function list_nodes_min packet-provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_min function must be called with -f or --function." + ) + + ret = {} + + for device in get_devices_by_token(): + ret[device.hostname] = {"id": device.id, "state": device.state} + + return ret + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields. + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full(), + __opts__["query.selection"], + call, + ) + + +def get_devices_by_token(): + vm_ = get_configured_provider() + manager = packet.Manager(auth_token=vm_["token"]) + + devices = [] + + for profile_name in vm_["profiles"]: + profile = vm_["profiles"][profile_name] + + devices.extend(manager.list_devices(profile["project_id"])) + + return devices + + +def list_nodes(call=None): + """ + Returns a list of devices, keeping only a brief listing. + + CLI Example: + + .. code-block:: bash + + salt-cloud -Q + salt-cloud --query + salt-cloud -f list_nodes packet-provider + .. + """ + + if call == "action": + raise SaltCloudException( + "The list_nodes function must be called with -f or --function." + ) + + ret = {} + + for device in get_devices_by_token(): + ret[device.hostname] = device.__dict__ + + return ret + + +def destroy(name, call=None): + """ + Destroys a Packet device by name. + + name + The hostname of VM to be be destroyed. + + CLI Example: + + .. code-block:: bash + + salt-cloud -d name + """ + if call == "function": + raise SaltCloudException( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + vm_ = get_configured_provider() + manager = packet.Manager(auth_token=vm_["token"]) + + nodes = list_nodes_min() + + node = nodes[name] + + for project in manager.list_projects(): + + for volume in manager.list_volumes(project.id): + if volume.attached_to == node["id"]: + volume.detach() + volume.delete() + break + + manager.call_api("devices/{id}".format(id=node["id"]), type="DELETE") + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return {} + + +def _get_ssh_interface(vm_): + """ + Return the ssh_interface type to connect to. Either 'public_ips' (default) + or 'private_ips'. + """ + return config.get_cloud_config_value( + "ssh_interface", vm_, __opts__, default="public_ips", search_global=False + ) diff --git a/salt/cloud/clouds/parallels.py b/salt/cloud/clouds/parallels.py new file mode 100644 index 000000000000..a5e6ca0fff78 --- /dev/null +++ b/salt/cloud/clouds/parallels.py @@ -0,0 +1,606 @@ +""" +Parallels Cloud Module +====================== + +The Parallels cloud module is used to control access to cloud providers using +the Parallels VPS system. + +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or + ``/etc/salt/cloud.providers.d/parallels.conf``: + +.. code-block:: yaml + + my-parallels-config: + # Parallels account information + user: myuser + password: mypassword + url: https://api.cloud.xmission.com:4465/paci/v1.0/ + driver: parallels + +""" + +import logging +import pprint +import time +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from urllib.error import URLError + +import salt.config as config +import salt.utils.cloud +from salt.exceptions import ( + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudNotFound, + SaltCloudSystemExit, +) + +log = logging.getLogger(__name__) + +__virtualname__ = "parallels" + + +# Only load in this module if the PARALLELS configurations are in place +def __virtual__(): + """ + Check for PARALLELS configurations + """ + if get_configured_provider() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ( + "user", + "password", + "url", + ), + ) + + +def avail_images(call=None): + """ + Return a list of the images that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + items = query(action="template") + ret = {} + for item in items: + ret[item.attrib["name"]] = item.attrib + + return ret + + +def list_nodes(call=None): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + ret = {} + items = query(action="ve") + + for item in items: + name = item.attrib["name"] + node = show_instance(name, call="action") + + ret[name] = { + "id": node["id"], + "image": node["platform"]["template-info"]["name"], + "state": node["state"], + } + if "private-ip" in node["network"]: + ret[name]["private_ips"] = [node["network"]["private-ip"]] + if "public-ip" in node["network"]: + ret[name]["public_ips"] = [node["network"]["public-ip"]] + + return ret + + +def list_nodes_full(call=None): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + ret = {} + items = query(action="ve") + + for item in items: + name = item.attrib["name"] + node = show_instance(name, call="action") + + ret[name] = node + ret[name]["image"] = node["platform"]["template-info"]["name"] + if "private-ip" in node["network"]: + ret[name]["private_ips"] = [node["network"]["private-ip"]["address"]] + if "public-ip" in node["network"]: + ret[name]["public_ips"] = [node["network"]["public-ip"]["address"]] + + return ret + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full(), + __opts__["query.selection"], + call, + ) + + +def get_image(vm_): + """ + Return the image object to use + """ + images = avail_images() + vm_image = config.get_cloud_config_value( + "image", vm_, __opts__, search_global=False + ) + for image in images: + if str(vm_image) in (images[image]["name"], images[image]["id"]): + return images[image]["id"] + raise SaltCloudNotFound("The specified image could not be found.") + + +def create_node(vm_): + """ + Build and submit the XML to create a node + """ + # Start the tree + content = ET.Element("ve") + + # Name of the instance + name = ET.SubElement(content, "name") + name.text = vm_["name"] + + # Description, defaults to name + desc = ET.SubElement(content, "description") + desc.text = config.get_cloud_config_value( + "desc", vm_, __opts__, default=vm_["name"], search_global=False + ) + + # How many CPU cores, and how fast they are + cpu = ET.SubElement(content, "cpu") + cpu.attrib["number"] = config.get_cloud_config_value( + "cpu_number", vm_, __opts__, default="1", search_global=False + ) + cpu.attrib["power"] = config.get_cloud_config_value( + "cpu_power", vm_, __opts__, default="1000", search_global=False + ) + + # How many megabytes of RAM + ram = ET.SubElement(content, "ram-size") + ram.text = config.get_cloud_config_value( + "ram", vm_, __opts__, default="256", search_global=False + ) + + # Bandwidth available, in kbps + bandwidth = ET.SubElement(content, "bandwidth") + bandwidth.text = config.get_cloud_config_value( + "bandwidth", vm_, __opts__, default="100", search_global=False + ) + + # How many public IPs will be assigned to this instance + ip_num = ET.SubElement(content, "no-of-public-ip") + ip_num.text = config.get_cloud_config_value( + "ip_num", vm_, __opts__, default="1", search_global=False + ) + + # Size of the instance disk + disk = ET.SubElement(content, "ve-disk") + disk.attrib["local"] = "true" + disk.attrib["size"] = config.get_cloud_config_value( + "disk_size", vm_, __opts__, default="10", search_global=False + ) + + # Attributes for the image + vm_image = config.get_cloud_config_value( + "image", vm_, __opts__, search_global=False + ) + image = show_image({"image": vm_image}, call="function") + platform = ET.SubElement(content, "platform") + template = ET.SubElement(platform, "template-info") + template.attrib["name"] = vm_image + os_info = ET.SubElement(platform, "os-info") + os_info.attrib["technology"] = image[vm_image]["technology"] + os_info.attrib["type"] = image[vm_image]["osType"] + + # Username and password + admin = ET.SubElement(content, "admin") + admin.attrib["login"] = config.get_cloud_config_value( + "ssh_username", vm_, __opts__, default="root" + ) + admin.attrib["password"] = config.get_cloud_config_value( + "password", vm_, __opts__, search_global=False + ) + + data = ET.tostring(content, encoding="UTF-8") + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args={ + "kwargs": __utils__["cloud.filter_event"]("requesting", data, list(data)), + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + node = query(action="ve", method="POST", data=data) + return node + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "parallels", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", vm_["name"]) + + try: + data = create_node(vm_) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on PARALLELS\n\n" + "The following exception was thrown when trying to " + "run the initial deployment: \n%s", + vm_["name"], + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + name = vm_["name"] + if not wait_until(name, "CREATED"): + return {"Error": f"Unable to start {name}, command timed out"} + start(vm_["name"], call="action") + + if not wait_until(name, "STARTED"): + return {"Error": f"Unable to start {name}, command timed out"} + + def __query_node_data(vm_name): + data = show_instance(vm_name, call="action") + if "public-ip" not in data["network"]: + # Trigger another iteration + return + return data + + try: + data = salt.utils.cloud.wait_for_ip( + __query_node_data, + update_args=(vm_["name"],), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=5 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=5 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + comps = data["network"]["public-ip"]["address"].split("/") + public_ip = comps[0] + + vm_["ssh_host"] = public_ip + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return data + + +def query(action=None, command=None, args=None, method="GET", data=None): + """ + Make a web call to a Parallels provider + """ + path = config.get_cloud_config_value( + "url", get_configured_provider(), __opts__, search_global=False + ) + auth_handler = urllib.request.HTTPBasicAuthHandler() + auth_handler.add_password( + realm="Parallels Instance Manager", + uri=path, + user=config.get_cloud_config_value( + "user", get_configured_provider(), __opts__, search_global=False + ), + passwd=config.get_cloud_config_value( + "password", get_configured_provider(), __opts__, search_global=False + ), + ) + opener = urllib.request.build_opener(auth_handler) + urllib.request.install_opener(opener) + + if action: + path += action + + if command: + path += f"/{command}" + + if not type(args, dict): + args = {} + + kwargs = {"data": data} + if isinstance(data, str) and " timeout: + return False + node = show_instance(name, call="action") + + +def destroy(name, call=None): + """ + Destroy a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud --destroy mymachine + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + node = show_instance(name, call="action") + if node["state"] == "STARTED": + stop(name, call="action") + if not wait_until(name, "STOPPED"): + return {"Error": f"Unable to destroy {name}, command timed out"} + + data = query(action="ve", command=name, method="DELETE") + + if "error" in data: + return data["error"] + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return {"Destroyed": f"{name} was destroyed."} + + +def start(name, call=None): + """ + Start a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start mymachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + data = query(action="ve", command=f"{name}/start", method="PUT") + + if "error" in data: + return data["error"] + + return {"Started": f"{name} was started."} + + +def stop(name, call=None): + """ + Stop a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop mymachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + data = query(action="ve", command=f"{name}/stop", method="PUT") + + if "error" in data: + return data["error"] + + return {"Stopped": f"{name} was stopped."} diff --git a/salt/cloud/clouds/profitbricks.py b/salt/cloud/clouds/profitbricks.py new file mode 100644 index 000000000000..a8a9351a7179 --- /dev/null +++ b/salt/cloud/clouds/profitbricks.py @@ -0,0 +1,1231 @@ +""" +ProfitBricks Cloud Module +========================= + +The ProfitBricks SaltStack cloud module allows a ProfitBricks server to +be automatically deployed and bootstraped with Salt. + +:depends: profitbrick >= 3.1.0 + +The module requires ProfitBricks credentials to be supplied along with +an existing virtual datacenter UUID where the server resources will +reside. The server should also be assigned a public LAN, a private LAN, +or both along with SSH key pairs. +... + +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or +``/etc/salt/cloud.providers.d/profitbricks.conf``: + +.. code-block:: yaml + + my-profitbricks-config: + driver: profitbricks + # The ProfitBricks login username + username: user@example.com + # The ProfitBricks login password + password: secretpassword + # The ProfitBricks virtual datacenter UUID + datacenter_id: + # SSH private key filename + ssh_private_key: /path/to/private.key + # SSH public key filename + ssh_public_key: /path/to/public.key + +.. code-block:: yaml + + my-profitbricks-profile: + provider: my-profitbricks-config + # Name of a predefined server size. + size: Micro Instance + # Assign CPU family to server. + cpu_family: INTEL_XEON + # Number of CPU cores to allocate to node (overrides server size). + cores: 4 + # Amount of RAM in multiples of 256 MB (overrides server size). + ram: 4096 + # The server availability zone. + availability_zone: ZONE_1 + # Name or UUID of the HDD image to use. + image: + # Image alias could be provided instead of image. + # Example 'ubuntu:latest' + #image_alias: + # Size of the node disk in GB (overrides server size). + disk_size: 40 + # Type of disk (HDD or SSD). + disk_type: SSD + # Storage availability zone to use. + disk_availability_zone: ZONE_2 + # Assign the server to the specified public LAN. + public_lan: + # Assign firewall rules to the network interface. + public_firewall_rules: + SSH: + protocol: TCP + port_range_start: 22 + port_range_end: 22 + # Assign the server to the specified private LAN. + private_lan: + # Enable NAT on the private NIC. + nat: true + # Assign additional volumes to the server. + volumes: + data-volume: + disk_size: 500 + disk_availability_zone: ZONE_3 + log-volume: + disk_size: 50 + disk_type: SSD + +To use a private IP for connecting and bootstrapping node: + +.. code-block:: yaml + + my-profitbricks-profile: + ssh_interface: private_lan + +Set ``deploy`` to False if Salt should not be installed on the node. + +.. code-block:: yaml + + my-profitbricks-profile: + deploy: False +""" + +import logging +import os +import pprint +import time + +import salt.config as config +import salt.utils.cloud +import salt.utils.files +import salt.utils.stringutils +from salt.exceptions import ( + SaltCloudConfigError, + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudNotFound, + SaltCloudSystemExit, +) +from salt.utils.versions import Version + +try: + # pylint: disable=no-name-in-module + import profitbricks + from profitbricks.client import ( + LAN, + NIC, + Datacenter, + FirewallRule, + IPBlock, + LoadBalancer, + PBError, + PBNotFoundError, + ProfitBricksService, + Server, + Volume, + ) + + # pylint: enable=no-name-in-module + HAS_PROFITBRICKS = True +except ImportError: + HAS_PROFITBRICKS = False + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "profitbricks" + + +# Only load in this module if the ProfitBricks configurations are in place +def __virtual__(): + """ + Check for ProfitBricks configurations. + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ("username", "password", "datacenter_id"), + ) + + +def version_compatible(version): + """ + Checks profitbricks version + """ + return Version(profitbricks.API_VERSION) >= Version(version) + + +def get_dependencies(): + """ + Warn if dependencies are not met. + """ + return config.check_driver_dependencies( + __virtualname__, {"profitbricks": HAS_PROFITBRICKS} + ) + + +def get_conn(): + """ + Return a conn object for the passed VM data + """ + return ProfitBricksService( + username=config.get_cloud_config_value( + "username", get_configured_provider(), __opts__, search_global=False + ), + password=config.get_cloud_config_value( + "password", get_configured_provider(), __opts__, search_global=False + ), + ) + + +def avail_locations(call=None): + """ + Return a dict of all available VM locations on the cloud provider with + relevant data + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-locations option" + ) + + ret = {} + conn = get_conn() + + for item in conn.list_locations()["items"]: + reg, loc = item["id"].split("/") + location = {"id": item["id"]} + + if reg not in ret: + ret[reg] = {} + + ret[reg][loc] = location + return ret + + +def avail_images(call=None): + """ + Return a list of the images that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + ret = {} + conn = get_conn() + + for item in conn.list_images()["items"]: + image = {"id": item["id"]} + image.update(item["properties"]) + ret[image["name"]] = image + + return ret + + +def list_images(call=None, kwargs=None): + """ + List all the images with alias by location + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_images my-profitbricks-config location=us/las + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_images function must be called with -f or --function." + ) + + if not version_compatible("4.0"): + raise SaltCloudNotFound( + "The 'image_alias' feature requires the profitbricks SDK v4.0.0 or greater." + ) + + ret = {} + conn = get_conn() + + if kwargs.get("location") is not None: + item = conn.get_location(kwargs.get("location"), 3) + ret[item["id"]] = {"image_alias": item["properties"]["imageAliases"]} + return ret + + for item in conn.list_locations(3)["items"]: + ret[item["id"]] = {"image_alias": item["properties"]["imageAliases"]} + + return ret + + +def avail_sizes(call=None): + """ + Return a dict of all available VM sizes on the cloud provider with + relevant data. Latest version can be found at: + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + + sizes = { + "Micro Instance": {"id": "1", "ram": 1024, "disk": 50, "cores": 1}, + "Small Instance": {"id": "2", "ram": 2048, "disk": 50, "cores": 1}, + "Medium Instance": {"id": "3", "ram": 4096, "disk": 50, "cores": 2}, + "Large Instance": {"id": "4", "ram": 7168, "disk": 50, "cores": 4}, + "Extra Large Instance": {"id": "5", "ram": 14336, "disk": 50, "cores": 8}, + "Memory Intensive Instance Medium": { + "id": "6", + "ram": 28672, + "disk": 50, + "cores": 4, + }, + "Memory Intensive Instance Large": { + "id": "7", + "ram": 57344, + "disk": 50, + "cores": 8, + }, + } + + return sizes + + +def get_size(vm_): + """ + Return the VM's size object + """ + vm_size = config.get_cloud_config_value("size", vm_, __opts__) + sizes = avail_sizes() + + if not vm_size: + return sizes["Small Instance"] + + for size in sizes: + combinations = (str(sizes[size]["id"]), str(size)) + if vm_size and str(vm_size) in combinations: + return sizes[size] + raise SaltCloudNotFound(f"The specified size, '{vm_size}', could not be found.") + + +def get_datacenter_id(): + """ + Return datacenter ID from provider configuration + """ + datacenter_id = config.get_cloud_config_value( + "datacenter_id", get_configured_provider(), __opts__, search_global=False + ) + + conn = get_conn() + + try: + conn.get_datacenter(datacenter_id=datacenter_id) + except PBNotFoundError: + log.error("Failed to get datacenter: %s", datacenter_id) + raise + + return datacenter_id + + +def list_loadbalancers(call=None): + """ + Return a list of the loadbalancers that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-loadbalancers option" + ) + + ret = {} + conn = get_conn() + datacenter = get_datacenter(conn) + + for item in conn.list_loadbalancers(datacenter["id"])["items"]: + lb = {"id": item["id"]} + lb.update(item["properties"]) + ret[lb["name"]] = lb + + return ret + + +def create_loadbalancer(call=None, kwargs=None): + """ + Creates a loadbalancer within the datacenter from the provider config. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_loadbalancer profitbricks name=mylb + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_address function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + conn = get_conn() + datacenter_id = get_datacenter_id() + loadbalancer = LoadBalancer( + name=kwargs.get("name"), ip=kwargs.get("ip"), dhcp=kwargs.get("dhcp") + ) + + response = conn.create_loadbalancer(datacenter_id, loadbalancer) + _wait_for_completion(conn, response, 60, "loadbalancer") + + return response + + +def get_datacenter(conn): + """ + Return the datacenter from the config provider datacenter ID + """ + datacenter_id = get_datacenter_id() + + for item in conn.list_datacenters()["items"]: + if item["id"] == datacenter_id: + return item + + raise SaltCloudNotFound( + f"The specified datacenter '{datacenter_id}' could not be found." + ) + + +def create_datacenter(call=None, kwargs=None): + """ + Creates a virtual datacenter based on supplied parameters. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_datacenter profitbricks name=mydatacenter + location=us/las description="my description" + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_address function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + if kwargs.get("name") is None: + raise SaltCloudExecutionFailure('The "name" parameter is required') + + if kwargs.get("location") is None: + raise SaltCloudExecutionFailure('The "location" parameter is required') + + conn = get_conn() + datacenter = Datacenter( + name=kwargs["name"], + location=kwargs["location"], + description=kwargs.get("description"), + ) + + response = conn.create_datacenter(datacenter) + _wait_for_completion(conn, response, 60, "create_datacenter") + + return response + + +def get_disk_type(vm_): + """ + Return the type of disk to use. Either 'HDD' (default) or 'SSD'. + """ + return config.get_cloud_config_value( + "disk_type", vm_, __opts__, default="HDD", search_global=False + ) + + +def get_wait_timeout(vm_): + """ + Return the wait_for_timeout for resource provisioning. + """ + return config.get_cloud_config_value( + "wait_for_timeout", vm_, __opts__, default=15 * 60, search_global=False + ) + + +def get_image(vm_): + """ + Return the image object to use + """ + vm_image = config.get_cloud_config_value("image", vm_, __opts__).encode( + "ascii", "salt-cloud-force-ascii" + ) + + images = avail_images() + for key in images: + if vm_image and vm_image in (images[key]["id"], images[key]["name"]): + return images[key] + + raise SaltCloudNotFound(f"The specified image, '{vm_image}', could not be found.") + + +def list_datacenters(conn=None, call=None): + """ + List all the data centers + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_datacenters my-profitbricks-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_datacenters function must be called with -f or --function." + ) + + datacenters = [] + + if not conn: + conn = get_conn() + + for item in conn.list_datacenters()["items"]: + datacenter = {"id": item["id"]} + datacenter.update(item["properties"]) + datacenters.append({item["properties"]["name"]: datacenter}) + + return {"Datacenters": datacenters} + + +def list_nodes(conn=None, call=None): + """ + Return a list of VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + if not conn: + conn = get_conn() + + ret = {} + datacenter_id = get_datacenter_id() + + try: + nodes = conn.list_servers(datacenter_id=datacenter_id) + except PBNotFoundError: + log.error("Failed to get nodes list from datacenter: %s", datacenter_id) + raise + + for item in nodes["items"]: + node = {"id": item["id"]} + node.update(item["properties"]) + node["state"] = node.pop("vmState") + ret[node["name"]] = node + + return ret + + +def list_nodes_full(conn=None, call=None): + """ + Return a list of the VMs that are on the provider, with all fields + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + if not conn: + conn = get_conn() # pylint: disable=E0602 + + ret = {} + datacenter_id = get_datacenter_id() + nodes = conn.list_servers(datacenter_id=datacenter_id, depth=3) + + for item in nodes["items"]: + node = {"id": item["id"]} + node.update(item["properties"]) + node["state"] = node.pop("vmState") + node["public_ips"] = [] + node["private_ips"] = [] + if item["entities"]["nics"]["items"] > 0: + for nic in item["entities"]["nics"]["items"]: + if nic["properties"]["ips"]: + pass + ip_address = nic["properties"]["ips"][0] + if salt.utils.cloud.is_public_ip(ip_address): + node["public_ips"].append(ip_address) + else: + node["private_ips"].append(ip_address) + + ret[node["name"]] = node + + __utils__["cloud.cache_node_list"]( + ret, _get_active_provider_name().split(":")[0], __opts__ + ) + + return ret + + +def reserve_ipblock(call=None, kwargs=None): + """ + Reserve the IP Block + """ + if call == "action": + raise SaltCloudSystemExit( + "The reserve_ipblock function must be called with -f or --function." + ) + + conn = get_conn() + + if kwargs is None: + kwargs = {} + + ret = {} + ret["ips"] = [] + + if kwargs.get("location") is None: + raise SaltCloudExecutionFailure('The "location" parameter is required') + location = kwargs.get("location") + + size = 1 + if kwargs.get("size") is not None: + size = kwargs.get("size") + + block = conn.reserve_ipblock(IPBlock(size=size, location=location)) + for item in block["properties"]["ips"]: + ret["ips"].append(item) + + return ret + + +def show_instance(name, call=None): + """ + Show the details from the provider concerning an instance + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + nodes = list_nodes_full() + __utils__["cloud.cache_node"](nodes[name], _get_active_provider_name(), __opts__) + return nodes[name] + + +def get_node(conn, name): + """ + Return a node for the named VM + """ + datacenter_id = get_datacenter_id() + + for item in conn.list_servers(datacenter_id)["items"]: + if item["properties"]["name"] == name: + node = {"id": item["id"]} + node.update(item["properties"]) + return node + + +def ssh_interface(vm_): + """ + Return the ssh_interface type to connect to. Either 'public_ips' (default) + or 'private_ips'. + """ + return config.get_cloud_config_value( + "ssh_interface", vm_, __opts__, default="public_ips", search_global=False + ) + + +def _get_nics(vm_): + """ + Create network interfaces on appropriate LANs as defined in cloud profile. + """ + nics = [] + if "public_lan" in vm_: + firewall_rules = [] + # Set LAN to public if it already exists, otherwise create a new + # public LAN. + if "public_firewall_rules" in vm_: + firewall_rules = _get_firewall_rules(vm_["public_firewall_rules"]) + nic = NIC( + lan=set_public_lan(int(vm_["public_lan"])), + name="public", + firewall_rules=firewall_rules, + ) + if "public_ips" in vm_: + nic.ips = _get_ip_addresses(vm_["public_ips"]) + nics.append(nic) + + if "private_lan" in vm_: + firewall_rules = [] + if "private_firewall_rules" in vm_: + firewall_rules = _get_firewall_rules(vm_["private_firewall_rules"]) + nic = NIC( + lan=int(vm_["private_lan"]), name="private", firewall_rules=firewall_rules + ) + if "private_ips" in vm_: + nic.ips = _get_ip_addresses(vm_["private_ips"]) + if "nat" in vm_ and "private_ips" not in vm_: + nic.nat = vm_["nat"] + nics.append(nic) + return nics + + +def set_public_lan(lan_id): + """ + Enables public Internet access for the specified public_lan. If no public + LAN is available, then a new public LAN is created. + """ + conn = get_conn() + datacenter_id = get_datacenter_id() + + try: + lan = conn.get_lan(datacenter_id=datacenter_id, lan_id=lan_id) + if not lan["properties"]["public"]: + conn.update_lan(datacenter_id=datacenter_id, lan_id=lan_id, public=True) + return lan["id"] + except Exception: # pylint: disable=broad-except + lan = conn.create_lan(datacenter_id, LAN(public=True, name="Public LAN")) + return lan["id"] + + +def get_public_keys(vm_): + """ + Retrieve list of SSH public keys. + """ + key_filename = config.get_cloud_config_value( + "ssh_public_key", vm_, __opts__, search_global=False, default=None + ) + if key_filename is not None: + key_filename = os.path.expanduser(key_filename) + if not os.path.isfile(key_filename): + raise SaltCloudConfigError( + f"The defined ssh_public_key '{key_filename}' does not exist" + ) + ssh_keys = [] + with salt.utils.files.fopen(key_filename) as rfh: + for key in rfh.readlines(): + ssh_keys.append(salt.utils.stringutils.to_unicode(key)) + + return ssh_keys + + +def get_key_filename(vm_): + """ + Check SSH private key file and return absolute path if exists. + """ + key_filename = config.get_cloud_config_value( + "ssh_private_key", vm_, __opts__, search_global=False, default=None + ) + if key_filename is not None: + key_filename = os.path.expanduser(key_filename) + if not os.path.isfile(key_filename): + raise SaltCloudConfigError( + f"The defined ssh_private_key '{key_filename}' does not exist" + ) + + return key_filename + + +def signal_event(vm_, event, description): + args = __utils__["cloud.filter_event"]( + event, vm_, ["name", "profile", "provider", "driver"] + ) + + __utils__["cloud.fire_event"]( + "event", + description, + "salt/cloud/{}/creating".format(vm_["name"]), + args=args, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + (_get_active_provider_name() or "profitbricks"), + vm_["profile"], + ) + is False + ): + return False + except AttributeError: + pass + + if "image_alias" in vm_ and not version_compatible("4.0"): + raise SaltCloudNotFound( + "The 'image_alias' parameter requires the profitbricks " + "SDK v4.0.0 or greater." + ) + + if "image" not in vm_ and "image_alias" not in vm_: + log.error("The image or image_alias parameter is required.") + + signal_event(vm_, "creating", "starting create") + + data = None + datacenter_id = get_datacenter_id() + conn = get_conn() + + # Assemble list of network interfaces from the cloud profile config. + nics = _get_nics(vm_) + + # Assemble list of volumes from the cloud profile config. + volumes = [_get_system_volume(vm_)] + if "volumes" in vm_: + volumes.extend(_get_data_volumes(vm_)) + + # Assembla the composite server object. + server = _get_server(vm_, volumes, nics) + + signal_event(vm_, "requesting", "requesting instance") + + try: + data = conn.create_server(datacenter_id=datacenter_id, server=server) + log.info( + "Create server request ID: %s", + data["requestId"], + exc_info_on_loglevel=logging.DEBUG, + ) + + _wait_for_completion(conn, data, get_wait_timeout(vm_), "create_server") + except PBError as exc: + log.error( + "Error creating %s on ProfitBricks\n\n" + "The following exception was thrown by the profitbricks library " + "when trying to run the initial deployment: \n%s", + vm_["name"], + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + except Exception as exc: # pylint: disable=W0703 + log.error( + "Error creating %s \n\nError: \n%s", + vm_["name"], + exc, + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + vm_["server_id"] = data["id"] + + def __query_node_data(vm_, data): + """ + Query node data until node becomes available. + """ + running = False + try: + data = show_instance(vm_["name"], "action") + if not data: + return False + log.debug( + "Loaded node data for %s:\nname: %s\nstate: %s", + vm_["name"], + pprint.pformat(data["name"]), + data["state"], + ) + except Exception as err: # pylint: disable=broad-except + log.error( + "Failed to get nodes list: %s", + err, + # Show the trackback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + # Trigger a failure in the wait for IP function + return False + + running = data["state"] == "RUNNING" + if not running: + # Still not running, trigger another iteration + return + + if ssh_interface(vm_) == "private_lan" and data["private_ips"]: + vm_["ssh_host"] = data["private_ips"][0] + + if ssh_interface(vm_) != "private_lan" and data["public_ips"]: + vm_["ssh_host"] = data["public_ips"][0] + + return data + + try: + data = salt.utils.cloud.wait_for_ip( + __query_node_data, + update_args=(vm_, data), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=10 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc.message)) + + log.debug("VM is now running") + log.info("Created Cloud VM %s", vm_) + log.debug("%s VM creation details:\n%s", vm_, pprint.pformat(data)) + + signal_event(vm_, "created", "created instance") + + if "ssh_host" in vm_: + vm_["key_filename"] = get_key_filename(vm_) + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + ret.update(data) + return ret + else: + raise SaltCloudSystemExit("A valid IP address was not found.") + + +def destroy(name, call=None): + """ + destroy a machine by name + + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: array of booleans , true if successfully stopped and true if + successfully removed + + CLI Example: + + .. code-block:: bash + + salt-cloud -d vm_name + + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + datacenter_id = get_datacenter_id() + conn = get_conn() + node = get_node(conn, name) + attached_volumes = None + + delete_volumes = config.get_cloud_config_value( + "delete_volumes", get_configured_provider(), __opts__, search_global=False + ) + # Get volumes before the server is deleted + attached_volumes = conn.get_attached_volumes( + datacenter_id=datacenter_id, server_id=node["id"] + ) + + conn.delete_server(datacenter_id=datacenter_id, server_id=node["id"]) + + # The server is deleted and now is safe to delete the volumes + if delete_volumes: + for vol in attached_volumes["items"]: + log.debug("Deleting volume %s", vol["id"]) + conn.delete_volume(datacenter_id=datacenter_id, volume_id=vol["id"]) + log.debug("Deleted volume %s", vol["id"]) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return True + + +def reboot(name, call=None): + """ + reboot a machine by name + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: true if successful + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot vm_name + """ + datacenter_id = get_datacenter_id() + conn = get_conn() + node = get_node(conn, name) + + conn.reboot_server(datacenter_id=datacenter_id, server_id=node["id"]) + + return True + + +def stop(name, call=None): + """ + stop a machine by name + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: true if successful + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop vm_name + """ + datacenter_id = get_datacenter_id() + conn = get_conn() + node = get_node(conn, name) + + conn.stop_server(datacenter_id=datacenter_id, server_id=node["id"]) + + return True + + +def start(name, call=None): + """ + start a machine by name + :param name: name given to the machine + :param call: call value in this case is 'action' + :return: true if successful + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start vm_name + """ + datacenter_id = get_datacenter_id() + conn = get_conn() + node = get_node(conn, name) + + conn.start_server(datacenter_id=datacenter_id, server_id=node["id"]) + + return True + + +def _override_size(vm_): + """ + Apply any extra component overrides to VM from the cloud profile. + """ + vm_size = get_size(vm_) + + if "cores" in vm_: + vm_size["cores"] = vm_["cores"] + + if "ram" in vm_: + vm_size["ram"] = vm_["ram"] + + return vm_size + + +def _get_server(vm_, volumes, nics): + """ + Construct server instance from cloud profile config + """ + # Apply component overrides to the size from the cloud profile config + vm_size = _override_size(vm_) + + # Set the server availability zone from the cloud profile config + availability_zone = config.get_cloud_config_value( + "availability_zone", vm_, __opts__, default=None, search_global=False + ) + + # Assign CPU family from the cloud profile config + cpu_family = config.get_cloud_config_value( + "cpu_family", vm_, __opts__, default=None, search_global=False + ) + + # Contruct server object + return Server( + name=vm_["name"], + ram=vm_size["ram"], + availability_zone=availability_zone, + cores=vm_size["cores"], + cpu_family=cpu_family, + create_volumes=volumes, + nics=nics, + ) + + +def _get_system_volume(vm_): + """ + Construct VM system volume list from cloud profile config + """ + + # Override system volume size if 'disk_size' is defined in cloud profile + disk_size = get_size(vm_)["disk"] + if "disk_size" in vm_: + disk_size = vm_["disk_size"] + + # Construct the system volume + volume = Volume( + name="{} Storage".format(vm_["name"]), + size=disk_size, + disk_type=get_disk_type(vm_), + ) + + if "image_password" in vm_: + image_password = vm_["image_password"] + volume.image_password = image_password + + # Retrieve list of SSH public keys + ssh_keys = get_public_keys(vm_) + volume.ssh_keys = ssh_keys + + if "image_alias" in vm_.keys(): + volume.image_alias = vm_["image_alias"] + else: + volume.image = get_image(vm_)["id"] + # Set volume availability zone if defined in the cloud profile + if "disk_availability_zone" in vm_: + volume.availability_zone = vm_["disk_availability_zone"] + + return volume + + +def _get_data_volumes(vm_): + """ + Construct a list of optional data volumes from the cloud profile + """ + ret = [] + volumes = vm_["volumes"] + for key, value in volumes.items(): + # Verify the required 'disk_size' property is present in the cloud + # profile config + if "disk_size" not in volumes[key].keys(): + raise SaltCloudConfigError(f"The volume '{key}' is missing 'disk_size'") + # Use 'HDD' if no 'disk_type' property is present in cloud profile + if "disk_type" not in volumes[key].keys(): + volumes[key]["disk_type"] = "HDD" + + # Construct volume object and assign to a list. + volume = Volume( + name=key, + size=volumes[key]["disk_size"], + disk_type=volumes[key]["disk_type"], + licence_type="OTHER", + ) + + # Set volume availability zone if defined in the cloud profile + if "disk_availability_zone" in volumes[key].keys(): + volume.availability_zone = volumes[key]["disk_availability_zone"] + + ret.append(volume) + + return ret + + +def _get_ip_addresses(ip_addresses): + """ + Construct a list of ip address + """ + ret = [] + for item in ip_addresses: + ret.append(item) + + return ret + + +def _get_firewall_rules(firewall_rules): + """ + Construct a list of optional firewall rules from the cloud profile. + """ + ret = [] + for key, value in firewall_rules.items(): + # Verify the required 'protocol' property is present in the cloud + # profile config + if "protocol" not in firewall_rules[key].keys(): + raise SaltCloudConfigError( + f"The firewall rule '{key}' is missing 'protocol'" + ) + ret.append( + FirewallRule( + name=key, + protocol=firewall_rules[key].get("protocol", None), + source_mac=firewall_rules[key].get("source_mac", None), + source_ip=firewall_rules[key].get("source_ip", None), + target_ip=firewall_rules[key].get("target_ip", None), + port_range_start=firewall_rules[key].get("port_range_start", None), + port_range_end=firewall_rules[key].get("port_range_end", None), + icmp_type=firewall_rules[key].get("icmp_type", None), + icmp_code=firewall_rules[key].get("icmp_code", None), + ) + ) + + return ret + + +def _wait_for_completion(conn, promise, wait_timeout, msg): + """ + Poll request status until resource is provisioned. + """ + if not promise: + return + wait_timeout = time.time() + wait_timeout + while wait_timeout > time.time(): + time.sleep(5) + operation_result = conn.get_request( + request_id=promise["requestId"], status=True + ) + + if operation_result["metadata"]["status"] == "DONE": + return + elif operation_result["metadata"]["status"] == "FAILED": + raise Exception( + "Request: {}, requestId: {} failed to complete:\n{}".format( + msg, + str(promise["requestId"]), + operation_result["metadata"]["message"], + ) + ) + + raise Exception( + 'Timed out waiting for asynchronous operation {} "{}" to complete.'.format( + msg, str(promise["requestId"]) + ) + ) diff --git a/salt/cloud/clouds/proxmox.py b/salt/cloud/clouds/proxmox.py new file mode 100644 index 000000000000..bab4dc9e08dd --- /dev/null +++ b/salt/cloud/clouds/proxmox.py @@ -0,0 +1,1371 @@ +""" +Proxmox Cloud Module +====================== + +.. versionadded:: 2014.7.0 + +The Proxmox cloud module is used to control access to cloud providers using +the Proxmox system (KVM / OpenVZ / LXC). + +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or + ``/etc/salt/cloud.providers.d/proxmox.conf``: + +.. code-block:: yaml + + my-proxmox-config: + # Proxmox account information + user: myuser@pam or myuser@pve + password: mypassword + url: hypervisor.domain.tld + port: 8006 + driver: proxmox + verify_ssl: True + +.. warning:: + This cloud provider will be removed from Salt in version 3009.0 in favor of + the `saltext.proxmox Salt Extension + `_ + +:maintainer: Frank Klaassen +:depends: requests >= 2.2.1 +:depends: IPy >= 0.81 +""" + +import logging +import pprint +import re +import socket +import time +import urllib + +import salt.config as config +import salt.utils.cloud +import salt.utils.json +from salt.exceptions import ( + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudSystemExit, +) + +try: + import requests + + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +try: + from IPy import IP + + HAS_IPY = True +except ImportError: + HAS_IPY = False + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "proxmox" + +__deprecated__ = ( + 3009, + "proxmox", + "https://github.com/salt-extensions/saltext-proxmox", +) + + +def __virtual__(): + """ + Check for PROXMOX configurations + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("user",) + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + deps = {"requests": HAS_REQUESTS, "IPy": HAS_IPY} + return config.check_driver_dependencies(__virtualname__, deps) + + +url = None +port = None +ticket = None +csrf = None +verify_ssl = None +api = None + + +def _authenticate(): + """ + Retrieve CSRF and API tickets for the Proxmox API + """ + global url, port, ticket, csrf, verify_ssl + url = config.get_cloud_config_value( + "url", get_configured_provider(), __opts__, search_global=False + ) + port = config.get_cloud_config_value( + "port", get_configured_provider(), __opts__, default=8006, search_global=False + ) + username = ( + config.get_cloud_config_value( + "user", get_configured_provider(), __opts__, search_global=False + ), + ) + passwd = config.get_cloud_config_value( + "password", get_configured_provider(), __opts__, search_global=False + ) + verify_ssl = config.get_cloud_config_value( + "verify_ssl", + get_configured_provider(), + __opts__, + default=True, + search_global=False, + ) + + connect_data = {"username": username, "password": passwd} + full_url = f"https://{url}:{port}/api2/json/access/ticket" + + response = requests.post( + full_url, verify=verify_ssl, data=connect_data, timeout=120 + ) + response.raise_for_status() + returned_data = response.json() + + ticket = {"PVEAuthCookie": returned_data["data"]["ticket"]} + csrf = str(returned_data["data"]["CSRFPreventionToken"]) + + +def query(conn_type, option, post_data=None): + """ + Execute the HTTP request to the API + """ + if ticket is None or csrf is None or url is None: + log.debug("Not authenticated yet, doing that now..") + _authenticate() + + full_url = f"https://{url}:{port}/api2/json/{option}" + + log.debug("%s: %s (%s)", conn_type, full_url, post_data) + + httpheaders = { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "salt-cloud-proxmox", + } + + if conn_type == "post": + httpheaders["CSRFPreventionToken"] = csrf + response = requests.post( + full_url, + verify=verify_ssl, + data=post_data, + cookies=ticket, + headers=httpheaders, + timeout=120, + ) + elif conn_type == "put": + httpheaders["CSRFPreventionToken"] = csrf + response = requests.put( + full_url, + verify=verify_ssl, + data=post_data, + cookies=ticket, + headers=httpheaders, + timeout=120, + ) + elif conn_type == "delete": + httpheaders["CSRFPreventionToken"] = csrf + response = requests.delete( + full_url, + verify=verify_ssl, + data=post_data, + cookies=ticket, + headers=httpheaders, + timeout=120, + ) + elif conn_type == "get": + response = requests.get( + full_url, verify=verify_ssl, cookies=ticket, timeout=120 + ) + + try: + response.raise_for_status() + except requests.exceptions.RequestException: + # Log the details of the response. + log.error("Error in %s query to %s:\n%s", conn_type, full_url, response.text) + raise + + try: + returned_data = response.json() + if "data" not in returned_data: + raise SaltCloudExecutionFailure + return returned_data["data"] + except Exception: # pylint: disable=broad-except + log.error("Error in trying to process JSON") + log.error(response) + + +def _get_vm_by_name(name, allDetails=False): + """ + Since Proxmox works based op id's rather than names as identifiers this + requires some filtering to retrieve the required information. + """ + vms = get_resources_vms(includeConfig=allDetails) + if name in vms: + return vms[name] + + log.info('VM with name "%s" could not be found.', name) + return False + + +def _get_vm_by_id(vmid, allDetails=False): + """ + Retrieve a VM based on the ID. + """ + for vm_name, vm_details in get_resources_vms(includeConfig=allDetails).items(): + if str(vm_details["vmid"]) == str(vmid): + return vm_details + + log.info('VM with ID "%s" could not be found.', vmid) + return False + + +def _get_next_vmid(): + """ + Proxmox allows the use of alternative ids instead of autoincrementing. + Because of that its required to query what the first available ID is. + """ + return int(query("get", "cluster/nextid")) + + +def _check_ip_available(ip_addr): + """ + Proxmox VMs refuse to start when the IP is already being used. + This function can be used to prevent VMs being created with duplicate + IP's or to generate a warning. + """ + for vm_name, vm_details in get_resources_vms(includeConfig=True).items(): + vm_config = vm_details["config"] + if ip_addr in vm_config["ip_address"] or vm_config["ip_address"] == ip_addr: + log.debug('IP "%s" is already defined', ip_addr) + return False + + log.debug("IP '%s' is available to be defined", ip_addr) + return True + + +def _parse_proxmox_upid(node, vm_=None): + """ + Upon requesting a task that runs for a longer period of time a UPID is given. + This includes information about the job and can be used to lookup information in the log. + """ + ret = {} + + upid = node + # Parse node response + node = node.split(":") + if node[0] == "UPID": + ret["node"] = str(node[1]) + ret["pid"] = str(node[2]) + ret["pstart"] = str(node[3]) + ret["starttime"] = str(node[4]) + ret["type"] = str(node[5]) + ret["vmid"] = str(node[6]) + ret["user"] = str(node[7]) + # include the upid again in case we'll need it again + ret["upid"] = str(upid) + + if vm_ is not None and "technology" in vm_: + ret["technology"] = str(vm_["technology"]) + + return ret + + +def _lookup_proxmox_task(upid): + """ + Retrieve the (latest) logs and retrieve the status for a UPID. + This can be used to verify whether a task has completed. + """ + log.debug("Getting creation status for upid: %s", upid) + tasks = query("get", "cluster/tasks") + + if tasks: + for task in tasks: + if task["upid"] == upid: + log.debug("Found upid task: %s", task) + return task + + return False + + +def get_resources_nodes(call=None, resFilter=None): + """ + Retrieve all hypervisors (nodes) available on this environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_resources_nodes my-proxmox-config + """ + log.debug("Getting resource: nodes.. (filter: %s)", resFilter) + resources = query("get", "cluster/resources") + + ret = {} + for resource in resources: + if "type" in resource and resource["type"] == "node": + name = resource["node"] + ret[name] = resource + + if resFilter is not None: + log.debug("Filter given: %s, returning requested resource: nodes", resFilter) + return ret[resFilter] + + log.debug("Filter not given: %s, returning all resource: nodes", ret) + return ret + + +def get_resources_vms(call=None, resFilter=None, includeConfig=True): + """ + Retrieve all VMs available on this environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_resources_vms my-proxmox-config + """ + timeoutTime = time.time() + 60 + while True: + log.debug("Getting resource: vms.. (filter: %s)", resFilter) + resources = query("get", "cluster/resources") + ret = {} + badResource = False + for resource in resources: + if "type" in resource and resource["type"] in ["openvz", "qemu", "lxc"]: + try: + name = resource["name"] + except KeyError: + badResource = True + log.debug("No name in VM resource %s", repr(resource)) + break + + ret[name] = resource + + if includeConfig: + # Requested to include the detailed configuration of a VM + ret[name]["config"] = get_vmconfig( + ret[name]["vmid"], ret[name]["node"], ret[name]["type"] + ) + + if time.time() > timeoutTime: + raise SaltCloudExecutionTimeout("FAILED to get the proxmox resources vms") + + # Carry on if there wasn't a bad resource return from Proxmox + if not badResource: + break + + time.sleep(0.5) + + if resFilter is not None: + log.debug("Filter given: %s, returning requested resource: nodes", resFilter) + return ret[resFilter] + + log.debug("Filter not given: %s, returning all resource: nodes", ret) + return ret + + +def script(vm_): + """ + Return the script deployment object + """ + script_name = config.get_cloud_config_value("script", vm_, __opts__) + if not script_name: + script_name = "bootstrap-salt" + + return salt.utils.cloud.os_script( + script_name, + vm_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, vm_) + ), + ) + + +def avail_locations(call=None): + """ + Return a list of the hypervisors (nodes) which this Proxmox PVE machine manages + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-locations my-proxmox-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + # could also use the get_resources_nodes but speed is ~the same + nodes = query("get", "nodes") + + ret = {} + for node in nodes: + name = node["node"] + ret[name] = node + + return ret + + +def avail_images(call=None, location="local"): + """ + Return a list of the images that are on the provider + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-images my-proxmox-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + ret = {} + for host_name, host_details in avail_locations().items(): + for item in query("get", f"nodes/{host_name}/storage/{location}/content"): + ret[item["volid"]] = item + return ret + + +def list_nodes(call=None): + """ + Return a list of the VMs that are managed by the provider + + CLI Example: + + .. code-block:: bash + + salt-cloud -Q my-proxmox-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + ret = {} + for vm_name, vm_details in get_resources_vms(includeConfig=True).items(): + log.debug("VM_Name: %s", vm_name) + log.debug("vm_details: %s", vm_details) + + # Limit resultset on what Salt-cloud demands: + ret[vm_name] = {} + ret[vm_name]["id"] = str(vm_details["vmid"]) + ret[vm_name]["image"] = str(vm_details["vmid"]) + ret[vm_name]["size"] = str(vm_details["disk"]) + ret[vm_name]["state"] = str(vm_details["status"]) + + # Figure out which is which to put it in the right column + private_ips = [] + public_ips = [] + + if ( + "ip_address" in vm_details["config"] + and vm_details["config"]["ip_address"] != "-" + ): + ips = vm_details["config"]["ip_address"].split(" ") + for ip_ in ips: + if IP(ip_).iptype() == "PRIVATE": + private_ips.append(str(ip_)) + else: + public_ips.append(str(ip_)) + + ret[vm_name]["private_ips"] = private_ips + ret[vm_name]["public_ips"] = public_ips + + return ret + + +def list_nodes_full(call=None): + """ + Return a list of the VMs that are on the provider + + CLI Example: + + .. code-block:: bash + + salt-cloud -F my-proxmox-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + return get_resources_vms(includeConfig=True) + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + + CLI Example: + + .. code-block:: bash + + salt-cloud -S my-proxmox-config + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full(), + __opts__["query.selection"], + call, + ) + + +def _stringlist_to_dictionary(input_string): + """ + Convert a stringlist (comma separated settings) to a dictionary + + The result of the string setting1=value1,setting2=value2 will be a python dictionary: + + {'setting1':'value1','setting2':'value2'} + """ + return dict(item.strip().split("=") for item in input_string.split(",") if item) + + +def _dictionary_to_stringlist(input_dict): + """ + Convert a dictionary to a stringlist (comma separated settings) + + The result of the dictionary {'setting1':'value1','setting2':'value2'} will be: + + setting1=value1,setting2=value2 + """ + return ",".join(f"{k}={input_dict[k]}" for k in sorted(input_dict.keys())) + + +def _reconfigure_clone(vm_, vmid): + """ + If we cloned a machine, see if we need to reconfigure any of the options such as net0, + ide2, etc. This enables us to have a different cloud-init ISO mounted for each VM that's brought up + :param vm_: + :return: + """ + if not vm_.get("technology") == "qemu": + log.warning("Reconfiguring clones is only available under `qemu`") + return + + # Determine which settings can be reconfigured. + query_path = "nodes/{}/qemu/{}/config" + valid_settings = set(_get_properties(query_path.format("{node}", "{vmid}"), "POST")) + + log.info("Configuring cloned VM") + + # Modify the settings for the VM one at a time so we can see any problems with the values + # as quickly as possible + for setting in vm_: + postParams = None + if setting == "vmid": + pass # vmid gets passed in the URL and can't be reconfigured + elif re.match(r"^net(\d+)$", setting): + # net strings are a list of comma seperated settings. We need to merge the settings so that + # the setting in the profile only changes the settings it touches and the other settings + # are left alone. An example of why this is necessary is because the MAC address is set + # in here and generally you don't want to alter or have to know the MAC address of the new + # instance, but you may want to set the VLAN bridge + data = query("get", "nodes/{}/qemu/{}/config".format(vm_["host"], vmid)) + + # Generate a dictionary of settings from the existing string + new_setting = {} + if setting in data: + new_setting.update(_stringlist_to_dictionary(data[setting])) + + # Merge the new settings (as a dictionary) into the existing dictionary to get the + # new merged settings + new_setting.update(_stringlist_to_dictionary(vm_[setting])) + + # Convert the dictionary back into a string list + postParams = {setting: _dictionary_to_stringlist(new_setting)} + + elif setting == "sshkeys": + postParams = {setting: urllib.parse.quote(vm_[setting], safe="")} + elif setting in valid_settings: + postParams = {setting: vm_[setting]} + + if postParams: + query( + "post", + "nodes/{}/qemu/{}/config".format(vm_["host"], vmid), + postParams, + ) + + +def create(vm_): + """ + Create a single VM from a data dict + + CLI Example: + + .. code-block:: bash + + salt-cloud -p proxmox-ubuntu vmhostname + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "proxmox", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + ret = {} + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", vm_["name"]) + + if "use_dns" in vm_ and "ip_address" not in vm_: + use_dns = vm_["use_dns"] + if use_dns: + from socket import gaierror, gethostbyname + + try: + ip_address = gethostbyname(str(vm_["name"])) + except gaierror: + log.debug("Resolving of %s failed", vm_["name"]) + else: + vm_["ip_address"] = str(ip_address) + + try: + newid = _get_next_vmid() + data = create_node(vm_, newid) + except Exception as exc: # pylint: disable=broad-except + msg = str(exc) + if ( + isinstance(exc, requests.exceptions.RequestException) + and exc.response is not None + ): + msg = msg + "\n" + exc.response.text + log.error( + "Error creating %s on PROXMOX\n\n" + "The following exception was thrown when trying to " + "run the initial deployment: \n%s", + vm_["name"], + msg, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + ret["creation_data"] = data + name = vm_["name"] # hostname which we know + vmid = data["vmid"] # vmid which we have received + host = data["node"] # host which we have received + nodeType = data["technology"] # VM tech (Qemu / OpenVZ) + + agent_get_ip = vm_.get("agent_get_ip", False) + + # wait until the vm has been created so we can start it + if not wait_for_created(data["upid"], timeout=300): + return {"Error": f"Unable to create {name}, command timed out"} + + if vm_.get("clone") is True: + _reconfigure_clone(vm_, vmid) + + # VM has been created. Starting.. + if not start(name, vmid, call="action"): + log.error("Node %s (%s) failed to start!", name, vmid) + raise SaltCloudExecutionFailure + + # Wait until the VM has fully started + log.debug('Waiting for state "running" for vm %s on %s', vmid, host) + if not wait_for_state(vmid, "running"): + return {"Error": f"Unable to start {name}, command timed out"} + + if agent_get_ip is True: + try: + ip_address = salt.utils.cloud.wait_for_fun( + _find_agent_ip, vm_=vm_, vmid=vmid + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # If VM was created but we can't connect, destroy it. + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + log.debug("Using IP address %s", ip_address) + else: + # Determine which IP to use in order of preference, *after* the VM + # has been created and started. Doing the lookup before the VM is + # running gave the provider no chance to discover an IP that + # Proxmox itself reports for the running guest (see #68353). + ip_address = None + if "ip_address" in vm_: + ip_address = str(vm_["ip_address"]) + else: + try: + node_info = list_nodes().get(name, {}) + except Exception: # pylint: disable=broad-except + node_info = {} + public_ips = node_info.get("public_ips") or [] + private_ips = node_info.get("private_ips") or [] + if public_ips: + ip_address = str(public_ips[0]) + elif private_ips: + ip_address = str(private_ips[0]) + + if ip_address is None: + raise SaltCloudExecutionFailure("Could not determine an IP address to use") + + log.debug("Using IP address %s", ip_address) + + ssh_username = config.get_cloud_config_value( + "ssh_username", vm_, __opts__, default="root" + ) + ssh_password = config.get_cloud_config_value( + "password", + vm_, + __opts__, + ) + + ret["ip_address"] = ip_address + ret["username"] = ssh_username + ret["password"] = ssh_password + + vm_["ssh_host"] = ip_address + vm_["password"] = ssh_password + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + # Report success! + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + ) + + return ret + + +def preferred_ip(vm_, ips): + """ + Return either an 'ipv4' (default) or 'ipv6' address depending on 'protocol' option. + The list of 'ipv4' IPs is filtered by ignore_cidr() to remove any unreachable private addresses. + """ + proto = config.get_cloud_config_value( + "protocol", vm_, __opts__, default="ipv4", search_global=False + ) + + family = socket.AF_INET + if proto == "ipv6": + family = socket.AF_INET6 + for ip in ips: + ignore_ip = ignore_cidr(vm_, ip) + if ignore_ip: + continue + try: + socket.inet_pton(family, ip) + return ip + except Exception: # pylint: disable=broad-except + continue + return False + + +def ignore_cidr(vm_, ip): + """ + Return True if we are to ignore the specified IP. + """ + from ipaddress import ip_address, ip_network + + cidrs = config.get_cloud_config_value( + "ignore_cidr", vm_, __opts__, default=[], search_global=False + ) + if cidrs and isinstance(cidrs, str): + cidrs = [cidrs] + for cidr in cidrs or []: + if ip_address(ip) in ip_network(cidr): + log.warning("IP %r found within %r; ignoring it.", ip, cidr) + return True + + return False + + +def _find_agent_ip(vm_, vmid): + """ + If VM is started we would return the IP-addresses that are returned by the qemu agent on the VM. + """ + + # This functionality is only available on qemu + if not vm_.get("technology") == "qemu": + log.warning("Find agent IP is only available under `qemu`") + return + + # Create an empty list of IP-addresses: + ips = [] + + endpoint = "nodes/{}/qemu/{}/agent/network-get-interfaces".format(vm_["host"], vmid) + interfaces = query("get", endpoint) + + # If we get a result from the agent, parse it + for interface in interfaces["result"]: + + # Skip interface if hardware-address is 00:00:00:00:00:00 (loopback interface) + if str(interface.get("hardware-address")) == "00:00:00:00:00:00": + continue + + # Skip entries without ip-addresses information + if "ip-addresses" not in interface: + continue + + for if_addr in interface["ip-addresses"]: + ip_addr = if_addr.get("ip-address") + if ip_addr is not None: + ips.append(str(ip_addr)) + + if len(ips) > 0: + return preferred_ip(vm_, ips) + + raise SaltCloudExecutionFailure + + +def _import_api(): + """ + Download https:///pve-docs/api-viewer/apidoc.js + Extract content of pveapi var (json formatted) + Load this json content into global variable "api" + """ + global api + full_url = f"https://{url}:{port}/pve-docs/api-viewer/apidoc.js" + returned_data = requests.get(full_url, verify=verify_ssl, timeout=120) + + re_filter = re.compile(" (?:pveapi|apiSchema) = (.*)^;", re.DOTALL | re.MULTILINE) + api_json = re_filter.findall(returned_data.text)[0] + api = salt.utils.json.loads(api_json) + + +def _get_properties(path="", method="GET", forced_params=None): + """ + Return the parameter list from api for defined path and HTTP method + """ + if api is None: + _import_api() + + sub = api + path_levels = [level for level in path.split("/") if level != ""] + search_path = "" + props = [] + parameters = set([] if forced_params is None else forced_params) + # Browse all path elements but last + for elem in path_levels[:-1]: + search_path += "/" + elem + # Lookup for a dictionary with path = "requested path" in list" and return its children + sub = next(item for item in sub if item["path"] == search_path)["children"] + # Get leaf element in path + search_path += "/" + path_levels[-1] + sub = next(item for item in sub if item["path"] == search_path) + try: + # get list of properties for requested method + props = sub["info"][method]["parameters"]["properties"].keys() + except KeyError as exc: + log.error('method not found: "%s"', exc) + for prop in props: + numerical = re.match(r"(\w+)\[n\]", prop) + # generate (arbitrarily) 10 properties for duplicatable properties identified by: + # "prop[n]" + if numerical: + for i in range(10): + parameters.add(numerical.group(1) + str(i)) + else: + parameters.add(prop) + return parameters + + +def create_node(vm_, newid): + """ + Build and submit the requestdata to create a new node + """ + newnode = {} + + if "technology" not in vm_: + vm_["technology"] = "openvz" # default virt tech if none is given + + if vm_["technology"] not in ["qemu", "openvz", "lxc"]: + # Wrong VM type given + log.error( + "Wrong VM type. Valid options are: qemu, openvz (proxmox3) or lxc" + " (proxmox4)" + ) + raise SaltCloudExecutionFailure + + if "host" not in vm_: + # Use globally configured/default location + vm_["host"] = config.get_cloud_config_value( + "default_host", get_configured_provider(), __opts__, search_global=False + ) + + if vm_["host"] is None: + # No location given for the profile + log.error("No host given to create this VM on") + raise SaltCloudExecutionFailure + + # Required by both OpenVZ and Qemu (KVM) + vmhost = vm_["host"] + newnode["vmid"] = newid + + for prop in "cpuunits", "description", "memory", "onboot": + if prop in vm_: # if the property is set, use it for the VM request + newnode[prop] = vm_[prop] + + if vm_["technology"] == "openvz": + # OpenVZ related settings, using non-default names: + newnode["hostname"] = vm_["name"] + newnode["ostemplate"] = vm_["image"] + + # optional VZ settings + for prop in ( + "cpus", + "disk", + "ip_address", + "nameserver", + "password", + "swap", + "poolid", + "storage", + ): + if prop in vm_: # if the property is set, use it for the VM request + newnode[prop] = vm_[prop] + + elif vm_["technology"] == "lxc": + # LXC related settings, using non-default names: + newnode["hostname"] = vm_["name"] + newnode["ostemplate"] = vm_["image"] + + static_props = ( + "cpuunits", + "cpulimit", + "rootfs", + "cores", + "description", + "memory", + "onboot", + "net0", + "password", + "nameserver", + "swap", + "storage", + "rootfs", + ) + for prop in _get_properties("/nodes/{node}/lxc", "POST", static_props): + if prop in vm_: # if the property is set, use it for the VM request + newnode[prop] = vm_[prop] + + if "pubkey" in vm_: + newnode["ssh-public-keys"] = vm_["pubkey"] + + # inform user the "disk" option is not supported for LXC hosts + if "disk" in vm_: + log.warning( + 'The "disk" option is not supported for LXC hosts and was ignored' + ) + + # LXC specific network config + # OpenVZ allowed specifying IP and gateway. To ease migration from + # Proxmox 3, I've mapped the ip_address and gw to a generic net0 config. + # If you need more control, please use the net0 option directly. + # This also assumes a /24 subnet. + if "ip_address" in vm_ and "net0" not in vm_: + newnode["net0"] = ( + "bridge=vmbr0,ip=" + vm_["ip_address"] + "/24,name=eth0,type=veth" + ) + + # gateway is optional and does not assume a default + if "gw" in vm_: + newnode["net0"] = newnode["net0"] + ",gw=" + vm_["gw"] + + elif vm_["technology"] == "qemu": + # optional Qemu settings + static_props = ( + "acpi", + "cores", + "cpu", + "pool", + "storage", + "sata0", + "ostype", + "ide2", + "net0", + ) + for prop in _get_properties("/nodes/{node}/qemu", "POST", static_props): + if prop in vm_: # if the property is set, use it for the VM request + # If specified, vmid will override newid. + newnode[prop] = vm_[prop] + + # The node is ready. Lets request it to be added + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", newnode, list(newnode) + ), + }, + sock_dir=__opts__["sock_dir"], + ) + + log.debug("Preparing to generate a node using these parameters: %s ", newnode) + if "clone" in vm_ and vm_["clone"] is True and vm_["technology"] == "qemu": + postParams = {} + postParams["newid"] = newnode["vmid"] + if "pool" in vm_: + postParams["pool"] = vm_["pool"] + + for prop in "description", "format", "full", "name": + if ( + "clone_" + prop in vm_ + ): # if the property is set, use it for the VM request + postParams[prop] = vm_["clone_" + prop] + + try: + int(vm_["clone_from"]) + except ValueError: + if ":" in vm_["clone_from"]: + vmhost = vm_["clone_from"].split(":")[0] + vm_["clone_from"] = vm_["clone_from"].split(":")[1] + + node = query( + "post", + "nodes/{}/qemu/{}/clone".format(vmhost, vm_["clone_from"]), + postParams, + ) + else: + node = query("post", "nodes/{}/{}".format(vmhost, vm_["technology"]), newnode) + result = _parse_proxmox_upid(node, vm_) + + # When cloning, the upid contains the clone_from vmid instead of the new vmid + result["vmid"] = newnode["vmid"] + + return result + + +def show_instance(name, call=None): + """ + Show the details from Proxmox concerning an instance + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + nodes = list_nodes_full() + __utils__["cloud.cache_node"](nodes[name], _get_active_provider_name(), __opts__) + return nodes[name] + + +def get_vmconfig(vmid, node=None, node_type="openvz"): + """ + Get VM configuration + """ + if node is None: + # We need to figure out which node this VM is on. + for host_name, host_details in avail_locations().items(): + for item in query("get", f"nodes/{host_name}/{node_type}"): + if item["vmid"] == vmid: + node = host_name + + # If we reached this point, we have all the information we need + data = query("get", f"nodes/{node}/{node_type}/{vmid}/config") + + return data + + +def wait_for_created(upid, timeout=300): + """ + Wait until a the vm has been created successfully + """ + start_time = time.time() + info = _lookup_proxmox_task(upid) + if not info: + log.error( + "wait_for_created: No task information retrieved based on given criteria." + ) + raise SaltCloudExecutionFailure + + while True: + if "status" in info and info["status"] == "OK": + log.debug("Host has been created!") + return True + time.sleep(3) # Little more patience, we're not in a hurry + if time.time() - start_time > timeout: + log.debug("Timeout reached while waiting for host to be created") + return False + info = _lookup_proxmox_task(upid) + + +def wait_for_state(vmid, state, timeout=300): + """ + Wait until a specific state has been reached on a node + """ + start_time = time.time() + node = get_vm_status(vmid=vmid) + if not node: + log.error("wait_for_state: No VM retrieved based on given criteria.") + raise SaltCloudExecutionFailure + + while True: + if node["status"] == state: + log.debug('Host %s is now in "%s" state!', node["name"], state) + return True + time.sleep(1) + if time.time() - start_time > timeout: + log.debug( + "Timeout reached while waiting for %s to become %s", node["name"], state + ) + return False + node = get_vm_status(vmid=vmid) + log.debug( + 'State for %s is: "%s" instead of "%s"', node["name"], node["status"], state + ) + + +def destroy(name, call=None): + """ + Destroy a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud --destroy mymachine + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + vmobj = _get_vm_by_name(name) + if vmobj is not None: + # stop the vm + if get_vm_status(vmid=vmobj["vmid"])["status"] != "stopped": + stop(name, vmobj["vmid"], "action") + + # wait until stopped + if not wait_for_state(vmobj["vmid"], "stopped"): + return {"Error": f"Unable to stop {name}, command timed out"} + + # required to wait a bit here, otherwise the VM is sometimes + # still locked and destroy fails. + time.sleep(3) + + query("delete", "nodes/{}/{}".format(vmobj["node"], vmobj["id"])) + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return {"Destroyed": f"{name} was destroyed."} + + +def set_vm_status(status, name=None, vmid=None): + """ + Convenience function for setting VM status + """ + log.debug("Set status to %s for %s (%s)", status, name, vmid) + + if vmid is not None: + log.debug("set_vm_status: via ID - VMID %s (%s): %s", vmid, name, status) + vmobj = _get_vm_by_id(vmid) + else: + log.debug("set_vm_status: via name - VMID %s (%s): %s", vmid, name, status) + vmobj = _get_vm_by_name(name) + + if not vmobj or "node" not in vmobj or "type" not in vmobj or "vmid" not in vmobj: + log.error("Unable to set status %s for %s (%s)", status, name, vmid) + raise SaltCloudExecutionTimeout + + log.debug("VM_STATUS: Has desired info (%s). Setting status..", vmobj) + data = query( + "post", + "nodes/{}/{}/{}/status/{}".format( + vmobj["node"], vmobj["type"], vmobj["vmid"], status + ), + ) + + result = _parse_proxmox_upid(data, vmobj) + + if result is not False and result is not None: + log.debug("Set_vm_status action result: %s", result) + return True + + return False + + +def get_vm_status(vmid=None, name=None): + """ + Get the status for a VM, either via the ID or the hostname + """ + if vmid is not None: + log.debug("get_vm_status: VMID %s", vmid) + vmobj = _get_vm_by_id(vmid) + elif name is not None: + log.debug("get_vm_status: name %s", name) + vmobj = _get_vm_by_name(name) + else: + log.debug("get_vm_status: No ID or NAME given") + raise SaltCloudExecutionFailure + + log.debug("VM found: %s", vmobj) + + if vmobj is not None and "node" in vmobj: + log.debug("VM_STATUS: Has desired info. Retrieving.. (%s)", vmobj["name"]) + data = query( + "get", + "nodes/{}/{}/{}/status/current".format( + vmobj["node"], vmobj["type"], vmobj["vmid"] + ), + ) + return data + + log.error("VM or requested status not found..") + return False + + +def start(name, vmid=None, call=None): + """ + Start a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start mymachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The start action must be called with -a or --action." + ) + + log.debug("Start: %s (%s) = Start", name, vmid) + if not set_vm_status("start", name, vmid=vmid): + log.error("Unable to bring VM %s (%s) up..", name, vmid) + raise SaltCloudExecutionFailure + + # xxx: TBD: Check here whether the status was actually changed to 'started' + + return {"Started": f"{name} was started."} + + +def stop(name, vmid=None, call=None): + """ + Stop a node ("pulling the plug"). + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop mymachine + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + if not set_vm_status("stop", name, vmid=vmid): + log.error("Unable to bring VM %s (%s) down..", name, vmid) + raise SaltCloudExecutionFailure + + # xxx: TBD: Check here whether the status was actually changed to 'stopped' + + return {"Stopped": f"{name} was stopped."} + + +def shutdown(name=None, vmid=None, call=None): + """ + Shutdown a node via ACPI. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a shutdown mymachine + """ + if call != "action": + raise SaltCloudSystemExit( + "The shutdown action must be called with -a or --action." + ) + + if not set_vm_status("shutdown", name, vmid=vmid): + log.error("Unable to shut VM %s (%s) down..", name, vmid) + raise SaltCloudExecutionFailure + + # xxx: TBD: Check here whether the status was actually changed to 'stopped' + + return {"Shutdown": f"{name} was shutdown."} diff --git a/salt/cloud/clouds/pyrax.py b/salt/cloud/clouds/pyrax.py new file mode 100644 index 000000000000..02dcec635409 --- /dev/null +++ b/salt/cloud/clouds/pyrax.py @@ -0,0 +1,106 @@ +""" +Pyrax Cloud Module +================== + +PLEASE NOTE: This module is currently in early development, and considered to +be experimental and unstable. It is not recommended for production use. Unless +you are actively developing code in this module, you should use the OpenStack +module instead. +""" + +import salt.config as config +import salt.utils.data + +# Import pyrax libraries +# This is typically against SaltStack coding styles, +# it should be 'import salt.utils.openstack.pyrax as suop'. Something +# in the loader is creating a name clash and making that form fail +from salt.utils.openstack import pyrax as suop + +__virtualname__ = "pyrax" + + +# Only load in this module is the PYRAX configurations are in place +def __virtual__(): + """ + Check for Pyrax configurations + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ( + "username", + "identity_url", + "compute_region", + ), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + return config.check_driver_dependencies(__virtualname__, {"pyrax": suop.HAS_PYRAX}) + + +def get_conn(conn_type): + """ + Return a conn object for the passed VM data + """ + vm_ = get_configured_provider() + + kwargs = vm_.copy() # pylint: disable=E1103 + + kwargs["username"] = vm_["username"] + kwargs["auth_endpoint"] = vm_.get("identity_url", None) + kwargs["region"] = vm_["compute_region"] + + conn = getattr(suop, conn_type) + + return conn(**kwargs) + + +def queues_exists(call, kwargs): + conn = get_conn("RackspaceQueues") + return conn.exists(kwargs["name"]) + + +def queues_show(call, kwargs): + conn = get_conn("RackspaceQueues") + return salt.utils.data.simple_types_filter(conn.show(kwargs["name"]).__dict__) + + +def queues_create(call, kwargs): + conn = get_conn("RackspaceQueues") + if conn.create(kwargs["name"]): + return salt.utils.data.simple_types_filter(conn.show(kwargs["name"]).__dict__) + else: + return {} + + +def queues_delete(call, kwargs): + conn = get_conn("RackspaceQueues") + if conn.delete(kwargs["name"]): + return {} + else: + return salt.utils.data.simple_types_filter(conn.show(kwargs["name"].__dict__)) diff --git a/salt/cloud/clouds/qingcloud.py b/salt/cloud/clouds/qingcloud.py new file mode 100644 index 000000000000..6c3bda925e4c --- /dev/null +++ b/salt/cloud/clouds/qingcloud.py @@ -0,0 +1,899 @@ +""" +QingCloud Cloud Module +====================== + +.. versionadded:: 2015.8.0 + +The QingCloud cloud module is used to control access to the QingCloud. +http://www.qingcloud.com/ + +Use of this module requires the ``access_key_id``, ``secret_access_key``, +``zone`` and ``key_filename`` parameter to be set. + +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or +``/etc/salt/cloud.providers.d/qingcloud.conf``: + +.. code-block:: yaml + + my-qingcloud: + driver: qingcloud + access_key_id: AKIDMRTGYONNLTFFRBQJ + secret_access_key: clYwH21U5UOmcov4aNV2V2XocaHCG3JZGcxEczFu + zone: pek2 + key_filename: /path/to/your.pem + +:depends: requests +""" + +import base64 +import hmac +import logging +import pprint +import time +import urllib.parse +from hashlib import sha256 + +import salt.config as config +import salt.utils.cloud +import salt.utils.data +import salt.utils.json +from salt.exceptions import ( + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudNotFound, + SaltCloudSystemExit, +) + +try: + import requests + + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "qingcloud" + +DEFAULT_QINGCLOUD_API_VERSION = 1 +DEFAULT_QINGCLOUD_SIGNATURE_VERSION = 1 + + +# Only load in this module if the qingcloud configurations are in place +def __virtual__(): + """ + Check for QingCloud configurations. + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ("access_key_id", "secret_access_key", "zone", "key_filename"), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + return config.check_driver_dependencies(__virtualname__, {"requests": HAS_REQUESTS}) + + +def _compute_signature(parameters, access_key_secret, method, path): + """ + Generate an API request signature. Detailed document can be found at: + + https://docs.qingcloud.com/api/common/signature.html + """ + parameters["signature_method"] = "HmacSHA256" + + string_to_sign = f"{method.upper()}\n{path}\n" + + keys = sorted(parameters.keys()) + pairs = [] + for key in keys: + val = str(parameters[key]).encode("utf-8") + pairs.append( + urllib.parse.quote(key, safe="") + "=" + urllib.parse.quote(val, safe="-_~") + ) + qs = "&".join(pairs) + string_to_sign += qs + + h = hmac.new(access_key_secret, digestmod=sha256) + h.update(string_to_sign) + + signature = base64.b64encode(h.digest()).strip() + + return signature + + +def query(params=None): + """ + Make a web call to QingCloud IaaS API. + """ + path = "https://api.qingcloud.com/iaas/" + + access_key_id = config.get_cloud_config_value( + "access_key_id", get_configured_provider(), __opts__, search_global=False + ) + access_key_secret = config.get_cloud_config_value( + "secret_access_key", get_configured_provider(), __opts__, search_global=False + ) + + verify_ssl = config.get_cloud_config_value( + "verify_ssl", + get_configured_provider(), + __opts__, + default=True, + search_global=False, + ) + + # public interface parameters + real_parameters = { + "access_key_id": access_key_id, + "signature_version": DEFAULT_QINGCLOUD_SIGNATURE_VERSION, + "time_stamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "version": DEFAULT_QINGCLOUD_API_VERSION, + } + + # include action or function parameters + if params: + for key, value in params.items(): + if isinstance(value, list): + for i in range(1, len(value) + 1): + if isinstance(value[i - 1], dict): + for sk, sv in value[i - 1].items(): + if isinstance(sv, dict) or isinstance(sv, list): + sv = salt.utils.json.dumps(sv, separators=(",", ":")) + real_parameters[f"{key}.{i}.{sk}"] = sv + else: + real_parameters[f"{key}.{i}"] = value[i - 1] + else: + real_parameters[key] = value + + # Calculate the string for Signature + signature = _compute_signature(real_parameters, access_key_secret, "GET", "/iaas/") + real_parameters["signature"] = signature + + # print('parameters:') + # pprint.pprint(real_parameters) + + request = requests.get(path, params=real_parameters, verify=verify_ssl, timeout=120) + + # print('url:') + # print(request.url) + + if request.status_code != 200: + raise SaltCloudSystemExit( + "An error occurred while querying QingCloud. HTTP Code: {} " + "Error: '{}'".format(request.status_code, request.text) + ) + + log.debug(request.url) + + content = request.text + result = salt.utils.json.loads(content) + + # print('response:') + # pprint.pprint(result) + + if result["ret_code"] != 0: + raise SaltCloudSystemExit(pprint.pformat(result.get("message", {}))) + + return result + + +def avail_locations(call=None): + """ + Return a dict of all available locations on the provider with + relevant data. + + CLI Examples: + + .. code-block:: bash + + salt-cloud --list-locations my-qingcloud + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + params = { + "action": "DescribeZones", + } + items = query(params=params) + + result = {} + for region in items["zone_set"]: + result[region["zone_id"]] = {} + for key in region: + result[region["zone_id"]][key] = str(region[key]) + + return result + + +def _get_location(vm_=None): + """ + Return the VM's location. Used by create(). + """ + locations = avail_locations() + + vm_location = str( + config.get_cloud_config_value("zone", vm_, __opts__, search_global=False) + ) + + if not vm_location: + raise SaltCloudNotFound("No location specified for this VM.") + + if vm_location in locations: + return vm_location + + raise SaltCloudNotFound( + f"The specified location, '{vm_location}', could not be found." + ) + + +def _get_specified_zone(kwargs=None, provider=None): + if provider is None: + provider = get_configured_provider() + + if isinstance(kwargs, dict): + zone = kwargs.get("zone", None) + if zone is not None: + return zone + + zone = provider["zone"] + return zone + + +def avail_images(kwargs=None, call=None): + """ + Return a list of the images that are on the provider. + + CLI Examples: + + .. code-block:: bash + + salt-cloud --list-images my-qingcloud + salt-cloud -f avail_images my-qingcloud zone=gd1 + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + if not isinstance(kwargs, dict): + kwargs = {} + + params = { + "action": "DescribeImages", + "provider": "system", + "zone": _get_specified_zone(kwargs, get_configured_provider()), + } + items = query(params=params) + + result = {} + for image in items["image_set"]: + result[image["image_id"]] = {} + for key in image: + result[image["image_id"]][key] = image[key] + + return result + + +def _get_image(vm_): + """ + Return the VM's image. Used by create(). + """ + images = avail_images() + vm_image = str( + config.get_cloud_config_value("image", vm_, __opts__, search_global=False) + ) + + if not vm_image: + raise SaltCloudNotFound("No image specified for this VM.") + + if vm_image in images: + return vm_image + + raise SaltCloudNotFound(f"The specified image, '{vm_image}', could not be found.") + + +def show_image(kwargs, call=None): + """ + Show the details from QingCloud concerning an image. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f show_image my-qingcloud image=trustysrvx64c + salt-cloud -f show_image my-qingcloud image=trustysrvx64c,coreos4 + salt-cloud -f show_image my-qingcloud image=trustysrvx64c zone=ap1 + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_images function must be called with -f or --function" + ) + + if not isinstance(kwargs, dict): + kwargs = {} + + images = kwargs["image"] + images = images.split(",") + + params = { + "action": "DescribeImages", + "images": images, + "zone": _get_specified_zone(kwargs, get_configured_provider()), + } + + items = query(params=params) + + if not items["image_set"]: + raise SaltCloudNotFound("The specified image could not be found.") + + result = {} + for image in items["image_set"]: + result[image["image_id"]] = {} + for key in image: + result[image["image_id"]][key] = image[key] + + return result + + +# QingCloud doesn't provide an API of geting instance sizes +QINGCLOUD_SIZES = { + "pek2": { + "c1m1": {"cpu": 1, "memory": "1G"}, + "c1m2": {"cpu": 1, "memory": "2G"}, + "c1m4": {"cpu": 1, "memory": "4G"}, + "c2m2": {"cpu": 2, "memory": "2G"}, + "c2m4": {"cpu": 2, "memory": "4G"}, + "c2m8": {"cpu": 2, "memory": "8G"}, + "c4m4": {"cpu": 4, "memory": "4G"}, + "c4m8": {"cpu": 4, "memory": "8G"}, + "c4m16": {"cpu": 4, "memory": "16G"}, + }, + "pek1": { + "small_b": {"cpu": 1, "memory": "1G"}, + "small_c": {"cpu": 1, "memory": "2G"}, + "medium_a": {"cpu": 2, "memory": "2G"}, + "medium_b": {"cpu": 2, "memory": "4G"}, + "medium_c": {"cpu": 2, "memory": "8G"}, + "large_a": {"cpu": 4, "memory": "4G"}, + "large_b": {"cpu": 4, "memory": "8G"}, + "large_c": {"cpu": 4, "memory": "16G"}, + }, +} +QINGCLOUD_SIZES["ap1"] = QINGCLOUD_SIZES["pek2"] +QINGCLOUD_SIZES["gd1"] = QINGCLOUD_SIZES["pek2"] + + +def avail_sizes(kwargs=None, call=None): + """ + Return a list of the instance sizes that are on the provider. + + CLI Examples: + + .. code-block:: bash + + salt-cloud --list-sizes my-qingcloud + salt-cloud -f avail_sizes my-qingcloud zone=pek2 + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + + zone = _get_specified_zone(kwargs, get_configured_provider()) + + result = {} + for size_key in QINGCLOUD_SIZES[zone]: + result[size_key] = {} + for attribute_key in QINGCLOUD_SIZES[zone][size_key]: + result[size_key][attribute_key] = QINGCLOUD_SIZES[zone][size_key][ + attribute_key + ] + + return result + + +def _get_size(vm_): + """ + Return the VM's size. Used by create(). + """ + sizes = avail_sizes() + + vm_size = str( + config.get_cloud_config_value("size", vm_, __opts__, search_global=False) + ) + + if not vm_size: + raise SaltCloudNotFound("No size specified for this instance.") + + if vm_size in sizes: + return vm_size + + raise SaltCloudNotFound(f"The specified size, '{vm_size}', could not be found.") + + +def _show_normalized_node(full_node): + """ + Normalize the QingCloud instance data. Used by list_nodes()-related + functions. + """ + public_ips = full_node.get("eip", []) + if public_ips: + public_ip = public_ips["eip_addr"] + public_ips = [ + public_ip, + ] + + private_ips = [] + for vxnet in full_node.get("vxnets", []): + private_ip = vxnet.get("private_ip", None) + if private_ip: + private_ips.append(private_ip) + + normalized_node = { + "id": full_node["instance_id"], + "image": full_node["image"]["image_id"], + "size": full_node["instance_type"], + "state": full_node["status"], + "private_ips": private_ips, + "public_ips": public_ips, + } + + return normalized_node + + +def list_nodes_full(call=None): + """ + Return a list of the instances that are on the provider. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -F my-qingcloud + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + zone = _get_specified_zone() + + params = { + "action": "DescribeInstances", + "zone": zone, + "status": ["pending", "running", "stopped", "suspended"], + } + items = query(params=params) + + log.debug("Total %s instances found in zone %s", items["total_count"], zone) + + result = {} + + if items["total_count"] == 0: + return result + + for node in items["instance_set"]: + normalized_node = _show_normalized_node(node) + node.update(normalized_node) + + result[node["instance_id"]] = node + + provider = _get_active_provider_name() or "qingcloud" + if ":" in provider: + comps = provider.split(":") + provider = comps[0] + + __opts__["update_cachedir"] = True + __utils__["cloud.cache_node_list"](result, provider, __opts__) + + return result + + +def list_nodes(call=None): + """ + Return a list of the instances that are on the provider. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -Q my-qingcloud + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + nodes = list_nodes_full() + + ret = {} + for instance_id, full_node in nodes.items(): + ret[instance_id] = { + "id": full_node["id"], + "image": full_node["image"], + "size": full_node["size"], + "state": full_node["state"], + "public_ips": full_node["public_ips"], + "private_ips": full_node["private_ips"], + } + + return ret + + +def list_nodes_min(call=None): + """ + Return a list of the instances that are on the provider. Only a list of + instances names, and their state, is returned. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f list_nodes_min my-qingcloud + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_nodes_min function must be called with -f or --function." + ) + + nodes = list_nodes_full() + + result = {} + for instance_id, full_node in nodes.items(): + result[instance_id] = { + "name": full_node["instance_name"], + "status": full_node["status"], + } + + return result + + +def list_nodes_select(call=None): + """ + Return a list of the instances that are on the provider, with selected + fields. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -S my-qingcloud + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full("function"), + __opts__["query.selection"], + call, + ) + + +def show_instance(instance_id, call=None, kwargs=None): + """ + Show the details from QingCloud concerning an instance. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a show_instance i-2f733r5n + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + params = { + "action": "DescribeInstances", + "instances.1": instance_id, + "zone": _get_specified_zone(kwargs=None, provider=get_configured_provider()), + } + items = query(params=params) + + if items["total_count"] == 0: + raise SaltCloudNotFound( + f"The specified instance, '{instance_id}', could not be found." + ) + + full_node = items["instance_set"][0] + normalized_node = _show_normalized_node(full_node) + full_node.update(normalized_node) + + result = full_node + + return result + + +def _query_node_data(instance_id): + data = show_instance(instance_id, call="action") + + if not data: + return False + + if data.get("private_ips", []): + return data + + +def create(vm_): + """ + Create a single instance from a data dict. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -p qingcloud-ubuntu-c1m1 hostname1 + salt-cloud -m /path/to/mymap.sls -P + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "qingcloud", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", vm_["name"]) + + # params + params = { + "action": "RunInstances", + "instance_name": vm_["name"], + "zone": _get_location(vm_), + "instance_type": _get_size(vm_), + "image_id": _get_image(vm_), + "vxnets.1": vm_["vxnets"], + "login_mode": vm_["login_mode"], + "login_keypair": vm_["login_keypair"], + } + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", params, list(params) + ), + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + result = query(params) + new_instance_id = result["instances"][0] + + try: + data = salt.utils.cloud.wait_for_ip( + _query_node_data, + update_args=(new_instance_id,), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=10 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + private_ip = data["private_ips"][0] + + log.debug("VM %s is now running", private_ip) + + vm_["ssh_host"] = private_ip + + # The instance is booted and accessible, let's Salt it! + __utils__["cloud.bootstrap"](vm_, __opts__) + + log.info("Created Cloud VM '%s'", vm_["name"]) + + log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return data + + +def script(vm_): + """ + Return the script deployment object. + """ + deploy_script = salt.utils.cloud.os_script( + config.get_cloud_config_value("script", vm_, __opts__), + vm_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, vm_) + ), + ) + + return deploy_script + + +def start(instance_id, call=None): + """ + Start an instance. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a start i-2f733r5n + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + log.info("Starting instance %s", instance_id) + + params = { + "action": "StartInstances", + "zone": _get_specified_zone(provider=get_configured_provider()), + "instances.1": instance_id, + } + result = query(params) + + return result + + +def stop(instance_id, force=False, call=None): + """ + Stop an instance. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a stop i-2f733r5n + salt-cloud -a stop i-2f733r5n force=True + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + log.info("Stopping instance %s", instance_id) + + params = { + "action": "StopInstances", + "zone": _get_specified_zone(provider=get_configured_provider()), + "instances.1": instance_id, + "force": int(force), + } + result = query(params) + + return result + + +def reboot(instance_id, call=None): + """ + Reboot an instance. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a reboot i-2f733r5n + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + log.info("Rebooting instance %s", instance_id) + + params = { + "action": "RestartInstances", + "zone": _get_specified_zone(provider=get_configured_provider()), + "instances.1": instance_id, + } + result = query(params) + + return result + + +def destroy(instance_id, call=None): + """ + Destroy an instance. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a destroy i-2f733r5n + salt-cloud -d i-2f733r5n + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + instance_data = show_instance(instance_id, call="action") + name = instance_data["instance_name"] + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + params = { + "action": "TerminateInstances", + "zone": _get_specified_zone(provider=get_configured_provider()), + "instances.1": instance_id, + } + result = query(params) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return result diff --git a/salt/cloud/clouds/scaleway.py b/salt/cloud/clouds/scaleway.py new file mode 100644 index 000000000000..d261e50360fc --- /dev/null +++ b/salt/cloud/clouds/scaleway.py @@ -0,0 +1,471 @@ +""" +Scaleway Cloud Module +===================== + +.. versionadded:: 2015.8.0 + +The Scaleway cloud module is used to interact with your Scaleway BareMetal +Servers. + +Use of this module only requires the ``api_key`` parameter to be set. Set up +the cloud configuration at ``/etc/salt/cloud.providers`` or +``/etc/salt/cloud.providers.d/scaleway.conf``: + +.. code-block:: yaml + + scaleway-config: + # Scaleway organization and token + access_key: 0e604a2c-aea6-4081-acb2-e1d1258ef95c + token: be8fd96b-04eb-4d39-b6ba-a9edbcf17f12 + driver: scaleway + +""" + +import logging +import os +import pprint +import time + +import salt.config as config +import salt.utils.cloud +import salt.utils.json +from salt.exceptions import ( + SaltCloudConfigError, + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudNotFound, + SaltCloudSystemExit, +) + +log = logging.getLogger(__name__) + +__virtualname__ = "scaleway" + + +# Only load in this module if the Scaleway configurations are in place +def __virtual__(): + """ + Check for Scaleway configurations. + """ + if get_configured_provider() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """Return the first configured instance.""" + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("token",) + ) + + +def avail_images(call=None): + """Return a list of the images that are on the provider.""" + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + items = query(method="images", root="marketplace_root") + ret = {} + for image in items["images"]: + ret[image["id"]] = {} + for item in image: + ret[image["id"]][item] = str(image[item]) + + return ret + + +def list_nodes(call=None): + """Return a list of the BareMetal servers that are on the provider.""" + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + items = query(method="servers") + + ret = {} + for node in items["servers"]: + public_ips = [] + private_ips = [] + image_id = "" + + if node.get("public_ip"): + public_ips = [node["public_ip"]["address"]] + + if node.get("private_ip"): + private_ips = [node["private_ip"]] + + if node.get("image"): + image_id = node["image"]["id"] + + ret[node["name"]] = { + "id": node["id"], + "image_id": image_id, + "public_ips": public_ips, + "private_ips": private_ips, + "size": node["volumes"]["0"]["size"], + "state": node["state"], + } + return ret + + +def list_nodes_full(call=None): + """Return a list of the BareMetal servers that are on the provider.""" + if call == "action": + raise SaltCloudSystemExit( + "list_nodes_full must be called with -f or --function" + ) + + items = query(method="servers") + + # For each server, iterate on its parameters. + ret = {} + for node in items["servers"]: + ret[node["name"]] = {} + for item in node: + value = node[item] + ret[node["name"]][item] = value + return ret + + +def list_nodes_select(call=None): + """Return a list of the BareMetal servers that are on the provider, with + select fields. + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full("function"), + __opts__["query.selection"], + call, + ) + + +def get_image(server_): + """Return the image object to use.""" + images = avail_images() + server_image = str( + config.get_cloud_config_value("image", server_, __opts__, search_global=False) + ) + for image in images: + if server_image in (images[image]["name"], images[image]["id"]): + return images[image]["id"] + raise SaltCloudNotFound( + f"The specified image, '{server_image}', could not be found." + ) + + +def create_node(args): + """Create a node.""" + node = query(method="servers", args=args, http_method="POST") + + action = query( + method="servers", + server_id=node["server"]["id"], + command="action", + args={"action": "poweron"}, + http_method="POST", + ) + return node + + +def create(server_): + """ + Create a single BareMetal server from a data dict. + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + server_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "scaleway", + server_["profile"], + vm_=server_, + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(server_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", server_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating a BareMetal server %s", server_["name"]) + + access_key = config.get_cloud_config_value( + "access_key", get_configured_provider(), __opts__, search_global=False + ) + + commercial_type = config.get_cloud_config_value( + "commercial_type", server_, __opts__, default="C1" + ) + + key_filename = config.get_cloud_config_value( + "ssh_key_file", server_, __opts__, search_global=False, default=None + ) + + if key_filename is not None and not os.path.isfile(key_filename): + raise SaltCloudConfigError( + f"The defined key_filename '{key_filename}' does not exist" + ) + + ssh_password = config.get_cloud_config_value("ssh_password", server_, __opts__) + + kwargs = { + "name": server_["name"], + "organization": access_key, + "image": get_image(server_), + "commercial_type": commercial_type, + } + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(server_["name"]), + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", kwargs, list(kwargs) + ), + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + ret = create_node(kwargs) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on Scaleway\n\n" + "The following exception was thrown when trying to " + "run the initial deployment: %s", + server_["name"], + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + def __query_node_data(server_name): + """Called to check if the server has a public IP address.""" + data = show_instance(server_name, "action") + if data and data.get("public_ip"): + return data + return False + + try: + data = salt.utils.cloud.wait_for_ip( + __query_node_data, + update_args=(server_["name"],), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", server_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", server_, __opts__, default=10 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + # It might be already up, let's destroy it! + destroy(server_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + server_["ssh_host"] = data["public_ip"]["address"] + server_["ssh_password"] = ssh_password + server_["key_filename"] = key_filename + ret = __utils__["cloud.bootstrap"](server_, __opts__) + + ret.update(data) + + log.info("Created BareMetal server '%s'", server_["name"]) + log.debug( + "'%s' BareMetal server creation details:\n%s", + server_["name"], + pprint.pformat(data), + ) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(server_["name"]), + args=__utils__["cloud.filter_event"]( + "created", server_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def query( + method="servers", + server_id=None, + command=None, + args=None, + http_method="GET", + root="api_root", +): + """Make a call to the Scaleway API.""" + + if root == "api_root": + default_url = "https://cp-par1.scaleway.com" + else: + default_url = "https://api-marketplace.scaleway.com" + + vm_ = get_configured_provider() + + base_path = str( + config.get_cloud_config_value( + root, + vm_, + __opts__, + search_global=False, + default=default_url, + ) + ) + + path = f"{base_path}/{method}/" + + if server_id: + path += f"{server_id}/" + + if command: + path += command + + if not isinstance(args, dict): + args = {} + + token = config.get_cloud_config_value("token", vm_, __opts__, search_global=False) + + data = salt.utils.json.dumps(args) + + request = __utils__["http.query"]( + path, + method=http_method, + data=data, + headers={ + "X-Auth-Token": token, + "User-Agent": "salt-cloud", + "Content-Type": "application/json", + }, + ) + if request.status_code > 299: + raise SaltCloudSystemExit( + "An error occurred while querying Scaleway. HTTP Code: {} " + "Error: '{}'".format(request.status_code, request.text) + ) + + # success without data + if request["status"] == 204: + return True + + return salt.utils.json.loads(request["body"]) + + +def script(server_): + """Return the script deployment object.""" + return salt.utils.cloud.os_script( + config.get_cloud_config_value("script", server_, __opts__), + server_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, server_) + ), + ) + + +def show_instance(name, call=None): + """Show the details from a Scaleway BareMetal server.""" + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + node = _get_node(name) + __utils__["cloud.cache_node"](node, _get_active_provider_name(), __opts__) + return node + + +def _get_node(name): + for attempt in reversed(list(range(10))): + try: + return list_nodes_full()[name] + except KeyError: + log.debug( + "Failed to get the data for node '%s'. Remaining attempts: %s", + name, + attempt, + ) + # Just a little delay between attempts... + time.sleep(0.5) + return {} + + +def destroy(name, call=None): + """Destroy a node. Will check termination protection and warn if enabled. + + CLI Example: + + .. code-block:: bash + + salt-cloud --destroy mymachine + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + data = show_instance(name, call="action") + node = query( + method="servers", + server_id=data["id"], + command="action", + args={"action": "terminate"}, + http_method="POST", + ) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return node diff --git a/salt/cloud/clouds/softlayer.py b/salt/cloud/clouds/softlayer.py new file mode 100644 index 000000000000..c0f282f84ed4 --- /dev/null +++ b/salt/cloud/clouds/softlayer.py @@ -0,0 +1,659 @@ +""" +SoftLayer Cloud Module +====================== + +The SoftLayer cloud module is used to control access to the SoftLayer VPS +system. + +Use of this module only requires the ``apikey`` parameter. Set up the cloud +configuration at: + +``/etc/salt/cloud.providers`` or ``/etc/salt/cloud.providers.d/softlayer.conf``: + +.. code-block:: yaml + + my-softlayer-config: + # SoftLayer account api key + user: MYLOGIN + apikey: JVkbSJDGHSDKUKSDJfhsdklfjgsjdkflhjlsdfffhgdgjkenrtuinv + driver: softlayer + +The SoftLayer Python Library needs to be installed in order to use the +SoftLayer salt.cloud modules. See: https://pypi.python.org/pypi/SoftLayer + +:depends: softlayer +""" + +import logging +import time + +import salt.config as config +import salt.utils.cloud +from salt.exceptions import SaltCloudSystemExit + +# Attempt to import softlayer lib +try: + import SoftLayer + + HAS_SLLIBS = True +except ImportError: + HAS_SLLIBS = False + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "softlayer" + + +# Only load in this module if the SoftLayer configurations are in place +def __virtual__(): + """ + Check for SoftLayer configurations. + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("apikey",) + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + return config.check_driver_dependencies(__virtualname__, {"softlayer": HAS_SLLIBS}) + + +def script(vm_): + """ + Return the script deployment object + """ + deploy_script = salt.utils.cloud.os_script( + config.get_cloud_config_value("script", vm_, __opts__), + vm_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, vm_) + ), + ) + return deploy_script + + +def get_conn(service="SoftLayer_Virtual_Guest"): + """ + Return a conn object for the passed VM data + """ + client = SoftLayer.Client( + username=config.get_cloud_config_value( + "user", get_configured_provider(), __opts__, search_global=False + ), + api_key=config.get_cloud_config_value( + "apikey", get_configured_provider(), __opts__, search_global=False + ), + ) + return client[service] + + +def avail_locations(call=None): + """ + List all available locations + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + ret = {} + conn = get_conn() + response = conn.getCreateObjectOptions() + # return response + for datacenter in response["datacenters"]: + # return data center + ret[datacenter["template"]["datacenter"]["name"]] = { + "name": datacenter["template"]["datacenter"]["name"], + } + return ret + + +def avail_sizes(call=None): + """ + Return a dict of all available VM sizes on the cloud provider with + relevant data. This data is provided in three dicts. + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + + ret = { + "block devices": {}, + "memory": {}, + "processors": {}, + } + conn = get_conn() + response = conn.getCreateObjectOptions() + for device in response["blockDevices"]: + # return device['template']['blockDevices'] + ret["block devices"][device["itemPrice"]["item"]["description"]] = { + "name": device["itemPrice"]["item"]["description"], + "capacity": device["template"]["blockDevices"][0]["diskImage"]["capacity"], + } + for memory in response["memory"]: + ret["memory"][memory["itemPrice"]["item"]["description"]] = { + "name": memory["itemPrice"]["item"]["description"], + "maxMemory": memory["template"]["maxMemory"], + } + for processors in response["processors"]: + ret["processors"][processors["itemPrice"]["item"]["description"]] = { + "name": processors["itemPrice"]["item"]["description"], + "start cpus": processors["template"]["startCpus"], + } + return ret + + +def avail_images(call=None): + """ + Return a dict of all available VM images on the cloud provider. + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + ret = {} + conn = get_conn() + response = conn.getCreateObjectOptions() + for image in response["operatingSystems"]: + ret[image["itemPrice"]["item"]["description"]] = { + "name": image["itemPrice"]["item"]["description"], + "template": image["template"]["operatingSystemReferenceCode"], + } + return ret + + +def list_custom_images(call=None): + """ + Return a dict of all custom VM images on the cloud provider. + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_vlans function must be called with -f or --function." + ) + + ret = {} + conn = get_conn("SoftLayer_Account") + response = conn.getBlockDeviceTemplateGroups() + for image in response: + if "globalIdentifier" not in image: + continue + ret[image["name"]] = { + "id": image["id"], + "name": image["name"], + "globalIdentifier": image["globalIdentifier"], + } + if "note" in image: + ret[image["name"]]["note"] = image["note"] + return ret + + +def get_location(vm_=None): + """ + Return the location to use, in this order: + - CLI parameter + - VM parameter + - Cloud profile setting + """ + return __opts__.get( + "location", + config.get_cloud_config_value( + "location", + vm_ or get_configured_provider(), + __opts__, + # default=DEFAULT_LOCATION, + search_global=False, + ), + ) + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "softlayer", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + name = vm_["name"] + hostname = name + domain = config.get_cloud_config_value("domain", vm_, __opts__, default=None) + if domain is None: + raise SaltCloudSystemExit("A domain name is required for the SoftLayer driver.") + + if vm_.get("use_fqdn"): + name = ".".join([name, domain]) + vm_["name"] = name + + __utils__["cloud.fire_event"]( + "event", + "starting create", + f"salt/cloud/{name}/creating", + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", name) + conn = get_conn() + kwargs = { + "hostname": hostname, + "domain": domain, + "startCpus": vm_["cpu_number"], + "maxMemory": vm_["ram"], + "hourlyBillingFlag": vm_["hourly_billing"], + } + + local_disk_flag = config.get_cloud_config_value( + "local_disk", vm_, __opts__, default=False + ) + kwargs["localDiskFlag"] = local_disk_flag + + if "image" in vm_: + kwargs["operatingSystemReferenceCode"] = vm_["image"] + kwargs["blockDevices"] = [] + disks = vm_["disk_size"] + + if isinstance(disks, int): + disks = [str(disks)] + elif isinstance(disks, str): + disks = [size.strip() for size in disks.split(",")] + + count = 0 + for disk in disks: + # device number '1' is reserved for the SWAP disk + if count == 1: + count += 1 + block_device = { + "device": str(count), + "diskImage": {"capacity": str(disk)}, + } + kwargs["blockDevices"].append(block_device) + count += 1 + + # Upper bound must be 5 as we're skipping '1' for the SWAP disk ID + if count > 5: + log.warning( + "More that 5 disks were specified for %s ." + "The first 5 disks will be applied to the VM, " + "but the remaining disks will be ignored.\n" + "Please adjust your cloud configuration to only " + "specify a maximum of 5 disks.", + name, + ) + break + + elif "global_identifier" in vm_: + kwargs["blockDeviceTemplateGroup"] = { + "globalIdentifier": vm_["global_identifier"] + } + + location = get_location(vm_) + if location: + kwargs["datacenter"] = {"name": location} + + private_vlan = config.get_cloud_config_value( + "private_vlan", vm_, __opts__, default=False + ) + if private_vlan: + kwargs["primaryBackendNetworkComponent"] = {"networkVlan": {"id": private_vlan}} + + private_network = config.get_cloud_config_value( + "private_network", vm_, __opts__, default=False + ) + if bool(private_network) is True: + kwargs["privateNetworkOnlyFlag"] = "True" + + public_vlan = config.get_cloud_config_value( + "public_vlan", vm_, __opts__, default=False + ) + if public_vlan: + kwargs["primaryNetworkComponent"] = {"networkVlan": {"id": public_vlan}} + + public_security_groups = config.get_cloud_config_value( + "public_security_groups", vm_, __opts__, default=False + ) + if public_security_groups: + secgroups = [ + {"securityGroup": {"id": int(sg)}} for sg in public_security_groups + ] + pnc = kwargs.get("primaryNetworkComponent", {}) + pnc["securityGroupBindings"] = secgroups + kwargs.update({"primaryNetworkComponent": pnc}) + + private_security_groups = config.get_cloud_config_value( + "private_security_groups", vm_, __opts__, default=False + ) + + if private_security_groups: + secgroups = [ + {"securityGroup": {"id": int(sg)}} for sg in private_security_groups + ] + pbnc = kwargs.get("primaryBackendNetworkComponent", {}) + pbnc["securityGroupBindings"] = secgroups + kwargs.update({"primaryBackendNetworkComponent": pbnc}) + + max_net_speed = config.get_cloud_config_value( + "max_net_speed", vm_, __opts__, default=10 + ) + if max_net_speed: + kwargs["networkComponents"] = [{"maxSpeed": int(max_net_speed)}] + + post_uri = config.get_cloud_config_value("post_uri", vm_, __opts__, default=None) + if post_uri: + kwargs["postInstallScriptUri"] = post_uri + + dedicated_host_id = config.get_cloud_config_value( + "dedicated_host_id", vm_, __opts__, default=None + ) + if dedicated_host_id: + kwargs["dedicatedHost"] = {"id": dedicated_host_id} + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + f"salt/cloud/{name}/requesting", + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", kwargs, list(kwargs) + ), + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + response = conn.createObject(kwargs) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on SoftLayer\n\n" + "The following exception was thrown when trying to " + "run the initial deployment: \n%s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + ip_type = "primaryIpAddress" + private_ssh = config.get_cloud_config_value( + "private_ssh", vm_, __opts__, default=False + ) + private_wds = config.get_cloud_config_value( + "private_windows", vm_, __opts__, default=False + ) + if private_ssh or private_wds or public_vlan is None: + ip_type = "primaryBackendIpAddress" + + def wait_for_ip(): + """ + Wait for the IP address to become available + """ + nodes = list_nodes_full() + if ip_type in nodes[hostname]: + return nodes[hostname][ip_type] + time.sleep(1) + return False + + ip_address = salt.utils.cloud.wait_for_fun( + wait_for_ip, + timeout=config.get_cloud_config_value( + "wait_for_fun_timeout", vm_, __opts__, default=15 * 60 + ), + ) + if config.get_cloud_config_value("deploy", vm_, __opts__) is not True: + return show_instance(hostname, call="action") + + SSH_PORT = 22 + WINDOWS_DS_PORT = 445 + managing_port = SSH_PORT + if config.get_cloud_config_value( + "windows", vm_, __opts__ + ) or config.get_cloud_config_value("win_installer", vm_, __opts__): + managing_port = WINDOWS_DS_PORT + + ssh_connect_timeout = config.get_cloud_config_value( + "ssh_connect_timeout", vm_, __opts__, 15 * 60 + ) + connect_timeout = config.get_cloud_config_value( + "connect_timeout", vm_, __opts__, ssh_connect_timeout + ) + if not salt.utils.cloud.wait_for_port( + ip_address, port=managing_port, timeout=connect_timeout + ): + raise SaltCloudSystemExit("Failed to authenticate against remote ssh") + + pass_conn = get_conn(service="SoftLayer_Account") + mask = { + "virtualGuests": {"powerState": "", "operatingSystem": {"passwords": ""}}, + } + + def get_credentials(): + """ + Wait for the password to become available + """ + node_info = pass_conn.getVirtualGuests(id=response["id"], mask=mask) + for node in node_info: + if ( + node["id"] == response["id"] + and "passwords" in node["operatingSystem"] + and node["operatingSystem"]["passwords"] + ): + return ( + node["operatingSystem"]["passwords"][0]["username"], + node["operatingSystem"]["passwords"][0]["password"], + ) + time.sleep(5) + return False + + username, passwd = salt.utils.cloud.wait_for_fun( # pylint: disable=W0633 + get_credentials, + timeout=config.get_cloud_config_value( + "wait_for_fun_timeout", vm_, __opts__, default=15 * 60 + ), + ) + response["username"] = username + response["password"] = passwd + response["public_ip"] = ip_address + + ssh_username = config.get_cloud_config_value( + "ssh_username", vm_, __opts__, default=username + ) + + vm_["ssh_host"] = ip_address + vm_["password"] = passwd + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + ret.update(response) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + f"salt/cloud/{name}/created", + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def list_nodes_full(mask="mask[id]", call=None): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + ret = {} + conn = get_conn(service="SoftLayer_Account") + response = conn.getVirtualGuests() + for node_id in response: + hostname = node_id["hostname"] + ret[hostname] = node_id + __utils__["cloud.cache_node_list"]( + ret, _get_active_provider_name().split(":")[0], __opts__ + ) + return ret + + +def list_nodes(call=None): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + ret = {} + nodes = list_nodes_full() + if "error" in nodes: + raise SaltCloudSystemExit( + "An error occurred while listing nodes: {}".format( + nodes["error"]["Errors"]["Error"]["Message"] + ) + ) + for node in nodes: + ret[node] = { + "id": nodes[node]["hostname"], + "ram": nodes[node]["maxMemory"], + "cpus": nodes[node]["maxCpu"], + } + if "primaryIpAddress" in nodes[node]: + ret[node]["public_ips"] = nodes[node]["primaryIpAddress"] + if "primaryBackendIpAddress" in nodes[node]: + ret[node]["private_ips"] = nodes[node]["primaryBackendIpAddress"] + if "status" in nodes[node]: + ret[node]["state"] = str(nodes[node]["status"]["name"]) + return ret + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full(), + __opts__["query.selection"], + call, + ) + + +def show_instance(name, call=None): + """ + Show the details from SoftLayer concerning a guest + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + nodes = list_nodes_full() + __utils__["cloud.cache_node"](nodes[name], _get_active_provider_name(), __opts__) + return nodes[name] + + +def destroy(name, call=None): + """ + Destroy a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud --destroy mymachine + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + node = show_instance(name, call="action") + conn = get_conn() + response = conn.deleteObject(id=node["id"]) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return response + + +def list_vlans(call=None): + """ + List all VLANs associated with the account + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_vlans function must be called with -f or --function." + ) + + conn = get_conn(service="SoftLayer_Account") + return conn.getNetworkVlans() diff --git a/salt/cloud/clouds/softlayer_hw.py b/salt/cloud/clouds/softlayer_hw.py new file mode 100644 index 000000000000..f8a92f8a8a8c --- /dev/null +++ b/salt/cloud/clouds/softlayer_hw.py @@ -0,0 +1,661 @@ +""" +SoftLayer HW Cloud Module +========================= + +The SoftLayer HW cloud module is used to control access to the SoftLayer +hardware cloud system + +Use of this module only requires the ``apikey`` parameter. Set up the cloud +configuration at: + +``/etc/salt/cloud.providers`` or ``/etc/salt/cloud.providers.d/softlayer.conf``: + +.. code-block:: yaml + + my-softlayer-config: + # SoftLayer account api key + user: MYLOGIN + apikey: JVkbSJDGHSDKUKSDJfhsdklfjgsjdkflhjlsdfffhgdgjkenrtuinv + driver: softlayer_hw + +The SoftLayer Python Library needs to be installed in order to use the +SoftLayer salt.cloud modules. See: https://pypi.python.org/pypi/SoftLayer + +:depends: softlayer +""" + +import decimal +import logging +import time + +import salt.config as config +import salt.utils.cloud +from salt.exceptions import SaltCloudSystemExit + +# Attempt to import softlayer lib +try: + import SoftLayer + + HAS_SLLIBS = True +except ImportError: + HAS_SLLIBS = False + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "softlayer_hw" + + +# Only load in this module if the SoftLayer configurations are in place +def __virtual__(): + """ + Check for SoftLayer configurations. + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("apikey",) + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + return config.check_driver_dependencies(__virtualname__, {"softlayer": HAS_SLLIBS}) + + +def script(vm_): + """ + Return the script deployment object + """ + deploy_script = salt.utils.cloud.os_script( + config.get_cloud_config_value("script", vm_, __opts__), + vm_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, vm_) + ), + ) + return deploy_script + + +def get_conn(service="SoftLayer_Hardware"): + """ + Return a conn object for the passed VM data + """ + client = SoftLayer.Client( + username=config.get_cloud_config_value( + "user", get_configured_provider(), __opts__, search_global=False + ), + api_key=config.get_cloud_config_value( + "apikey", get_configured_provider(), __opts__, search_global=False + ), + ) + return client[service] + + +def avail_locations(call=None): + """ + List all available locations + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + ret = {} + conn = get_conn(service="SoftLayer_Product_Package") + + locations = conn.getLocations(id=50) + for location in locations: + ret[location["id"]] = { + "id": location["id"], + "name": location["name"], + "location": location["longName"], + } + + available = conn.getAvailableLocations(id=50) + for location in available: + if location.get("isAvailable", 0) == 0: + continue + ret[location["locationId"]]["available"] = True + + return ret + + +def avail_sizes(call=None): + """ + Return a dict of all available VM sizes on the cloud provider with + relevant data. This data is provided in three dicts. + + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + + ret = {} + conn = get_conn(service="SoftLayer_Product_Package") + for category in conn.getCategories(id=50): + if category["categoryCode"] != "server_core": + continue + for group in category["groups"]: + for price in group["prices"]: + ret[price["id"]] = price["item"].copy() + del ret[price["id"]]["id"] + return ret + + +def avail_images(call=None): + """ + Return a dict of all available VM images on the cloud provider. + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + ret = {} + conn = get_conn(service="SoftLayer_Product_Package") + for category in conn.getCategories(id=50): + if category["categoryCode"] != "os": + continue + for group in category["groups"]: + for price in group["prices"]: + ret[price["id"]] = price["item"].copy() + del ret[price["id"]]["id"] + return ret + + +def get_location(vm_=None): + """ + Return the location to use, in this order: + - CLI parameter + - VM parameter + - Cloud profile setting + """ + return __opts__.get( + "location", + config.get_cloud_config_value( + "location", + vm_ or get_configured_provider(), + __opts__, + # default=DEFAULT_LOCATION, + search_global=False, + ), + ) + + +def create(vm_): + """ + Create a single VM from a data dict + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "softlayer_hw", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + name = vm_["name"] + hostname = name + domain = config.get_cloud_config_value("domain", vm_, __opts__, default=None) + if domain is None: + raise SaltCloudSystemExit("A domain name is required for the SoftLayer driver.") + + if vm_.get("use_fqdn"): + name = ".".join([name, domain]) + vm_["name"] = name + + __utils__["cloud.fire_event"]( + "event", + "starting create", + f"salt/cloud/{name}/creating", + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info("Creating Cloud VM %s", name) + conn = get_conn(service="SoftLayer_Product_Order") + kwargs = { + "complexType": "SoftLayer_Container_Product_Order_Hardware_Server", + "quantity": 1, + "hardware": [{"hostname": hostname, "domain": domain}], + # Baremetal Package + "packageId": 50, + "prices": [ + # Size Ex: 1921: 2 x 2.0 GHz Core Bare Metal Instance - 2 GB Ram + {"id": vm_["size"]}, + # HDD Ex: 19: 250GB SATA II + {"id": vm_["hdd"]}, + # Image Ex: 13963: CentOS 6.0 - Minimal Install (64 bit) + {"id": vm_["image"]}, + # The following items are currently required + # Reboot / Remote Console + {"id": "905"}, + # 1 IP Address + {"id": "21"}, + # Host Ping Monitoring + {"id": "55"}, + # Email and Ticket Notifications + {"id": "57"}, + # Automated Notification Response + {"id": "58"}, + # Unlimited SSL VPN Users & 1 PPTP VPN User per account + {"id": "420"}, + # Nessus Vulnerability Assessment & Reporting + {"id": "418"}, + ], + } + + optional_products = config.get_cloud_config_value( + "optional_products", vm_, __opts__, default=[] + ) + for product in optional_products: + kwargs["prices"].append({"id": product}) + + # Default is 273 (100 Mbps Public & Private Networks) + port_speed = config.get_cloud_config_value("port_speed", vm_, __opts__, default=273) + kwargs["prices"].append({"id": port_speed}) + + # Default is 1800 (0 GB Bandwidth) + bandwidth = config.get_cloud_config_value("bandwidth", vm_, __opts__, default=1800) + kwargs["prices"].append({"id": bandwidth}) + + post_uri = config.get_cloud_config_value("post_uri", vm_, __opts__, default=None) + if post_uri: + kwargs["prices"].append({"id": post_uri}) + + vlan_id = config.get_cloud_config_value("vlan", vm_, __opts__, default=False) + if vlan_id: + kwargs["primaryNetworkComponent"] = {"networkVlan": {"id": vlan_id}} + + location = get_location(vm_) + if location: + kwargs["location"] = location + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + f"salt/cloud/{name}/requesting", + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", kwargs, list(kwargs) + ), + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + response = conn.placeOrder(kwargs) + # Leaving the following line in, commented, for easy debugging + # response = conn.verifyOrder(kwargs) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on SoftLayer\n\n" + "The following exception was thrown when trying to " + "run the initial deployment: \n%s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + def wait_for_ip(): + """ + Wait for the IP address to become available + """ + nodes = list_nodes_full() + if "primaryIpAddress" in nodes[hostname]: + return nodes[hostname]["primaryIpAddress"] + time.sleep(1) + return False + + ip_address = salt.utils.cloud.wait_for_fun( + wait_for_ip, + timeout=config.get_cloud_config_value( + "wait_for_fun_timeout", vm_, __opts__, default=15 * 60 + ), + ) + + ssh_connect_timeout = config.get_cloud_config_value( + # 15 minutes + "ssh_connect_timeout", + vm_, + __opts__, + 900, + ) + if not salt.utils.cloud.wait_for_port(ip_address, timeout=ssh_connect_timeout): + raise SaltCloudSystemExit("Failed to authenticate against remote ssh") + + pass_conn = get_conn(service="SoftLayer_Account") + mask = { + "virtualGuests": {"powerState": "", "operatingSystem": {"passwords": ""}}, + } + + def get_passwd(): + """ + Wait for the password to become available + """ + node_info = pass_conn.getVirtualGuests(id=response["id"], mask=mask) + for node in node_info: + if ( + node["id"] == response["id"] + and "passwords" in node["operatingSystem"] + and node["operatingSystem"]["passwords"] + ): + return node["operatingSystem"]["passwords"][0]["password"] + time.sleep(5) + return False + + passwd = salt.utils.cloud.wait_for_fun( + get_passwd, + timeout=config.get_cloud_config_value( + "wait_for_fun_timeout", vm_, __opts__, default=15 * 60 + ), + ) + response["password"] = passwd + response["public_ip"] = ip_address + + ssh_username = config.get_cloud_config_value( + "ssh_username", vm_, __opts__, default="root" + ) + + vm_["ssh_host"] = ip_address + vm_["password"] = passwd + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + ret.update(response) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + f"salt/cloud/{name}/created", + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def list_nodes_full( + mask="mask[id, hostname, primaryIpAddress, primaryBackendIpAddress, processorPhysicalCoreAmount, memoryCount]", + call=None, +): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + ret = {} + conn = get_conn(service="SoftLayer_Account") + response = conn.getHardware(mask=mask) + + for node in response: + ret[node["hostname"]] = node + __utils__["cloud.cache_node_list"]( + ret, _get_active_provider_name().split(":")[0], __opts__ + ) + return ret + + +def list_nodes(call=None): + """ + Return a list of the VMs that are on the provider + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + ret = {} + nodes = list_nodes_full() + if "error" in nodes: + raise SaltCloudSystemExit( + "An error occurred while listing nodes: {}".format( + nodes["error"]["Errors"]["Error"]["Message"] + ) + ) + for node in nodes: + ret[node] = { + "id": nodes[node]["hostname"], + "ram": nodes[node]["memoryCount"], + "cpus": nodes[node]["processorPhysicalCoreAmount"], + } + if "primaryIpAddress" in nodes[node]: + ret[node]["public_ips"] = nodes[node]["primaryIpAddress"] + if "primaryBackendIpAddress" in nodes[node]: + ret[node]["private_ips"] = nodes[node]["primaryBackendIpAddress"] + return ret + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full(), + __opts__["query.selection"], + call, + ) + + +def show_instance(name, call=None): + """ + Show the details from SoftLayer concerning a guest + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + nodes = list_nodes_full() + __utils__["cloud.cache_node"](nodes[name], _get_active_provider_name(), __opts__) + return nodes[name] + + +def destroy(name, call=None): + """ + Destroy a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud --destroy mymachine + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + node = show_instance(name, call="action") + conn = get_conn(service="SoftLayer_Ticket") + response = conn.createCancelServerTicket( + { + "id": node["id"], + "reason": "Salt Cloud Hardware Server Cancellation", + "content": "Please cancel this server", + "cancelAssociatedItems": True, + "attachmentType": "HARDWARE", + } + ) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return response + + +def list_vlans(call=None): + """ + List all VLANs associated with the account + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_vlans function must be called with -f or --function." + ) + + conn = get_conn(service="SoftLayer_Account") + return conn.getNetworkVlans() + + +def show_pricing(kwargs=None, call=None): + """ + Show pricing for a particular profile. This is only an estimate, based on + unofficial pricing sources. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f show_pricing my-softlayerhw-config profile=my-profile + + If pricing sources have not been cached, they will be downloaded. Once they + have been cached, they will not be updated automatically. To manually update + all prices, use the following command: + + .. code-block:: bash + + salt-cloud -f update_pricing + + .. versionadded:: 2015.8.0 + """ + profile = __opts__["profiles"].get(kwargs["profile"], {}) + if not profile: + return {"Error": "The requested profile was not found"} + + # Make sure the profile belongs to Softlayer HW + provider = profile.get("provider", "0:0") + comps = provider.split(":") + if len(comps) < 2 or comps[1] != "softlayer_hw": + return {"Error": "The requested profile does not belong to Softlayer HW"} + + raw = {} + ret = {} + ret["per_hour"] = 0 + conn = get_conn(service="SoftLayer_Product_Item_Price") + for item in profile: + if item in ("profile", "provider", "location"): + continue + price = conn.getObject(id=profile[item]) + raw[item] = price + ret["per_hour"] += decimal.Decimal(price.get("hourlyRecurringFee", 0)) + + ret["per_day"] = ret["per_hour"] * 24 + ret["per_week"] = ret["per_day"] * 7 + ret["per_month"] = ret["per_day"] * 30 + ret["per_year"] = ret["per_week"] * 52 + + if kwargs.get("raw", False): + ret["_raw"] = raw + + return {profile["profile"]: ret} + + +def show_all_prices(call=None, kwargs=None): + """ + Return a dict of all prices on the cloud provider. + """ + if call == "action": + raise SaltCloudSystemExit( + "The show_all_prices function must be called with -f or --function." + ) + + if kwargs is None: + kwargs = {} + + conn = get_conn(service="SoftLayer_Product_Package") + if "code" not in kwargs: + return conn.getCategories(id=50) + + ret = {} + for category in conn.getCategories(id=50): + if category["categoryCode"] != kwargs["code"]: + continue + for group in category["groups"]: + for price in group["prices"]: + ret[price["id"]] = price["item"].copy() + del ret[price["id"]]["id"] + return ret + + +def show_all_categories(call=None): + """ + Return a dict of all available categories on the cloud provider. + + .. versionadded:: 2016.3.0 + """ + if call == "action": + raise SaltCloudSystemExit( + "The show_all_categories function must be called with -f or --function." + ) + + conn = get_conn(service="SoftLayer_Product_Package") + categories = [] + + for category in conn.getCategories(id=50): + categories.append(category["categoryCode"]) + + return {"category_codes": categories} diff --git a/salt/cloud/clouds/tencentcloud.py b/salt/cloud/clouds/tencentcloud.py new file mode 100644 index 000000000000..374f4a3247b3 --- /dev/null +++ b/salt/cloud/clouds/tencentcloud.py @@ -0,0 +1,1042 @@ +""" +Tencent Cloud Cloud Module +============================= + +.. versionadded:: 3000 + +The Tencent Cloud Cloud Module is used to control access to the Tencent Cloud instance. +https://intl.cloud.tencent.com/ + +To use this module, set up the cloud configuration at + ``/etc/salt/cloud.providers`` or ``/etc/salt/cloud.providers.d/*.conf``: + +.. code-block:: yaml + + my-tencentcloud-config: + driver: tencentcloud + # Tencent Cloud Secret Id + id: AKIDA64pOio9BMemkApzevX0HS169S4b750A + # Tencent Cloud Secret Key + key: 8r2xmPn0C5FDvRAlmcJimiTZKVRsk260 + # Tencent Cloud Region + location: ap-guangzhou + +:depends: tencentcloud-sdk-python +""" + +import logging +import pprint +import time + +import salt.config as config +import salt.utils.cloud +import salt.utils.data +import salt.utils.json +from salt.exceptions import ( + SaltCloudExecutionFailure, + SaltCloudExecutionTimeout, + SaltCloudNotFound, + SaltCloudSystemExit, +) + +try: + # Try import tencentcloud sdk + from tencentcloud.common import credential # pylint: disable=no-name-in-module + + # pylint: disable=no-name-in-module + from tencentcloud.common.profile.client_profile import ClientProfile + from tencentcloud.cvm.v20170312 import cvm_client + from tencentcloud.cvm.v20170312 import models as cvm_models + from tencentcloud.vpc.v20170312 import models as vpc_models + from tencentcloud.vpc.v20170312 import vpc_client + + # pylint: enable=no-name-in-module + + HAS_TENCENTCLOUD_SDK = True +except ImportError: + HAS_TENCENTCLOUD_SDK = False + +# Get logging started +log = logging.getLogger(__name__) + +# The default region +DEFAULT_REGION = "ap-guangzhou" + +# The Tencent Cloud +__virtualname__ = "tencentcloud" + + +def __virtual__(): + """ + Only load in this module if the Tencent Cloud configurations are in place + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("id", "key") + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + return config.check_driver_dependencies( + __virtualname__, {"tencentcloud-sdk-python": HAS_TENCENTCLOUD_SDK} + ) + + +def get_provider_client(name=None): + """ + Return a new provider client + """ + provider = get_configured_provider() + + secretId = provider.get("id") + secretKey = provider.get("key") + region = __get_location(None) + + cpf = ClientProfile() + cpf.language = "en-US" + crd = credential.Credential(secretId, secretKey) + + if name == "cvm_client": + client = cvm_client.CvmClient(crd, region, cpf) + elif name == "vpc_client": + client = vpc_client.VpcClient(crd, region, cpf) + else: + raise SaltCloudSystemExit(f"Client name {name} is not supported") + + return client + + +def avail_locations(call=None): + """ + Return Tencent Cloud available region + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-locations my-tencentcloud-config + salt-cloud -f avail_locations my-tencentcloud-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option" + ) + + client = get_provider_client("cvm_client") + req = cvm_models.DescribeRegionsRequest() + resp = client.DescribeRegions(req) + + ret = {} + for region in resp.RegionSet: + if region.RegionState != "AVAILABLE": + continue + ret[region.Region] = region.RegionName + + return ret + + +def avail_images(call=None): + """ + Return Tencent Cloud available image + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-images my-tencentcloud-config + salt-cloud -f avail_images my-tencentcloud-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option" + ) + + return _get_images( + ["PUBLIC_IMAGE", "PRIVATE_IMAGE", "IMPORT_IMAGE", "SHARED_IMAGE"] + ) + + +def avail_sizes(call=None): + """ + Return Tencent Cloud available instance type + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-sizes my-tencentcloud-config + salt-cloud -f avail_sizes my-tencentcloud-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option" + ) + + client = get_provider_client("cvm_client") + req = cvm_models.DescribeInstanceTypeConfigsRequest() + resp = client.DescribeInstanceTypeConfigs(req) + + ret = {} + for typeConfig in resp.InstanceTypeConfigSet: + ret[typeConfig.InstanceType] = { + "Zone": typeConfig.Zone, + "InstanceFamily": typeConfig.InstanceFamily, + "Memory": f"{typeConfig.Memory}GB", + "CPU": f"{typeConfig.CPU}-Core", + } + if typeConfig.GPU: + ret[typeConfig.InstanceType]["GPU"] = f"{typeConfig.GPU}-Core" + + return ret + + +def list_securitygroups(call=None): + """ + Return all Tencent Cloud security groups in current region + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_securitygroups my-tencentcloud-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_securitygroups function must be called with -f or --function." + ) + + client = get_provider_client("vpc_client") + req = vpc_models.DescribeSecurityGroupsRequest() + req.Offset = 0 + req.Limit = 100 + resp = client.DescribeSecurityGroups(req) + + ret = {} + for sg in resp.SecurityGroupSet: + ret[sg.SecurityGroupId] = { + "SecurityGroupName": sg.SecurityGroupName, + "SecurityGroupDesc": sg.SecurityGroupDesc, + "ProjectId": sg.ProjectId, + "IsDefault": sg.IsDefault, + "CreatedTime": sg.CreatedTime, + } + + return ret + + +def list_custom_images(call=None): + """ + Return all Tencent Cloud images in current region + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_custom_images my-tencentcloud-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_custom_images function must be called with -f or --function." + ) + + return _get_images(["PRIVATE_IMAGE", "IMPORT_IMAGE"]) + + +def list_availability_zones(call=None): + """ + Return all Tencent Cloud availability zones in current region + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_availability_zones my-tencentcloud-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_availability_zones function must be called with -f or --function." + ) + + client = get_provider_client("cvm_client") + req = cvm_models.DescribeZonesRequest() + resp = client.DescribeZones(req) + + ret = {} + for zone in resp.ZoneSet: + if zone.ZoneState != "AVAILABLE": + continue + ret[zone.Zone] = (zone.ZoneName,) + + return ret + + +def list_nodes(call=None): + """ + Return a list of instances that are on the provider + + CLI Examples: + + .. code-block:: bash + + salt-cloud -Q + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + ret = {} + nodes = _get_nodes() + for instance in nodes: + ret[instance.InstanceId] = { + "InstanceId": instance.InstanceId, + "InstanceName": instance.InstanceName, + "InstanceType": instance.InstanceType, + "ImageId": instance.ImageId, + "PublicIpAddresses": instance.PublicIpAddresses, + "PrivateIpAddresses": instance.PrivateIpAddresses, + "InstanceState": instance.InstanceState, + } + + return ret + + +def list_nodes_full(call=None): + """ + Return a list of instances that are on the provider, with full details + + CLI Examples: + + .. code-block:: bash + + salt-cloud -F + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + ret = {} + nodes = _get_nodes() + for instance in nodes: + instanceAttribute = vars(instance) + ret[instance.InstanceName] = instanceAttribute + for k in [ + "DataDisks", + "InternetAccessible", + "LoginSettings", + "Placement", + "SystemDisk", + "Tags", + "VirtualPrivateCloud", + ]: + ret[instance.InstanceName][k] = str(instanceAttribute[k]) + + provider = _get_active_provider_name() or "tencentcloud" + if ":" in provider: + comps = provider.split(":") + provider = comps[0] + + __opts__["update_cachedir"] = True + __utils__["cloud.cache_node_list"](ret, provider, __opts__) + + return ret + + +def list_nodes_select(call=None): + """ + Return a list of instances that are on the provider, with select fields + + CLI Examples: + + .. code-block:: bash + + salt-cloud -S + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full("function"), + __opts__["query.selection"], + call, + ) + + +def list_nodes_min(call=None): + """ + Return a list of instances that are on the provider, Only names, and their state, is returned. + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f list_nodes_min my-tencentcloud-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_min function must be called with -f or --function." + ) + + ret = {} + nodes = _get_nodes() + for instance in nodes: + ret[instance.InstanceName] = { + "InstanceId": instance.InstanceId, + "InstanceState": instance.InstanceState, + } + + return ret + + +def create(vm_): + """ + Create a single Tencent Cloud instance from a data dict. + + Tencent Cloud profiles require a ``provider``, ``availability_zone``, ``image`` and ``size``. + Set up profile at ``/etc/salt/cloud.profiles`` or ``/etc/salt/cloud.profiles.d/*.conf``: + + .. code-block:: yaml + + tencentcloud-guangzhou-s1sm1: + provider: my-tencentcloud-config + availability_zone: ap-guangzhou-3 + image: img-31tjrtph + size: S1.SMALL1 + allocate_public_ip: True + internet_max_bandwidth_out: 1 + password: '153e41ec96140152' + securitygroups: + - sg-5e90804b + + CLI Examples: + + .. code-block:: bash + + salt-cloud -p tencentcloud-guangzhou-s1 myinstance + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "tencentcloud", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.debug("Try creating instance: %s", pprint.pformat(vm_)) + + # Init cvm client + client = get_provider_client("cvm_client") + req = cvm_models.RunInstancesRequest() + req.InstanceName = vm_["name"] + + # Required parameters + req.InstanceType = __get_size(vm_) + req.ImageId = __get_image(vm_) + + zone = __get_availability_zone(vm_) + projectId = vm_.get("project_id", 0) + req.Placement = {"Zone": zone, "ProjectId": projectId} + + # Optional parameters + + req.SecurityGroupIds = __get_securitygroups(vm_) + req.HostName = vm_.get("hostname", vm_["name"]) + + req.InstanceChargeType = vm_.get("instance_charge_type", "POSTPAID_BY_HOUR") + if req.InstanceChargeType == "PREPAID": + period = vm_.get("instance_charge_type_prepaid_period", 1) + renewFlag = vm_.get( + "instance_charge_type_prepaid_renew_flag", "NOTIFY_AND_MANUAL_RENEW" + ) + req.InstanceChargePrepaid = {"Period": period, "RenewFlag": renewFlag} + + allocate_public_ip = vm_.get("allocate_public_ip", False) + internet_max_bandwidth_out = vm_.get("internet_max_bandwidth_out", 0) + if allocate_public_ip and internet_max_bandwidth_out > 0: + req.InternetAccessible = { + "PublicIpAssigned": allocate_public_ip, + "InternetMaxBandwidthOut": internet_max_bandwidth_out, + } + internet_charge_type = vm_.get("internet_charge_type", "") + if internet_charge_type != "": + req.InternetAccessible["InternetChargeType"] = internet_charge_type + + req.LoginSettings = {} + req.VirtualPrivateCloud = {} + req.SystemDisk = {} + + keyId = vm_.get("key_name", "") + if keyId: + req.LoginSettings["KeyIds"] = [keyId] + + password = vm_.get("password", "") + if password: + req.LoginSettings["Password"] = password + + private_ip = vm_.get("private_ip", "") + if private_ip: + req.VirtualPrivateCloud["PrivateIpAddresses"] = private_ip + + vpc_id = vm_.get("vpc_id", "") + if vpc_id: + req.VirtualPrivateCloud["VpcId"] = vpc_id + + subnetId = vm_.get("subnet_id", "") + if subnetId: + req.VirtualPrivateCloud["SubnetId"] = subnetId + + system_disk_size = vm_.get("system_disk_size", 0) + if system_disk_size: + req.SystemDisk["DiskSize"] = system_disk_size + + system_disk_type = vm_.get("system_disk_type", "") + if system_disk_type: + req.SystemDisk["DiskType"] = system_disk_type + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args=__utils__["cloud.filter_event"]("requesting", vm_, list(vm_)), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + resp = client.RunInstances(req) + if not resp.InstanceIdSet: + raise SaltCloudSystemExit("Unexpected error, no instance created") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on tencentcloud\n\n" + "The following exception was thrown when trying to " + "run the initial deployment: %s", + vm_["name"], + str(exc), + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + time.sleep(5) + + def __query_node_data(vm_name): + data = show_instance(vm_name, call="action") + if not data: + return False + if data["InstanceState"] != "RUNNING": + return False + if data["PrivateIpAddresses"]: + return data + + try: + data = salt.utils.cloud.wait_for_ip( + __query_node_data, + update_args=(vm_["name"],), + timeout=config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 + ), + interval=config.get_cloud_config_value( + "wait_for_ip_interval", vm_, __opts__, default=10 + ), + ) + except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: + try: + destroy(vm_["name"]) + except SaltCloudSystemExit: + pass + finally: + raise SaltCloudSystemExit(str(exc)) + + if data["PublicIpAddresses"]: + ssh_ip = data["PublicIpAddresses"][0] + elif data["PrivateIpAddresses"]: + ssh_ip = data["PrivateIpAddresses"][0] + else: + log.error("No available ip: cant connect to salt") + return False + + log.debug("Instance %s: %s is now running", vm_["name"], ssh_ip) + vm_["ssh_host"] = ssh_ip + + # The instance is booted and accessible, let's Salt it! + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + ret.update(data) + + log.debug("'%s' instance creation details:\n%s", vm_["name"], pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def start(name, call=None): + """ + Start a Tencent Cloud instance + Notice: the instance state must be stopped + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a start myinstance + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + node = _get_node(name) + + client = get_provider_client("cvm_client") + req = cvm_models.StartInstancesRequest() + req.InstanceIds = [node.InstanceId] + resp = client.StartInstances(req) + + return resp + + +def stop(name, force=False, call=None): + """ + Stop a Tencent Cloud running instance + Note: use `force=True` to make force stop + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a stop myinstance + salt-cloud -a stop myinstance force=True + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + node = _get_node(name) + + client = get_provider_client("cvm_client") + req = cvm_models.StopInstancesRequest() + req.InstanceIds = [node.InstanceId] + if force: + req.ForceStop = "TRUE" + resp = client.StopInstances(req) + + return resp + + +def reboot(name, call=None): + """ + Reboot a Tencent Cloud instance + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a reboot myinstance + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + node = _get_node(name) + + client = get_provider_client("cvm_client") + req = cvm_models.RebootInstancesRequest() + req.InstanceIds = [node.InstanceId] + resp = client.RebootInstances(req) + + return resp + + +def destroy(name, call=None): + """ + Destroy a Tencent Cloud instance + + CLI Example: + + .. code-block:: bash + + salt-cloud -a destroy myinstance + salt-cloud -d myinstance + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + node = _get_node(name) + + client = get_provider_client("cvm_client") + req = cvm_models.TerminateInstancesRequest() + req.InstanceIds = [node.InstanceId] + resp = client.TerminateInstances(req) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return resp + + +def script(vm_): + """ + Return the script deployment object + """ + return salt.utils.cloud.os_script( + config.get_cloud_config_value("script", vm_, __opts__), + vm_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, vm_) + ), + ) + + +def show_image(kwargs, call=None): + """ + Show the details of Tencent Cloud image + + CLI Examples: + + .. code-block:: bash + + salt-cloud -f show_image tencentcloud image=img-31tjrtph + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_image function must be called with -f or --function" + ) + + if not isinstance(kwargs, dict): + kwargs = {} + + if "image" not in kwargs: + raise SaltCloudSystemExit("No image specified.") + + image = kwargs["image"] + + client = get_provider_client("cvm_client") + req = cvm_models.DescribeImagesRequest() + req.ImageIds = [image] + resp = client.DescribeImages(req) + + if not resp.ImageSet: + raise SaltCloudNotFound(f"The specified image '{image}' could not be found.") + + ret = {} + for image in resp.ImageSet: + ret[image.ImageId] = { + "ImageName": image.ImageName, + "ImageType": image.ImageType, + "ImageSource": image.ImageSource, + "Platform": image.Platform, + "Architecture": image.Architecture, + "ImageSize": f"{image.ImageSize}GB", + "ImageState": image.ImageState, + } + + return ret + + +def show_instance(name, call=None): + """ + Show the details of Tencent Cloud instance + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a show_instance myinstance + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + node = _get_node(name) + ret = vars(node) + for k in [ + "DataDisks", + "InternetAccessible", + "LoginSettings", + "Placement", + "SystemDisk", + "Tags", + "VirtualPrivateCloud", + ]: + ret[k] = str(ret[k]) + + return ret + + +def show_disk(name, call=None): + """ + Show the disk details of Tencent Cloud instance + + CLI Examples: + + .. code-block:: bash + + salt-cloud -a show_disk myinstance + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_disks action must be called with -a or --action." + ) + + node = _get_node(name) + + ret = {} + ret[node.SystemDisk.DiskId] = { + "SystemDisk": True, + "DiskSize": node.SystemDisk.DiskSize, + "DiskType": node.SystemDisk.DiskType, + "DeleteWithInstance": True, + "SnapshotId": "", + } + + if node.DataDisks: + for disk in node.DataDisks: + ret[disk.DiskId] = { + "SystemDisk": False, + "DiskSize": disk.DiskSize, + "DiskType": disk.DiskType, + "DeleteWithInstance": disk.DeleteWithInstance, + "SnapshotId": disk.SnapshotId, + } + + return ret + + +def _get_node(name): + """ + Return Tencent Cloud instance detail by name + """ + attempts = 5 + while attempts >= 0: + try: + client = get_provider_client("cvm_client") + req = cvm_models.DescribeInstancesRequest() + req.Filters = [{"Name": "instance-name", "Values": [name]}] + resp = client.DescribeInstances(req) + return resp.InstanceSet[0] + except Exception as ex: # pylint: disable=broad-except + attempts -= 1 + log.debug( + "Failed to get data for node '%s': %s. Remaining attempts: %d", + name, + ex, + attempts, + ) + time.sleep(0.5) + + raise SaltCloudNotFound(f"Failed to get instance info {name}") + + +def _get_nodes(): + """ + Return all list of Tencent Cloud instances + """ + ret = [] + offset = 0 + limit = 100 + + while True: + client = get_provider_client("cvm_client") + req = cvm_models.DescribeInstancesRequest() + req.Offset = offset + req.Limit = limit + resp = client.DescribeInstances(req) + for v in resp.InstanceSet: + ret.append(v) + if len(ret) >= resp.TotalCount: + break + offset += len(resp.InstanceSet) + + return ret + + +def _get_images(image_type): + """ + Return all list of Tencent Cloud images + """ + client = get_provider_client("cvm_client") + req = cvm_models.DescribeImagesRequest() + req.Filters = [{"Name": "image-type", "Values": image_type}] + req.Offset = 0 + req.Limit = 100 + resp = client.DescribeImages(req) + + ret = {} + for image in resp.ImageSet: + if image.ImageState != "NORMAL": + continue + ret[image.ImageId] = { + "ImageName": image.ImageName, + "ImageType": image.ImageType, + "ImageSource": image.ImageSource, + "Platform": image.Platform, + "Architecture": image.Architecture, + "ImageSize": f"{image.ImageSize}GB", + } + + return ret + + +def __get_image(vm_): + vm_image = str( + config.get_cloud_config_value("image", vm_, __opts__, search_global=False) + ) + + if not vm_image: + raise SaltCloudNotFound("No image specified.") + + images = avail_images() + if vm_image in images: + return vm_image + + raise SaltCloudNotFound(f"The specified image '{vm_image}' could not be found.") + + +def __get_size(vm_): + vm_size = str( + config.get_cloud_config_value("size", vm_, __opts__, search_global=False) + ) + + if not vm_size: + raise SaltCloudNotFound("No size specified.") + + sizes = avail_sizes() + if vm_size in sizes: + return vm_size + + raise SaltCloudNotFound(f"The specified size '{vm_size}' could not be found.") + + +def __get_securitygroups(vm_): + vm_securitygroups = config.get_cloud_config_value( + "securitygroups", vm_, __opts__, search_global=False + ) + + if not vm_securitygroups: + return [] + + securitygroups = list_securitygroups() + for idx, value in enumerate(vm_securitygroups): + vm_securitygroups[idx] = str(value) + if vm_securitygroups[idx] not in securitygroups: + raise SaltCloudNotFound( + "The specified securitygroups '{}' could not be found.".format( + vm_securitygroups[idx] + ) + ) + + return vm_securitygroups + + +def __get_availability_zone(vm_): + vm_availability_zone = str( + config.get_cloud_config_value( + "availability_zone", vm_, __opts__, search_global=False + ) + ) + + if not vm_availability_zone: + raise SaltCloudNotFound("No availability_zone specified.") + + availability_zones = list_availability_zones() + if vm_availability_zone in availability_zones: + return vm_availability_zone + + raise SaltCloudNotFound( + "The specified availability_zone '{}' could not be found.".format( + vm_availability_zone + ) + ) + + +def __get_location(vm_): + """ + Return the Tencent Cloud region to use, in this order: + - CLI parameter + - VM parameter + - Cloud profile setting + """ + vm_location = str( + __opts__.get( + "location", + config.get_cloud_config_value( + "location", + vm_ or get_configured_provider(), + __opts__, + default=DEFAULT_REGION, + search_global=False, + ), + ) + ) + + if not vm_location: + raise SaltCloudNotFound("No location specified.") + + return vm_location diff --git a/salt/cloud/clouds/vagrant.py b/salt/cloud/clouds/vagrant.py new file mode 100644 index 000000000000..ac9119b70d96 --- /dev/null +++ b/salt/cloud/clouds/vagrant.py @@ -0,0 +1,361 @@ +""" +Vagrant Cloud Driver +==================== + +The Vagrant cloud is designed to "vagrant up" a virtual machine as a +Salt minion. + +Use of this module requires some configuration in cloud profile and provider +files as described in the +:ref:`Getting Started with Vagrant ` documentation. + +.. versionadded:: 2018.3.0 + + +""" + +import logging +import os +import tempfile + +import salt.client +import salt.config as config +import salt.utils.cloud +from salt._compat import ipaddress +from salt.exceptions import SaltCloudException, SaltCloudSystemExit, SaltInvocationError + +log = logging.getLogger(__name__) + + +def __virtual__(): + """ + Needs no special configuration + """ + return True + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def avail_locations(call=None): + r""" + This function returns a list of locations available. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-locations my-cloud-provider + + # \[ vagrant will always returns an empty dictionary \] + + """ + + return {} + + +def avail_images(call=None): + """This function returns a list of images available for this cloud provider. + vagrant will return a list of profiles. + salt-cloud --list-images my-cloud-provider + """ + vm_ = get_configured_provider() + return {"Profiles": [profile for profile in vm_["profiles"]]} + + +def avail_sizes(call=None): + r""" + This function returns a list of sizes available for this cloud provider. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-sizes my-cloud-provider + + # \[ vagrant always returns an empty dictionary \] + + """ + return {} + + +def list_nodes(call=None): + """ + List the nodes which have salt-cloud:driver:vagrant grains. + + CLI Example: + + .. code-block:: bash + + salt-cloud -Q + """ + nodes = _list_nodes(call) + return _build_required_items(nodes) + + +def _build_required_items(nodes): + ret = {} + for name, grains in nodes.items(): + if grains: + private_ips = [] + public_ips = [] + ips = grains["ipv4"] + grains["ipv6"] + for adrs in ips: + ip_ = ipaddress.ip_address(adrs) + if not ip_.is_loopback: + if ip_.is_private: + private_ips.append(adrs) + else: + public_ips.append(adrs) + + ret[name] = { + "id": grains["id"], + "image": grains["salt-cloud"]["profile"], + "private_ips": private_ips, + "public_ips": public_ips, + "size": "", + "state": "running", + } + + return ret + + +def list_nodes_full(call=None): + """ + List the nodes, ask all 'vagrant' minions, return dict of grains (enhanced). + + CLI Example: + + .. code-block:: bash + + salt-call -F + """ + ret = _list_nodes(call) + + for ( + key, + grains, + ) in ret.items(): # clean up some hyperverbose grains -- everything is too much + try: + del ( + grains["cpu_flags"], + grains["disks"], + grains["pythonpath"], + grains["dns"], + grains["gpus"], + ) + except KeyError: + pass # ignore absence of things we are eliminating + except TypeError: + del ret[key] # eliminate all reference to unexpected (None) values. + + reqs = _build_required_items(ret) + for name in ret: + ret[name].update(reqs[name]) + return ret + + +def _list_nodes(call=None): + """ + List the nodes, ask all 'vagrant' minions, return dict of grains. + """ + with salt.client.LocalClient() as local: + return local.cmd( + "salt-cloud:driver:vagrant", "grains.items", "", tgt_type="grain" + ) + + +def list_nodes_select(call=None): + """ + Return a list of the minions that have salt-cloud grains, with + select fields. + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full("function"), + __opts__["query.selection"], + call, + ) + + +def show_instance(name, call=None): + """ + List the a single node, return dict of grains. + """ + with salt.client.LocalClient() as local: + ret = local.cmd(name, "grains.items", "") + reqs = _build_required_items(ret) + ret[name].update(reqs[name]) + return ret + + +def _get_my_info(name): + with salt.client.LocalClient() as local: + return local.cmd(name, "grains.get", ["salt-cloud"]) + + +def create(vm_): + """ + Provision a single machine + + CLI Example: + + .. code-block:: bash + + salt-cloud -p my_profile new_node_1 + + """ + name = vm_["name"] + machine = config.get_cloud_config_value("machine", vm_, __opts__, default="") + vm_["machine"] = machine + host = config.get_cloud_config_value("host", vm_, __opts__, default=NotImplemented) + vm_["cwd"] = config.get_cloud_config_value("cwd", vm_, __opts__, default="/") + vm_["runas"] = config.get_cloud_config_value( + "vagrant_runas", vm_, __opts__, default=os.getenv("SUDO_USER") + ) + vm_["timeout"] = config.get_cloud_config_value( + "vagrant_up_timeout", vm_, __opts__, default=300 + ) + vm_["vagrant_provider"] = config.get_cloud_config_value( + "vagrant_provider", vm_, __opts__, default="" + ) + vm_["grains"] = {"salt-cloud:vagrant": {"host": host, "machine": machine}} + + log.info("sending 'vagrant.init %s machine=%s' command to %s", name, machine, host) + + with salt.client.LocalClient() as local: + ret = local.cmd(host, "vagrant.init", [name], kwarg={"vm": vm_, "start": True}) + log.info("response ==> %s", ret[host]) + + network_mask = config.get_cloud_config_value( + "network_mask", vm_, __opts__, default="" + ) + if "ssh_host" not in vm_: + ret = local.cmd( + host, + "vagrant.get_ssh_config", + [name], + kwarg={"network_mask": network_mask, "get_private_key": True}, + )[host] + with tempfile.NamedTemporaryFile() as pks: + if "private_key" not in vm_ and ret and ret.get("private_key", False): + pks.write(ret["private_key"]) + pks.flush() + log.debug("wrote private key to %s", pks.name) + vm_["key_filename"] = pks.name + if "ssh_host" not in vm_: + try: + vm_.setdefault("ssh_username", ret["ssh_username"]) + if ret.get("ip_address"): + vm_["ssh_host"] = ret["ip_address"] + else: # if probe failed or not used, use Vagrant's reported ssh info + vm_["ssh_host"] = ret["ssh_host"] + vm_.setdefault("ssh_port", ret["ssh_port"]) + except (KeyError, TypeError): + raise SaltInvocationError( + f"Insufficient SSH addressing information for {name}" + ) + + log.info( + "Provisioning machine %s as node %s using ssh %s", + machine, + name, + vm_["ssh_host"], + ) + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + return ret + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + ret = config.is_provider_configured( + __opts__, _get_active_provider_name() or "vagrant", "" + ) + return ret + + +# noinspection PyTypeChecker +def destroy(name, call=None): + """ + Destroy a node. + + CLI Example: + + .. code-block:: bash + + salt-cloud --destroy mymachine + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a, or --action." + ) + + opts = __opts__ + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=opts["sock_dir"], + transport=opts["transport"], + ) + my_info = _get_my_info(name) + if my_info: + profile_name = my_info[name]["profile"] + profile = opts["profiles"][profile_name] + host = profile["host"] + with salt.client.LocalClient() as local: + ret = local.cmd(host, "vagrant.destroy", [name]) + + if ret[host]: + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=opts["sock_dir"], + transport=opts["transport"], + ) + + if opts.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], opts + ) + + return {"Destroyed": f"{name} was destroyed."} + else: + return {"Error": f"Error destroying {name}"} + else: + return {"Error": f"No response from {name}. Cannot destroy."} + + +# noinspection PyTypeChecker +def reboot(name, call=None): + """ + Reboot a vagrant minion. + + name + The name of the VM to reboot. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reboot vm_name + """ + if call != "action": + raise SaltCloudException( + "The reboot action must be called with -a or --action." + ) + my_info = _get_my_info(name) + profile_name = my_info[name]["profile"] + profile = __opts__["profiles"][profile_name] + host = profile["host"] + with salt.client.LocalClient() as local: + return local.cmd(host, "vagrant.reboot", [name]) diff --git a/salt/cloud/clouds/virtualbox.py b/salt/cloud/clouds/virtualbox.py new file mode 100644 index 000000000000..0f7c169cef62 --- /dev/null +++ b/salt/cloud/clouds/virtualbox.py @@ -0,0 +1,449 @@ +""" +A salt cloud provider that lets you use virtualbox on your machine +and act as a cloud. + +:depends: vboxapi + +For now this will only clone existing VMs. It's best to create a template +from which we will clone. + +Followed +https://docs.saltproject.io/en/latest/topics/cloud/cloud.html#non-libcloud-based-modules +to create this. + +Dicts provided by salt: + __opts__ : contains the options used to run Salt Cloud, + as well as a set of configuration and environment variables +""" + +import logging + +import salt.config as config +from salt.exceptions import SaltCloudSystemExit + +try: + import vboxapi # pylint: disable=unused-import + + from salt.utils.virtualbox import ( + treat_machine_dict, + vb_clone_vm, + vb_destroy_machine, + vb_get_machine, + vb_list_machines, + vb_machine_exists, + vb_start_vm, + vb_stop_vm, + vb_wait_for_network_address, + ) + + HAS_VBOX = True +except ImportError: + HAS_VBOX = False + +log = logging.getLogger(__name__) + +# The name salt will identify the lib by +__virtualname__ = "virtualbox" + +# if no clone mode is specified in the virtualbox profile +# then default to 0 which was the old default value +DEFAULT_CLONE_MODE = 0 + + +def __virtual__(): + """ + This function determines whether or not + to make this cloud module available upon execution. + Most often, it uses get_configured_provider() to determine + if the necessary configuration has been set up. + It may also check for necessary imports decide whether to load the module. + In most cases, it will return a True or False value. + If the name of the driver used does not match the filename, + then that name should be returned instead of True. + + @return True|False|str + """ + if not HAS_VBOX: + return ( + False, + "The virtualbox driver cannot be loaded: 'vboxapi' is not installed.", + ) + + if get_configured_provider() is False: + return ( + False, + "The virtualbox driver cannot be loaded: 'virtualbox' provider is not" + " configured.", + ) + + # If the name of the driver used does not match the filename, + # then that name should be returned instead of True. + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + configured = config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + (), # keys we need from the provider configuration + ) + return configured + + +def map_clonemode(vm_info): + """ + Convert the virtualbox config file values for clone_mode into the integers the API requires + """ + mode_map = {"state": 0, "child": 1, "all": 2} + + if not vm_info: + return DEFAULT_CLONE_MODE + + if "clonemode" not in vm_info: + return DEFAULT_CLONE_MODE + + if vm_info["clonemode"] in mode_map: + return mode_map[vm_info["clonemode"]] + else: + raise SaltCloudSystemExit( + "Illegal clonemode for virtualbox profile. Legal values are: {}".format( + ",".join(mode_map.keys()) + ) + ) + + +def create(vm_info): + """ + Creates a virtual machine from the given VM information + + This is what is used to request a virtual machine to be created by the + cloud provider, wait for it to become available, and then (optionally) log + in and install Salt on it. + + Events fired: + + This function fires the event ``salt/cloud/vm_name/creating``, with the + payload containing the names of the VM, profile, and provider. + + @param vm_info + + .. code-block:: text + + { + name: + profile: + driver: : + clonefrom: + clonemode: (default: state, choices: state, child, all) + } + + @type vm_info dict + @return dict of resulting vm. !!!Passwords can and should be included!!! + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_info["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "virtualbox", + vm_info["profile"], + ) + is False + ): + return False + except AttributeError: + pass + + vm_name = vm_info["name"] + deploy = config.get_cloud_config_value( + "deploy", vm_info, __opts__, search_global=False, default=True + ) + wait_for_ip_timeout = config.get_cloud_config_value( + "wait_for_ip_timeout", vm_info, __opts__, default=60 + ) + boot_timeout = config.get_cloud_config_value( + "boot_timeout", vm_info, __opts__, default=60 * 1000 + ) + power = config.get_cloud_config_value("power_on", vm_info, __opts__, default=False) + key_filename = config.get_cloud_config_value( + "private_key", vm_info, __opts__, search_global=False, default=None + ) + clone_mode = map_clonemode(vm_info) + wait_for_pattern = ( + vm_info["waitforpattern"] if "waitforpattern" in vm_info.keys() else None + ) + interface_index = ( + vm_info["interfaceindex"] if "interfaceindex" in vm_info.keys() else 0 + ) + + log.debug("Going to fire event: starting create") + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_info["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_info, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + # to create the virtual machine. + request_kwargs = { + "name": vm_info["name"], + "clone_from": vm_info["clonefrom"], + "clone_mode": clone_mode, + } + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_info["name"]), + args=__utils__["cloud.filter_event"]( + "requesting", request_kwargs, list(request_kwargs) + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + vm_result = vb_clone_vm(**request_kwargs) + + # Booting and deploying if needed + if power: + vb_start_vm(vm_name, timeout=boot_timeout) + ips = vb_wait_for_network_address( + wait_for_ip_timeout, machine_name=vm_name, wait_for_pattern=wait_for_pattern + ) + + if ips: + ip = ips[interface_index] + log.info("[ %s ] IPv4 is: %s", vm_name, ip) + # ssh or smb using ip and install salt only if deploy is True + if deploy: + vm_info["key_filename"] = key_filename + vm_info["ssh_host"] = ip + + res = __utils__["cloud.bootstrap"](vm_info, __opts__) + vm_result.update(res) + + __utils__["cloud.fire_event"]( + "event", + "created machine", + "salt/cloud/{}/created".format(vm_info["name"]), + args=__utils__["cloud.filter_event"]("created", vm_result, list(vm_result)), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + # Passwords should be included in this object!! + return vm_result + + +def list_nodes_full(kwargs=None, call=None): + """ + All information available about all nodes should be returned in this function. + The fields in the list_nodes() function should also be returned, + even if they would not normally be provided by the cloud provider. + + This is because some functions both within Salt and 3rd party will break if an expected field is not present. + This function is normally called with the -F option: + + + .. code-block:: bash + + salt-cloud -F + + + @param kwargs: + @type kwargs: + @param call: + @type call: + @return: + @rtype: + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + machines = {} + + # TODO ask for the correct attributes e.g state and private_ips + for machine in vb_list_machines(): + name = machine.get("name") + if name: + machines[name] = treat_machine_dict(machine) + del machine["name"] + + return machines + + +def list_nodes(kwargs=None, call=None): + """ + This function returns a list of nodes available on this cloud provider, using the following fields: + + id (str) + image (str) + size (str) + state (str) + private_ips (list) + public_ips (list) + + No other fields should be returned in this function, and all of these fields should be returned, even if empty. + The private_ips and public_ips fields should always be of a list type, even if empty, + and the other fields should always be of a str type. + This function is normally called with the -Q option: + + .. code-block:: bash + + salt-cloud -Q + + + @param kwargs: + @type kwargs: + @param call: + @type call: + @return: + @rtype: + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + attributes = [ + "id", + "image", + "size", + "state", + "private_ips", + "public_ips", + ] + return __utils__["cloud.list_nodes_select"]( + list_nodes_full("function"), + attributes, + call, + ) + + +def list_nodes_select(call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + return __utils__["cloud.list_nodes_select"]( + list_nodes_full("function"), + __opts__["query.selection"], + call, + ) + + +def destroy(name, call=None): + """ + This function irreversibly destroys a virtual machine on the cloud provider. + Before doing so, it should fire an event on the Salt event bus. + + The tag for this event is `salt/cloud//destroying`. + Once the virtual machine has been destroyed, another event is fired. + The tag for that event is `salt/cloud//destroyed`. + + Dependencies: + list_nodes + + @param name: + @type name: str + @param call: + @type call: + @return: True if all went well, otherwise an error message + @rtype: bool|str + """ + log.info("Attempting to delete instance %s", name) + if not vb_machine_exists(name): + return f"{name} doesn't exist and can't be deleted" + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + vb_destroy_machine(name) + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + +def start(name, call=None): + """ + Start a machine. + @param name: Machine to start + @type name: str + @param call: Must be "action" + @type call: str + """ + if call != "action": + raise SaltCloudSystemExit( + "The instance action must be called with -a or --action." + ) + + log.info("Starting machine: %s", name) + vb_start_vm(name) + machine = vb_get_machine(name) + del machine["name"] + return treat_machine_dict(machine) + + +def stop(name, call=None): + """ + Stop a running machine. + @param name: Machine to stop + @type name: str + @param call: Must be "action" + @type call: str + """ + if call != "action": + raise SaltCloudSystemExit( + "The instance action must be called with -a or --action." + ) + + log.info("Stopping machine: %s", name) + vb_stop_vm(name) + machine = vb_get_machine(name) + del machine["name"] + return treat_machine_dict(machine) + + +def show_image(kwargs, call=None): + """ + Show the details of an image + """ + if call != "function": + raise SaltCloudSystemExit( + "The show_image action must be called with -f or --function." + ) + + name = kwargs["image"] + log.info("Showing image %s", name) + machine = vb_get_machine(name) + + ret = {machine["name"]: treat_machine_dict(machine)} + del machine["name"] + return ret diff --git a/salt/cloud/clouds/vmware.py b/salt/cloud/clouds/vmware.py new file mode 100644 index 000000000000..91b1f1b3d251 --- /dev/null +++ b/salt/cloud/clouds/vmware.py @@ -0,0 +1,4958 @@ +# pylint: disable=C0302 +""" +VMware Cloud Module +=================== + +.. versionadded:: 2015.5.4 + +The VMware cloud module allows you to manage VMware ESX, ESXi, and vCenter. + +See :ref:`Getting started with VMware ` to get started. + +:codeauthor: Nitin Madhok + + +Dependencies +============ + +- pyVmomi Python Module + +pyVmomi +------- + +PyVmomi can be installed via pip: + +.. code-block:: bash + + pip install pyVmomi + +.. note:: + + Version 6.0 of pyVmomi has some problems with SSL error handling on certain + versions of Python. If using version 6.0 of pyVmomi, Python 2.6, + Python 2.7.9, or newer must be present. This is due to an upstream dependency + in pyVmomi 6.0 that is not supported in Python versions 2.7 to 2.7.8. If the + version of Python is not in the supported range, you will need to install an + earlier version of pyVmomi. See `Issue #29537`_ for more information. + +.. _Issue #29537: https://github.com/saltstack/salt/issues/29537 + +Based on the note above, to install an earlier version of pyVmomi than the +version currently listed in PyPi, run the following: + +.. code-block:: bash + + pip install pyVmomi==5.5.0.2014.1.1 + +The 5.5.0.2014.1.1 is a known stable version that this original VMware cloud +driver was developed against. + +.. note:: + Ensure python pyVmomi module is installed by running following one-liner + check. The output should be 0. + + .. code-block:: bash + + python -c "import pyVmomi" ; echo $? + + +Configuration +============= + +To use this module, set up the vCenter or ESX/ESXi URL, username and password in the +cloud configuration at +``/etc/salt/cloud.providers`` or ``/etc/salt/cloud.providers.d/vmware.conf``: + +.. code-block:: yaml + + my-vmware-config: + driver: vmware + user: 'DOMAIN\\user' + password: 'verybadpass' + url: '10.20.30.40' + + vcenter01: + driver: vmware + user: 'DOMAIN\\user' + password: 'verybadpass' + url: 'vcenter01.domain.com' + protocol: 'https' + port: 443 + + vcenter02: + driver: vmware + user: 'DOMAIN\\user' + password: 'verybadpass' + url: 'vcenter02.domain.com' + protocol: 'http' + port: 80 + + esx01: + driver: vmware + user: 'admin' + password: 'verybadpass' + url: 'esx01.domain.com' + +.. note:: + + Optionally, ``protocol`` and ``port`` can be specified if the vCenter + server is not using the defaults. Default is ``protocol: https`` and + ``port: 443``. + +.. note:: + .. versionchanged:: 2015.8.0 + + The ``provider`` parameter in cloud provider configuration was renamed to ``driver``. + This change was made to avoid confusion with the ``provider`` parameter that is + used in cloud profile configuration. Cloud provider configuration now uses ``driver`` + to refer to the salt-cloud driver that provides the underlying functionality to + connect to a cloud provider, while cloud profile configuration continues to use + ``provider`` to refer to the cloud provider configuration that you define. + +To test the connection for ``my-vmware-config`` specified in the cloud +configuration, run :py:func:`test_vcenter_connection` +""" + +import logging +import os.path +import pprint +import re +import subprocess +import time +from random import randint + +import salt.config as config +import salt.utils.cloud +import salt.utils.network +import salt.utils.stringutils +import salt.utils.vmware +import salt.utils.xmlutil +from salt.exceptions import SaltCloudSystemExit + +try: + # Attempt to import pyVmomi libs + from pyVmomi import vim # pylint: disable=no-name-in-module + + HAS_PYVMOMI = True +except ImportError: + HAS_PYVMOMI = False + +# Disable InsecureRequestWarning generated on python > 2.6 +try: + from requests.packages.urllib3 import ( # pylint: disable=no-name-in-module + disable_warnings, + ) + + disable_warnings() +except ImportError: + pass + +ESX_5_5_NAME_PORTION = "VMware ESXi 5.5" +SAFE_ESX_5_5_CONTROLLER_KEY_INDEX = 200 +FLATTEN_DISK_FULL_CLONE = "moveAllDiskBackingsAndDisallowSharing" +COPY_ALL_DISKS_FULL_CLONE = "moveAllDiskBackingsAndAllowSharing" +CURRENT_STATE_LINKED_CLONE = "moveChildMostDiskBacking" +QUICK_LINKED_CLONE = "createNewChildDiskBacking" + + +IP_RE = r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "vmware" + + +# Only load in this module if the VMware configurations are in place +def __virtual__(): + """ + Check for VMware configuration and if required libs are available. + """ + if get_configured_provider() is False: + return False + + if get_dependencies() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, + _get_active_provider_name() or __virtualname__, + ( + "url", + "user", + "password", + ), + ) + + +def get_dependencies(): + """ + Warn if dependencies aren't met. + """ + deps = { + "pyVmomi": HAS_PYVMOMI, + } + return config.check_driver_dependencies(__virtualname__, deps) + + +def script(vm_): + """ + Return the script deployment object + """ + script_name = config.get_cloud_config_value("script", vm_, __opts__) + if not script_name: + script_name = "bootstrap-salt" + + return salt.utils.cloud.os_script( + script_name, + vm_, + __opts__, + salt.utils.cloud.salt_config_to_yaml( + salt.utils.cloud.minion_config(__opts__, vm_) + ), + ) + + +def _str_to_bool(var): + if isinstance(var, bool): + return var + + if isinstance(var, str): + return True if var.lower() == "true" else False + + return None + + +def _get_si(): + """ + Authenticate with vCenter server and return service instance object. + """ + + url = config.get_cloud_config_value( + "url", get_configured_provider(), __opts__, search_global=False + ) + username = config.get_cloud_config_value( + "user", get_configured_provider(), __opts__, search_global=False + ) + password = config.get_cloud_config_value( + "password", get_configured_provider(), __opts__, search_global=False + ) + protocol = config.get_cloud_config_value( + "protocol", + get_configured_provider(), + __opts__, + search_global=False, + default="https", + ) + port = config.get_cloud_config_value( + "port", get_configured_provider(), __opts__, search_global=False, default=443 + ) + verify_ssl = config.get_cloud_config_value( + "verify_ssl", + get_configured_provider(), + __opts__, + search_global=False, + default=True, + ) + return salt.utils.vmware.get_service_instance( + url, username, password, protocol=protocol, port=port, verify_ssl=verify_ssl + ) + + +def _edit_existing_hard_disk_helper(disk, size_kb=None, size_gb=None, mode=None): + if size_kb or size_gb: + disk.capacityInKB = size_kb if size_kb else int(size_gb * 1024.0 * 1024.0) + if mode: + disk.backing.diskMode = mode + disk_spec = vim.vm.device.VirtualDeviceSpec() + disk_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit + disk_spec.device = disk + + return disk_spec + + +def _add_new_hard_disk_helper( + disk_label, + size_gb, + unit_number, + controller_key=1000, + thin_provision=False, + eagerly_scrub=False, + datastore=None, + vm_name=None, +): + random_key = randint(-2099, -2000) + size_kb = int(size_gb * 1024.0 * 1024.0) + + disk_spec = vim.vm.device.VirtualDeviceSpec() + disk_spec.fileOperation = "create" + disk_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add + + disk_spec.device = vim.vm.device.VirtualDisk() + disk_spec.device.key = random_key + disk_spec.device.deviceInfo = vim.Description() + disk_spec.device.deviceInfo.label = disk_label + disk_spec.device.deviceInfo.summary = f"{size_gb} GB" + + disk_spec.device.backing = vim.vm.device.VirtualDisk.FlatVer2BackingInfo() + disk_spec.device.backing.thinProvisioned = thin_provision + disk_spec.device.backing.eagerlyScrub = eagerly_scrub + disk_spec.device.backing.diskMode = "persistent" + + if datastore: + datastore_ref = salt.utils.vmware.get_mor_using_container_view( + _get_si(), vim.Datastore, datastore + ) + + if not datastore_ref: + # check if it is a datastore cluster instead + datastore_cluster_ref = salt.utils.vmware.get_mor_using_container_view( + _get_si(), vim.StoragePod, datastore + ) + + if not datastore_cluster_ref: + # datastore/datastore cluster specified does not exist + raise SaltCloudSystemExit( + "Specified datastore/datastore cluster ({}) for disk ({}) does not" + " exist".format(datastore, disk_label) + ) + + # datastore cluster has been specified + # find datastore with most free space available + # + # TODO: Get DRS Recommendations instead of finding datastore with most free space + datastore_list = salt.utils.vmware.get_datastores( + _get_si(), datastore_cluster_ref, get_all_datastores=True + ) + datastore_free_space = 0 + for ds_ref in datastore_list: + log.trace( + "Found datastore (%s) with free space (%s) in datastore " + "cluster (%s)", + ds_ref.name, + ds_ref.summary.freeSpace, + datastore, + ) + if ( + ds_ref.summary.accessible + and ds_ref.summary.freeSpace > datastore_free_space + ): + datastore_free_space = ds_ref.summary.freeSpace + datastore_ref = ds_ref + + if not datastore_ref: + # datastore cluster specified does not have any accessible datastores + raise SaltCloudSystemExit( + "Specified datastore cluster ({}) for disk ({}) does not have any" + " accessible datastores available".format(datastore, disk_label) + ) + + datastore_path = "[" + str(datastore_ref.name) + "] " + vm_name + disk_spec.device.backing.fileName = datastore_path + "/" + disk_label + ".vmdk" + disk_spec.device.backing.datastore = datastore_ref + log.trace( + "Using datastore (%s) for disk (%s), vm_name (%s)", + datastore_ref.name, + disk_label, + vm_name, + ) + + disk_spec.device.controllerKey = controller_key + disk_spec.device.unitNumber = unit_number + disk_spec.device.capacityInKB = size_kb + + return disk_spec + + +def _edit_existing_network_adapter( + network_adapter, new_network_name, adapter_type, switch_type, container_ref=None +): + adapter_type.strip().lower() + switch_type.strip().lower() + + if adapter_type in ["vmxnet", "vmxnet2", "vmxnet3", "e1000", "e1000e"]: + edited_network_adapter = salt.utils.vmware.get_network_adapter_type( + adapter_type + ) + if isinstance(network_adapter, type(edited_network_adapter)): + edited_network_adapter = network_adapter + else: + log.debug( + "Changing type of '%s' from '%s' to '%s'", + network_adapter.deviceInfo.label, + type(network_adapter).__name__.rsplit(".", 1)[1][7:].lower(), + adapter_type, + ) + else: + # If type not specified or does not match, don't change adapter type + if adapter_type: + log.error( + "Cannot change type of '%s' to '%s'. Not changing type", + network_adapter.deviceInfo.label, + adapter_type, + ) + edited_network_adapter = network_adapter + + if switch_type == "standard": + network_ref = salt.utils.vmware.get_mor_by_property( + _get_si(), vim.Network, new_network_name, container_ref=container_ref + ) + edited_network_adapter.backing = ( + vim.vm.device.VirtualEthernetCard.NetworkBackingInfo() + ) + edited_network_adapter.backing.deviceName = new_network_name + edited_network_adapter.backing.network = network_ref + elif switch_type == "distributed": + network_ref = salt.utils.vmware.get_mor_by_property( + _get_si(), + vim.dvs.DistributedVirtualPortgroup, + new_network_name, + container_ref=container_ref, + ) + dvs_port_connection = vim.dvs.PortConnection( + portgroupKey=network_ref.key, + switchUuid=network_ref.config.distributedVirtualSwitch.uuid, + ) + edited_network_adapter.backing = ( + vim.vm.device.VirtualEthernetCard.DistributedVirtualPortBackingInfo() + ) + edited_network_adapter.backing.port = dvs_port_connection + else: + # If switch type not specified or does not match, show error and return + if not switch_type: + err_msg = ( + "The switch type to be used by '{}' has not been specified".format( + network_adapter.deviceInfo.label + ) + ) + else: + err_msg = "Cannot create '{}'. Invalid/unsupported switch type '{}'".format( + network_adapter.deviceInfo.label, switch_type + ) + raise SaltCloudSystemExit(err_msg) + + edited_network_adapter.key = network_adapter.key + edited_network_adapter.deviceInfo = network_adapter.deviceInfo + edited_network_adapter.deviceInfo.summary = new_network_name + edited_network_adapter.connectable = network_adapter.connectable + edited_network_adapter.slotInfo = network_adapter.slotInfo + edited_network_adapter.controllerKey = network_adapter.controllerKey + edited_network_adapter.unitNumber = network_adapter.unitNumber + edited_network_adapter.addressType = network_adapter.addressType + edited_network_adapter.macAddress = network_adapter.macAddress + edited_network_adapter.wakeOnLanEnabled = network_adapter.wakeOnLanEnabled + network_spec = vim.vm.device.VirtualDeviceSpec() + network_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit + network_spec.device = edited_network_adapter + + return network_spec + + +def _add_new_network_adapter_helper( + network_adapter_label, + network_name, + adapter_type, + switch_type, + mac, + container_ref=None, +): + random_key = randint(-4099, -4000) + + adapter_type.strip().lower() + switch_type.strip().lower() + network_spec = vim.vm.device.VirtualDeviceSpec() + + if adapter_type in ["vmxnet", "vmxnet2", "vmxnet3", "e1000", "e1000e"]: + network_spec.device = salt.utils.vmware.get_network_adapter_type(adapter_type) + else: + # If type not specified or does not match, create adapter of type vmxnet3 + if not adapter_type: + log.debug( + "The type of '%s' has not been specified. " + "Creating default type 'vmxnet3'", + network_adapter_label, + ) + else: + log.error( + "Cannot create network adapter of type '%s'. " + "Creating '%s' of default type 'vmxnet3'", + adapter_type, + network_adapter_label, + ) + network_spec.device = vim.vm.device.VirtualVmxnet3() + + network_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add + + if switch_type == "standard": + network_spec.device.backing = ( + vim.vm.device.VirtualEthernetCard.NetworkBackingInfo() + ) + network_spec.device.backing.deviceName = network_name + network_spec.device.backing.network = salt.utils.vmware.get_mor_by_property( + _get_si(), vim.Network, network_name, container_ref=container_ref + ) + elif switch_type == "distributed": + network_ref = salt.utils.vmware.get_mor_by_property( + _get_si(), + vim.dvs.DistributedVirtualPortgroup, + network_name, + container_ref=container_ref, + ) + dvs_port_connection = vim.dvs.PortConnection( + portgroupKey=network_ref.key, + switchUuid=network_ref.config.distributedVirtualSwitch.uuid, + ) + network_spec.device.backing = ( + vim.vm.device.VirtualEthernetCard.DistributedVirtualPortBackingInfo() + ) + network_spec.device.backing.port = dvs_port_connection + else: + # If switch type not specified or does not match, show error and return + if not switch_type: + err_msg = ( + "The switch type to be used by '{}' has not been specified".format( + network_adapter_label + ) + ) + else: + err_msg = "Cannot create '{}'. Invalid/unsupported switch type '{}'".format( + network_adapter_label, switch_type + ) + raise SaltCloudSystemExit(err_msg) + + if mac != "": + network_spec.device.addressType = "assigned" + network_spec.device.macAddress = mac + network_spec.device.key = random_key + network_spec.device.deviceInfo = vim.Description() + network_spec.device.deviceInfo.label = network_adapter_label + network_spec.device.deviceInfo.summary = network_name + network_spec.device.wakeOnLanEnabled = True + network_spec.device.connectable = vim.vm.device.VirtualDevice.ConnectInfo() + network_spec.device.connectable.startConnected = True + network_spec.device.connectable.allowGuestControl = True + + return network_spec + + +def _edit_existing_scsi_controller(scsi_controller, bus_sharing): + scsi_controller.sharedBus = bus_sharing + scsi_spec = vim.vm.device.VirtualDeviceSpec() + scsi_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit + scsi_spec.device = scsi_controller + + return scsi_spec + + +def _add_new_scsi_controller_helper(scsi_controller_label, properties, bus_number): + random_key = randint(-1050, -1000) + adapter_type = properties["type"].strip().lower() if "type" in properties else None + bus_sharing = ( + properties["bus_sharing"].strip().lower() + if "bus_sharing" in properties + else None + ) + + scsi_spec = vim.vm.device.VirtualDeviceSpec() + + if adapter_type == "lsilogic": + summary = "LSI Logic" + scsi_spec.device = vim.vm.device.VirtualLsiLogicController() + elif adapter_type == "lsilogic_sas": + summary = "LSI Logic Sas" + scsi_spec.device = vim.vm.device.VirtualLsiLogicSASController() + elif adapter_type == "paravirtual": + summary = "VMware paravirtual SCSI" + scsi_spec.device = vim.vm.device.ParaVirtualSCSIController() + else: + # If type not specified or does not match, show error and return + if not adapter_type: + err_msg = "The type of '{}' has not been specified".format( + scsi_controller_label + ) + else: + err_msg = "Cannot create '{}'. Invalid/unsupported type '{}'".format( + scsi_controller_label, adapter_type + ) + raise SaltCloudSystemExit(err_msg) + + scsi_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add + + scsi_spec.device.key = random_key + scsi_spec.device.busNumber = bus_number + scsi_spec.device.deviceInfo = vim.Description() + scsi_spec.device.deviceInfo.label = scsi_controller_label + scsi_spec.device.deviceInfo.summary = summary + + if bus_sharing == "virtual": + # Virtual disks can be shared between virtual machines on the same server + scsi_spec.device.sharedBus = ( + vim.vm.device.VirtualSCSIController.Sharing.virtualSharing + ) + + elif bus_sharing == "physical": + # Virtual disks can be shared between virtual machines on any server + scsi_spec.device.sharedBus = ( + vim.vm.device.VirtualSCSIController.Sharing.physicalSharing + ) + + else: + # Virtual disks cannot be shared between virtual machines + scsi_spec.device.sharedBus = ( + vim.vm.device.VirtualSCSIController.Sharing.noSharing + ) + + return scsi_spec + + +def _add_new_ide_controller_helper(ide_controller_label, controller_key, bus_number): + """ + Helper function for adding new IDE controllers + + .. versionadded:: 2016.3.0 + + Args: + ide_controller_label: label of the IDE controller + controller_key: if not None, the controller key to use; otherwise it is randomly generated + bus_number: bus number + + Returns: created device spec for an IDE controller + + """ + if controller_key is None: + controller_key = randint(-200, 250) + + ide_spec = vim.vm.device.VirtualDeviceSpec() + ide_spec.device = vim.vm.device.VirtualIDEController() + + ide_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add + + ide_spec.device.key = controller_key + ide_spec.device.busNumber = bus_number + ide_spec.device.deviceInfo = vim.Description() + ide_spec.device.deviceInfo.label = ide_controller_label + ide_spec.device.deviceInfo.summary = ide_controller_label + + return ide_spec + + +def _set_cd_or_dvd_backing_type(drive, device_type, mode, iso_path): + if device_type == "datastore_iso_file": + drive.backing = vim.vm.device.VirtualCdrom.IsoBackingInfo() + drive.backing.fileName = iso_path + + datastore = iso_path.partition("[")[-1].rpartition("]")[0] + datastore_ref = salt.utils.vmware.get_mor_by_property( + _get_si(), vim.Datastore, datastore + ) + if datastore_ref: + drive.backing.datastore = datastore_ref + + drive.deviceInfo.summary = f"ISO {iso_path}" + + elif device_type == "client_device": + if mode == "passthrough": + drive.backing = vim.vm.device.VirtualCdrom.RemotePassthroughBackingInfo() + drive.deviceInfo.summary = "Remote Device" + elif mode == "atapi": + drive.backing = vim.vm.device.VirtualCdrom.RemoteAtapiBackingInfo() + drive.deviceInfo.summary = "Remote ATAPI" + + return drive + + +def _edit_existing_cd_or_dvd_drive(drive, device_type, mode, iso_path): + device_type.strip().lower() + mode.strip().lower() + + drive_spec = vim.vm.device.VirtualDeviceSpec() + drive_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit + drive_spec.device = _set_cd_or_dvd_backing_type(drive, device_type, mode, iso_path) + + return drive_spec + + +def _add_new_cd_or_dvd_drive_helper( + drive_label, controller_key, device_type, mode, iso_path +): + random_key = randint(-3025, -3000) + + device_type.strip().lower() + mode.strip().lower() + + drive_spec = vim.vm.device.VirtualDeviceSpec() + drive_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add + drive_spec.device = vim.vm.device.VirtualCdrom() + drive_spec.device.deviceInfo = vim.Description() + + if device_type in ["datastore_iso_file", "client_device"]: + drive_spec.device = _set_cd_or_dvd_backing_type( + drive_spec.device, device_type, mode, iso_path + ) + else: + # If device_type not specified or does not match, create drive of Client type with Passthough mode + if not device_type: + log.debug( + "The 'device_type' of '%s' has not been specified. " + "Creating default type 'client_device'", + drive_label, + ) + else: + log.error( + "Cannot create CD/DVD drive of type '%s'. " + "Creating '%s' of default type 'client_device'", + device_type, + drive_label, + ) + drive_spec.device.backing = ( + vim.vm.device.VirtualCdrom.RemotePassthroughBackingInfo() + ) + drive_spec.device.deviceInfo.summary = "Remote Device" + + drive_spec.device.key = random_key + drive_spec.device.deviceInfo.label = drive_label + drive_spec.device.controllerKey = controller_key + drive_spec.device.connectable = vim.vm.device.VirtualDevice.ConnectInfo() + drive_spec.device.connectable.startConnected = True + drive_spec.device.connectable.allowGuestControl = True + + return drive_spec + + +def _set_network_adapter_mapping(adapter_specs): + adapter_mapping = vim.vm.customization.AdapterMapping() + adapter_mapping.adapter = vim.vm.customization.IPSettings() + + if "domain" in list(adapter_specs.keys()): + domain = adapter_specs["domain"] + adapter_mapping.adapter.dnsDomain = domain + if "gateway" in list(adapter_specs.keys()): + gateway = adapter_specs["gateway"] + adapter_mapping.adapter.gateway = gateway + if "ip" in list(adapter_specs.keys()): + ip = str(adapter_specs["ip"]) + subnet_mask = str(adapter_specs["subnet_mask"]) + adapter_mapping.adapter.ip = vim.vm.customization.FixedIp(ipAddress=ip) + adapter_mapping.adapter.subnetMask = subnet_mask + else: + adapter_mapping.adapter.ip = vim.vm.customization.DhcpIpGenerator() + + return adapter_mapping + + +def _get_mode_spec(device, mode, disk_spec): + if device.backing.diskMode != mode: + if not disk_spec: + disk_spec = _edit_existing_hard_disk_helper(disk=device, mode=mode) + else: + disk_spec.device.backing.diskMode = mode + return disk_spec + + +def _get_size_spec(device, size_gb=None, size_kb=None): + if size_kb is None and size_gb is not None: + size_kb = int(size_gb * 1024.0 * 1024.0) + disk_spec = ( + _edit_existing_hard_disk_helper(disk=device, size_kb=size_kb) + if device.capacityInKB < size_kb + else None + ) + return disk_spec + + +def _iter_disk_unit_number(unit_number): + """ + Apparently vmware reserves ID 7 for SCSI controllers, so we cannot specify + hard drives for 7. + + Skip 7 to make sure. + """ + unit_number += 1 + if unit_number == 7: + unit_number += 1 + return unit_number + + +def _manage_devices(devices, vm=None, container_ref=None, new_vm_name=None): + unit_number = 0 + bus_number = 0 + device_specs = [] + existing_disks_label = [] + existing_scsi_controllers_label = [] + existing_ide_controllers_label = [] + existing_network_adapters_label = [] + existing_cd_drives_label = [] + ide_controllers = {} + nics_map = [] + cloning_from_vm = vm is not None + + if cloning_from_vm: + # loop through all the devices the vm/template has + # check if the device needs to be created or configured + for device in vm.config.hardware.device: + if isinstance(device, vim.vm.device.VirtualDisk): + # this is a hard disk + if "disk" in list(devices.keys()): + # there is atleast one disk specified to be created/configured + unit_number = _iter_disk_unit_number(unit_number) + existing_disks_label.append(device.deviceInfo.label) + if device.deviceInfo.label in list(devices["disk"].keys()): + disk_spec = None + if "size" in devices["disk"][device.deviceInfo.label]: + size_gb = float( + devices["disk"][device.deviceInfo.label]["size"] + ) + size_kb = int(size_gb * 1024.0 * 1024.0) + else: + # User didn't specify disk size in the cloud + # profile so use the existing disk size + size_kb = device.capacityInKB + size_gb = size_kb / (1024.0 * 1024.0) + log.debug( + "Virtual disk size for '%s' was not " + "specified in the cloud profile or map file. " + "Using existing virtual disk size of '%sGB'", + device.deviceInfo.label, + size_gb, + ) + + if device.capacityInKB > size_kb: + raise SaltCloudSystemExit( + "The specified disk size '{}GB' for '{}' is " + "smaller than the disk image size '{}GB'. It must " + "be equal to or greater than the disk image".format( + float( + devices["disk"][device.deviceInfo.label]["size"] + ), + device.deviceInfo.label, + float(device.capacityInKB / (1024.0 * 1024.0)), + ) + ) + else: + disk_spec = _get_size_spec(device=device, size_kb=size_kb) + + if "mode" in devices["disk"][device.deviceInfo.label]: + if devices["disk"][device.deviceInfo.label]["mode"] in [ + "independent_persistent", + "independent_nonpersistent", + "dependent", + ]: + mode = devices["disk"][device.deviceInfo.label]["mode"] + disk_spec = _get_mode_spec(device, mode, disk_spec) + else: + raise SaltCloudSystemExit( + "Invalid disk backing mode specified!" + ) + if disk_spec is not None: + device_specs.append(disk_spec) + + elif isinstance( + device.backing, + ( + vim.vm.device.VirtualEthernetCard.NetworkBackingInfo, + vim.vm.device.VirtualEthernetCard.DistributedVirtualPortBackingInfo, + ), + ): + # this is a network adapter + if "network" in list(devices.keys()): + # there is atleast one network adapter specified to be created/configured + existing_network_adapters_label.append(device.deviceInfo.label) + if device.deviceInfo.label in list(devices["network"].keys()): + network_name = devices["network"][device.deviceInfo.label][ + "name" + ] + adapter_type = ( + devices["network"][device.deviceInfo.label]["adapter_type"] + if "adapter_type" + in devices["network"][device.deviceInfo.label] + else "" + ) + switch_type = ( + devices["network"][device.deviceInfo.label]["switch_type"] + if "switch_type" + in devices["network"][device.deviceInfo.label] + else "" + ) + network_spec = _edit_existing_network_adapter( + device, + network_name, + adapter_type, + switch_type, + container_ref, + ) + adapter_mapping = _set_network_adapter_mapping( + devices["network"][device.deviceInfo.label] + ) + device_specs.append(network_spec) + nics_map.append(adapter_mapping) + + elif hasattr(device, "scsiCtlrUnitNumber"): + # this is a SCSI controller + if "scsi" in list(devices.keys()): + # there is atleast one SCSI controller specified to be created/configured + bus_number += 1 + existing_scsi_controllers_label.append(device.deviceInfo.label) + if device.deviceInfo.label in list(devices["scsi"].keys()): + # Modify the existing SCSI controller + scsi_controller_properties = devices["scsi"][ + device.deviceInfo.label + ] + bus_sharing = ( + scsi_controller_properties["bus_sharing"].strip().lower() + if "bus_sharing" in scsi_controller_properties + else None + ) + if bus_sharing and bus_sharing in ["virtual", "physical", "no"]: + bus_sharing = f"{bus_sharing}Sharing" + if bus_sharing != device.sharedBus: + # Only edit the SCSI controller if bus_sharing is different + scsi_spec = _edit_existing_scsi_controller( + device, bus_sharing + ) + device_specs.append(scsi_spec) + + elif isinstance(device, vim.vm.device.VirtualCdrom): + # this is a cd/dvd drive + if "cd" in list(devices.keys()): + # there is atleast one cd/dvd drive specified to be created/configured + existing_cd_drives_label.append(device.deviceInfo.label) + if device.deviceInfo.label in list(devices["cd"].keys()): + device_type = ( + devices["cd"][device.deviceInfo.label]["device_type"] + if "device_type" in devices["cd"][device.deviceInfo.label] + else "" + ) + mode = ( + devices["cd"][device.deviceInfo.label]["mode"] + if "mode" in devices["cd"][device.deviceInfo.label] + else "" + ) + iso_path = ( + devices["cd"][device.deviceInfo.label]["iso_path"] + if "iso_path" in devices["cd"][device.deviceInfo.label] + else "" + ) + cd_drive_spec = _edit_existing_cd_or_dvd_drive( + device, device_type, mode, iso_path + ) + device_specs.append(cd_drive_spec) + + elif isinstance(device, vim.vm.device.VirtualIDEController): + # this is an IDE controller to add new cd drives to + ide_controllers[device.key] = len(device.device) + + if "network" in list(devices.keys()): + network_adapters_to_create = list( + set(devices["network"].keys()) - set(existing_network_adapters_label) + ) + network_adapters_to_create.sort() + if network_adapters_to_create: + log.debug("Networks adapters to create: %s", network_adapters_to_create) + for network_adapter_label in network_adapters_to_create: + network_name = devices["network"][network_adapter_label]["name"] + adapter_type = ( + devices["network"][network_adapter_label]["adapter_type"] + if "adapter_type" in devices["network"][network_adapter_label] + else "" + ) + switch_type = ( + devices["network"][network_adapter_label]["switch_type"] + if "switch_type" in devices["network"][network_adapter_label] + else "" + ) + mac = ( + devices["network"][network_adapter_label]["mac"] + if "mac" in devices["network"][network_adapter_label] + else "" + ) + # create the network adapter + network_spec = _add_new_network_adapter_helper( + network_adapter_label, + network_name, + adapter_type, + switch_type, + mac, + container_ref, + ) + adapter_mapping = _set_network_adapter_mapping( + devices["network"][network_adapter_label] + ) + device_specs.append(network_spec) + nics_map.append(adapter_mapping) + + if "scsi" in list(devices.keys()): + scsi_controllers_to_create = list( + set(devices["scsi"].keys()) - set(existing_scsi_controllers_label) + ) + scsi_controllers_to_create.sort() + if scsi_controllers_to_create: + log.debug("SCSI controllers to create: %s", scsi_controllers_to_create) + for scsi_controller_label in scsi_controllers_to_create: + # create the SCSI controller + scsi_controller_properties = devices["scsi"][scsi_controller_label] + scsi_spec = _add_new_scsi_controller_helper( + scsi_controller_label, scsi_controller_properties, bus_number + ) + device_specs.append(scsi_spec) + bus_number += 1 + + if "ide" in list(devices.keys()): + ide_controllers_to_create = list( + set(devices["ide"].keys()) - set(existing_ide_controllers_label) + ) + ide_controllers_to_create.sort() + if ide_controllers_to_create: + log.debug("IDE controllers to create: %s", ide_controllers_to_create) + + # ESX 5.5 (and possibly earlier?) set the IDE controller key themselves, indexed starting at + # 200. Rather than doing a create task/get vm/reconfig task dance we query the server and + # if it's ESX 5.5 we supply a controller starting at 200 and work out way upwards from there + # ESX 6 (and, one assumes, vCenter) does not display this problem and so continues to use + # the randomly generated indexes + vcenter_name = get_vcenter_version(call="function") + controller_index = ( + SAFE_ESX_5_5_CONTROLLER_KEY_INDEX + if ESX_5_5_NAME_PORTION in vcenter_name + else None + ) + + for ide_controller_label in ide_controllers_to_create: + # create the IDE controller + ide_spec = _add_new_ide_controller_helper( + ide_controller_label, controller_index, bus_number + ) + device_specs.append(ide_spec) + bus_number += 1 + if controller_index is not None: + controller_index += 1 + + if "disk" in list(devices.keys()): + disks_to_create = list(set(devices["disk"].keys()) - set(existing_disks_label)) + disks_to_create.sort() + if disks_to_create: + log.debug("Hard disks to create: %s", disks_to_create) + for disk_label in disks_to_create: + # create the disk + size_gb = float(devices["disk"][disk_label]["size"]) + thin_provision = ( + bool(devices["disk"][disk_label]["thin_provision"]) + if "thin_provision" in devices["disk"][disk_label] + else False + ) + eagerly_scrub = ( + bool(devices["disk"][disk_label]["eagerly_scrub"]) + if "eagerly_scrub" in devices["disk"][disk_label] + else False + ) + datastore = devices["disk"][disk_label].get("datastore", None) + disk_spec = _add_new_hard_disk_helper( + disk_label, + size_gb, + unit_number, + thin_provision=thin_provision, + eagerly_scrub=eagerly_scrub, + datastore=datastore, + vm_name=new_vm_name, + ) + + # when creating both SCSI controller and Hard disk at the same time we need the randomly + # assigned (temporary) key of the newly created SCSI controller + if "controller" in devices["disk"][disk_label]: + for spec in device_specs: + if ( + spec.device.deviceInfo.label + == devices["disk"][disk_label]["controller"] + ): + disk_spec.device.controllerKey = spec.device.key + break + + device_specs.append(disk_spec) + unit_number = _iter_disk_unit_number(unit_number) + + if "cd" in list(devices.keys()): + cd_drives_to_create = list( + set(devices["cd"].keys()) - set(existing_cd_drives_label) + ) + cd_drives_to_create.sort() + if cd_drives_to_create: + log.debug("CD/DVD drives to create: %s", cd_drives_to_create) + for cd_drive_label in cd_drives_to_create: + # create the CD/DVD drive + device_type = ( + devices["cd"][cd_drive_label]["device_type"] + if "device_type" in devices["cd"][cd_drive_label] + else "" + ) + mode = ( + devices["cd"][cd_drive_label]["mode"] + if "mode" in devices["cd"][cd_drive_label] + else "" + ) + iso_path = ( + devices["cd"][cd_drive_label]["iso_path"] + if "iso_path" in devices["cd"][cd_drive_label] + else "" + ) + controller_key = None + + # When creating both IDE controller and CD/DVD drive at the same time we need the randomly + # assigned (temporary) key of the newly created IDE controller + if "controller" in devices["cd"][cd_drive_label]: + for spec in device_specs: + if ( + spec.device.deviceInfo.label + == devices["cd"][cd_drive_label]["controller"] + ): + controller_key = spec.device.key + ide_controllers[controller_key] = 0 + break + else: + for ide_controller_key, num_devices in ide_controllers.items(): + if num_devices < 2: + controller_key = ide_controller_key + break + + if not controller_key: + log.error( + "No more available controllers for '%s'. " + "All IDE controllers are currently in use", + cd_drive_label, + ) + else: + cd_drive_spec = _add_new_cd_or_dvd_drive_helper( + cd_drive_label, controller_key, device_type, mode, iso_path + ) + device_specs.append(cd_drive_spec) + ide_controllers[controller_key] += 1 + + ret = {"device_specs": device_specs, "nics_map": nics_map} + + return ret + + +def _wait_for_vmware_tools(vm_ref, max_wait): + time_counter = 0 + starttime = time.time() + while time_counter < max_wait: + if time_counter % 5 == 0: + log.info( + "[ %s ] Waiting for VMware tools to be running [%s s]", + vm_ref.name, + time_counter, + ) + if str(vm_ref.summary.guest.toolsRunningStatus) == "guestToolsRunning": + log.info( + "[ %s ] Successfully got VMware tools running on the guest in " + "%s seconds", + vm_ref.name, + time_counter, + ) + return True + + time.sleep(1.0 - ((time.time() - starttime) % 1.0)) + time_counter += 1 + log.warning( + "[ %s ] Timeout Reached. VMware tools still not running after waiting " + "for %s seconds", + vm_ref.name, + max_wait, + ) + return False + + +def _valid_ip(ip_address): + """ + Check if the IP address is valid + Return either True or False + """ + + # Make sure IP has four octets + octets = ip_address.split(".") + if len(octets) != 4: + return False + + # convert octet from string to int + for i, octet in enumerate(octets): + + try: + octets[i] = int(octet) + except ValueError: + # couldn't convert octet to an integer + return False + + # map variables to elements of octets list + first_octet, second_octet, third_octet, fourth_octet = octets + + # Check first_octet meets conditions + if first_octet < 1 or first_octet > 223 or first_octet == 127: + return False + + # Check 169.254.X.X condition + if first_octet == 169 and second_octet == 254: + return False + + # Check 2nd - 4th octets + for octet in (second_octet, third_octet, fourth_octet): + if (octet < 0) or (octet > 255): + return False + # Passed all of the checks + return True + + +def _wait_for_ip(vm_ref, max_wait): + max_wait_vmware_tools = max_wait + max_wait_ip = max_wait + vmware_tools_status = _wait_for_vmware_tools(vm_ref, max_wait_vmware_tools) + if not vmware_tools_status: + # VMware will only report the IP if VMware tools are installed. Try to + # determine the IP using DNS + vm_name = vm_ref.summary.config.name + resolved_ips = salt.utils.network.host_to_ips(vm_name) + log.debug( + "Timeout waiting for VMware tools. The name %s resolved to %s", + vm_name, + resolved_ips, + ) + if isinstance(resolved_ips, list) and resolved_ips: + return resolved_ips[0] + return False + time_counter = 0 + starttime = time.time() + while time_counter < max_wait_ip: + if time_counter % 5 == 0: + log.info( + "[ %s ] Waiting to retrieve IPv4 information [%s s]", + vm_ref.name, + time_counter, + ) + + if vm_ref.summary.guest.ipAddress and _valid_ip(vm_ref.summary.guest.ipAddress): + log.info( + "[ %s ] Successfully retrieved IPv4 information in %s seconds", + vm_ref.name, + time_counter, + ) + return vm_ref.summary.guest.ipAddress + for net in vm_ref.guest.net: + if net.ipConfig.ipAddress: + for current_ip in net.ipConfig.ipAddress: + if _valid_ip(current_ip.ipAddress): + log.info( + "[ %s ] Successfully retrieved IPv4 information " + "in %s seconds", + vm_ref.name, + time_counter, + ) + return current_ip.ipAddress + time.sleep(1.0 - ((time.time() - starttime) % 1.0)) + time_counter += 1 + log.warning( + "[ %s ] Timeout Reached. Unable to retrieve IPv4 information after " + "waiting for %s seconds", + vm_ref.name, + max_wait_ip, + ) + return False + + +def _wait_for_host(host_ref, task_type, sleep_seconds=5, log_level="debug"): + time_counter = 0 + starttime = time.time() + while host_ref.runtime.connectionState != "notResponding": + if time_counter % sleep_seconds == 0: + log.log( + logging.INFO if log_level == "info" else logging.DEBUG, + "[ %s ] Waiting for host %s to finish [%s s]", + host_ref.name, + task_type, + time_counter, + ) + time.sleep(1.0 - ((time.time() - starttime) % 1.0)) + time_counter += 1 + while host_ref.runtime.connectionState != "connected": + if time_counter % sleep_seconds == 0: + log.log( + logging.INFO if log_level == "info" else logging.DEBUG, + "[ %s ] Waiting for host %s to finish [%s s]", + host_ref.name, + task_type, + time_counter, + ) + time.sleep(1.0 - ((time.time() - starttime) % 1.0)) + time_counter += 1 + if host_ref.runtime.connectionState == "connected": + log.log( + logging.INFO if log_level == "info" else logging.DEBUG, + "[ %s ] Successfully completed host %s in %s seconds", + host_ref.name, + task_type, + time_counter, + ) + else: + log.error("Could not connect back to the host system") + + +def _format_instance_info_select(vm, selection): + def defaultto(machine, section, default="N/A"): + """ + Return either a named value from a VirtualMachineConfig or a + default string "N/A". + """ + return default if section not in machine else machine[section] + + vm_select_info = {} + + if "id" in selection: + vm_select_info["id"] = vm["name"] + + if "image" in selection: + vm_select_info["image"] = "{} (Detected)".format( + defaultto(vm, "config.guestFullName") + ) + + if "size" in selection: + cpu = defaultto(vm, "config.hardware.numCPU") + ram = "{} MB".format(defaultto(vm, "config.hardware.memoryMB")) + vm_select_info["size"] = f"cpu: {cpu}\nram: {ram}" + vm_select_info["size_dict"] = { + "cpu": cpu, + "memory": ram, + } + + if "state" in selection: + vm_select_info["state"] = str(defaultto(vm, "summary.runtime.powerState")) + + if "guest_id" in selection: + vm_select_info["guest_id"] = defaultto(vm, "config.guestId") + + if "hostname" in selection: + vm_select_info["hostname"] = vm["object"].guest.hostName + + if "path" in selection: + vm_select_info["path"] = defaultto(vm, "config.files.vmPathName") + + if "tools_status" in selection: + vm_select_info["tools_status"] = str(defaultto(vm, "guest.toolsStatus")) + + if "private_ips" in selection or "networks" in selection: + network_full_info = {} + ip_addresses = [] + + if "guest.net" in vm: + for net in vm["guest.net"]: + network_full_info[net.network] = { + "connected": net.connected, + "ip_addresses": net.ipAddress, + "mac_address": net.macAddress, + } + ip_addresses.extend(net.ipAddress) + + if "private_ips" in selection: + vm_select_info["private_ips"] = ip_addresses + + if "networks" in selection: + vm_select_info["networks"] = network_full_info + + if any(x in ["devices", "mac_address", "mac_addresses"] for x in selection): + device_full_info = {} + device_mac_addresses = [] + if "config.hardware.device" in vm: + for device in vm["config.hardware.device"]: + device_full_info[device.deviceInfo.label] = {} + if "devices" in selection: + device_full_info[device.deviceInfo.label]["key"] = (device.key,) + device_full_info[device.deviceInfo.label]["label"] = ( + device.deviceInfo.label, + ) + device_full_info[device.deviceInfo.label]["summary"] = ( + device.deviceInfo.summary, + ) + device_full_info[device.deviceInfo.label]["type"] = type( + device + ).__name__.rsplit(".", 1)[1] + + if device.unitNumber: + device_full_info[device.deviceInfo.label][ + "unitNumber" + ] = device.unitNumber + + if hasattr(device, "connectable") and device.connectable: + device_full_info[device.deviceInfo.label][ + "startConnected" + ] = device.connectable.startConnected + device_full_info[device.deviceInfo.label][ + "allowGuestControl" + ] = device.connectable.allowGuestControl + device_full_info[device.deviceInfo.label][ + "connected" + ] = device.connectable.connected + device_full_info[device.deviceInfo.label][ + "status" + ] = device.connectable.status + + if hasattr(device, "controllerKey") and device.controllerKey: + device_full_info[device.deviceInfo.label][ + "controllerKey" + ] = device.controllerKey + + if hasattr(device, "addressType"): + device_full_info[device.deviceInfo.label][ + "addressType" + ] = device.addressType + + if hasattr(device, "busNumber"): + device_full_info[device.deviceInfo.label][ + "busNumber" + ] = device.busNumber + + if hasattr(device, "device"): + device_full_info[device.deviceInfo.label][ + "deviceKeys" + ] = device.device + + if hasattr(device, "videoRamSizeInKB"): + device_full_info[device.deviceInfo.label][ + "videoRamSizeInKB" + ] = device.videoRamSizeInKB + + if isinstance(device, vim.vm.device.VirtualDisk): + device_full_info[device.deviceInfo.label][ + "capacityInKB" + ] = device.capacityInKB + device_full_info[device.deviceInfo.label][ + "diskMode" + ] = device.backing.diskMode + device_full_info[device.deviceInfo.label][ + "fileName" + ] = device.backing.fileName + + if hasattr(device, "macAddress"): + device_full_info[device.deviceInfo.label][ + "macAddress" + ] = device.macAddress + device_mac_addresses.append(device.macAddress) + + if "devices" in selection: + vm_select_info["devices"] = device_full_info + + if "mac_address" in selection or "mac_addresses" in selection: + vm_select_info["mac_addresses"] = device_mac_addresses + + if "storage" in selection: + storage_full_info = { + "committed": ( + int(vm["summary.storage.committed"]) + if "summary.storage.committed" in vm + else "N/A" + ), + "uncommitted": ( + int(vm["summary.storage.uncommitted"]) + if "summary.storage.uncommitted" in vm + else "N/A" + ), + "unshared": ( + int(vm["summary.storage.unshared"]) + if "summary.storage.unshared" in vm + else "N/A" + ), + } + vm_select_info["storage"] = storage_full_info + + if "files" in selection: + file_full_info = {} + if "layoutEx.file" in vm: + for filename in vm["layoutEx.file"]: + file_full_info[filename.key] = { + "key": filename.key, + "name": filename.name, + "size": filename.size, + "type": filename.type, + } + vm_select_info["files"] = file_full_info + + return vm_select_info + + +def _format_instance_info(vm): + device_full_info = {} + device_mac_addresses = [] + if "config.hardware.device" in vm: + for device in vm["config.hardware.device"]: + device_full_info[device.deviceInfo.label] = { + "key": device.key, + "label": device.deviceInfo.label, + "summary": device.deviceInfo.summary, + "type": type(device).__name__.rsplit(".", 1)[1], + } + + if device.unitNumber: + device_full_info[device.deviceInfo.label][ + "unitNumber" + ] = device.unitNumber + + if hasattr(device, "connectable") and device.connectable: + device_full_info[device.deviceInfo.label][ + "startConnected" + ] = device.connectable.startConnected + device_full_info[device.deviceInfo.label][ + "allowGuestControl" + ] = device.connectable.allowGuestControl + device_full_info[device.deviceInfo.label][ + "connected" + ] = device.connectable.connected + device_full_info[device.deviceInfo.label][ + "status" + ] = device.connectable.status + + if hasattr(device, "controllerKey") and device.controllerKey: + device_full_info[device.deviceInfo.label][ + "controllerKey" + ] = device.controllerKey + + if hasattr(device, "addressType"): + device_full_info[device.deviceInfo.label][ + "addressType" + ] = device.addressType + + if hasattr(device, "macAddress"): + device_full_info[device.deviceInfo.label][ + "macAddress" + ] = device.macAddress + device_mac_addresses.append(device.macAddress) + + if hasattr(device, "busNumber"): + device_full_info[device.deviceInfo.label][ + "busNumber" + ] = device.busNumber + + if hasattr(device, "device"): + device_full_info[device.deviceInfo.label]["deviceKeys"] = device.device + + if hasattr(device, "videoRamSizeInKB"): + device_full_info[device.deviceInfo.label][ + "videoRamSizeInKB" + ] = device.videoRamSizeInKB + + if isinstance(device, vim.vm.device.VirtualDisk): + device_full_info[device.deviceInfo.label][ + "capacityInKB" + ] = device.capacityInKB + device_full_info[device.deviceInfo.label][ + "diskMode" + ] = device.backing.diskMode + device_full_info[device.deviceInfo.label][ + "fileName" + ] = device.backing.fileName + + storage_full_info = { + "committed": ( + int(vm["summary.storage.committed"]) + if "summary.storage.committed" in vm + else "N/A" + ), + "uncommitted": ( + int(vm["summary.storage.uncommitted"]) + if "summary.storage.uncommitted" in vm + else "N/A" + ), + "unshared": ( + int(vm["summary.storage.unshared"]) + if "summary.storage.unshared" in vm + else "N/A" + ), + } + + file_full_info = {} + if "layoutEx.file" in vm: + for filename in vm["layoutEx.file"]: + file_full_info[filename.key] = { + "key": filename.key, + "name": filename.name, + "size": filename.size, + "type": filename.type, + } + + network_full_info = {} + ip_addresses = [] + if "guest.net" in vm: + for net in vm["guest.net"]: + network_full_info[net.network] = { + "connected": net.connected, + "ip_addresses": net.ipAddress, + "mac_address": net.macAddress, + } + ip_addresses.extend(net.ipAddress) + + cpu = vm["config.hardware.numCPU"] if "config.hardware.numCPU" in vm else "N/A" + ram = ( + "{} MB".format(vm["config.hardware.memoryMB"]) + if "config.hardware.memoryMB" in vm + else "N/A" + ) + vm_full_info = { + "id": str(vm["name"]), + "image": ( + "{} (Detected)".format(vm["config.guestFullName"]) + if "config.guestFullName" in vm + else "N/A" + ), + "size": f"cpu: {cpu}\nram: {ram}", + "size_dict": {"cpu": cpu, "memory": ram}, + "state": ( + str(vm["summary.runtime.powerState"]) + if "summary.runtime.powerState" in vm + else "N/A" + ), + "private_ips": ip_addresses, + "public_ips": [], + "devices": device_full_info, + "storage": storage_full_info, + "files": file_full_info, + "guest_id": str(vm["config.guestId"]) if "config.guestId" in vm else "N/A", + "hostname": str(vm["object"].guest.hostName), + "mac_addresses": device_mac_addresses, + "networks": network_full_info, + "path": ( + str(vm["config.files.vmPathName"]) + if "config.files.vmPathName" in vm + else "N/A" + ), + "tools_status": ( + str(vm["guest.toolsStatus"]) if "guest.toolsStatus" in vm else "N/A" + ), + } + + return vm_full_info + + +def _get_snapshots(snapshot_list, current_snapshot=None, parent_snapshot_path=""): + snapshots = {} + for snapshot in snapshot_list: + snapshot_path = f"{parent_snapshot_path}/{snapshot.name}" + snapshots[snapshot_path] = { + "name": snapshot.name, + "description": snapshot.description, + "created": str(snapshot.createTime).split(".", maxsplit=1)[0], + "state": snapshot.state, + "path": snapshot_path, + } + + if current_snapshot and current_snapshot == snapshot.snapshot: + return snapshots[snapshot_path] + + # Check if child snapshots exist + if snapshot.childSnapshotList: + ret = _get_snapshots( + snapshot.childSnapshotList, current_snapshot, snapshot_path + ) + if current_snapshot: + return ret + snapshots.update(ret) + + return snapshots + + +def _get_snapshot_ref_helper(base_snapshot, snapshot_name): + if base_snapshot.name == snapshot_name: + return base_snapshot + + for snapshot in base_snapshot.childSnapshotList: + snapshot_ref = _get_snapshot_ref_helper(snapshot, snapshot_name) + if snapshot_ref is not None: + return snapshot_ref + + return None + + +def _get_snapshot_ref_by_name(vm_ref, snapshot_name): + snapshot_ref = None + try: + for root_snapshot in vm_ref.snapshot.rootSnapshotList: + snapshot_ref = _get_snapshot_ref_helper(root_snapshot, snapshot_name) + if snapshot_ref is not None: + break + except (IndexError, AttributeError): + snapshot_ref = None + + return snapshot_ref + + +def _upg_tools_helper(vm, reboot=False): + # Exit if template + if vm.config.template: + status = "VMware tools cannot be updated on a template" + + # Exit if VMware tools is already up to date + elif vm.guest.toolsStatus == "toolsOk": + status = "VMware tools is already up to date" + + # Exit if VM is not powered on + elif vm.summary.runtime.powerState != "poweredOn": + status = "VM must be powered on to upgrade tools" + + # Exit if VMware tools is either not running or not installed + elif vm.guest.toolsStatus in ["toolsNotRunning", "toolsNotInstalled"]: + status = "VMware tools is either not running or not installed" + + # If vmware tools is out of date, check major OS family + # Upgrade tools on Linux and Windows guests + elif vm.guest.toolsStatus == "toolsOld": + log.info("Upgrading VMware tools on %s", vm.name) + try: + if vm.guest.guestFamily == "windowsGuest" and not reboot: + log.info("Reboot suppressed on %s", vm.name) + task = vm.UpgradeTools('/S /v"/qn REBOOT=R"') + elif vm.guest.guestFamily in ["linuxGuest", "windowsGuest"]: + task = vm.UpgradeTools() + else: + return "Only Linux and Windows guests are currently supported" + salt.utils.vmware.wait_for_task( + task, vm.name, "tools upgrade", sleep_seconds=5, log_level="info" + ) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while upgrading VMware tools on VM %s: %s", + vm.name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "VMware tools upgrade failed" + status = "VMware tools upgrade succeeded" + else: + status = "VMWare tools could not be upgraded" + + return status + + +def _get_hba_type(hba_type): + """ + Convert a string representation of a HostHostBusAdapter into an + object reference. + """ + if hba_type == "parallel": + return vim.host.ParallelScsiHba + elif hba_type == "block": + return vim.host.BlockHba + elif hba_type == "iscsi": + return vim.host.InternetScsiHba + elif hba_type == "fibre": + return vim.host.FibreChannelHba + + raise ValueError("Unknown Host Bus Adapter Type") + + +def test_vcenter_connection(kwargs=None, call=None): + """ + Test if the connection can be made to the vCenter server using + the specified credentials inside ``/etc/salt/cloud.providers`` + or ``/etc/salt/cloud.providers.d/vmware.conf`` + + CLI Example: + + .. code-block:: bash + + salt-cloud -f test_vcenter_connection my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The test_vcenter_connection function must be called with -f or --function." + ) + + try: + # Get the service instance object + _get_si() + except Exception as exc: # pylint: disable=broad-except + return f"failed to connect: {exc}" + + return "connection successful" + + +def get_vcenter_version(kwargs=None, call=None): + """ + Show the vCenter Server version with build number. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f get_vcenter_version my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The get_vcenter_version function must be called with -f or --function." + ) + + # Get the inventory + inv = salt.utils.vmware.get_inventory(_get_si()) + + return inv.about.fullName + + +def list_datacenters(kwargs=None, call=None): + """ + List all the data centers for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_datacenters my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_datacenters function must be called with -f or --function." + ) + + return {"Datacenters": salt.utils.vmware.list_datacenters(_get_si())} + + +def list_portgroups(kwargs=None, call=None): + """ + List all the distributed virtual portgroups for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_portgroups my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_portgroups function must be called with -f or --function." + ) + + return {"Portgroups": salt.utils.vmware.list_portgroups(_get_si())} + + +def list_clusters(kwargs=None, call=None): + """ + List all the clusters for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_clusters my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_clusters function must be called with -f or --function." + ) + + return {"Clusters": salt.utils.vmware.list_clusters(_get_si())} + + +def list_datastore_clusters(kwargs=None, call=None): + """ + List all the datastore clusters for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_datastore_clusters my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_datastore_clusters function must be called with -f or --function." + ) + + return {"Datastore Clusters": salt.utils.vmware.list_datastore_clusters(_get_si())} + + +def list_datastores(kwargs=None, call=None): + """ + List all the datastores for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_datastores my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_datastores function must be called with -f or --function." + ) + + return {"Datastores": salt.utils.vmware.list_datastores(_get_si())} + + +def list_hosts(kwargs=None, call=None): + """ + List all the hosts for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_hosts my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_hosts function must be called with -f or --function." + ) + + return {"Hosts": salt.utils.vmware.list_hosts(_get_si())} + + +def list_resourcepools(kwargs=None, call=None): + """ + List all the resource pools for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_resourcepools my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_resourcepools function must be called with -f or --function." + ) + + return {"Resource Pools": salt.utils.vmware.list_resourcepools(_get_si())} + + +def list_networks(kwargs=None, call=None): + """ + List all the standard networks for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_networks my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_networks function must be called with -f or --function." + ) + + return {"Networks": salt.utils.vmware.list_networks(_get_si())} + + +def list_nodes_min(kwargs=None, call=None): + """ + Return a list of all VMs and templates that are on the specified provider, with no details + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_nodes_min my-vmware-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_min function must be called with -f or --function." + ) + + ret = {} + vm_properties = ["name"] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + ret[vm["name"]] = {"state": "Running", "id": vm["name"]} + + return ret + + +def list_nodes(kwargs=None, call=None): + """ + Return a list of all VMs and templates that are on the specified provider, with basic fields + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_nodes my-vmware-config + + To return a list of all VMs and templates present on ALL configured providers, with basic + fields: + + CLI Example: + + .. code-block:: bash + + salt-cloud -Q + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes function must be called with -f or --function." + ) + + ret = {} + vm_properties = [ + "name", + "guest.ipAddress", + "config.guestFullName", + "config.hardware.numCPU", + "config.hardware.memoryMB", + "summary.runtime.powerState", + ] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + cpu = vm["config.hardware.numCPU"] if "config.hardware.numCPU" in vm else "N/A" + ram = ( + "{} MB".format(vm["config.hardware.memoryMB"]) + if "config.hardware.memoryMB" in vm + else "N/A" + ) + vm_info = { + "id": vm["name"], + "image": ( + "{} (Detected)".format(vm["config.guestFullName"]) + if "config.guestFullName" in vm + else "N/A" + ), + "size": f"cpu: {cpu}\nram: {ram}", + "size_dict": {"cpu": cpu, "memory": ram}, + "state": ( + str(vm["summary.runtime.powerState"]) + if "summary.runtime.powerState" in vm + else "N/A" + ), + "private_ips": [vm["guest.ipAddress"]] if "guest.ipAddress" in vm else [], + "public_ips": [], + } + ret[vm_info["id"]] = vm_info + + return ret + + +def list_nodes_full(kwargs=None, call=None): + """ + Return a list of all VMs and templates that are on the specified provider, with full details + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_nodes_full my-vmware-config + + To return a list of all VMs and templates present on ALL configured providers, with full + details: + + CLI Example: + + .. code-block:: bash + + salt-cloud -F + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_full function must be called with -f or --function." + ) + + ret = {} + vm_properties = [ + "config.hardware.device", + "summary.storage.committed", + "summary.storage.uncommitted", + "summary.storage.unshared", + "layoutEx.file", + "config.guestFullName", + "config.guestId", + "guest.net", + "config.hardware.memoryMB", + "name", + "config.hardware.numCPU", + "config.files.vmPathName", + "summary.runtime.powerState", + "guest.toolsStatus", + ] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + ret[vm["name"]] = _format_instance_info(vm) + + return ret + + +def list_nodes_select(call=None): + """ + Return a list of all VMs and templates that are on the specified provider, with fields + specified under ``query.selection`` in ``/etc/salt/cloud`` + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_nodes_select my-vmware-config + + To return a list of all VMs and templates present on ALL configured providers, with + fields specified under ``query.selection`` in ``/etc/salt/cloud``: + + CLI Example: + + .. code-block:: bash + + salt-cloud -S + """ + if call == "action": + raise SaltCloudSystemExit( + "The list_nodes_select function must be called with -f or --function." + ) + + ret = {} + vm_properties = [] + selection = __opts__.get("query.selection") + + if not selection: + raise SaltCloudSystemExit("query.selection not found in /etc/salt/cloud") + + if "id" in selection: + vm_properties.append("name") + + if "image" in selection: + vm_properties.append("config.guestFullName") + + if "size" in selection: + vm_properties.extend(["config.hardware.numCPU", "config.hardware.memoryMB"]) + + if "state" in selection: + vm_properties.append("summary.runtime.powerState") + + if "private_ips" in selection or "networks" in selection: + vm_properties.append("guest.net") + + if ( + "devices" in selection + or "mac_address" in selection + or "mac_addresses" in selection + ): + vm_properties.append("config.hardware.device") + + if "storage" in selection: + vm_properties.extend( + [ + "config.hardware.device", + "summary.storage.committed", + "summary.storage.uncommitted", + "summary.storage.unshared", + ] + ) + + if "files" in selection: + vm_properties.append("layoutEx.file") + + if "guest_id" in selection: + vm_properties.append("config.guestId") + + if "hostname" in selection: + vm_properties.append("guest.hostName") + + if "path" in selection: + vm_properties.append("config.files.vmPathName") + + if "tools_status" in selection: + vm_properties.append("guest.toolsStatus") + + if not vm_properties: + return {} + elif "name" not in vm_properties: + vm_properties.append("name") + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + ret[vm["name"]] = _format_instance_info_select(vm, selection) + return ret + + +def show_instance(name, call=None): + """ + List all available details of the specified VM + + CLI Example: + + .. code-block:: bash + + salt-cloud -a show_instance vmname + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + vm_properties = [ + "config.hardware.device", + "summary.storage.committed", + "summary.storage.uncommitted", + "summary.storage.unshared", + "layoutEx.file", + "config.guestFullName", + "config.guestId", + "guest.net", + "config.hardware.memoryMB", + "name", + "config.hardware.numCPU", + "config.files.vmPathName", + "summary.runtime.powerState", + "guest.toolsStatus", + ] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + if vm["name"] == name: + return _format_instance_info(vm) + + return {} + + +def avail_images(call=None): + """ + Return a list of all the templates present in this VMware environment with basic + details + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-images my-vmware-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_images function must be called with " + "-f or --function, or with the --list-images option." + ) + + templates = {} + vm_properties = [ + "name", + "config.template", + "config.guestFullName", + "config.hardware.numCPU", + "config.hardware.memoryMB", + ] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + if "config.template" in vm and vm["config.template"]: + templates[vm["name"]] = { + "name": vm["name"], + "guest_fullname": ( + vm["config.guestFullName"] + if "config.guestFullName" in vm + else "N/A" + ), + "cpus": ( + vm["config.hardware.numCPU"] + if "config.hardware.numCPU" in vm + else "N/A" + ), + "ram": ( + vm["config.hardware.memoryMB"] + if "config.hardware.memoryMB" in vm + else "N/A" + ), + } + + return templates + + +def avail_locations(call=None): + """ + Return a list of all the available locations/datacenters in this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-locations my-vmware-config + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_locations function must be called with " + "-f or --function, or with the --list-locations option." + ) + + return list_datacenters(call="function") + + +def avail_sizes(call=None): + """ + Return a list of all the available sizes in this VMware environment. + + CLI Example: + + .. code-block:: bash + + salt-cloud --list-sizes my-vmware-config + + .. note:: + + Since sizes are built into templates, this function will return + an empty dictionary. + + """ + if call == "action": + raise SaltCloudSystemExit( + "The avail_sizes function must be called with " + "-f or --function, or with the --list-sizes option." + ) + + log.warning( + "Because sizes are built into templates with VMware, there are no sizes " + "to return." + ) + + return {} + + +def list_templates(kwargs=None, call=None): + """ + List all the templates present in this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_templates my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_templates function must be called with -f or --function." + ) + + return {"Templates": avail_images(call="function")} + + +def list_folders(kwargs=None, call=None): + """ + List all the folders for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_folders my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_folders function must be called with -f or --function." + ) + + return {"Folders": salt.utils.vmware.list_folders(_get_si())} + + +def list_snapshots(kwargs=None, call=None): + """ + List snapshots either for all VMs and templates or for a specific VM/template + in this VMware environment + + To list snapshots for all VMs and templates: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_snapshots my-vmware-config + + To list snapshots for a specific VM/template: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_snapshots my-vmware-config name="vmname" + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_snapshots function must be called with -f or --function." + ) + + ret = {} + vm_properties = ["name", "rootSnapshot", "snapshot"] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + if vm["rootSnapshot"]: + if kwargs and kwargs.get("name") == vm["name"]: + return {vm["name"]: _get_snapshots(vm["snapshot"].rootSnapshotList)} + else: + ret[vm["name"]] = _get_snapshots(vm["snapshot"].rootSnapshotList) + else: + if kwargs and kwargs.get("name") == vm["name"]: + return {} + + return ret + + +def start(name, call=None): + """ + To start/power on a VM using its name + + CLI Example: + + .. code-block:: bash + + salt-cloud -a start vmname + """ + if call != "action": + raise SaltCloudSystemExit( + "The start action must be called with -a or --action." + ) + + vm_properties = ["name", "summary.runtime.powerState"] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + if vm["name"] == name: + if vm["summary.runtime.powerState"] == "poweredOn": + ret = "already powered on" + log.info("VM %s %s", name, ret) + return ret + try: + log.info("Starting VM %s", name) + task = vm["object"].PowerOn() + salt.utils.vmware.wait_for_task(task, name, "power on") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while powering on VM %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "failed to power on" + + return "powered on" + + +def stop(name, soft=False, call=None): + """ + To stop/power off a VM using its name + + .. note:: + + If ``soft=True`` then issues a command to the guest operating system + asking it to perform a clean shutdown of all services. + Default is soft=False + + For ``soft=True`` vmtools should be installed on guest system. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a stop vmname + salt-cloud -a stop vmname soft=True + """ + if call != "action": + raise SaltCloudSystemExit("The stop action must be called with -a or --action.") + + vm_properties = ["name", "summary.runtime.powerState"] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + if vm["name"] == name: + if vm["summary.runtime.powerState"] == "poweredOff": + ret = "already powered off" + log.info("VM %s %s", name, ret) + return ret + try: + log.info("Stopping VM %s", name) + if soft: + vm["object"].ShutdownGuest() + else: + task = vm["object"].PowerOff() + salt.utils.vmware.wait_for_task(task, name, "power off") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while powering off VM %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "failed to power off" + + return "powered off" + + +def suspend(name, call=None): + """ + To suspend a VM using its name + + CLI Example: + + .. code-block:: bash + + salt-cloud -a suspend vmname + """ + if call != "action": + raise SaltCloudSystemExit( + "The suspend action must be called with -a or --action." + ) + + vm_properties = ["name", "summary.runtime.powerState"] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + if vm["name"] == name: + if vm["summary.runtime.powerState"] == "poweredOff": + ret = "cannot suspend in powered off state" + log.info("VM %s %s", name, ret) + return ret + elif vm["summary.runtime.powerState"] == "suspended": + ret = "already suspended" + log.info("VM %s %s", name, ret) + return ret + try: + log.info("Suspending VM %s", name) + task = vm["object"].Suspend() + salt.utils.vmware.wait_for_task(task, name, "suspend") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while suspending VM %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "failed to suspend" + + return "suspended" + + +def reset(name, soft=False, call=None): + """ + To reset a VM using its name + + .. note:: + + If ``soft=True`` then issues a command to the guest operating system + asking it to perform a reboot. Otherwise hypervisor will terminate VM and start it again. + Default is soft=False + + For ``soft=True`` vmtools should be installed on guest system. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a reset vmname + salt-cloud -a reset vmname soft=True + """ + if call != "action": + raise SaltCloudSystemExit( + "The reset action must be called with -a or --action." + ) + + vm_properties = ["name", "summary.runtime.powerState"] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + if vm["name"] == name: + if ( + vm["summary.runtime.powerState"] == "suspended" + or vm["summary.runtime.powerState"] == "poweredOff" + ): + ret = "cannot reset in suspended/powered off state" + log.info("VM %s %s", name, ret) + return ret + try: + log.info("Resetting VM %s", name) + if soft: + vm["object"].RebootGuest() + else: + task = vm["object"].ResetVM_Task() + salt.utils.vmware.wait_for_task(task, name, "reset") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while resetting VM %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "failed to reset" + + return "reset" + + +def terminate(name, call=None): + """ + To do an immediate power off of a VM using its name. A ``SIGKILL`` + is issued to the vmx process of the VM + + CLI Example: + + .. code-block:: bash + + salt-cloud -a terminate vmname + """ + if call != "action": + raise SaltCloudSystemExit( + "The terminate action must be called with -a or --action." + ) + + vm_properties = ["name", "summary.runtime.powerState"] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + if vm["name"] == name: + if vm["summary.runtime.powerState"] == "poweredOff": + ret = "already powered off" + log.info("VM %s %s", name, ret) + return ret + try: + log.info("Terminating VM %s", name) + vm["object"].Terminate() + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while terminating VM %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "failed to terminate" + + return "terminated" + + +def destroy(name, call=None): + """ + To destroy a VM from the VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -d vmname + salt-cloud --destroy vmname + salt-cloud -a destroy vmname + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + vm_properties = ["name", "summary.runtime.powerState"] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + if vm["name"] == name: + if vm["summary.runtime.powerState"] != "poweredOff": + # Power off the vm first + try: + log.info("Powering Off VM %s", name) + task = vm["object"].PowerOff() + salt.utils.vmware.wait_for_task(task, name, "power off") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while powering off VM %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "failed to destroy" + try: + log.info("Destroying VM %s", name) + task = vm["object"].Destroy_Task() + salt.utils.vmware.wait_for_task(task, name, "destroy") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while destroying VM %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "failed to destroy" + + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + + return True + + +def create(vm_): + """ + To create a single VM in the VMware environment. + + Sample profile and arguments that can be specified in it can be found + :ref:`here. ` + + CLI Example: + + .. code-block:: bash + + salt-cloud -p vmware-centos6.5 vmname + """ + try: + # Check for required profile parameters before sending any API calls. + if ( + vm_["profile"] + and config.is_profile_configured( + __opts__, + _get_active_provider_name() or "vmware", + vm_["profile"], + vm_=vm_, + ) + is False + ): + return False + except AttributeError: + pass + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + vm_name = config.get_cloud_config_value("name", vm_, __opts__, default=None) + folder = config.get_cloud_config_value("folder", vm_, __opts__, default=None) + datacenter = config.get_cloud_config_value( + "datacenter", vm_, __opts__, default=None + ) + resourcepool = config.get_cloud_config_value( + "resourcepool", vm_, __opts__, default=None + ) + cluster = config.get_cloud_config_value("cluster", vm_, __opts__, default=None) + datastore = config.get_cloud_config_value("datastore", vm_, __opts__, default=None) + host = config.get_cloud_config_value("host", vm_, __opts__, default=None) + template = config.get_cloud_config_value("template", vm_, __opts__, default=False) + num_cpus = config.get_cloud_config_value("num_cpus", vm_, __opts__, default=None) + cores_per_socket = config.get_cloud_config_value( + "cores_per_socket", vm_, __opts__, default=None + ) + instant_clone = config.get_cloud_config_value( + "instant_clone", vm_, __opts__, default=False + ) + memory = config.get_cloud_config_value("memory", vm_, __opts__, default=None) + devices = config.get_cloud_config_value("devices", vm_, __opts__, default=None) + extra_config = config.get_cloud_config_value( + "extra_config", vm_, __opts__, default=None + ) + annotation = config.get_cloud_config_value( + "annotation", vm_, __opts__, default=None + ) + power = config.get_cloud_config_value("power_on", vm_, __opts__, default=True) + key_filename = config.get_cloud_config_value( + "private_key", vm_, __opts__, search_global=False, default=None + ) + deploy = config.get_cloud_config_value( + "deploy", vm_, __opts__, search_global=True, default=True + ) + wait_for_ip_timeout = config.get_cloud_config_value( + "wait_for_ip_timeout", vm_, __opts__, default=20 * 60 + ) + domain = config.get_cloud_config_value( + "domain", vm_, __opts__, search_global=False, default="local" + ) + hardware_version = config.get_cloud_config_value( + "hardware_version", vm_, __opts__, search_global=False, default=None + ) + guest_id = config.get_cloud_config_value( + "image", vm_, __opts__, search_global=False, default=None + ) + customization = config.get_cloud_config_value( + "customization", vm_, __opts__, search_global=False, default=True + ) + customization_spec = config.get_cloud_config_value( + "customization_spec", vm_, __opts__, search_global=False, default=None + ) + win_password = config.get_cloud_config_value( + "win_password", vm_, __opts__, search_global=False, default=None + ) + win_organization_name = config.get_cloud_config_value( + "win_organization_name", + vm_, + __opts__, + search_global=False, + default="Organization", + ) + plain_text = config.get_cloud_config_value( + "plain_text", vm_, __opts__, search_global=False, default=False + ) + win_user_fullname = config.get_cloud_config_value( + "win_user_fullname", vm_, __opts__, search_global=False, default="Windows User" + ) + win_run_once = config.get_cloud_config_value( + "win_run_once", vm_, __opts__, search_global=False, default=None + ) + cpu_hot_add = config.get_cloud_config_value( + "cpu_hot_add", vm_, __opts__, search_global=False, default=None + ) + cpu_hot_remove = config.get_cloud_config_value( + "cpu_hot_remove", vm_, __opts__, search_global=False, default=None + ) + mem_hot_add = config.get_cloud_config_value( + "mem_hot_add", vm_, __opts__, search_global=False, default=None + ) + nested_hv = config.get_cloud_config_value( + "nested_hv", vm_, __opts__, search_global=False, default=None + ) + vpmc = config.get_cloud_config_value( + "vpmc", vm_, __opts__, search_global=False, default=None + ) + + # Get service instance object + si = _get_si() + + container_ref = None + + # If datacenter is specified, set the container reference to start search from it instead + if datacenter: + datacenter_ref = salt.utils.vmware.get_mor_by_property( + _get_si(), vim.Datacenter, datacenter + ) + container_ref = datacenter_ref if datacenter_ref else None + + if "clonefrom" in vm_: + # If datacenter is specified, set the container reference to start search from it instead + if datacenter: + datacenter_ref = salt.utils.vmware.get_mor_by_property( + si, vim.Datacenter, datacenter + ) + container_ref = datacenter_ref if datacenter_ref else None + + # Clone VM/template from specified VM/template + object_ref = salt.utils.vmware.get_mor_by_property( + si, vim.VirtualMachine, vm_["clonefrom"], container_ref=container_ref + ) + if object_ref: + clone_type = "template" if object_ref.config.template else "vm" + else: + raise SaltCloudSystemExit( + "The VM/template that you have specified under clonefrom does not" + " exist." + ) + else: + clone_type = None + object_ref = None + + # Either a cluster, or a resource pool must be specified when cloning from template or creating. + if resourcepool: + resourcepool_ref = salt.utils.vmware.get_mor_by_property( + si, vim.ResourcePool, resourcepool, container_ref=container_ref + ) + if not resourcepool_ref: + log.error("Specified resource pool: '%s' does not exist", resourcepool) + if not clone_type or clone_type == "template": + raise SaltCloudSystemExit( + "You must specify a resource pool that exists." + ) + elif cluster: + cluster_ref = salt.utils.vmware.get_mor_by_property( + si, vim.ClusterComputeResource, cluster, container_ref=container_ref + ) + if not cluster_ref: + log.error("Specified cluster: '%s' does not exist", cluster) + if not clone_type or clone_type == "template": + raise SaltCloudSystemExit("You must specify a cluster that exists.") + else: + resourcepool_ref = cluster_ref.resourcePool + elif clone_type == "template": + raise SaltCloudSystemExit( + "You must either specify a cluster or a resource pool when cloning from a" + " template." + ) + elif not clone_type: + raise SaltCloudSystemExit( + "You must either specify a cluster or a resource pool when creating." + ) + else: + log.debug("Using resource pool used by the %s %s", clone_type, vm_["clonefrom"]) + + # Either a datacenter or a folder can be optionally specified when cloning, required when creating. + # If not specified when cloning, the existing VM/template\'s parent folder is used. + if folder: + folder_parts = folder.split("/") + search_reference = container_ref + for folder_part in folder_parts: + if folder_part: + folder_ref = salt.utils.vmware.get_mor_by_property( + si, vim.Folder, folder_part, container_ref=search_reference + ) + search_reference = folder_ref + if not folder_ref: + log.error("Specified folder: '%s' does not exist", folder) + log.debug( + "Using folder in which %s %s is present", clone_type, vm_["clonefrom"] + ) + folder_ref = object_ref.parent + elif datacenter: + if not datacenter_ref: + log.error("Specified datacenter: '%s' does not exist", datacenter) + log.debug( + "Using datacenter folder in which %s %s is present", + clone_type, + vm_["clonefrom"], + ) + folder_ref = object_ref.parent + else: + folder_ref = datacenter_ref.vmFolder + elif not clone_type: + raise SaltCloudSystemExit( + "You must either specify a folder or a datacenter when creating not" + " cloning." + ) + else: + log.debug( + "Using folder in which %s %s is present", clone_type, vm_["clonefrom"] + ) + folder_ref = object_ref.parent + + if "clonefrom" in vm_: + # Create the relocation specs + reloc_spec = vim.vm.RelocateSpec() + + if (resourcepool and resourcepool_ref) or (cluster and cluster_ref): + reloc_spec.pool = resourcepool_ref + + # Either a datastore/datastore cluster can be optionally specified. + # If not specified, the current datastore is used. + if datastore: + datastore_ref = salt.utils.vmware.get_mor_by_property( + si, vim.Datastore, datastore, container_ref=container_ref + ) + if datastore_ref: + # specific datastore has been specified + reloc_spec.datastore = datastore_ref + else: + datastore_cluster_ref = salt.utils.vmware.get_mor_by_property( + si, vim.StoragePod, datastore, container_ref=container_ref + ) + if not datastore_cluster_ref: + log.error( + "Specified datastore/datastore cluster: '%s' does not exist", + datastore, + ) + log.debug( + "Using datastore used by the %s %s", + clone_type, + vm_["clonefrom"], + ) + else: + log.debug("No datastore/datastore cluster specified") + log.debug("Using datastore used by the %s %s", clone_type, vm_["clonefrom"]) + + if host: + host_ref = salt.utils.vmware.get_mor_by_property( + si, vim.HostSystem, host, container_ref=container_ref + ) + if host_ref: + reloc_spec.host = host_ref + else: + log.error("Specified host: '%s' does not exist", host) + + if instant_clone: + instant_clone_spec = vim.vm.InstantCloneSpec() + instant_clone_spec.name = vm_name + instant_clone_spec.location = reloc_spec + + event_kwargs = vm_.copy() + if event_kwargs.get("password"): + del event_kwargs["password"] + + try: + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "requesting", event_kwargs, list(event_kwargs) + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + log.info( + "Creating %s from %s(%s)", vm_["name"], clone_type, vm_["clonefrom"] + ) + + if datastore and not datastore_ref and datastore_cluster_ref: + # datastore cluster has been specified so apply Storage DRS recommendations + pod_spec = vim.storageDrs.PodSelectionSpec( + storagePod=datastore_cluster_ref + ) + + storage_spec = vim.storageDrs.StoragePlacementSpec( + type="clone", + vm=object_ref, + podSelectionSpec=pod_spec, + cloneName=vm_name, + folder=folder_ref, + ) + + # get recommended datastores + recommended_datastores = ( + si.content.storageResourceManager.RecommendDatastores( + storageSpec=storage_spec + ) + ) + + # apply storage DRS recommendations + task = si.content.storageResourceManager.ApplyStorageDrsRecommendation_Task( + recommended_datastores.recommendations[0].key + ) + salt.utils.vmware.wait_for_task( + task, vm_name, "apply storage DRS recommendations", 5, "info" + ) + else: + # Instant clone the VM + task = object_ref.InstantClone_Task(spec=instant_clone_spec) + salt.utils.vmware.wait_for_task( + task, vm_name, "Instantclone", 5, "info" + ) + + except Exception as exc: # pylint: disable=broad-except + err_msg = "Error Instant cloning {}: {}".format(vm_["name"], exc) + log.error( + err_msg, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {"Error": err_msg} + + new_vm_ref = salt.utils.vmware.get_mor_by_property( + si, vim.VirtualMachine, vm_name, container_ref=container_ref + ) + out = None + if not template and power: + ip = _wait_for_ip(new_vm_ref, wait_for_ip_timeout) + if ip: + log.info("[ %s ] IPv4 is: %s", vm_name, ip) + # ssh or smb using ip and install salt only if deploy is True + if deploy: + vm_["key_filename"] = key_filename + # if specified, prefer ssh_host to the discovered ip address + if "ssh_host" not in vm_: + vm_["ssh_host"] = ip + log.info("[ %s ] Deploying to %s", vm_name, vm_["ssh_host"]) + + out = __utils__["cloud.bootstrap"](vm_, __opts__) + + data = show_instance(vm_name, call="action") + + if deploy and isinstance(out, dict): + data["deploy_kwargs"] = out.get("deploy_kwargs", {}) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return {"Instant Clone created successfully": data} + + else: + if not datastore: + raise SaltCloudSystemExit( + "You must specify a datastore when creating not cloning." + ) + else: + datastore_ref = salt.utils.vmware.get_mor_by_property( + si, vim.Datastore, datastore + ) + if not datastore_ref: + raise SaltCloudSystemExit( + f"Specified datastore: '{datastore}' does not exist" + ) + + if host: + host_ref = salt.utils.vmware.get_mor_by_property( + _get_si(), vim.HostSystem, host, container_ref=container_ref + ) + if not host_ref: + log.error("Specified host: '%s' does not exist", host) + + # Create the config specs + config_spec = vim.vm.ConfigSpec() + + # If the hardware version is specified and if it is different from the current + # hardware version, then schedule a hardware version upgrade + if hardware_version and object_ref is not None: + hardware_version = f"vmx-{hardware_version:02}" + if hardware_version != object_ref.config.version: + log.debug( + "Scheduling hardware version upgrade from %s to %s", + object_ref.config.version, + hardware_version, + ) + scheduled_hardware_upgrade = vim.vm.ScheduledHardwareUpgradeInfo() + scheduled_hardware_upgrade.upgradePolicy = "always" + scheduled_hardware_upgrade.versionKey = hardware_version + config_spec.scheduledHardwareUpgradeInfo = scheduled_hardware_upgrade + else: + log.debug("Virtual hardware version already set to %s", hardware_version) + + if num_cpus: + log.debug("Setting cpu to: %s", num_cpus) + config_spec.numCPUs = int(num_cpus) + + if cores_per_socket: + log.debug("Setting cores per socket to: %s", cores_per_socket) + config_spec.numCoresPerSocket = int(cores_per_socket) + + if memory: + try: + memory_num, memory_unit = re.findall(r"[^\W\d_]+|\d+.\d+|\d+", memory) + if memory_unit.lower() == "mb": + memory_mb = int(memory_num) + elif memory_unit.lower() == "gb": + memory_mb = int(float(memory_num) * 1024.0) + else: + err_msg = f"Invalid memory type specified: '{memory_unit}'" + log.error(err_msg) + return {"Error": err_msg} + except (TypeError, ValueError): + memory_mb = int(memory) + log.debug("Setting memory to: %s MB", memory_mb) + config_spec.memoryMB = memory_mb + + if devices: + specs = _manage_devices( + devices, vm=object_ref, container_ref=container_ref, new_vm_name=vm_name + ) + config_spec.deviceChange = specs["device_specs"] + + if cpu_hot_add and hasattr(config_spec, "cpuHotAddEnabled"): + config_spec.cpuHotAddEnabled = bool(cpu_hot_add) + + if cpu_hot_remove and hasattr(config_spec, "cpuHotRemoveEnabled"): + config_spec.cpuHotRemoveEnabled = bool(cpu_hot_remove) + + if mem_hot_add and hasattr(config_spec, "memoryHotAddEnabled"): + config_spec.memoryHotAddEnabled = bool(mem_hot_add) + + if nested_hv and hasattr(config_spec, "nestedHVEnabled"): + config_spec.nestedHVEnabled = bool(nested_hv) + + if vpmc and hasattr(config_spec, "vPMCEnabled"): + config_spec.vPMCEnabled = bool(vpmc) + + if extra_config: + for key, value in extra_config.items(): + option = vim.option.OptionValue(key=key, value=value) + config_spec.extraConfig.append(option) + + if annotation: + config_spec.annotation = str(annotation) + + if "clonefrom" in vm_: + clone_spec = handle_snapshot(config_spec, object_ref, reloc_spec, template, vm_) + if not clone_spec: + clone_spec = build_clonespec(config_spec, object_ref, reloc_spec, template) + + if customization and customization_spec: + customization_spec = salt.utils.vmware.get_customizationspec_ref( + si=si, customization_spec_name=customization_spec + ) + clone_spec.customization = customization_spec.spec + elif customization and (devices and "network" in list(devices.keys())): + global_ip = vim.vm.customization.GlobalIPSettings() + if "dns_servers" in list(vm_.keys()): + global_ip.dnsServerList = vm_["dns_servers"] + + if "domain" in list(vm_.keys()): + global_ip.dnsSuffixList = vm_["domain"] + + non_hostname_chars = re.compile(r"[^\w-]") + if re.search(non_hostname_chars, vm_name): + host_name = re.split(non_hostname_chars, vm_name, maxsplit=1)[0] + domain_name = re.split(non_hostname_chars, vm_name, maxsplit=1)[-1] + else: + host_name = vm_name + domain_name = domain + + if "Windows" not in object_ref.config.guestFullName: + identity = vim.vm.customization.LinuxPrep() + identity.hostName = vim.vm.customization.FixedName(name=host_name) + identity.domain = domain_name + else: + identity = vim.vm.customization.Sysprep() + identity.guiUnattended = vim.vm.customization.GuiUnattended() + identity.guiUnattended.autoLogon = True + identity.guiUnattended.autoLogonCount = 1 + identity.guiUnattended.password = vim.vm.customization.Password() + identity.guiUnattended.password.value = win_password + identity.guiUnattended.password.plainText = plain_text + if win_run_once: + identity.guiRunOnce = vim.vm.customization.GuiRunOnce() + identity.guiRunOnce.commandList = win_run_once + identity.userData = vim.vm.customization.UserData() + identity.userData.fullName = win_user_fullname + identity.userData.orgName = win_organization_name + identity.userData.computerName = vim.vm.customization.FixedName() + identity.userData.computerName.name = host_name + identity.identification = vim.vm.customization.Identification() + custom_spec = vim.vm.customization.Specification( + globalIPSettings=global_ip, + identity=identity, + nicSettingMap=specs["nics_map"], + ) + clone_spec.customization = custom_spec + + if not template: + clone_spec.powerOn = power + + log.debug("clone_spec set to:\n%s", pprint.pformat(clone_spec)) + + else: + config_spec.name = vm_name + config_spec.files = vim.vm.FileInfo() + config_spec.files.vmPathName = "[{0}] {1}/{1}.vmx".format(datastore, vm_name) + config_spec.guestId = guest_id + + log.debug("config_spec set to:\n%s", pprint.pformat(config_spec)) + + event_kwargs = vm_.copy() + if event_kwargs.get("password"): + del event_kwargs["password"] + + try: + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "requesting", event_kwargs, list(event_kwargs) + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + if "clonefrom" in vm_: + log.info( + "Creating %s from %s(%s)", vm_["name"], clone_type, vm_["clonefrom"] + ) + + if datastore and not datastore_ref and datastore_cluster_ref: + # datastore cluster has been specified so apply Storage DRS recommendations + pod_spec = vim.storageDrs.PodSelectionSpec( + storagePod=datastore_cluster_ref + ) + + storage_spec = vim.storageDrs.StoragePlacementSpec( + type="clone", + vm=object_ref, + podSelectionSpec=pod_spec, + cloneSpec=clone_spec, + cloneName=vm_name, + folder=folder_ref, + ) + + # get recommended datastores + recommended_datastores = ( + si.content.storageResourceManager.RecommendDatastores( + storageSpec=storage_spec + ) + ) + + # apply storage DRS recommendations + task = si.content.storageResourceManager.ApplyStorageDrsRecommendation_Task( + recommended_datastores.recommendations[0].key + ) + salt.utils.vmware.wait_for_task( + task, vm_name, "apply storage DRS recommendations", 5, "info" + ) + else: + # clone the VM/template + task = object_ref.Clone(folder_ref, vm_name, clone_spec) + salt.utils.vmware.wait_for_task(task, vm_name, "clone", 5, "info") + else: + log.info("Creating %s", vm_["name"]) + + if host: + task = folder_ref.CreateVM_Task(config_spec, resourcepool_ref, host_ref) + else: + task = folder_ref.CreateVM_Task(config_spec, resourcepool_ref) + salt.utils.vmware.wait_for_task(task, vm_name, "create", 15, "info") + except Exception as exc: # pylint: disable=broad-except + err_msg = "Error creating {}: {}".format(vm_["name"], exc) + log.error( + err_msg, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {"Error": err_msg} + + new_vm_ref = salt.utils.vmware.get_mor_by_property( + si, vim.VirtualMachine, vm_name, container_ref=container_ref + ) + + # Find how to power on in CreateVM_Task (if possible), for now this will do + try: + if not clone_type and power: + task = new_vm_ref.PowerOn() + salt.utils.vmware.wait_for_task(task, vm_name, "power", 5, "info") + except Exception as exc: # pylint: disable=broad-except + log.info("Powering on the VM threw this exception. Ignoring.") + log.info(exc) + + # If it a template or if it does not need to be powered on then do not wait for the IP + out = None + if not template and power: + ip = _wait_for_ip(new_vm_ref, wait_for_ip_timeout) + if ip: + log.info("[ %s ] IPv4 is: %s", vm_name, ip) + # ssh or smb using ip and install salt only if deploy is True + if deploy: + vm_["key_filename"] = key_filename + # if specified, prefer ssh_host to the discovered ip address + if "ssh_host" not in vm_: + vm_["ssh_host"] = ip + log.info("[ %s ] Deploying to %s", vm_name, vm_["ssh_host"]) + + out = __utils__["cloud.bootstrap"](vm_, __opts__) + + data = show_instance(vm_name, call="action") + + if deploy and isinstance(out, dict): + data["deploy_kwargs"] = out.get("deploy_kwargs", {}) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return data + + +def handle_snapshot(config_spec, object_ref, reloc_spec, template, vm_): + """ + Returns a clone spec for cloning from shapshots + :rtype vim.vm.CloneSpec + """ + if "snapshot" not in vm_: + return None + + allowed_types = [ + FLATTEN_DISK_FULL_CLONE, + COPY_ALL_DISKS_FULL_CLONE, + CURRENT_STATE_LINKED_CLONE, + QUICK_LINKED_CLONE, + ] + + clone_spec = get_clonespec_for_valid_snapshot( + config_spec, object_ref, reloc_spec, template, vm_ + ) + if not clone_spec: + raise SaltCloudSystemExit( + "Invalid disk move type specified supported types are {}".format( + " ".join(allowed_types) + ) + ) + return clone_spec + + +def get_clonespec_for_valid_snapshot( + config_spec, object_ref, reloc_spec, template, vm_ +): + """ + return clonespec only if values are valid + """ + moving = True + if QUICK_LINKED_CLONE == vm_["snapshot"]["disk_move_type"]: + reloc_spec.diskMoveType = QUICK_LINKED_CLONE + elif CURRENT_STATE_LINKED_CLONE == vm_["snapshot"]["disk_move_type"]: + reloc_spec.diskMoveType = CURRENT_STATE_LINKED_CLONE + elif COPY_ALL_DISKS_FULL_CLONE == vm_["snapshot"]["disk_move_type"]: + reloc_spec.diskMoveType = COPY_ALL_DISKS_FULL_CLONE + elif FLATTEN_DISK_FULL_CLONE == vm_["snapshot"]["disk_move_type"]: + reloc_spec.diskMoveType = FLATTEN_DISK_FULL_CLONE + else: + moving = False + + if moving: + return build_clonespec(config_spec, object_ref, reloc_spec, template) + + return None + + +def build_clonespec(config_spec, object_ref, reloc_spec, template): + """ + Returns the clone spec + """ + if reloc_spec.diskMoveType == QUICK_LINKED_CLONE: + return vim.vm.CloneSpec( + template=template, + location=reloc_spec, + config=config_spec, + snapshot=object_ref.snapshot.currentSnapshot, + ) + + return vim.vm.CloneSpec(template=template, location=reloc_spec, config=config_spec) + + +def create_datacenter(kwargs=None, call=None): + """ + Create a new data center in this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_datacenter my-vmware-config name="MyNewDatacenter" + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_datacenter function must be called with -f or --function." + ) + + datacenter_name = kwargs.get("name") if kwargs and "name" in kwargs else None + + if not datacenter_name: + raise SaltCloudSystemExit( + "You must specify name of the new datacenter to be created." + ) + + if not datacenter_name or len(datacenter_name) >= 80: + raise SaltCloudSystemExit( + "The datacenter name must be a non empty string of less than 80 characters." + ) + + # Get the service instance + si = _get_si() + + # Check if datacenter already exists + datacenter_ref = salt.utils.vmware.get_mor_by_property( + si, vim.Datacenter, datacenter_name + ) + if datacenter_ref: + return {datacenter_name: "datacenter already exists"} + + folder = si.content.rootFolder + + # Verify that the folder is of type vim.Folder + if isinstance(folder, vim.Folder): + try: + folder.CreateDatacenter(name=datacenter_name) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating datacenter %s: %s", + datacenter_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + log.debug("Created datacenter %s", datacenter_name) + return {datacenter_name: "created"} + + return False + + +def create_cluster(kwargs=None, call=None): + """ + Create a new cluster under the specified datacenter in this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_cluster my-vmware-config name="myNewCluster" datacenter="datacenterName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_cluster function must be called with -f or --function." + ) + + cluster_name = kwargs.get("name") if kwargs and "name" in kwargs else None + datacenter = kwargs.get("datacenter") if kwargs and "datacenter" in kwargs else None + + if not cluster_name: + raise SaltCloudSystemExit( + "You must specify name of the new cluster to be created." + ) + + if not datacenter: + raise SaltCloudSystemExit( + "You must specify name of the datacenter where the cluster should be" + " created." + ) + + # Get the service instance + si = _get_si() + + if not isinstance(datacenter, vim.Datacenter): + datacenter = salt.utils.vmware.get_mor_by_property( + si, vim.Datacenter, datacenter + ) + if not datacenter: + raise SaltCloudSystemExit("The specified datacenter does not exist.") + + # Check if cluster already exists + cluster_ref = salt.utils.vmware.get_mor_by_property( + si, vim.ClusterComputeResource, cluster_name + ) + if cluster_ref: + return {cluster_name: "cluster already exists"} + + cluster_spec = vim.cluster.ConfigSpecEx() + folder = datacenter.hostFolder + + # Verify that the folder is of type vim.Folder + if isinstance(folder, vim.Folder): + try: + folder.CreateClusterEx(name=cluster_name, spec=cluster_spec) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating cluster %s: %s", + cluster_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + log.debug( + "Created cluster %s under datacenter %s", cluster_name, datacenter.name + ) + return {cluster_name: "created"} + + return False + + +def rescan_hba(kwargs=None, call=None): + """ + To rescan a specified HBA or all the HBAs on the Host System + + CLI Example: + + .. code-block:: bash + + salt-cloud -f rescan_hba my-vmware-config host="hostSystemName" + salt-cloud -f rescan_hba my-vmware-config hba="hbaDeviceName" host="hostSystemName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The rescan_hba function must be called with -f or --function." + ) + + hba = kwargs.get("hba") if kwargs and "hba" in kwargs else None + host_name = kwargs.get("host") if kwargs and "host" in kwargs else None + + if not host_name: + raise SaltCloudSystemExit("You must specify name of the host system.") + + host_ref = salt.utils.vmware.get_mor_by_property( + _get_si(), vim.HostSystem, host_name + ) + + try: + if hba: + log.info("Rescanning HBA %s on host %s", hba, host_name) + host_ref.configManager.storageSystem.RescanHba(hba) + ret = f"rescanned HBA {hba}" + else: + log.info("Rescanning all HBAs on host %s", host_name) + host_ref.configManager.storageSystem.RescanAllHba() + ret = "rescanned all HBAs" + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while rescaning HBA on host %s: %s", + host_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {host_name: "failed to rescan HBA"} + + return {host_name: ret} + + +def upgrade_tools_all(call=None): + """ + To upgrade VMware Tools on all virtual machines present in + the specified provider + + .. note:: + + If the virtual machine is running Windows OS, this function + will attempt to suppress the automatic reboot caused by a + VMware Tools upgrade. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f upgrade_tools_all my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The upgrade_tools_all function must be called with -f or --function." + ) + + ret = {} + vm_properties = ["name"] + + vm_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.VirtualMachine, vm_properties + ) + + for vm in vm_list: + ret[vm["name"]] = _upg_tools_helper(vm["object"]) + + return ret + + +def upgrade_tools(name, reboot=False, call=None): + """ + To upgrade VMware Tools on a specified virtual machine. + + .. note:: + + If the virtual machine is running Windows OS, use ``reboot=True`` + to reboot the virtual machine after VMware tools upgrade. Default + is ``reboot=False`` + + CLI Example: + + .. code-block:: bash + + salt-cloud -a upgrade_tools vmname + salt-cloud -a upgrade_tools vmname reboot=True + """ + if call != "action": + raise SaltCloudSystemExit( + "The upgrade_tools action must be called with -a or --action." + ) + + vm_ref = salt.utils.vmware.get_mor_by_property(_get_si(), vim.VirtualMachine, name) + + return _upg_tools_helper(vm_ref, reboot) + + +def list_hosts_by_cluster(kwargs=None, call=None): + """ + List hosts for each cluster; or hosts for a specified cluster in + this VMware environment + + To list hosts for each cluster: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_hosts_by_cluster my-vmware-config + + To list hosts for a specified cluster: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_hosts_by_cluster my-vmware-config cluster="clusterName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_hosts_by_cluster function must be called with -f or --function." + ) + + ret = {} + cluster_name = kwargs.get("cluster") if kwargs and "cluster" in kwargs else None + cluster_properties = ["name"] + + cluster_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.ClusterComputeResource, cluster_properties + ) + + for cluster in cluster_list: + ret[cluster["name"]] = [] + for host in cluster["object"].host: + if isinstance(host, vim.HostSystem): + ret[cluster["name"]].append(host.name) + if cluster_name and cluster_name == cluster["name"]: + return {"Hosts by Cluster": {cluster_name: ret[cluster_name]}} + + return {"Hosts by Cluster": ret} + + +def list_clusters_by_datacenter(kwargs=None, call=None): + """ + List clusters for each datacenter; or clusters for a specified datacenter in + this VMware environment + + To list clusters for each datacenter: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_clusters_by_datacenter my-vmware-config + + To list clusters for a specified datacenter: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_clusters_by_datacenter my-vmware-config datacenter="datacenterName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_clusters_by_datacenter function must be called with " + "-f or --function." + ) + + ret = {} + datacenter_name = ( + kwargs.get("datacenter") if kwargs and "datacenter" in kwargs else None + ) + datacenter_properties = ["name"] + + datacenter_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.Datacenter, datacenter_properties + ) + + for datacenter in datacenter_list: + ret[datacenter["name"]] = [] + for cluster in datacenter["object"].hostFolder.childEntity: + if isinstance(cluster, vim.ClusterComputeResource): + ret[datacenter["name"]].append(cluster.name) + if datacenter_name and datacenter_name == datacenter["name"]: + return {"Clusters by Datacenter": {datacenter_name: ret[datacenter_name]}} + + return {"Clusters by Datacenter": ret} + + +def list_hosts_by_datacenter(kwargs=None, call=None): + """ + List hosts for each datacenter; or hosts for a specified datacenter in + this VMware environment + + To list hosts for each datacenter: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_hosts_by_datacenter my-vmware-config + + To list hosts for a specified datacenter: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_hosts_by_datacenter my-vmware-config datacenter="datacenterName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_hosts_by_datacenter function must be called with " + "-f or --function." + ) + + ret = {} + datacenter_name = ( + kwargs.get("datacenter") if kwargs and "datacenter" in kwargs else None + ) + datacenter_properties = ["name"] + + datacenter_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.Datacenter, datacenter_properties + ) + + for datacenter in datacenter_list: + ret[datacenter["name"]] = [] + for cluster in datacenter["object"].hostFolder.childEntity: + if isinstance(cluster, vim.ClusterComputeResource): + for host in cluster.host: + if isinstance(host, vim.HostSystem): + ret[datacenter["name"]].append(host.name) + if datacenter_name and datacenter_name == datacenter["name"]: + return {"Hosts by Datacenter": {datacenter_name: ret[datacenter_name]}} + + return {"Hosts by Datacenter": ret} + + +def list_hbas(kwargs=None, call=None): + """ + List all HBAs for each host system; or all HBAs for a specified host + system; or HBAs of specified type for each host system; or HBAs of + specified type for a specified host system in this VMware environment + + .. note:: + + You can specify type as either ``parallel``, ``iscsi``, ``block`` + or ``fibre``. + + To list all HBAs for each host system: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_hbas my-vmware-config + + To list all HBAs for a specified host system: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_hbas my-vmware-config host="hostSystemName" + + To list HBAs of specified type for each host system: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_hbas my-vmware-config type="HBAType" + + To list HBAs of specified type for a specified host system: + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_hbas my-vmware-config host="hostSystemName" type="HBAtype" + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_hbas function must be called with -f or --function." + ) + + ret = {} + hba_type = kwargs.get("type").lower() if kwargs and "type" in kwargs else None + host_name = kwargs.get("host") if kwargs and "host" in kwargs else None + host_properties = ["name", "config.storageDevice.hostBusAdapter"] + + if hba_type and hba_type not in ["parallel", "block", "iscsi", "fibre"]: + raise SaltCloudSystemExit( + f"Specified hba type {hba_type} currently not supported." + ) + + host_list = salt.utils.vmware.get_mors_with_properties( + _get_si(), vim.HostSystem, host_properties + ) + + for host in host_list: + ret[host["name"]] = {} + for hba in host["config.storageDevice.hostBusAdapter"]: + hba_spec = { + "driver": hba.driver, + "status": hba.status, + "type": type(hba).__name__.rsplit(".", 1)[1], + } + if hba_type: + if isinstance(hba, _get_hba_type(hba_type)): + if hba.model in ret[host["name"]]: + ret[host["name"]][hba.model][hba.device] = hba_spec + else: + ret[host["name"]][hba.model] = {hba.device: hba_spec} + else: + if hba.model in ret[host["name"]]: + ret[host["name"]][hba.model][hba.device] = hba_spec + else: + ret[host["name"]][hba.model] = {hba.device: hba_spec} + if host["name"] == host_name: + return {"HBAs by Host": {host_name: ret[host_name]}} + + return {"HBAs by Host": ret} + + +def list_dvs(kwargs=None, call=None): + """ + List all the distributed virtual switches for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_dvs my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_dvs function must be called with -f or --function." + ) + + return {"Distributed Virtual Switches": salt.utils.vmware.list_dvs(_get_si())} + + +def list_vapps(kwargs=None, call=None): + """ + List all the vApps for this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f list_vapps my-vmware-config + """ + if call != "function": + raise SaltCloudSystemExit( + "The list_vapps function must be called with -f or --function." + ) + + return {"vApps": salt.utils.vmware.list_vapps(_get_si())} + + +def enter_maintenance_mode(kwargs=None, call=None): + """ + To put the specified host system in maintenance mode in this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f enter_maintenance_mode my-vmware-config host="myHostSystemName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The enter_maintenance_mode function must be called with -f or --function." + ) + + host_name = kwargs.get("host") if kwargs and "host" in kwargs else None + + host_ref = salt.utils.vmware.get_mor_by_property( + _get_si(), vim.HostSystem, host_name + ) + + if not host_name or not host_ref: + raise SaltCloudSystemExit("You must specify a valid name of the host system.") + + if host_ref.runtime.inMaintenanceMode: + return {host_name: "already in maintenance mode"} + + try: + task = host_ref.EnterMaintenanceMode(timeout=0, evacuatePoweredOffVms=True) + salt.utils.vmware.wait_for_task(task, host_name, "enter maintenance mode") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while moving host system %s in maintenance mode: %s", + host_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {host_name: "failed to enter maintenance mode"} + + return {host_name: "entered maintenance mode"} + + +def exit_maintenance_mode(kwargs=None, call=None): + """ + To take the specified host system out of maintenance mode in this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f exit_maintenance_mode my-vmware-config host="myHostSystemName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The exit_maintenance_mode function must be called with -f or --function." + ) + + host_name = kwargs.get("host") if kwargs and "host" in kwargs else None + + host_ref = salt.utils.vmware.get_mor_by_property( + _get_si(), vim.HostSystem, host_name + ) + + if not host_name or not host_ref: + raise SaltCloudSystemExit("You must specify a valid name of the host system.") + + if not host_ref.runtime.inMaintenanceMode: + return {host_name: "already not in maintenance mode"} + + try: + task = host_ref.ExitMaintenanceMode(timeout=0) + salt.utils.vmware.wait_for_task(task, host_name, "exit maintenance mode") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while moving host system %s out of maintenance mode: %s", + host_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {host_name: "failed to exit maintenance mode"} + + return {host_name: "exited maintenance mode"} + + +def create_folder(kwargs=None, call=None): + """ + Create the specified folder path in this VMware environment + + .. note:: + + To create a Host and Cluster Folder under a Datacenter, specify + ``path="/yourDatacenterName/host/yourFolderName"`` + + To create a Network Folder under a Datacenter, specify + ``path="/yourDatacenterName/network/yourFolderName"`` + + To create a Storage Folder under a Datacenter, specify + ``path="/yourDatacenterName/datastore/yourFolderName"`` + + To create a VM and Template Folder under a Datacenter, specify + ``path="/yourDatacenterName/vm/yourFolderName"`` + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_folder my-vmware-config path="/Local/a/b/c" + salt-cloud -f create_folder my-vmware-config path="/MyDatacenter/vm/MyVMFolder" + salt-cloud -f create_folder my-vmware-config path="/MyDatacenter/host/MyHostFolder" + salt-cloud -f create_folder my-vmware-config path="/MyDatacenter/network/MyNetworkFolder" + salt-cloud -f create_folder my-vmware-config path="/MyDatacenter/storage/MyStorageFolder" + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_folder function must be called with -f or --function." + ) + + # Get the service instance object + si = _get_si() + + folder_path = kwargs.get("path") if kwargs and "path" in kwargs else None + + if not folder_path: + raise SaltCloudSystemExit("You must specify a non empty folder path.") + + folder_refs = [] + inventory_path = "/" + path_exists = True + + # Split the path in a list and loop over it to check for its existence + for index, folder_name in enumerate( + os.path.normpath(folder_path.strip("/")).split("/") + ): + inventory_path = os.path.join(inventory_path, folder_name) + folder_ref = si.content.searchIndex.FindByInventoryPath( + inventoryPath=inventory_path + ) + if isinstance(folder_ref, vim.Folder): + # This is a folder that exists so just append and skip it + log.debug("Path %s/ exists in the inventory", inventory_path) + folder_refs.append(folder_ref) + elif isinstance(folder_ref, vim.Datacenter): + # This is a datacenter that exists so just append and skip it + log.debug("Path %s/ exists in the inventory", inventory_path) + folder_refs.append(folder_ref) + else: + path_exists = False + if not folder_refs: + # If this is the first folder, create it under the rootFolder + log.debug( + "Creating folder %s under rootFolder in the inventory", folder_name + ) + folder_refs.append(si.content.rootFolder.CreateFolder(folder_name)) + else: + # Create the folder under the parent folder + log.debug("Creating path %s/ in the inventory", inventory_path) + folder_refs.append(folder_refs[index - 1].CreateFolder(folder_name)) + + if path_exists: + return {inventory_path: "specified path already exists"} + + return {inventory_path: "created the specified path"} + + +def create_snapshot(name, kwargs=None, call=None): + """ + Create a snapshot of the specified virtual machine in this VMware + environment + + .. note:: + + If the VM is powered on, the internal state of the VM (memory + dump) is included in the snapshot by default which will also set + the power state of the snapshot to "powered on". You can set + ``memdump=False`` to override this. This field is ignored if + the virtual machine is powered off or if the VM does not support + snapshots with memory dumps. Default is ``memdump=True`` + + .. note:: + + If the VM is powered on when the snapshot is taken, VMware Tools + can be used to quiesce the file system in the virtual machine by + setting ``quiesce=True``. This field is ignored if the virtual + machine is powered off; if VMware Tools are not available or if + ``memdump=True``. Default is ``quiesce=False`` + + CLI Example: + + .. code-block:: bash + + salt-cloud -a create_snapshot vmname snapshot_name="mySnapshot" + salt-cloud -a create_snapshot vmname snapshot_name="mySnapshot" [description="My snapshot"] [memdump=False] [quiesce=True] + """ + if call != "action": + raise SaltCloudSystemExit( + "The create_snapshot action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + snapshot_name = ( + kwargs.get("snapshot_name") if kwargs and "snapshot_name" in kwargs else None + ) + + if not snapshot_name: + raise SaltCloudSystemExit( + "You must specify snapshot name for the snapshot to be created." + ) + + memdump = _str_to_bool(kwargs.get("memdump", True)) + quiesce = _str_to_bool(kwargs.get("quiesce", False)) + + vm_ref = salt.utils.vmware.get_mor_by_property(_get_si(), vim.VirtualMachine, name) + + if vm_ref.summary.runtime.powerState != "poweredOn": + log.debug( + "VM %s is not powered on. Setting both memdump and quiesce to False", name + ) + memdump = False + quiesce = False + + if memdump and quiesce: + # Either memdump or quiesce should be set to True + log.warning( + "You can only set either memdump or quiesce to True. Setting quiesce=False" + ) + quiesce = False + + desc = kwargs.get("description") if "description" in kwargs else "" + + try: + task = vm_ref.CreateSnapshot(snapshot_name, desc, memdump, quiesce) + salt.utils.vmware.wait_for_task(task, name, "create snapshot", 5, "info") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while creating snapshot of %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "failed to create snapshot" + + return { + "Snapshot created successfully": _get_snapshots( + vm_ref.snapshot.rootSnapshotList, vm_ref.snapshot.currentSnapshot + ) + } + + +def revert_to_snapshot(name, kwargs=None, call=None): + """ + Revert virtual machine to its current snapshot. If no snapshot + exists, the state of the virtual machine remains unchanged + + .. note:: + + The virtual machine will be powered on if the power state of + the snapshot when it was created was set to "Powered On". Set + ``power_off=True`` so that the virtual machine stays powered + off regardless of the power state of the snapshot when it was + created. Default is ``power_off=False``. + + If the power state of the snapshot when it was created was + "Powered On" and if ``power_off=True``, the VM will be put in + suspended state after it has been reverted to the snapshot. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a revert_to_snapshot vmame [power_off=True] + salt-cloud -a revert_to_snapshot vmame snapshot_name="selectedSnapshot" [power_off=True] + """ + if call != "action": + raise SaltCloudSystemExit( + "The revert_to_snapshot action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + snapshot_name = ( + kwargs.get("snapshot_name") if kwargs and "snapshot_name" in kwargs else None + ) + + suppress_power_on = _str_to_bool(kwargs.get("power_off", False)) + + vm_ref = salt.utils.vmware.get_mor_by_property(_get_si(), vim.VirtualMachine, name) + + if not vm_ref.rootSnapshot: + log.error("VM %s does not contain any current snapshots", name) + return "revert failed" + + msg = "reverted to current snapshot" + + try: + if snapshot_name is None: + log.debug("Reverting VM %s to current snapshot", name) + task = vm_ref.RevertToCurrentSnapshot(suppressPowerOn=suppress_power_on) + else: + log.debug("Reverting VM %s to snapshot %s", name, snapshot_name) + msg = f"reverted to snapshot {snapshot_name}" + snapshot_ref = _get_snapshot_ref_by_name(vm_ref, snapshot_name) + if snapshot_ref is None: + return f"specified snapshot '{snapshot_name}' does not exist" + task = snapshot_ref.snapshot.Revert(suppressPowerOn=suppress_power_on) + + salt.utils.vmware.wait_for_task(task, name, "revert to snapshot", 5, "info") + + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while reverting VM %s to snapshot: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "revert failed" + + return msg + + +def remove_snapshot(name, kwargs=None, call=None): + """ + Remove a snapshot of the specified virtual machine in this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -a remove_snapshot vmname snapshot_name="mySnapshot" + salt-cloud -a remove_snapshot vmname snapshot_name="mySnapshot" [remove_children="True"] + """ + + if call != "action": + raise SaltCloudSystemExit( + "The create_snapshot action must be called with -a or --action." + ) + + if kwargs is None: + kwargs = {} + + snapshot_name = ( + kwargs.get("snapshot_name") if kwargs and "snapshot_name" in kwargs else None + ) + remove_children = _str_to_bool(kwargs.get("remove_children", False)) + + if not snapshot_name: + raise SaltCloudSystemExit( + "You must specify snapshot name for the snapshot to be deleted." + ) + + vm_ref = salt.utils.vmware.get_mor_by_property(_get_si(), vim.VirtualMachine, name) + + if not _get_snapshot_ref_by_name(vm_ref, snapshot_name): + raise SaltCloudSystemExit( + "Сould not find the snapshot with the specified name." + ) + + try: + snap_obj = _get_snapshot_ref_by_name(vm_ref, snapshot_name).snapshot + task = snap_obj.RemoveSnapshot_Task(remove_children) + salt.utils.vmware.wait_for_task(task, name, "remove snapshot", 5, "info") + + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while removing snapshot of %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "failed to remove snapshot" + + if vm_ref.snapshot: + return { + "Snapshot removed successfully": _get_snapshots( + vm_ref.snapshot.rootSnapshotList, vm_ref.snapshot.currentSnapshot + ) + } + + return "Snapshots removed successfully" + + +def remove_all_snapshots(name, kwargs=None, call=None): + """ + Remove all the snapshots present for the specified virtual machine. + + .. note:: + + All the snapshots higher up in the hierarchy of the current snapshot tree + are consolidated and their virtual disks are merged. To override this + behavior and only remove all snapshots, set ``merge_snapshots=False``. + Default is ``merge_snapshots=True`` + + CLI Example: + + .. code-block:: bash + + salt-cloud -a remove_all_snapshots vmname [merge_snapshots=False] + """ + if call != "action": + raise SaltCloudSystemExit( + "The remove_all_snapshots action must be called with -a or --action." + ) + + vm_ref = salt.utils.vmware.get_mor_by_property(_get_si(), vim.VirtualMachine, name) + + try: + task = vm_ref.RemoveAllSnapshots() + salt.utils.vmware.wait_for_task(task, name, "remove snapshots", 5, "info") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while removing snapshots on VM %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "Failed to remove snapshots" + + return "Removed all snapshots" + + +def convert_to_template(name, kwargs=None, call=None): + """ + Convert the specified virtual machine to template. + + CLI Example: + + .. code-block:: bash + + salt-cloud -a convert_to_template vmname + """ + if call != "action": + raise SaltCloudSystemExit( + "The convert_to_template action must be called with -a or --action." + ) + + vm_ref = salt.utils.vmware.get_mor_by_property(_get_si(), vim.VirtualMachine, name) + + if vm_ref.config.template: + raise SaltCloudSystemExit(f"{name} already a template") + + try: + vm_ref.MarkAsTemplate() + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while converting VM to template %s: %s", + name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return "failed to convert to teamplate" + + return f"{name} converted to template" + + +def add_host(kwargs=None, call=None): + """ + Add a host system to the specified cluster or datacenter in this VMware environment + + .. note:: + + To use this function, you need to specify ``esxi_host_user`` and + ``esxi_host_password`` under your provider configuration set up at + ``/etc/salt/cloud.providers`` or ``/etc/salt/cloud.providers.d/vmware.conf``: + + .. code-block:: yaml + + vcenter01: + driver: vmware + user: 'DOMAIN\\user' + password: 'verybadpass' + url: 'vcenter01.domain.com' + + # Required when adding a host system + esxi_host_user: 'root' + esxi_host_password: 'myhostpassword' + # Optional fields that can be specified when adding a host system + esxi_host_ssl_thumbprint: '12:A3:45:B6:CD:7E:F8:90:A1:BC:23:45:D6:78:9E:FA:01:2B:34:CD' + + The SSL thumbprint of the host system can be optionally specified by setting + ``esxi_host_ssl_thumbprint`` under your provider configuration. To get the SSL + thumbprint of the host system, execute the following command from a remote + server: + + .. code-block:: bash + + echo -n | openssl s_client -connect :443 2>/dev/null | openssl x509 -noout -fingerprint -sha1 + + CLI Example: + + .. code-block:: bash + + salt-cloud -f add_host my-vmware-config host="myHostSystemName" cluster="myClusterName" + salt-cloud -f add_host my-vmware-config host="myHostSystemName" datacenter="myDatacenterName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The add_host function must be called with -f or --function." + ) + + host_name = kwargs.get("host") if kwargs and "host" in kwargs else None + cluster_name = kwargs.get("cluster") if kwargs and "cluster" in kwargs else None + datacenter_name = ( + kwargs.get("datacenter") if kwargs and "datacenter" in kwargs else None + ) + + host_user = config.get_cloud_config_value( + "esxi_host_user", get_configured_provider(), __opts__, search_global=False + ) + host_password = config.get_cloud_config_value( + "esxi_host_password", get_configured_provider(), __opts__, search_global=False + ) + host_ssl_thumbprint = config.get_cloud_config_value( + "esxi_host_ssl_thumbprint", + get_configured_provider(), + __opts__, + search_global=False, + ) + + if not host_user: + raise SaltCloudSystemExit( + "You must specify the ESXi host username in your providers config." + ) + + if not host_password: + raise SaltCloudSystemExit( + "You must specify the ESXi host password in your providers config." + ) + + if not host_name: + raise SaltCloudSystemExit( + "You must specify either the IP or DNS name of the host system." + ) + + if (cluster_name and datacenter_name) or not (cluster_name or datacenter_name): + raise SaltCloudSystemExit( + "You must specify either the cluster name or the datacenter name." + ) + + # Get the service instance + si = _get_si() + + if cluster_name: + cluster_ref = salt.utils.vmware.get_mor_by_property( + si, vim.ClusterComputeResource, cluster_name + ) + if not cluster_ref: + raise SaltCloudSystemExit("Specified cluster does not exist.") + + if datacenter_name: + datacenter_ref = salt.utils.vmware.get_mor_by_property( + si, vim.Datacenter, datacenter_name + ) + if not datacenter_ref: + raise SaltCloudSystemExit("Specified datacenter does not exist.") + + spec = vim.host.ConnectSpec( + hostName=host_name, + userName=host_user, + password=host_password, + ) + + if host_ssl_thumbprint: + spec.sslThumbprint = host_ssl_thumbprint + else: + log.warning("SSL thumbprint has not been specified in provider configuration") + # This smells like a not-so-good idea. A plenty of VMWare VCenters + # do not listen to the default port 443. + try: + log.debug("Trying to get the SSL thumbprint directly from the host system") + p1 = subprocess.Popen( + ("echo", "-n"), stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + p2 = subprocess.Popen( + ("openssl", "s_client", "-connect", f"{host_name}:443"), + stdin=p1.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + p3 = subprocess.Popen( + ("openssl", "x509", "-noout", "-fingerprint", "-sha1"), + stdin=p2.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + out = salt.utils.stringutils.to_str(p3.stdout.read()) + ssl_thumbprint = out.split("=")[-1].strip() + log.debug( + "SSL thumbprint received from the host system: %s", ssl_thumbprint + ) + spec.sslThumbprint = ssl_thumbprint + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while trying to get SSL thumbprint of host %s: %s", + host_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {host_name: "failed to add host"} + + try: + if cluster_name: + task = cluster_ref.AddHost(spec=spec, asConnected=True) + ret = f"added host system to cluster {cluster_name}" + if datacenter_name: + task = datacenter_ref.hostFolder.AddStandaloneHost( + spec=spec, addConnected=True + ) + ret = f"added host system to datacenter {datacenter_name}" + salt.utils.vmware.wait_for_task(task, host_name, "add host system", 5, "info") + except Exception as exc: # pylint: disable=broad-except + if isinstance(exc, vim.fault.SSLVerifyFault): + log.error("Authenticity of the host's SSL certificate is not verified") + log.info( + "Try again after setting the esxi_host_ssl_thumbprint " + "to %s in provider configuration", + spec.sslThumbprint, + ) + log.error( + "Error while adding host %s: %s", + host_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {host_name: "failed to add host"} + + return {host_name: ret} + + +def remove_host(kwargs=None, call=None): + """ + Remove the specified host system from this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f remove_host my-vmware-config host="myHostSystemName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The remove_host function must be called with -f or --function." + ) + + host_name = kwargs.get("host") if kwargs and "host" in kwargs else None + + if not host_name: + raise SaltCloudSystemExit("You must specify name of the host system.") + + # Get the service instance + si = _get_si() + + host_ref = salt.utils.vmware.get_mor_by_property(si, vim.HostSystem, host_name) + if not host_ref: + raise SaltCloudSystemExit("Specified host system does not exist.") + + try: + if isinstance(host_ref.parent, vim.ClusterComputeResource): + # This is a host system that is part of a Cluster + task = host_ref.Destroy_Task() + else: + # This is a standalone host system + task = host_ref.parent.Destroy_Task() + salt.utils.vmware.wait_for_task( + task, host_name, "remove host", log_level="info" + ) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while removing host %s: %s", + host_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {host_name: "failed to remove host"} + + return {host_name: "removed host from vcenter"} + + +def connect_host(kwargs=None, call=None): + """ + Connect the specified host system in this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f connect_host my-vmware-config host="myHostSystemName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The connect_host function must be called with -f or --function." + ) + + host_name = kwargs.get("host") if kwargs and "host" in kwargs else None + + if not host_name: + raise SaltCloudSystemExit("You must specify name of the host system.") + + # Get the service instance + si = _get_si() + + host_ref = salt.utils.vmware.get_mor_by_property(si, vim.HostSystem, host_name) + if not host_ref: + raise SaltCloudSystemExit("Specified host system does not exist.") + + if host_ref.runtime.connectionState == "connected": + return {host_name: "host system already connected"} + + try: + task = host_ref.ReconnectHost_Task() + salt.utils.vmware.wait_for_task(task, host_name, "connect host", 5, "info") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while connecting host %s: %s", + host_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {host_name: "failed to connect host"} + + return {host_name: "connected host"} + + +def disconnect_host(kwargs=None, call=None): + """ + Disconnect the specified host system in this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f disconnect_host my-vmware-config host="myHostSystemName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The disconnect_host function must be called with -f or --function." + ) + + host_name = kwargs.get("host") if kwargs and "host" in kwargs else None + + if not host_name: + raise SaltCloudSystemExit("You must specify name of the host system.") + + # Get the service instance + si = _get_si() + + host_ref = salt.utils.vmware.get_mor_by_property(si, vim.HostSystem, host_name) + if not host_ref: + raise SaltCloudSystemExit("Specified host system does not exist.") + + if host_ref.runtime.connectionState == "disconnected": + return {host_name: "host system already disconnected"} + + try: + task = host_ref.DisconnectHost_Task() + salt.utils.vmware.wait_for_task( + task, host_name, "disconnect host", log_level="info" + ) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while disconnecting host %s: %s", + host_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {host_name: "failed to disconnect host"} + + return {host_name: "disconnected host"} + + +def reboot_host(kwargs=None, call=None): + """ + Reboot the specified host system in this VMware environment + + .. note:: + + If the host system is not in maintenance mode, it will not be rebooted. If you + want to reboot the host system regardless of whether it is in maintenance mode, + set ``force=True``. Default is ``force=False``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f reboot_host my-vmware-config host="myHostSystemName" [force=True] + """ + if call != "function": + raise SaltCloudSystemExit( + "The reboot_host function must be called with -f or --function." + ) + + host_name = kwargs.get("host") if kwargs and "host" in kwargs else None + force = _str_to_bool(kwargs.get("force")) if kwargs and "force" in kwargs else False + + if not host_name: + raise SaltCloudSystemExit("You must specify name of the host system.") + + # Get the service instance + si = _get_si() + + host_ref = salt.utils.vmware.get_mor_by_property(si, vim.HostSystem, host_name) + if not host_ref: + raise SaltCloudSystemExit("Specified host system does not exist.") + + if host_ref.runtime.connectionState == "notResponding": + raise SaltCloudSystemExit( + "Specified host system cannot be rebooted in it's current state (not" + " responding)." + ) + + if not host_ref.capability.rebootSupported: + raise SaltCloudSystemExit("Specified host system does not support reboot.") + + if not host_ref.runtime.inMaintenanceMode and not force: + raise SaltCloudSystemExit( + "Specified host system is not in maintenance mode. Specify force=True to" + " force reboot even if there are virtual machines running or other" + " operations in progress." + ) + + try: + host_ref.RebootHost_Task(force) + _wait_for_host(host_ref, "reboot", 10, "info") + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while rebooting host %s: %s", + host_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {host_name: "failed to reboot host"} + + return {host_name: "rebooted host"} + + +def create_datastore_cluster(kwargs=None, call=None): + """ + Create a new datastore cluster for the specified datacenter in this VMware environment + + CLI Example: + + .. code-block:: bash + + salt-cloud -f create_datastore_cluster my-vmware-config name="datastoreClusterName" datacenter="datacenterName" + """ + if call != "function": + raise SaltCloudSystemExit( + "The create_datastore_cluster function must be called with " + "-f or --function." + ) + + datastore_cluster_name = kwargs.get("name") if kwargs and "name" in kwargs else None + datacenter_name = ( + kwargs.get("datacenter") if kwargs and "datacenter" in kwargs else None + ) + + if not datastore_cluster_name: + raise SaltCloudSystemExit( + "You must specify name of the new datastore cluster to be created." + ) + + if not datastore_cluster_name or len(datastore_cluster_name) >= 80: + raise SaltCloudSystemExit( + "The datastore cluster name must be a non empty string of less than 80" + " characters." + ) + + if not datacenter_name: + raise SaltCloudSystemExit( + "You must specify name of the datacenter where the datastore cluster should" + " be created." + ) + + # Get the service instance + si = _get_si() + + # Check if datastore cluster already exists + datastore_cluster_ref = salt.utils.vmware.get_mor_by_property( + si, vim.StoragePod, datastore_cluster_name + ) + if datastore_cluster_ref: + return {datastore_cluster_name: "datastore cluster already exists"} + + datacenter_ref = salt.utils.vmware.get_mor_by_property( + si, vim.Datacenter, datacenter_name + ) + if not datacenter_ref: + raise SaltCloudSystemExit("The specified datacenter does not exist.") + + try: + datacenter_ref.datastoreFolder.CreateStoragePod(name=datastore_cluster_name) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating datastore cluster %s: %s", + datastore_cluster_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return False + + return {datastore_cluster_name: "created"} + + +def shutdown_host(kwargs=None, call=None): + """ + Shut down the specified host system in this VMware environment + + .. note:: + + If the host system is not in maintenance mode, it will not be shut down. If you + want to shut down the host system regardless of whether it is in maintenance mode, + set ``force=True``. Default is ``force=False``. + + CLI Example: + + .. code-block:: bash + + salt-cloud -f shutdown_host my-vmware-config host="myHostSystemName" [force=True] + """ + if call != "function": + raise SaltCloudSystemExit( + "The shutdown_host function must be called with -f or --function." + ) + + host_name = kwargs.get("host") if kwargs and "host" in kwargs else None + force = _str_to_bool(kwargs.get("force")) if kwargs and "force" in kwargs else False + + if not host_name: + raise SaltCloudSystemExit("You must specify name of the host system.") + + # Get the service instance + si = _get_si() + + host_ref = salt.utils.vmware.get_mor_by_property(si, vim.HostSystem, host_name) + if not host_ref: + raise SaltCloudSystemExit("Specified host system does not exist.") + + if host_ref.runtime.connectionState == "notResponding": + raise SaltCloudSystemExit( + "Specified host system cannot be shut down in it's current state (not" + " responding)." + ) + + if not host_ref.capability.rebootSupported: + raise SaltCloudSystemExit("Specified host system does not support shutdown.") + + if not host_ref.runtime.inMaintenanceMode and not force: + raise SaltCloudSystemExit( + "Specified host system is not in maintenance mode. Specify force=True to" + " force reboot even if there are virtual machines running or other" + " operations in progress." + ) + + try: + host_ref.ShutdownHost_Task(force) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error while shutting down host %s: %s", + host_name, + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + return {host_name: "failed to shut down host"} + + return {host_name: "shut down host"} diff --git a/salt/cloud/clouds/vultrpy.py b/salt/cloud/clouds/vultrpy.py new file mode 100644 index 000000000000..a67f23a292a5 --- /dev/null +++ b/salt/cloud/clouds/vultrpy.py @@ -0,0 +1,652 @@ +""" +Vultr Cloud Module using python-vultr bindings +============================================== + +.. versionadded:: 2016.3.0 + +The Vultr cloud module is used to control access to the Vultr VPS system. + +Use of this module only requires the ``api_key`` parameter. + +Set up the cloud configuration at ``/etc/salt/cloud.providers`` or +``/etc/salt/cloud.providers.d/vultr.conf``: + +.. code-block:: yaml + + my-vultr-config: + # Vultr account api key + api_key: + driver: vultr + +Set up the cloud profile at ``/etc/salt/cloud.profiles`` or +``/etc/salt/cloud.profiles.d/vultr.conf``: + +.. code-block:: yaml + + nyc-4gb-4cpu-ubuntu-14-04: + location: 1 + provider: my-vultr-config + image: 160 + size: 95 + enable_private_network: True + +This driver also supports Vultr's `startup script` feature. You can list startup +scripts in your account with + +.. code-block:: bash + + salt-cloud -f list_scripts + +That list will include the IDs of the scripts in your account. Thus, if you +have a script called 'setup-networking' with an ID of 493234 you can specify +that startup script in a profile like so: + +.. code-block:: yaml + + nyc-2gb-1cpu-ubuntu-17-04: + location: 1 + provider: my-vultr-config + image: 223 + size: 13 + startup_script_id: 493234 + +Similarly you can also specify a fiewall group ID using the option firewall_group_id. You can list +firewall groups with + +.. code-block:: bash + + salt-cloud -f list_firewall_groups + +To specify SSH keys to be preinstalled on the server, use the ssh_key_names setting + +.. code-block:: yaml + + nyc-2gb-1cpu-ubuntu-17-04: + location: 1 + provider: my-vultr-config + image: 223 + size: 13 + ssh_key_names: dev1,dev2,salt-master + +You can list SSH keys available on your account using + +.. code-block:: bash + + salt-cloud -f list_keypairs + +""" + +import logging +import pprint +import time +import urllib.parse + +import salt.config as config +from salt.exceptions import SaltCloudConfigError, SaltCloudSystemExit + +# Get logging started +log = logging.getLogger(__name__) + +__virtualname__ = "vultr" + +DETAILS = {} + + +def __virtual__(): + """ + Set up the Vultr functions and check for configurations + """ + if get_configured_provider() is False: + return False + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def get_configured_provider(): + """ + Return the first configured instance + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or "vultr", ("api_key",) + ) + + +def _cache_provider_details(conn=None): + """ + Provide a place to hang onto results of --list-[locations|sizes|images] + so we don't have to go out to the API and get them every time. + """ + DETAILS["avail_locations"] = {} + DETAILS["avail_sizes"] = {} + DETAILS["avail_images"] = {} + locations = avail_locations(conn) + images = avail_images(conn) + sizes = avail_sizes(conn) + + for key, location in locations.items(): + DETAILS["avail_locations"][location["name"]] = location + DETAILS["avail_locations"][key] = location + + for key, image in images.items(): + DETAILS["avail_images"][image["name"]] = image + DETAILS["avail_images"][key] = image + + for key, vm_size in sizes.items(): + DETAILS["avail_sizes"][vm_size["name"]] = vm_size + DETAILS["avail_sizes"][key] = vm_size + + +def avail_locations(conn=None): + """ + return available datacenter locations + """ + return _query("regions/list") + + +def avail_scripts(conn=None): + """ + return available startup scripts + """ + return _query("startupscript/list") + + +def avail_firewall_groups(conn=None): + """ + return available firewall groups + """ + return _query("firewall/group_list") + + +def avail_keys(conn=None): + """ + return available SSH keys + """ + return _query("sshkey/list") + + +def list_scripts(conn=None, call=None): + """ + return list of Startup Scripts + """ + return avail_scripts() + + +def list_firewall_groups(conn=None, call=None): + """ + return list of firewall groups + """ + return avail_firewall_groups() + + +def list_keypairs(conn=None, call=None): + """ + return list of SSH keys + """ + return avail_keys() + + +def show_keypair(kwargs=None, call=None): + """ + return list of SSH keys + """ + if not kwargs: + kwargs = {} + + if "keyname" not in kwargs: + log.error("A keyname is required.") + return False + + keys = list_keypairs(call="function") + keyid = keys[kwargs["keyname"]]["SSHKEYID"] + log.debug("Key ID is %s", keyid) + + return keys[kwargs["keyname"]] + + +def avail_sizes(conn=None): + """ + Return available sizes ("plans" in VultrSpeak) + """ + return _query("plans/list") + + +def avail_images(conn=None): + """ + Return available images + """ + return _query("os/list") + + +def list_nodes(**kwargs): + """ + Return basic data on nodes + """ + ret = {} + + nodes = list_nodes_full() + for node in nodes: + ret[node] = {} + for prop in "id", "image", "size", "state", "private_ips", "public_ips": + ret[node][prop] = nodes[node][prop] + + return ret + + +def list_nodes_full(**kwargs): + """ + Return all data on nodes + """ + nodes = _query("server/list") + ret = {} + + for node in nodes: + name = nodes[node]["label"] + ret[name] = nodes[node].copy() + ret[name]["id"] = node + ret[name]["image"] = nodes[node]["os"] + ret[name]["size"] = nodes[node]["VPSPLANID"] + ret[name]["state"] = nodes[node]["status"] + ret[name]["private_ips"] = nodes[node]["internal_ip"] + ret[name]["public_ips"] = nodes[node]["main_ip"] + + return ret + + +def list_nodes_select(conn=None, call=None): + """ + Return a list of the VMs that are on the provider, with select fields + """ + return __utils__["cloud.list_nodes_select"]( + list_nodes_full(), + __opts__["query.selection"], + call, + ) + + +def destroy(name): + """ + Remove a node from Vultr + """ + node = show_instance(name, call="action") + params = {"SUBID": node["SUBID"]} + result = _query( + "server/destroy", + method="POST", + decode=False, + data=urllib.parse.urlencode(params), + ) + + # The return of a destroy call is empty in the case of a success. + # Errors are only indicated via HTTP status code. Status code 200 + # effetively therefore means "success". + if result.get("body") == "" and result.get("text") == "": + return True + return result + + +def stop(*args, **kwargs): + """ + Execute a "stop" action on a VM + """ + return _query("server/halt") + + +def start(*args, **kwargs): + """ + Execute a "start" action on a VM + """ + return _query("server/start") + + +def show_instance(name, call=None): + """ + Show the details from the provider concerning an instance + """ + if call != "action": + raise SaltCloudSystemExit( + "The show_instance action must be called with -a or --action." + ) + + nodes = list_nodes_full() + # Find under which cloud service the name is listed, if any + if name not in nodes: + return {} + __utils__["cloud.cache_node"](nodes[name], _get_active_provider_name(), __opts__) + return nodes[name] + + +def _lookup_vultrid(which_key, availkey, keyname): + """ + Helper function to retrieve a Vultr ID + """ + if DETAILS == {}: + _cache_provider_details() + + which_key = str(which_key) + try: + return DETAILS[availkey][which_key][keyname] + except KeyError: + return False + + +def create(vm_): + """ + Create a single VM from a data dict + """ + if "driver" not in vm_: + vm_["driver"] = vm_["provider"] + + private_networking = config.get_cloud_config_value( + "enable_private_network", + vm_, + __opts__, + search_global=False, + default=False, + ) + + ssh_key_ids = config.get_cloud_config_value( + "ssh_key_names", vm_, __opts__, search_global=False, default=None + ) + + startup_script = config.get_cloud_config_value( + "startup_script_id", + vm_, + __opts__, + search_global=False, + default=None, + ) + + if startup_script and str(startup_script) not in avail_scripts(): + log.error( + "Your Vultr account does not have a startup script with ID %s", + str(startup_script), + ) + return False + + firewall_group_id = config.get_cloud_config_value( + "firewall_group_id", + vm_, + __opts__, + search_global=False, + default=None, + ) + + if firewall_group_id and str(firewall_group_id) not in avail_firewall_groups(): + log.error( + "Your Vultr account does not have a firewall group with ID %s", + str(firewall_group_id), + ) + return False + if ssh_key_ids is not None: + key_list = ssh_key_ids.split(",") + available_keys = avail_keys() + for key in key_list: + if key and str(key) not in available_keys: + log.error("Your Vultr account does not have a key with ID %s", str(key)) + return False + + if private_networking is not None: + if not isinstance(private_networking, bool): + raise SaltCloudConfigError( + "'private_networking' should be a boolean value." + ) + if private_networking is True: + enable_private_network = "yes" + else: + enable_private_network = "no" + + __utils__["cloud.fire_event"]( + "event", + "starting create", + "salt/cloud/{}/creating".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "creating", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + osid = _lookup_vultrid(vm_["image"], "avail_images", "OSID") + if not osid: + log.error("Vultr does not have an image with id or name %s", vm_["image"]) + return False + + vpsplanid = _lookup_vultrid(vm_["size"], "avail_sizes", "VPSPLANID") + if not vpsplanid: + log.error("Vultr does not have a size with id or name %s", vm_["size"]) + return False + + dcid = _lookup_vultrid(vm_["location"], "avail_locations", "DCID") + if not dcid: + log.error("Vultr does not have a location with id or name %s", vm_["location"]) + return False + + kwargs = { + "label": vm_["name"], + "OSID": osid, + "VPSPLANID": vpsplanid, + "DCID": dcid, + "hostname": vm_["name"], + "enable_private_network": enable_private_network, + } + if startup_script: + kwargs["SCRIPTID"] = startup_script + + if firewall_group_id: + kwargs["FIREWALLGROUPID"] = firewall_group_id + + if ssh_key_ids: + kwargs["SSHKEYID"] = ssh_key_ids + + log.info("Creating Cloud VM %s", vm_["name"]) + + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + "salt/cloud/{}/requesting".format(vm_["name"]), + args={ + "kwargs": __utils__["cloud.filter_event"]( + "requesting", kwargs, list(kwargs) + ), + }, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + try: + data = _query( + "server/create", method="POST", data=urllib.parse.urlencode(kwargs) + ) + if int(data.get("status", "200")) >= 300: + log.error( + "Error creating %s on Vultr\n\nVultr API returned %s\n", + vm_["name"], + data, + ) + log.error( + "Status 412 may mean that you are requesting an\n" + "invalid location, image, or size." + ) + + __utils__["cloud.fire_event"]( + "event", + "instance request failed", + "salt/cloud/{}/requesting/failed".format(vm_["name"]), + args={"kwargs": kwargs}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return False + except Exception as exc: # pylint: disable=broad-except + log.error( + "Error creating %s on Vultr\n\n" + "The following exception was thrown when trying to " + "run the initial deployment:\n%s", + vm_["name"], + exc, + # Show the traceback if the debug logging level is enabled + exc_info_on_loglevel=logging.DEBUG, + ) + __utils__["cloud.fire_event"]( + "event", + "instance request failed", + "salt/cloud/{}/requesting/failed".format(vm_["name"]), + args={"kwargs": kwargs}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return False + + def wait_for_hostname(): + """ + Wait for the IP address to become available + """ + data = show_instance(vm_["name"], call="action") + main_ip = str(data.get("main_ip", "0")) + if main_ip.startswith("0"): + time.sleep(3) + return False + return data["main_ip"] + + def wait_for_default_password(): + """ + Wait for the IP address to become available + """ + data = show_instance(vm_["name"], call="action") + # print("Waiting for default password") + # pprint.pprint(data) + default_password = str(data.get("default_password", "")) + if default_password == "" or default_password == "not supported": + time.sleep(1) + return False + return data["default_password"] + + def wait_for_status(): + """ + Wait for the IP address to become available + """ + data = show_instance(vm_["name"], call="action") + # print("Waiting for status normal") + # pprint.pprint(data) + if str(data.get("status", "")) != "active": + time.sleep(1) + return False + return data["default_password"] + + def wait_for_server_state(): + """ + Wait for the IP address to become available + """ + data = show_instance(vm_["name"], call="action") + # print("Waiting for server state ok") + # pprint.pprint(data) + if str(data.get("server_state", "")) != "ok": + time.sleep(1) + return False + return data["default_password"] + + vm_["ssh_host"] = __utils__["cloud.wait_for_fun"]( + wait_for_hostname, + timeout=config.get_cloud_config_value( + "wait_for_fun_timeout", vm_, __opts__, default=15 * 60 + ), + ) + vm_["password"] = __utils__["cloud.wait_for_fun"]( + wait_for_default_password, + timeout=config.get_cloud_config_value( + "wait_for_fun_timeout", vm_, __opts__, default=15 * 60 + ), + ) + __utils__["cloud.wait_for_fun"]( + wait_for_status, + timeout=config.get_cloud_config_value( + "wait_for_fun_timeout", vm_, __opts__, default=15 * 60 + ), + ) + __utils__["cloud.wait_for_fun"]( + wait_for_server_state, + timeout=config.get_cloud_config_value( + "wait_for_fun_timeout", vm_, __opts__, default=15 * 60 + ), + ) + + __opts__["hard_timeout"] = config.get_cloud_config_value( + "hard_timeout", + get_configured_provider(), + __opts__, + search_global=False, + default=None, + ) + + # Bootstrap + ret = __utils__["cloud.bootstrap"](vm_, __opts__) + + ret.update(show_instance(vm_["name"], call="action")) + + log.info("Created Cloud VM '%s'", vm_["name"]) + log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data)) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + "salt/cloud/{}/created".format(vm_["name"]), + args=__utils__["cloud.filter_event"]( + "created", vm_, ["name", "profile", "provider", "driver"] + ), + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + return ret + + +def _query(path, method="GET", data=None, params=None, header_dict=None, decode=True): + """ + Perform a query directly against the Vultr REST API + """ + api_key = config.get_cloud_config_value( + "api_key", + get_configured_provider(), + __opts__, + search_global=False, + ) + management_host = config.get_cloud_config_value( + "management_host", + get_configured_provider(), + __opts__, + search_global=False, + default="api.vultr.com", + ) + url = "https://{management_host}/v1/{path}?api_key={api_key}".format( + management_host=management_host, + path=path, + api_key=api_key, + ) + + if header_dict is None: + header_dict = {} + + result = __utils__["http.query"]( + url, + method=method, + params=params, + data=data, + header_dict=header_dict, + port=443, + text=True, + decode=decode, + decode_type="json", + hide_fields=["api_key"], + opts=__opts__, + ) + if "dict" in result: + return result["dict"] + + return result diff --git a/salt/cloud/clouds/xen.py b/salt/cloud/clouds/xen.py new file mode 100644 index 000000000000..810ff4602276 --- /dev/null +++ b/salt/cloud/clouds/xen.py @@ -0,0 +1,1305 @@ +""" +XenServer Cloud Driver +====================== + +The XenServer driver is designed to work with a Citrix XenServer. + +Requires XenServer SDK +(can be downloaded from https://www.citrix.com/downloads/xenserver/product-software/ ) + +Place a copy of the XenAPI.py in the Python site-packages folder. + +:depends: XenAPI + +Example provider configuration: + + .. code-block:: yaml + + # /etc/salt/cloud.providers.d/myxen.conf + myxen: + driver: xen + url: http://10.0.0.120 + user: root + password: p@ssw0rd + +Example profile configuration: + + .. code-block:: yaml + + # /etc/salt/cloud.profiles.d/myxen.conf + suse: + provider: myxen + user: root + password: p@ssw0rd + image: opensuseleap42_2-template + storage_repo: 'Local storage' + resource_pool: default_pool + clone: True + minion: + master: 10.0.0.18 + sles: + provider: myxen + user: root + clone: False + image: sles12sp2-template + deploy: False + w2k12: + provider: myxen + image: w2k12svr-template + clone: True + userdata_file: /srv/salt/win/files/windows-firewall.ps1 + win_installer: /srv/salt/win/files/Salt-Minion-2016.11.3-AMD64-Setup.exe + win_username: Administrator + win_password: p@ssw0rd + use_winrm: False + ipv4_cidr: 10.0.0.215/24 + ipv4_gw: 10.0.0.1 + +""" + +import logging +import time +from datetime import datetime + +import salt.config as config +import salt.utils.cloud +from salt.exceptions import SaltCloudException, SaltCloudSystemExit + +# Get logging started +log = logging.getLogger(__name__) + +try: + import XenAPI + + HAS_XEN_API = True +except ImportError: + HAS_XEN_API = False + +__virtualname__ = "xen" +cache = None + + +def __virtual__(): + """ + Only load if Xen configuration and XEN SDK is found. + """ + if get_configured_provider() is False: + return False + if _get_dependencies() is False: + return False + + global cache # pylint: disable=global-statement,invalid-name + cache = salt.cache.Cache(__opts__) + + return __virtualname__ + + +def _get_active_provider_name(): + try: + return __active_provider_name__.value() + except AttributeError: + return __active_provider_name__ + + +def _get_dependencies(): + """ + Warn if dependencies aren't met. + + Checks for the XenAPI.py module + """ + return config.check_driver_dependencies(__virtualname__, {"XenAPI": HAS_XEN_API}) + + +def get_configured_provider(): + """ + Return the first configured instance. + """ + return config.is_provider_configured( + __opts__, _get_active_provider_name() or __virtualname__, ("url",) + ) + + +def _get_session(): + """ + Get a connection to the XenServer host + """ + api_version = "1.0" + originator = f"salt_cloud_{__virtualname__}_driver" + url = config.get_cloud_config_value( + "url", get_configured_provider(), __opts__, search_global=False + ) + user = config.get_cloud_config_value( + "user", get_configured_provider(), __opts__, search_global=False + ) + password = config.get_cloud_config_value( + "password", get_configured_provider(), __opts__, search_global=False + ) + ignore_ssl = config.get_cloud_config_value( + "ignore_ssl", + get_configured_provider(), + __opts__, + default=False, + search_global=False, + ) + try: + session = XenAPI.Session(url, ignore_ssl=ignore_ssl) + log.debug( + "url: %s user: %s password: %s, originator: %s", + url, + user, + "XXX-pw-redacted-XXX", + originator, + ) + session.xenapi.login_with_password(user, password, api_version, originator) + except XenAPI.Failure as ex: + pool_master_addr = str(ex.__dict__["details"][1]) + slash_parts = url.split("/") + new_url = "/".join(slash_parts[:2]) + "/" + pool_master_addr + session = XenAPI.Session(new_url) + log.debug( + "session is -> url: %s user: %s password: %s, originator:%s", + new_url, + user, + "XXX-pw-redacted-XXX", + originator, + ) + session.xenapi.login_with_password(user, password, api_version, originator) + return session + + +def list_nodes(): + """ + List virtual machines + + .. code-block:: bash + + salt-cloud -Q + + """ + session = _get_session() + vms = session.xenapi.VM.get_all_records() + ret = {} + for vm in vms: + record = session.xenapi.VM.get_record(vm) + if not record["is_a_template"] and not record["is_control_domain"]: + try: + base_template_name = record["other_config"]["base_template_name"] + except Exception: # pylint: disable=broad-except + base_template_name = None + log.debug( + "VM %s, does not have base_template_name attribute", + record["name_label"], + ) + ret[record["name_label"]] = { + "id": record["uuid"], + "image": base_template_name, + "name": record["name_label"], + "size": record["memory_dynamic_max"], + "state": record["power_state"], + "private_ips": get_vm_ip(record["name_label"], session), + "public_ips": None, + } + return ret + + +def get_vm_ip(name=None, session=None, call=None): + """ + Get the IP address of the VM + + .. code-block:: bash + + salt-cloud -a get_vm_ip xenvm01 + + .. note:: Requires xen guest tools to be installed in VM + + """ + if call == "function": + raise SaltCloudException("This function must be called with -a or --action.") + if session is None: + log.debug("New session being created") + session = _get_session() + vm = _get_vm(name, session=session) + ret = None + # -- try to get ip from vif + vifs = session.xenapi.VM.get_VIFs(vm) + if vifs is not None: + for vif in vifs: + if session.xenapi.VIF.get_ipv4_addresses(vif): + cidr = session.xenapi.VIF.get_ipv4_addresses(vif).pop() + ret, subnet = cidr.split("/") + log.debug("VM vif returned for instance: %s ip: %s", name, ret) + return ret + # -- try to get ip from get tools metrics + vgm = session.xenapi.VM.get_guest_metrics(vm) + try: + net = session.xenapi.VM_guest_metrics.get_networks(vgm) + if "0/ip" in net.keys(): + log.debug( + "VM guest metrics returned for instance: %s 0/ip: %s", name, net["0/ip"] + ) + ret = net["0/ip"] + # except Exception as ex: # pylint: disable=broad-except + except XenAPI.Failure: + log.info("Could not get vm metrics at this time") + return ret + + +def set_vm_ip(name=None, ipv4_cidr=None, ipv4_gw=None, session=None, call=None): + """ + Set the IP address on a virtual interface (vif) + + """ + mode = "static" + # TODO: Need to add support for IPv6 + if call == "function": + raise SaltCloudException("The function must be called with -a or --action.") + + log.debug( + "Setting name: %s ipv4_cidr: %s ipv4_gw: %s mode: %s", + name, + ipv4_cidr, + ipv4_gw, + mode, + ) + if session is None: + log.debug("New session being created") + session = _get_session() + vm = _get_vm(name, session) + # -- try to get ip from vif + # TODO: for now will take first interface + # addition consideration needed for + # multiple interface(vif) VMs + vifs = session.xenapi.VM.get_VIFs(vm) + if vifs is not None: + log.debug("There are %s vifs.", len(vifs)) + for vif in vifs: + record = session.xenapi.VIF.get_record(vif) + log.debug(record) + try: + session.xenapi.VIF.configure_ipv4(vif, mode, ipv4_cidr, ipv4_gw) + except XenAPI.Failure: + log.info("Static IP assignment could not be performed.") + + return True + + +def list_nodes_full(session=None): + """ + List full virtual machines + + .. code-block:: bash + + salt-cloud -F + + """ + if session is None: + session = _get_session() + + ret = {} + vms = session.xenapi.VM.get_all() + for vm in vms: + record = session.xenapi.VM.get_record(vm) + if not record["is_a_template"] and not record["is_control_domain"]: + # deal with cases where the VM doesn't have 'base_template_name' attribute + try: + base_template_name = record["other_config"]["base_template_name"] + except Exception: # pylint: disable=broad-except + base_template_name = None + log.debug( + "VM %s, does not have base_template_name attribute", + record["name_label"], + ) + vm_cfg = session.xenapi.VM.get_record(vm) + vm_cfg["id"] = record["uuid"] + vm_cfg["name"] = record["name_label"] + vm_cfg["image"] = base_template_name + vm_cfg["size"] = None + vm_cfg["state"] = record["power_state"] + vm_cfg["private_ips"] = get_vm_ip(record["name_label"], session) + vm_cfg["public_ips"] = None + if "snapshot_time" in vm_cfg.keys(): + del vm_cfg["snapshot_time"] + ret[record["name_label"]] = vm_cfg + + provider = _get_active_provider_name() or "xen" + if ":" in provider: + comps = provider.split(":") + provider = comps[0] + log.debug("ret: %s", ret) + log.debug("provider: %s", provider) + log.debug("__opts__: %s", __opts__) + __utils__["cloud.cache_node_list"](ret, provider, __opts__) + return ret + + +def list_nodes_select(call=None): + """ + Perform a select query on Xen VM instances + + .. code-block:: bash + + salt-cloud -S + + """ + return salt.utils.cloud.list_nodes_select( + list_nodes_full(), + __opts__["query.selection"], + call, + ) + + +def vdi_list(call=None, kwargs=None): + """ + Return available Xen VDI images + + If this function is called with the ``-f`` or ``--function`` then + it can return a list with minimal deatil using the ``terse=True`` keyword + argument. + + .. code-block:: bash + + salt-cloud -f vdi_list myxen terse=True + + """ + if call == "action": + raise SaltCloudException("This function must be called with -f or --function.") + log.debug("kwargs is %s", kwargs) + if kwargs is not None: + if "terse" in kwargs: + if kwargs["terse"] == "True": + terse = True + else: + terse = False + else: + terse = False + else: + kwargs = {} + terse = False + session = _get_session() + vdis = session.xenapi.VDI.get_all() + ret = {} + for vdi in vdis: + data = session.xenapi.VDI.get_record(vdi) + log.debug(type(terse)) + if terse is True: + ret[data.get("name_label")] = {"uuid": data.get("uuid"), "OpqueRef": vdi} + else: + data.update({"OpaqueRef": vdi}) + ret[data.get("name_label")] = data + return ret + + +def avail_locations(session=None, call=None): + """ + Return available Xen locations (not implemented) + + .. code-block:: bash + + salt-cloud --list-locations myxen + + """ + # TODO: need to figure out a good meaning of locations in Xen + if call == "action": + raise SaltCloudException( + "The avail_locations function must be called with -f or --function." + ) + return pool_list() + + +def avail_sizes(session=None, call=None): + """ + Return a list of Xen template definitions + + .. code-block:: bash + + salt-cloud --list-sizes myxen + + """ + if call == "action": + raise SaltCloudException( + "The avail_sizes function must be called with -f or --function." + ) + return { + "STATUS": ( + "Sizes are build into templates. Consider running --list-images to see" + " sizes" + ) + } + + +def template_list(call=None): + """ + Return available Xen template information. + + This returns the details of + each template to show number cores, memory sizes, etc.. + + .. code-block:: bash + + salt-cloud -f template_list myxen + + """ + templates = {} + session = _get_session() + vms = session.xenapi.VM.get_all() + for vm in vms: + record = session.xenapi.VM.get_record(vm) + if record["is_a_template"]: + templates[record["name_label"]] = record + return templates + + +def show_instance(name, session=None, call=None): + """ + Show information about a specific VM or template + + .. code-block:: bash + + salt-cloud -a show_instance xenvm01 + + .. note:: memory is memory_dynamic_max + + """ + if call == "function": + raise SaltCloudException( + "The show_instnce function must be called with -a or --action." + ) + log.debug("show_instance-> name: %s session: %s", name, session) + if session is None: + session = _get_session() + vm = _get_vm(name, session=session) + record = session.xenapi.VM.get_record(vm) + if not record["is_a_template"] and not record["is_control_domain"]: + try: + base_template_name = record["other_config"]["base_template_name"] + except Exception: # pylint: disable=broad-except + base_template_name = None + log.debug( + "VM %s, does not have base_template_name attribute", + record["name_label"], + ) + ret = { + "id": record["uuid"], + "image": base_template_name, + "name": record["name_label"], + "size": record["memory_dynamic_max"], + "state": record["power_state"], + "private_ips": get_vm_ip(name, session), + "public_ips": None, + } + + __utils__["cloud.cache_node"](ret, _get_active_provider_name(), __opts__) + return ret + + +def _determine_resource_pool(session, vm_): + """ + Called by create() used to determine resource pool + """ + resource_pool = "" + if "resource_pool" in vm_.keys(): + resource_pool = _get_pool(vm_["resource_pool"], session) + else: + pool = session.xenapi.pool.get_all() + if not pool: + resource_pool = None + else: + first_pool = session.xenapi.pool.get_all()[0] + resource_pool = first_pool + pool_record = session.xenapi.pool.get_record(resource_pool) + log.debug("resource pool: %s", pool_record["name_label"]) + return resource_pool + + +def _determine_storage_repo(session, resource_pool, vm_): + """ + Called by create() used to determine storage repo for create + """ + storage_repo = "" + if "storage_repo" in vm_.keys(): + storage_repo = _get_sr(vm_["storage_repo"], session) + else: + storage_repo = None + if resource_pool: + default_sr = session.xenapi.pool.get_default_SR(resource_pool) + sr_record = session.xenapi.SR.get_record(default_sr) + log.debug("storage repository: %s", sr_record["name_label"]) + storage_repo = default_sr + else: + storage_repo = None + log.debug("storage repository: %s", storage_repo) + return storage_repo + + +def create(vm_): + """ + Create a VM in Xen + + The configuration for this function is read from the profile settings. + + .. code-block:: bash + + salt-cloud -p some_profile xenvm01 + + """ + name = vm_["name"] + record = {} + ret = {} + + # fire creating event + __utils__["cloud.fire_event"]( + "event", + "starting create", + f"salt/cloud/{name}/creating", + args={"name": name, "profile": vm_["profile"], "provider": vm_["driver"]}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + log.debug("Adding %s to cloud cache.", name) + __utils__["cloud.cachedir_index_add"]( + vm_["name"], vm_["profile"], "xen", vm_["driver"] + ) + + # connect to xen + session = _get_session() + + # determine resource pool + resource_pool = _determine_resource_pool(session, vm_) + + # determine storage repo + storage_repo = _determine_storage_repo(session, resource_pool, vm_) + + # build VM + image = vm_.get("image") + clone = vm_.get("clone") + if clone is None: + clone = True + log.debug("Clone: %s ", clone) + + # fire event to read new vm properties (requesting) + __utils__["cloud.fire_event"]( + "event", + "requesting instance", + f"salt/cloud/{name}/requesting", + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + + # create by cloning template + if clone: + _clone_vm(image, name, session) + else: + _copy_vm(image, name, session, storage_repo) + + # provision template to vm + _provision_vm(name, session) + vm = _get_vm(name, session) + + # start vm + start(name, None, session) + + # get new VM + vm = _get_vm(name, session) + + # wait for vm to report IP via guest tools + _wait_for_ip(name, session) + + # set static IP if configured + _set_static_ip(name, session, vm_) + + # if not deploying salt then exit + deploy = vm_.get("deploy", True) + log.debug("delopy is set to %s", deploy) + if deploy: + record = session.xenapi.VM.get_record(vm) + if record is not None: + _deploy_salt_minion(name, session, vm_) + else: + log.debug("The Salt minion will not be installed, deploy: %s", vm_["deploy"]) + record = session.xenapi.VM.get_record(vm) + ret = show_instance(name) + ret.update({"extra": record}) + + __utils__["cloud.fire_event"]( + "event", + "created instance", + f"salt/cloud/{name}/created", + args={"name": name, "profile": vm_["profile"], "provider": vm_["driver"]}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + return ret + + +def _deploy_salt_minion(name, session, vm_): + """ + Deploy salt minion during create() + """ + # Get bootstrap values + vm_["ssh_host"] = get_vm_ip(name, session) + vm_["user"] = vm_.get("user", "root") + vm_["password"] = vm_.get("password", "p@ssw0rd!") + vm_["provider"] = vm_.get("provider", "xen") + log.debug("%s has IP of %s", name, vm_["ssh_host"]) + # Bootstrap Salt minion! + if vm_["ssh_host"] is not None: + log.info("Installing Salt minion on %s", name) + boot_ret = __utils__["cloud.bootstrap"](vm_, __opts__) + log.debug("boot return: %s", boot_ret) + + +def _set_static_ip(name, session, vm_): + """ + Set static IP during create() if defined + """ + ipv4_cidr = "" + ipv4_gw = "" + if "ipv4_gw" in vm_.keys(): + log.debug("ipv4_gw is found in keys") + ipv4_gw = vm_["ipv4_gw"] + if "ipv4_cidr" in vm_.keys(): + log.debug("ipv4_cidr is found in keys") + ipv4_cidr = vm_["ipv4_cidr"] + log.debug("attempting to set IP in instance") + set_vm_ip(name, ipv4_cidr, ipv4_gw, session, None) + + +def _wait_for_ip(name, session): + """ + Wait for IP to be available during create() + """ + start_time = datetime.now() + status = None + while status is None: + status = get_vm_ip(name, session) + if status is not None: + # ignore APIPA address + if status.startswith("169"): + status = None + check_time = datetime.now() + delta = check_time - start_time + log.debug( + "Waited %s seconds for %s to report ip address...", delta.seconds, name + ) + if delta.seconds > 180: + log.warning("Timeout getting IP address") + break + time.sleep(5) + + +def _run_async_task(task=None, session=None): + """ + Run XenAPI task in asynchronous mode to prevent timeouts + """ + if task is None or session is None: + return None + task_name = session.xenapi.task.get_name_label(task) + log.debug("Running %s", task_name) + while session.xenapi.task.get_status(task) == "pending": + progress = round(session.xenapi.task.get_progress(task), 2) * 100 + log.debug("Task progress %.2f%%", progress) + time.sleep(1) + log.debug("Cleaning up task %s", task_name) + session.xenapi.task.destroy(task) + + +def _clone_vm(image=None, name=None, session=None): + """ + Create VM by cloning + + This is faster and should be used if source and target are + in the same storage repository + + """ + if session is None: + session = _get_session() + log.debug("Creating VM %s by cloning %s", name, image) + source = _get_vm(image, session) + task = session.xenapi.Async.VM.clone(source, name) + _run_async_task(task, session) + + +def _copy_vm(template=None, name=None, session=None, sr=None): + """ + Create VM by copy + + This is slower and should be used if source and target are + NOT in the same storage repository + + template = object reference + name = string name of new VM + session = object reference + sr = object reference + """ + if session is None: + session = _get_session() + log.debug("Creating VM %s by copying %s", name, template) + source = _get_vm(template, session) + task = session.xenapi.Async.VM.copy(source, name, sr) + _run_async_task(task, session) + + +def _provision_vm(name=None, session=None): + """ + Provision vm right after clone/copy + """ + if session is None: + session = _get_session() + log.info("Provisioning VM %s", name) + vm = _get_vm(name, session) + task = session.xenapi.Async.VM.provision(vm) + _run_async_task(task, session) + + +def start(name, call=None, session=None): + """ + Start a vm + + .. code-block:: bash + + salt-cloud -a start xenvm01 + + """ + if call == "function": + raise SaltCloudException( + "The show_instnce function must be called with -a or --action." + ) + if session is None: + session = _get_session() + log.info("Starting VM %s", name) + vm = _get_vm(name, session) + task = session.xenapi.Async.VM.start(vm, False, True) + _run_async_task(task, session) + return show_instance(name) + + +def pause(name, call=None, session=None): + """ + Pause a vm + + .. code-block:: bash + + salt-cloud -a pause xenvm01 + + """ + if call == "function": + raise SaltCloudException( + "The show_instnce function must be called with -a or --action." + ) + if session is None: + session = _get_session() + log.info("Pausing VM %s", name) + vm = _get_vm(name, session) + task = session.xenapi.Async.VM.pause(vm) + _run_async_task(task, session) + return show_instance(name) + + +def unpause(name, call=None, session=None): + """ + UnPause a vm + + .. code-block:: bash + + salt-cloud -a unpause xenvm01 + + """ + if call == "function": + raise SaltCloudException( + "The show_instnce function must be called with -a or --action." + ) + if session is None: + session = _get_session() + log.info("Unpausing VM %s", name) + vm = _get_vm(name, session) + task = session.xenapi.Async.VM.unpause(vm) + _run_async_task(task, session) + return show_instance(name) + + +def suspend(name, call=None, session=None): + """ + Suspend a vm to disk + + .. code-block:: bash + + salt-cloud -a suspend xenvm01 + + """ + if call == "function": + raise SaltCloudException( + "The show_instnce function must be called with -a or --action." + ) + if session is None: + session = _get_session() + log.info("Suspending VM %s", name) + vm = _get_vm(name, session) + task = session.xenapi.Async.VM.suspend(vm) + _run_async_task(task, session) + return show_instance(name) + + +def resume(name, call=None, session=None): + """ + Resume a vm from disk + + .. code-block:: bash + + salt-cloud -a resume xenvm01 + + """ + if call == "function": + raise SaltCloudException( + "The show_instnce function must be called with -a or --action." + ) + if session is None: + session = _get_session() + log.info("Resuming VM %s", name) + vm = _get_vm(name, session) + task = session.xenapi.Async.VM.resume(vm, False, True) + _run_async_task(task, session) + return show_instance(name) + + +def stop(name, call=None, session=None): + """ + Stop a vm + + .. code-block:: bash + + salt-cloud -a stop xenvm01 + + + """ + if call == "function": + raise SaltCloudException( + "The show_instnce function must be called with -a or --action." + ) + return shutdown(name, call, session) + + +def shutdown(name, call=None, session=None): + """ + Shutdown a vm + + .. code-block:: bash + + salt-cloud -a shutdown xenvm01 + + """ + if call == "function": + raise SaltCloudException( + "The show_instnce function must be called with -a or --action." + ) + if session is None: + session = _get_session() + log.info("Starting VM %s", name) + vm = _get_vm(name, session) + task = session.xenapi.Async.VM.shutdown(vm) + _run_async_task(task, session) + return show_instance(name) + + +def reboot(name, call=None, session=None): + """ + Reboot a vm + + .. code-block:: bash + + salt-cloud -a reboot xenvm01 + + """ + if call == "function": + raise SaltCloudException( + "The show_instnce function must be called with -a or --action." + ) + if session is None: + session = _get_session() + log.info("Starting VM %s", name) + vm = _get_vm(name, session) + power_state = session.xenapi.VM.get_power_state(vm) + if power_state == "Running": + task = session.xenapi.Async.VM.clean_reboot(vm) + _run_async_task(task, session) + return show_instance(name) + else: + return f"{name} is not running to be rebooted" + + +def _get_vm(name=None, session=None): + """ + Get XEN vm instance object reference + """ + if session is None: + session = _get_session() + vms = session.xenapi.VM.get_by_name_label(name) + vms = [x for x in vms if not session.xenapi.VM.get_is_a_template(x)] + if len(vms) == 1: + return vms[0] + else: + log.error("VM %s returned %s matches. 1 match expected.", name, len(vms)) + return None + + +def _get_sr(name=None, session=None): + """ + Get XEN sr (storage repo) object reference + """ + if session is None: + session = _get_session() + srs = session.xenapi.SR.get_by_name_label(name) + if len(srs) == 1: + return srs[0] + return None + + +def _get_pool(name=None, session=None): + """ + Get XEN resource pool object reference + """ + if session is None: + session = _get_session() + pools = session.xenapi.pool.get_all() + for pool in pools: + pool_record = session.xenapi.pool.get_record(pool) + if name in pool_record.get("name_label"): + return pool + return None + + +def destroy(name=None, call=None): + """ + Destroy Xen VM or template instance + + .. code-block:: bash + + salt-cloud -d xenvm01 + + """ + if call == "function": + raise SaltCloudSystemExit( + "The destroy action must be called with -d, --destroy, -a or --action." + ) + ret = {} + __utils__["cloud.fire_event"]( + "event", + "destroying instance", + f"salt/cloud/{name}/destroying", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + session = _get_session() + vm = _get_vm(name) + if vm: + # get vm + record = session.xenapi.VM.get_record(vm) + log.debug("power_state: %s", record["power_state"]) + # shut down + if record["power_state"] != "Halted": + task = session.xenapi.Async.VM.hard_shutdown(vm) + _run_async_task(task, session) + + # destroy disk (vdi) by reading vdb on vm + ret["vbd"] = destroy_vm_vdis(name, session) + # destroy vm + task = session.xenapi.Async.VM.destroy(vm) + _run_async_task(task, session) + ret["destroyed"] = True + __utils__["cloud.fire_event"]( + "event", + "destroyed instance", + f"salt/cloud/{name}/destroyed", + args={"name": name}, + sock_dir=__opts__["sock_dir"], + transport=__opts__["transport"], + ) + if __opts__.get("update_cachedir", False) is True: + __utils__["cloud.delete_minion_cachedir"]( + name, _get_active_provider_name().split(":")[0], __opts__ + ) + __utils__["cloud.cachedir_index_del"](name) + return ret + + +def sr_list(call=None): + """ + Geta list of storage repositories + + .. code-block:: bash + + salt-cloud -f sr_list myxen + + """ + if call != "function": + raise SaltCloudSystemExit( + "This function must be called with -f, --function argument." + ) + ret = {} + session = _get_session() + srs = session.xenapi.SR.get_all() + for sr in srs: + sr_record = session.xenapi.SR.get_record(sr) + ret[sr_record["name_label"]] = sr_record + return ret + + +def host_list(call=None): + """ + Get a list of Xen Servers + + .. code-block:: bash + + salt-cloud -f host_list myxen + """ + if call == "action": + raise SaltCloudSystemExit( + "This function must be called with -f, --function argument." + ) + ret = {} + session = _get_session() + hosts = session.xenapi.host.get_all() + for host in hosts: + host_record = session.xenapi.host.get_record(host) + ret[host_record["name_label"]] = host_record + return ret + + +def pool_list(call=None): + """ + Get a list of Resource Pools + + .. code-block:: bash + + salt-cloud -f pool_list myxen + + """ + if call == "action": + raise SaltCloudSystemExit( + "This function must be called with -f, --function argument." + ) + ret = {} + session = _get_session() + pools = session.xenapi.pool.get_all() + for pool in pools: + pool_record = session.xenapi.pool.get_record(pool) + ret[pool_record["name_label"]] = pool_record + return ret + + +def pif_list(call=None): + """ + Get a list of Resource Pools + + .. code-block:: bash + + salt-cloud -f pool_list myxen + """ + if call != "function": + raise SaltCloudSystemExit( + "This function must be called with -f, --function argument." + ) + ret = {} + session = _get_session() + pifs = session.xenapi.PIF.get_all() + for pif in pifs: + record = session.xenapi.PIF.get_record(pif) + ret[record["uuid"]] = record + return ret + + +def vif_list(name, call=None, kwargs=None): + """ + Get a list of virtual network interfaces on a VM + + **requires**: the name of the vm with the vbd definition + + .. code-block:: bash + + salt-cloud -a vif_list xenvm01 + + """ + if call == "function": + raise SaltCloudSystemExit( + "This function must be called with -a, --action argument." + ) + if name is None: + return "A name kwarg is rquired" + ret = {} + data = {} + session = _get_session() + vm = _get_vm(name) + vifs = session.xenapi.VM.get_VIFs(vm) + if vifs is not None: + x = 0 + for vif in vifs: + vif_record = session.xenapi.VIF.get_record(vif) + data[f"vif-{x}"] = vif_record + x += 1 + ret[name] = data + return ret + + +def vbd_list(name=None, call=None): + """ + Get a list of VBDs on a VM + + **requires**: the name of the vm with the vbd definition + + .. code-block:: bash + + salt-cloud -a vbd_list xenvm01 + + """ + if call == "function": + raise SaltCloudSystemExit( + "This function must be called with -a, --action argument." + ) + if name is None: + return "A name kwarg is rquired" + ret = {} + data = {} + session = _get_session() + vms = session.xenapi.VM.get_by_name_label(name) + if len(vms) == 1: + vm = vms[0] + vbds = session.xenapi.VM.get_VBDs(vm) + if vbds is not None: + x = 0 + for vbd in vbds: + vbd_record = session.xenapi.VBD.get_record(vbd) + data[f"vbd-{x}"] = vbd_record + x += 1 + ret = data + return ret + + +def avail_images(call=None): + """ + Get a list of images from Xen + + If called with the `--list-images` then it returns + images with all details. + + .. code-block:: bash + + salt-cloud --list-images myxen + + """ + if call == "action": + raise SaltCloudSystemExit( + "This function must be called with -f, --function argument." + ) + return template_list() + + +def destroy_vm_vdis(name=None, session=None, call=None): + """ + Get virtual block devices on VM + + .. code-block:: bash + + salt-cloud -a destroy_vm_vdis xenvm01 + + """ + if session is None: + session = _get_session() + ret = {} + # get vm object + vms = session.xenapi.VM.get_by_name_label(name) + if len(vms) == 1: + # read virtual block device (vdb) + vbds = session.xenapi.VM.get_VBDs(vms[0]) + if vbds is not None: + x = 0 + for vbd in vbds: + vbd_record = session.xenapi.VBD.get_record(vbd) + if vbd_record["VDI"] != "OpaqueRef:NULL": + # read vdi on vdb + vdi_record = session.xenapi.VDI.get_record(vbd_record["VDI"]) + if "iso" not in vdi_record["name_label"]: + session.xenapi.VDI.destroy(vbd_record["VDI"]) + ret[f"vdi-{x}"] = vdi_record["name_label"] + x += 1 + return ret + + +def destroy_template(name=None, call=None, kwargs=None): + """ + Destroy Xen VM or template instance + + .. code-block:: bash + + salt-cloud -f destroy_template myxen name=testvm2 + + """ + if call == "action": + raise SaltCloudSystemExit( + "The destroy_template function must be called with -f." + ) + if kwargs is None: + kwargs = {} + name = kwargs.get("name", None) + session = _get_session() + vms = session.xenapi.VM.get_all_records() + ret = {} + found = False + for vm in vms: + record = session.xenapi.VM.get_record(vm) + if record["is_a_template"]: + if record["name_label"] == name: + found = True + # log.debug(record['name_label']) + session.xenapi.VM.destroy(vm) + ret[name] = {"status": "destroyed"} + if not found: + ret[name] = {"status": "not found"} + return ret + + +def get_pv_args(name, session=None, call=None): + """ + Get PV arguments for a VM + + .. code-block:: bash + + salt-cloud -a get_pv_args xenvm01 + + """ + if call == "function": + raise SaltCloudException("This function must be called with -a or --action.") + if session is None: + log.debug("New session being created") + session = _get_session() + vm = _get_vm(name, session=session) + pv_args = session.xenapi.VM.get_PV_args(vm) + if pv_args: + return pv_args + return None + + +def set_pv_args(name, kwargs=None, session=None, call=None): + """ + Set PV arguments for a VM + + .. code-block:: bash + + salt-cloud -a set_pv_args xenvm01 pv_args="utf-8 graphical" + + """ + if call == "function": + raise SaltCloudException("This function must be called with -a or --action.") + if session is None: + log.debug("New session being created") + session = _get_session() + vm = _get_vm(name, session=session) + try: + log.debug("Setting PV Args: %s", kwargs["pv_args"]) + session.xenapi.VM.set_PV_args(vm, str(kwargs["pv_args"])) + except KeyError: + log.error("No pv_args parameter found.") + return False + except XenAPI.Failure: + log.info("Setting PV Args failed.") + return False + return True diff --git a/salt/cluster/__init__.py b/salt/cluster/__init__.py deleted file mode 100644 index 4ef32e69ab74..000000000000 --- a/salt/cluster/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Salt multi-master cluster support (hash ring, coordination, consensus). -""" diff --git a/salt/cluster/consensus/__init__.py b/salt/cluster/consensus/__init__.py deleted file mode 100644 index dbfe4efcb604..000000000000 --- a/salt/cluster/consensus/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Consensus primitives for Salt master clusters (Raft-based metadata). - -Prefer asyncio for integration-layer I/O and orchestration. The -``salt.cluster.consensus.raft`` package keeps a callback-driven synchronous -core for testability; outer code bridges asyncio to that surface. -""" diff --git a/salt/cluster/consensus/peer.py b/salt/cluster/consensus/peer.py deleted file mode 100644 index 023d73a066c5..000000000000 --- a/salt/cluster/consensus/peer.py +++ /dev/null @@ -1,458 +0,0 @@ -""" -SaltPeer — bridges the Raft callback surface to the cluster channel transport. - -The Raft core (``salt.cluster.consensus.raft.Node``) talks to peers through -the ``Peer`` interface: fire-and-forget RPCs whose *reply* arrives later via -a callback. ``SaltPeer`` implements that interface by: - - • **Sending** — serialising the RPC with ``salt.cluster.consensus.rpc`` and - pushing it to the remote master's ``cluster_pool_port`` via the per-peer - ``PublishServer`` pusher that already exists in - ``MasterPubServerChannel._publish_daemon``. - - • **Receiving** — ``handle_pool_publish`` in the channel server calls - ``RaftDispatcher.dispatch`` for every ``cluster/raft/*`` tag. - ``RaftDispatcher`` holds a reference to the local ``Node`` and routes - each inbound message to the correct ``Node`` method, then fires the - reply callback (which writes the reply back through the sender's pusher). - -Asyncio is used for all I/O; the Raft node methods themselves remain -synchronous and callback-oriented. -""" - -import asyncio -import logging -import uuid - -from salt.cluster.consensus import rpc -from salt.cluster.consensus.raft.node import Peer - -log = logging.getLogger(__name__) - - -async def _publish(pusher, raw): - """ - Send *raw* over a Raft peer's TCP pusher using a truly-async path. - - Why this exists - --------------- - ``salt.transport.tcp.PublishServer.publish`` is declared ``async def`` - but its body is synchronous: it drives a Tornado event loop via - :class:`salt.utils.asynchronous.SyncWrapper`. Awaiting it directly - on a busy asyncio loop blocks the loop on the underlying TCP - connect/send retry; offloading to ``loop.run_in_executor`` (the - previous shape of this code) makes the executor thread invoke - ``SyncWrapper.run_sync`` from outside the loop's thread, which - races with loop teardown — under CPU contention or fixture - shutdown the thread schedules late, finds the loop stopped, and - raises ``RuntimeError: Event loop stopped before Future completed.`` - The Raft RPC is silently dropped, elections never converge, and - the test eventually fails with "no leader elected" or split-brain. - - Local repro of the bug pre-fix: 7/20 fail under stress-ng on - debian-12 amd64. Post-fix: 0/20 expected. - - What this does - -------------- - Lazily attach a private :class:`salt.transport.tcp._TCPPubServerPublisher` - to the pusher object on first send and reuse it on subsequent - sends. That class exposes a real ``async def send`` that uses the - underlying Tornado IOStream directly — no SyncWrapper, no executor, - no cross-thread loop access. - - Why we don't change ``salt.transport.tcp.PublishServer`` - ------------------------------------------------------- - Adding a public ``publish_async`` method to ``PublishServer`` would - expand salt's transport API surface, which is a salt-wide decision - requiring buy-in across all transport implementations. Keeping the - truly-async client as a private attribute on the pusher (only used - by our consensus code) confines the change to this branch. - - Test fakes / mocks - ------------------ - Pushers that aren't real ``PublishServer`` instances (test fakes, - in-memory mocks, etc.) are detected by class module — their - ``publish`` is already truly async, so just await it directly. - ``MagicMock`` auto-creates any attribute on access, so a - ``hasattr`` probe for ``pull_host`` / ``pull_port`` would falsely - classify a mock as a real publisher; the module check avoids that. - """ - module = getattr(type(pusher), "__module__", "") or "" - if not module.startswith("salt.transport"): - await pusher.publish(raw) - return - - client = getattr(pusher, "_consensus_async_client", None) - if client is None: - # Lazy import — keeps this module loadable in test environments - # that monkey-patch out salt.transport. - from salt.transport.tcp import ( # pylint: disable=import-outside-toplevel - _TCPPubServerPublisher, - ) - - client = _TCPPubServerPublisher( - pusher.pull_host, pusher.pull_port, getattr(pusher, "pull_path", None) - ) - await client.connect() - # Stash on the pusher so the next send reuses the connection. - # The pusher's lifetime exceeds the master process; the kernel - # closes the fd at exit, so explicit teardown isn't required. - pusher._consensus_async_client = client - await client.send(raw) - - -class SaltPeer(Peer): - """ - A ``Peer`` that sends Raft RPCs over the cluster pool channel. - - :param node_id: The remote master's node-id — its - ``opts["interface"]`` address (matches the - entries in ``opts["cluster_peers"]`` and the - ``peer_pushers`` keys used everywhere else - in the cluster code). - :param pusher: The ``PublishServer`` instance already - connected to that master's - ``cluster_pool_port``. - :param local_id: This master's own node-id (used as ``src`` - in envelopes). - :param voting: Whether the peer counts toward quorum. - :param raft_group_id: Which Raft group this peer belongs to. - ``"cluster"`` (default) is the main cluster - group; per-ring peers carry the ring name so - the dispatcher on the receiving side routes - RPCs to the correct local ``Node``. - """ - - def __init__(self, node_id, pusher, local_id, voting=True, raft_group_id="cluster"): - # Pass None for the node object; we manage node_id directly. - super().__init__(None, node_id=node_id, voting=voting) - self._pusher = pusher - self._local_id = local_id - self._raft_group_id = raft_group_id - - @property - def node_id(self): - return self._node_id - - @property - def address(self): - return self._node_id - - # ------------------------------------------------------------------ - # Internal send helper - # ------------------------------------------------------------------ - - async def _send(self, tag, payload, rpc_id=None): - rpc_id = rpc_id or str(uuid.uuid4()) - raw = rpc.pack( - tag, - self._local_id, - rpc_id, - payload, - raft_group_id=self._raft_group_id, - ) - try: - await _publish(self._pusher, raw) - except Exception: # pylint: disable=broad-except - log.exception("SaltPeer: failed to send %s to %s", tag, self._node_id) - - def _fire(self, tag, payload, rpc_id=None): - """Schedule ``_send`` on the running event loop (non-blocking).""" - try: - loop = asyncio.get_running_loop() - loop.create_task(self._send(tag, payload, rpc_id)) - except RuntimeError: - # No running loop — fall back to a new one (test / bootstrap context). - asyncio.run(self._send(tag, payload, rpc_id)) - - # ------------------------------------------------------------------ - # Peer interface - # ------------------------------------------------------------------ - - def request_vote(self, callback, node_id, term, last_log_term, last_log_index): - self._fire( - rpc.REQUEST_VOTE, - { - "callback_node": self._local_id, - "candidate_id": node_id, - "term": term, - "last_log_term": last_log_term, - "last_log_index": last_log_index, - }, - ) - - def pre_request_vote(self, callback, node_id, term, last_log_term, last_log_index): - self._fire( - rpc.PRE_REQUEST_VOTE, - { - "callback_node": self._local_id, - "candidate_id": node_id, - "term": term, - "last_log_term": last_log_term, - "last_log_index": last_log_index, - }, - ) - - def append_entries( - self, - callback, - leader_id, - term, - prev_log_term, - prev_log_index, - leader_commit, - *entries, - **kwargs, - ): - self._fire( - rpc.APPEND_ENTRIES, - { - "callback_node": self._local_id, - "leader_id": leader_id, - "term": term, - "prev_log_term": prev_log_term, - "prev_log_index": prev_log_index, - "leader_commit": leader_commit, - "entries": [ - ( - e - if isinstance(e, dict) - else e._asdict() if hasattr(e, "_asdict") else list(e) - ) - for e in entries - ], - "leader_client_address": kwargs.get("leader_client_address"), - }, - ) - - def install_snapshot( - self, - callback, - leader_id, - term, - last_included_index, - last_included_term, - data, - **kwargs, - ): - # snapshot data may be bytes — encode as list of ints for msgpack portability - if isinstance(data, (bytes, bytearray, memoryview)): - data = list(bytes(data)) - self._fire( - rpc.INSTALL_SNAPSHOT, - { - "callback_node": self._local_id, - "leader_id": leader_id, - "term": term, - "last_included_index": last_included_index, - "last_included_term": last_included_term, - "data": data, - }, - ) - - -class RaftDispatcher: - """ - Receives decoded ``cluster/raft/*`` messages from ``handle_pool_publish`` - and drives the local Raft ``Node``, then sends the reply back via the - appropriate pusher. - - One ``RaftDispatcher`` instance lives inside ``MasterPubServerChannel`` - alongside the existing pushers. When this master hosts multiple - Raft groups (the main cluster group plus per-ring groups) it owns - one ``Node`` per group; inbound RPCs carry a ``raft_group_id`` - field that selects the target ``Node``. - - :param node: Either a single ``Node`` (treated as the - ``"cluster"`` group) or a ``dict[str, Node]`` - mapping group-id to its local ``Node``. Passing - a single Node preserves the pre-multi-ring - constructor signature. - :param local_id: This master's node-id (``opts["interface"]``) — used as - ``src`` in outbound RPC envelopes. - :param pushers: Dict mapping peer node-id (interface address) -> - ``PublishServer`` pusher. - """ - - def __init__(self, node, local_id, pushers): - # Normalise to a dict keyed by group-id. Callers that pass a - # bare Node (or None) are interpreted as the main cluster - # group; the dict-of-nodes form is the multi-ring shape. - if isinstance(node, dict): - self._nodes = dict(node) - else: - self._nodes = {"cluster": node} - self._local_id = local_id - # pushers keyed by peer node-id for O(1) lookup - self._pushers = pushers # dict[str, PublishServer] - - @property - def _node(self): - """ - Backward-compat accessor for callers that reach in for the - single Node (tests primarily). Returns the cluster group's - Node so existing assertions keep working. - """ - return self._nodes.get("cluster") - - @_node.setter - def _node(self, node): - """ - Mutating ``dispatcher._node`` (used by some tests to simulate - a failed leader losing its Node) maps to the cluster group. - Setting ``None`` removes the cluster Node entirely so - :meth:`dispatch` drops inbound RPCs. - """ - if node is None: - self._nodes.pop("cluster", None) - else: - self._nodes["cluster"] = node - - def register_node(self, raft_group_id, node): - """ - Add or replace the ``Node`` for *raft_group_id*. - - Used when ``RaftService`` brings up a per-ring Raft group - after the dispatcher has already been constructed. - """ - self._nodes[raft_group_id] = node - - def unregister_node(self, raft_group_id): - """Remove the ``Node`` registered for *raft_group_id*, if any.""" - self._nodes.pop(raft_group_id, None) - - async def _reply(self, dst, tag, payload, raft_group_id="cluster"): - pusher = self._pushers.get(dst) - if pusher is None: - log.warning("RaftDispatcher: no pusher for %s, dropping %s reply", dst, tag) - return - raw = rpc.pack( - tag, - self._local_id, - str(uuid.uuid4()), - payload, - raft_group_id=raft_group_id, - ) - try: - await _publish(pusher, raw) - except Exception: # pylint: disable=broad-except - log.exception("RaftDispatcher: failed to send %s reply to %s", tag, dst) - - async def dispatch(self, tag, src, rpc_id, payload, raft_group_id="cluster"): - """Route one inbound Raft RPC to the correct Node method.""" - node = self._nodes.get(raft_group_id) - if node is None: - log.debug( - "RaftDispatcher: no node for group %s, dropping %s", - raft_group_id, - tag, - ) - return - - try: - if tag == rpc.REQUEST_VOTE: - granted, term, lc = node.request_vote( - payload["candidate_id"], - payload["term"], - last_log_term=payload.get("last_log_term"), - last_log_index=payload.get("last_log_index"), - ) - await self._reply( - payload["callback_node"], - rpc.REQUEST_VOTE_REPLY, - {"granted": granted, "term": term, "voter_id": self._local_id}, - raft_group_id=raft_group_id, - ) - - elif tag == rpc.PRE_REQUEST_VOTE: - granted, term, lc = node.pre_request_vote( - payload["candidate_id"], - payload["term"], - last_log_term=payload.get("last_log_term"), - last_log_index=payload.get("last_log_index"), - ) - await self._reply( - payload["callback_node"], - rpc.PRE_REQUEST_VOTE_REPLY, - {"granted": granted, "term": term, "voter_id": self._local_id}, - raft_group_id=raft_group_id, - ) - - elif tag == rpc.REQUEST_VOTE_REPLY: - node.request_vote_reply(src, payload["granted"], payload["term"]) - - elif tag == rpc.PRE_REQUEST_VOTE_REPLY: - node.pre_request_vote_reply(src, payload["granted"], payload["term"]) - - elif tag == rpc.APPEND_ENTRIES: - entries = payload.get("entries", []) - success, term, last_idx, conflict_term, lc = node.handle_append_entries( - payload["leader_id"], - payload["term"], - payload.get("prev_log_term"), - payload.get("prev_log_index"), - payload.get("leader_commit"), - *entries, - leader_client_address=payload.get("leader_client_address"), - ) - sent_log_index = ( - payload.get("prev_log_index", -1) + len(entries) - if payload.get("prev_log_index") is not None - else len(entries) - 1 - ) - await self._reply( - payload["callback_node"], - rpc.APPEND_ENTRIES_REPLY, - { - "term": payload["term"], - "prev_log_term": payload.get("prev_log_term"), - "prev_log_index": payload.get("prev_log_index"), - "sent_log_index": sent_log_index, - "peer_id": self._local_id, - "our_term": term, - "success": success, - "conflict_index": last_idx, - "conflict_term": conflict_term, - }, - raft_group_id=raft_group_id, - ) - - elif tag == rpc.APPEND_ENTRIES_REPLY: - node.append_entries_reply( - payload["term"], - payload.get("prev_log_term"), - payload.get("prev_log_index"), - payload.get("sent_log_index"), - payload["peer_id"], - payload["our_term"], - payload["success"], - payload.get("conflict_index"), - payload.get("conflict_term"), - ) - - elif tag == rpc.INSTALL_SNAPSHOT: - raw_data = payload.get("data", []) - if isinstance(raw_data, list): - raw_data = bytes(raw_data) - our_term, lc = node.install_snapshot( - payload["leader_id"], - payload["term"], - payload["last_included_index"], - payload["last_included_term"], - raw_data, - ) - await self._reply( - payload["callback_node"], - rpc.INSTALL_SNAPSHOT_REPLY, - {"our_term": our_term, "peer_id": self._local_id}, - raft_group_id=raft_group_id, - ) - - elif tag == rpc.INSTALL_SNAPSHOT_REPLY: - node.install_snapshot_reply(payload["peer_id"], payload["our_term"]) - - else: - log.warning("RaftDispatcher: unhandled tag %s", tag) - - except Exception: # pylint: disable=broad-except - log.exception("RaftDispatcher: error handling %s from %s", tag, src) diff --git a/salt/cluster/consensus/raft/__init__.py b/salt/cluster/consensus/raft/__init__.py deleted file mode 100644 index 2f957797f1b9..000000000000 --- a/salt/cluster/consensus/raft/__init__.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Portable Raft core: log, node, timers, and test helpers. - -Public objects are re-exported here so callers can use -``salt.cluster.consensus.raft`` without reaching into submodules. - -The core is synchronous and callback-oriented; use asyncio in callers -(see ``salt.cluster.consensus`` package docstring). -""" - -from salt.cluster.consensus.raft.log import ( - BaseStateMachine, - BaseStorage, - CounterStateMachine, - Log, - LogEntry, - LogEntryCommitStatus, - LogEntryType, -) -from salt.cluster.consensus.raft.node import ( - NOOPLOCK, - Candidacy, - CandidacyError, - LockingNode, - ManualPeer, - Node, - NodeState, - NoOpLock, - NotLeader, - Peer, - Vote, -) -from salt.cluster.consensus.raft.scheduler import ( - AsyncTimeoutScheduler, - ManualTimeoutScheduler, - ThreadedTimeoutScheduler, - TimeoutHandle, - TimeoutScheduler, -) -from salt.cluster.consensus.raft.util import ( - gettimeout, - is_socket_closed, - load_class, - log_exceptions, - log_exceptions_async, - log_generator, -) - -__all__ = ( - "AsyncTimeoutScheduler", - "BaseStateMachine", - "BaseStorage", - "Candidacy", - "CandidacyError", - "CounterStateMachine", - "LockingNode", - "Log", - "LogEntry", - "LogEntryCommitStatus", - "LogEntryType", - "ManualPeer", - "ManualTimeoutScheduler", - "NOOPLOCK", - "Node", - "NodeState", - "NoOpLock", - "NotLeader", - "Peer", - "ThreadedTimeoutScheduler", - "TimeoutHandle", - "TimeoutScheduler", - "Vote", - "gettimeout", - "is_socket_closed", - "load_class", - "log_exceptions", - "log_exceptions_async", - "log_generator", -) diff --git a/salt/cluster/consensus/raft/log.py b/salt/cluster/consensus/raft/log.py deleted file mode 100644 index 113bff671a11..000000000000 --- a/salt/cluster/consensus/raft/log.py +++ /dev/null @@ -1,1079 +0,0 @@ -""" -Raft replicated log, persistence interfaces, and state machine hooks. - -### Maintainability guardrail: logic / side-effect firewall - -Classes here define the **side-effect boundaries** the consensus core talks -to. :class:`BaseStorage` and :class:`BaseStateMachine` are abstract so the -algorithm stays testable without real disks or networks. The production -implementation lives in :mod:`salt.cluster.consensus.storage`. -""" - -import base64 -import json -import logging -from typing import NamedTuple - -log = logging.getLogger(__name__) - -# Envelope marker for multi-state-machine snapshots. See -# Log.snapshot / Log.restore_state_machines_from_data. Bumping this -# version is a breaking change for on-disk snapshot compatibility. -SNAPSHOT_ENVELOPE_VERSION = "raft.snapshot.v1" - - -class LogEntryCommitStatus: - """ - Tracks which nodes have replicated a specific log entry. - - Used by the leader to determine when an entry is committed (replicated - to a majority). - """ - - def __init__(self, total_nodes, initial_node=None): - """Initialize the commitment status.""" - self.total_nodes = total_nodes - self._committed_nodes = set() - if initial_node: - self._committed_nodes.add(initial_node) - - def set(self, node_id): - """Mark this entry as replicated by node_id.""" - self._committed_nodes.add(node_id) - - def committed(self): - """Return True if a majority of nodes have replicated this entry.""" - count = len(self._committed_nodes) - return count >= (self.total_nodes // 2) + 1 - - def info(self, include_commits=False): - """Return a dict summary of commitment status.""" - data = {"committed": self.committed()} - if include_commits: - data["committed_nodes"] = list(self._committed_nodes) - return data - - -class LogEntryType: - """Enum for types of log entries.""" - - COMMAND = 0 - CONFIG = 1 - SNAPSHOT = 2 - # Ring policy commit (members source + replication factor). Lives - # in a *per-ring* Raft log and drives that ring's - # ``RingConfigStateMachine``. Was originally a cluster-log entry - # in the single-ring design; the multi-ring world treats it as - # per-ring state. - RING_CONFIG = 3 - # Cluster-log registry entry: ``{"ring_id": str, - # "founding_voters": [str, ...], "status": "active"|"destroyed"}``. - # Applied by ``RingRegistryStateMachine`` on the cluster log; the - # daemon brings up (or tears down) the named ring's Raft group - # when the entry commits. - RING_REGISTRY = 4 - # Cluster-log data-type routing entry: ``{"data_type": str, - # "ring_id": str|None}``. Applied by ``RoutingStateMachine`` on - # the cluster log; gates consult the routing table to decide - # which ring (if any) owns a given cache. - ROUTE = 5 - - -class LogEntry(NamedTuple): - """Represents a single entry in the Raft log (zero-copy optimized). - - The 'cmd' field is treated as raw cargo (bytes or memoryview). - """ - - term: int - index: int - cmd: bytes # Can also be memoryview for zero-copy access - node_id: str = None # noqa: TYP005 - type: int = 0 # 0 = COMMAND, 1 = CONFIG # noqa: TYP005 - client_id: str = None # noqa: TYP005 - sequence_num: int = None # noqa: TYP005 - - @property - def cmd_bytes(self): - """Get cmd as bytes, converting memoryview if necessary.""" - if isinstance(self.cmd, memoryview): - return self.cmd.tobytes() - return self.cmd - - def __eq__(self, other): - if isinstance(other, (bytes, str, memoryview)): - target = other - if isinstance(target, str): - target = target.encode() - elif isinstance(target, memoryview): - target = target.tobytes() - return self.cmd_bytes == target - return tuple.__eq__(self, other) - - @property - def cmd_view(self): - """Get cmd as memoryview for zero-copy access.""" - if isinstance(self.cmd, memoryview): - return self.cmd - return memoryview(self.cmd) - - def info(self, include_commits=False): # noqa: TYP004 - """Return a serializable representation of the entry.""" - # Convert bytes/memoryview cmd to string for JSON serialization - cmd_str = self.cmd_bytes - if isinstance(cmd_str, bytes): - cmd_str = cmd_str.decode("utf-8", errors="replace") - return ( - self.term, - self.index, - cmd_str, - self.node_id, - self.type, - self.client_id, - self.sequence_num, - ) - - -class BaseStorage: - """ - Abstract interface for log and state persistence. - - Implementations should handle low-level disk I/O and durability (fsync). - """ - - def save_state(self, term, voted_for): - """Persist currentTerm and votedFor (§5.2).""" - raise NotImplementedError - - def load_state(self): - """Load persisted state. Returns dict with 'term' and 'voted_for'.""" - raise NotImplementedError - - def save_log(self, entries): - """Rewrite the entire log (used during log truncation or recovery).""" - raise NotImplementedError - - def append_log(self, entry): - """Append a single entry to the log (optional optimization).""" - - def load_log(self): - """Load all persisted log entries. Returns list of LogEntry.""" - raise NotImplementedError - - def save_snapshot(self, data, index, term): - """Persist a state machine snapshot and its metadata.""" - raise NotImplementedError - - def load_snapshot(self): - """Load the latest snapshot. Returns dict with 'data', 'index', 'term'.""" - raise NotImplementedError - - -class Log: - """ - Manages the replicated log for a Raft node. - - Handles Raft indexing, log offsets due to compaction, consistency checks, - and delegation to persistence storage. - """ - - def __init__(self, term=0, index=None, storage=None, **kwargs): - """Initialize the replicated log.""" - self._term = term - self.entries = [] - self.storage = storage - self.last_included_index = -1 - self.last_included_term = 0 - self._cached_index = -1 - self.state_machine = kwargs.get("state_machine") - # Additional named state machines (e.g. ``membership_sm``) whose state - # must also survive log compaction. Snapshot/restore dispatches - # through this registry on top of ``self.state_machine``. - self._extra_state_machines = dict(kwargs.get("state_machines") or {}) - self.max_log_size = kwargs.get("max_log_size") - self.commit_index = -1 - self.last_applied = -1 - - if self.storage: - self.entries = self.storage.load_log() - snapshot = self.storage.load_snapshot() - if snapshot: - self.last_included_index = snapshot["index"] - self.last_included_term = snapshot["term"] - self.restore_state_machines_from_data(snapshot["data"]) - state = self.storage.load_state() - self._term = state.get("term", 0) if isinstance(state, dict) else state[0] - - self._update_cached_index() - - def register_state_machine(self, name, sm): - """ - Register an additional named state machine. - - The SM's ``get_snapshot()`` / ``restore_snapshot()`` are wired into - :meth:`snapshot` and :meth:`restore_state_machines_from_data` so its - state survives log compaction along with the application state - machine. ``name`` keys the SM inside the snapshot envelope and must - be stable across restarts. - """ - self._extra_state_machines[name] = sm - - def _update_cached_index(self): - """Update the cached index based on current entries and snapshot.""" - if not self.entries: - self._cached_index = self.last_included_index - else: - self._cached_index = self.entries[-1].index - - def __repr__(self): - """Return a string representation of the log.""" - return f"" - - @property - def index(self): - """Return the Raft index of the latest entry in the log.""" - if self.entries: - return self.entries[-1].index - return self.last_included_index - - @property - def last_index(self): - return self.index - - @property - def term(self): - """Return the current term of the log.""" - return self._term - - @term.setter - def term(self, value): - self._term = value - - @property - def last_term(self): - if self.entries: - return self.entries[-1].term - return self.last_included_term - - def get_entry(self, index): - """ - Retrieve the entry at a specific Raft index, accounting for log offsets. - - Return None if the index has been discarded by snapshotting or doesn't exist. - """ - if index <= self.last_included_index: - return None - internal_idx = index - (self.last_included_index + 1) - res = None - if 0 <= internal_idx < len(self.entries): - res = self.entries[internal_idx] - return res - - def get(self, index): - return self.get_entry(index) - - def add( - self, - term, - cmd, - commit_status=None, - node_id=None, - index=None, - entry_type=LogEntryType.COMMAND, - in_memory_only=False, - client_id=None, - sequence_num=None, - ): - """ - Add a new entry to the log. - """ - if term > self.term: - self.term = term - - res = None - if index is None: - new_index = self.index + 1 - entry = LogEntry( - term, new_index, cmd, node_id, entry_type, client_id, sequence_num - ) - self.entries.append(entry) - if self.storage and not in_memory_only: - self.storage.append_log(entry) - res = new_index - else: - if index <= self.last_included_index: - return False - - internal_idx = index - (self.last_included_index + 1) - - if internal_idx < len(self.entries): - existing = self.entries[internal_idx] - if existing.term == term: - return index - self.entries = self.entries[:internal_idx] - entry = LogEntry( - term, index, cmd, node_id, entry_type, client_id, sequence_num - ) - self.entries.append(entry) - if self.storage and not in_memory_only: - self.storage.save_log(self.entries) - res = index - else: - entry = LogEntry( - term, index, cmd, node_id, entry_type, client_id, sequence_num - ) - self.entries.append(entry) - if self.storage and not in_memory_only: - self.storage.append_log(entry) - res = index - - self._update_cached_index() - - # Trigger automatic snapshot if log exceeds max size - if self.max_log_size and len(self.entries) >= self.max_log_size: - if self.entries and self.commit_index >= self.entries[0].index: - self.snapshot() - - return res - - def append( - self, - term, - data, - index=None, - entry_type=LogEntryType.COMMAND, - client_id=None, - sequence_num=None, - ): - return self.add( - term, - data, - index=index, - entry_type=entry_type, - client_id=client_id, - sequence_num=sequence_num, - ) - - def snapshot(self): - """ - Compact the log by snapshotting every registered state machine. - - Writes a versioned envelope:: - - {"__envelope__": "raft.snapshot.v1", - "machines": {"state_machine": ..., "membership_sm": ..., ...}} - - Each SM's payload is the value of its ``get_snapshot()``; bytes - payloads are base64-wrapped so the envelope stays JSON-safe. Older - single-SM snapshots written before this format are still recognised - on load (see :meth:`restore_state_machines_from_data`). - """ - if not self.entries: - return - last_entry = self.entries[-1] - self.last_included_index = last_entry.index - self.last_included_term = last_entry.term - - machines = {} - if self.state_machine: - machines["state_machine"] = self._encode_sm_payload( - self.state_machine.get_snapshot() - ) - for name, sm in self._extra_state_machines.items(): - machines[name] = self._encode_sm_payload(sm.get_snapshot()) - - if machines and self.storage: - envelope = { - "__envelope__": SNAPSHOT_ENVELOPE_VERSION, - "machines": machines, - } - self.storage.save_snapshot( - envelope, self.last_included_index, self.last_included_term - ) - - # Discard entries up to last_included_index - self.entries = [] - self._update_cached_index() - - def restore_state_machines_from_data(self, data): - """ - Restore every registered state machine from snapshot ``data``. - - Recognises three input shapes: - - * Envelope dict (or JSON bytes containing one) with the - ``__envelope__`` marker — dispatches each ``machines[name]`` - payload to the SM registered under that name. - * Anything else — legacy single-SM payload; passed straight through - to ``self.state_machine.restore_snapshot``. Extra SMs keep their - current state; the post-snapshot log replay rebuilds them. - - Missing keys are silently ignored so a snapshot written by an older - node (or a node that didn't yet register a particular SM) restores - cleanly. - """ - envelope = self._maybe_envelope(data) - if envelope is not None: - machines = envelope.get("machines", {}) or {} - sm_payload = machines.get("state_machine") - if sm_payload is not None and self.state_machine: - self.state_machine.restore_snapshot(self._decode_sm_payload(sm_payload)) - for name, sm in self._extra_state_machines.items(): - if name in machines: - sm.restore_snapshot(self._decode_sm_payload(machines[name])) - return - if self.state_machine is not None: - self.state_machine.restore_snapshot(data) - - @staticmethod - def _maybe_envelope(data): - """Return *data* as an envelope dict, or ``None`` if it isn't one.""" - if isinstance(data, dict): - if data.get("__envelope__") == SNAPSHOT_ENVELOPE_VERSION: - return data - return None - if isinstance(data, (bytes, bytearray, memoryview)): - try: - obj = json.loads(bytes(data).decode("utf-8")) - except (ValueError, UnicodeDecodeError): - return None - if ( - isinstance(obj, dict) - and obj.get("__envelope__") == SNAPSHOT_ENVELOPE_VERSION - ): - return obj - return None - - @staticmethod - def _encode_sm_payload(payload): - """Make an SM ``get_snapshot()`` value safe to embed in a JSON envelope.""" - if isinstance(payload, (bytes, bytearray, memoryview)): - return { - "__bytes__": base64.b64encode(bytes(payload)).decode("ascii"), - } - return payload - - @staticmethod - def _decode_sm_payload(payload): - """Inverse of :meth:`_encode_sm_payload`.""" - if isinstance(payload, dict) and len(payload) == 1 and "__bytes__" in payload: - return base64.b64decode(payload["__bytes__"]) - return payload - - def commit(self, index): - self.commit_index = max(getattr(self, "commit_index", -1), index) - - def clear(self): - """Discard all log entries.""" - self.entries = [] - if self.storage: - self.storage.save_log(self.entries) - self._update_cached_index() - - def has_entry(self, term, index, cmd=None): - """ - Check Raft log entries for consistency. - """ - if index is None or index == -1: - return True - - if index == self.last_included_index: - return term == self.last_included_term - - entry = self.get_entry(index) - if entry is None: - return False - - if entry.term != term: - return False - - return True - - def truncate_prefix(self, index): - """ - Discard all log entries up to and including 'index'. - """ - if index <= self.last_included_index: - return - - entry = self.get_entry(index) - if entry: - self.last_included_term = entry.term - - internal_idx = index - (self.last_included_index + 1) - self.entries = self.entries[internal_idx + 1 :] - self.last_included_index = index - - if self.storage: - self.storage.save_log(self.entries) - - self._update_cached_index() - - -class BaseStateMachine: - """ - Interface for the application-level State Machine. - """ - - def apply(self, cmd, client_id=None, sequence_num=None): - """Apply a committed command to the state machine.""" - raise NotImplementedError - - def get_snapshot(self): - """Serialize the current state of the state machine to bytes.""" - raise NotImplementedError - - def restore_snapshot(self, data): - """Restore the state machine from a serialized snapshot.""" - raise NotImplementedError - - -class MembershipStateMachine(BaseStateMachine): - """ - State machine for Raft cluster membership. - - Applies committed ``CONFIG`` log entries to maintain the authoritative - set of voting members and learners. Snapshot/restore support allows the - membership state to survive log compaction. - - The ``on_change`` callback (if set) is called after every successful - ``apply`` with ``(voters: list[str], learners: list[str])``. Nodes use - this hook to update their in-memory peer routing tables via - ``Node.on_config_change``. - - Sequence - -------- - 1. Leader proposes ``CONFIG`` entry ``{voters: [...], learners: [...]}``. - 2. Entry is replicated and committed. - 3. ``Node.apply_entries`` calls ``membership_sm.apply(cmd, index=i)``. - 4. ``MembershipStateMachine`` updates its voter/learner sets and calls - ``on_change(voters, learners)``. - 5. ``Node.on_config_change`` (wired as ``on_change``) updates - ``Node.peers`` voting flags and ``Node.voting``. - """ - - def __init__(self, on_change=None): - """ - :param on_change: Optional ``callable(voters, learners)`` called after - each successful ``apply``. When set to ``None`` the - SM operates as a pure query store with no side effects. - """ - self._voters = set() - self._learners = set() - self._membership_version = -1 - self.on_change = on_change - - # ------------------------------------------------------------------ - # BaseStateMachine interface - # ------------------------------------------------------------------ - - def apply(self, cmd, client_id=None, sequence_num=None, index=-1): - """ - Apply a committed CONFIG entry. - - :param cmd: ``dict`` with keys ``"voters"`` (list[str]) and - optionally ``"learners"`` (list[str]). Non-dict values - are treated as a plain voter list with no learners. - :param index: Raft log index of this entry (used as version stamp). - """ - if isinstance(cmd, dict): - voters = list(cmd.get("voters", [])) - learners = list(cmd.get("learners", [])) - else: - voters = list(cmd) if cmd else [] - learners = [] - - self._voters = set(voters) - self._learners = set(learners) - self._membership_version = index - - if self.on_change is not None: - self.on_change(voters, learners) - - def get_snapshot(self): - """Return JSON-serialisable dict of current membership state.""" - return { - "voters": sorted(self._voters), - "learners": sorted(self._learners), - "version": self._membership_version, - } - - def restore_snapshot(self, data): - """Restore membership from a snapshot dict (as produced by ``get_snapshot``).""" - if isinstance(data, (bytes, bytearray)): - data = json.loads(data.decode()) - if not isinstance(data, dict): - return - self._voters = set(data.get("voters", [])) - self._learners = set(data.get("learners", [])) - self._membership_version = data.get("version", -1) - - # ------------------------------------------------------------------ - # Query API - # ------------------------------------------------------------------ - - def current_voters(self): - """Return a sorted list of current voting members.""" - return sorted(self._voters) - - def current_learners(self): - """Return a sorted list of current learner (non-voting) members.""" - return sorted(self._learners) - - def is_voter(self, node_id): - """Return True if *node_id* is in the current voter set.""" - return node_id in self._voters - - def is_learner(self, node_id): - """Return True if *node_id* is in the current learner set.""" - return node_id in self._learners - - @property - def membership_version(self): - """Log index of the most recently applied CONFIG entry, or -1 if none.""" - return self._membership_version - - def __repr__(self): - return ( - f"" - ) - - -# --------------------------------------------------------------------------- -# RingConfigStateMachine -# --------------------------------------------------------------------------- - - -# Valid values for the ring's ``members`` policy. -RING_MEMBERS_SELF = "self" -RING_MEMBERS_VOTERS = "voters" -RING_MEMBERS_VALID = (RING_MEMBERS_SELF, RING_MEMBERS_VOTERS) - - -class RingConfigStateMachine(BaseStateMachine): - """ - State machine for the cluster's :class:`~salt.cluster.ring.HashRing` - policy — *what* the ring contains and *how many replicas* per key. - - Two committable knobs: - - * ``members`` — ``"self"`` (default; ring contains only this master so - every key is owned locally — preserves today's broadcast behaviour) - or ``"voters"`` (ring is rebuilt from the committed Raft voter set - so writes shard across the cluster). - * ``replicas`` — replication factor. ``1`` (default) means each key - has exactly one owner with no backups. Higher values request the - ring to keep the top-N nodes as replicas; the runner validates - against ``len(voters)``. - - Driven by a ``LogEntryType.RING_CONFIG`` entry proposed through - Raft (typically by a ``cluster.ring`` runner). Operators flip from - self-only to cluster-wide sharding by committing a single entry; no - code changes required. - - The ``on_change`` callback (if set) runs after every successful - ``apply`` with ``(members, replicas)``. ``RaftService`` wires this - to update :func:`salt.cluster.ring_membership.rebuild` so the - process-local ring re-syncs to the new policy. - - Snapshot/restore round-trips through the same envelope shape used by - ``MembershipStateMachine`` (registered under name ``"ring_sm"``), so - ring config survives log compaction. - """ - - def __init__(self, on_change=None): - self._members = RING_MEMBERS_SELF - self._replicas = 1 - self._version = -1 - self.on_change = on_change - - # ------------------------------------------------------------------ - # BaseStateMachine interface - # ------------------------------------------------------------------ - - def apply(self, cmd, client_id=None, sequence_num=None, index=-1): - """ - Apply a committed RING_CONFIG entry. - - :param cmd: ``dict`` with keys ``"members"`` (str, one of - ``RING_MEMBERS_VALID``) and ``"replicas"`` (int). - Either may be omitted to keep the existing value - — useful for partial updates that only flip one - knob. Unknown keys are ignored. - :param index: Raft log index of this entry; stored as the - version stamp visible via :attr:`config_version`. - """ - if isinstance(cmd, dict): - new_members = cmd.get("members", self._members) - new_replicas = cmd.get("replicas", self._replicas) - if new_members in RING_MEMBERS_VALID: - self._members = new_members - else: - log.warning( - "RingConfigStateMachine: ignoring unknown members policy %r " - "(expected one of %s)", - new_members, - RING_MEMBERS_VALID, - ) - try: - self._replicas = max(1, int(new_replicas)) - except (TypeError, ValueError): - log.warning( - "RingConfigStateMachine: ignoring non-integer replicas %r", - new_replicas, - ) - self._version = index - if self.on_change is not None: - self.on_change(self._members, self._replicas) - - def get_snapshot(self): - """Return the JSON-serialisable ring policy.""" - return { - "members": self._members, - "replicas": self._replicas, - "version": self._version, - } - - def restore_snapshot(self, data): - """Restore from a snapshot dict (as produced by :meth:`get_snapshot`).""" - if isinstance(data, (bytes, bytearray)): - data = json.loads(data.decode()) - if not isinstance(data, dict): - return - members = data.get("members", self._members) - if members in RING_MEMBERS_VALID: - self._members = members - try: - self._replicas = max(1, int(data.get("replicas", self._replicas))) - except (TypeError, ValueError): - pass - self._version = data.get("version", -1) - - # ------------------------------------------------------------------ - # Query API - # ------------------------------------------------------------------ - - @property - def members(self): - """Current members policy (``"self"`` or ``"voters"``).""" - return self._members - - @property - def replicas(self): - """Current replication factor (>= 1).""" - return self._replicas - - @property - def config_version(self): - """Log index of the most recently applied RING_CONFIG entry, or -1 if none.""" - return self._version - - def __repr__(self): - return ( - f"" - ) - - -# --------------------------------------------------------------------------- -# Multi-ring state machines (live on the cluster Raft log) -# --------------------------------------------------------------------------- - - -# Valid ring lifecycle states recorded in the registry. -RING_STATUS_ACTIVE = "active" -RING_STATUS_DESTROYED = "destroyed" -RING_STATUS_VALID = (RING_STATUS_ACTIVE, RING_STATUS_DESTROYED) - - -class RingRegistryStateMachine(BaseStateMachine): - """ - Cluster-log registry of named rings. - - For each named Raft "ring" (a separate consensus group used to - shard one Salt cache), the registry tracks ``founding_voters`` - (initial voter list at create time) and ``status`` (``"active"`` - or ``"destroyed"``). Once a ring is created and brought up, - further membership and policy churn lives in *that ring's own* - Raft log — the registry only records the lifecycle moments - cluster-wide consensus needs to agree on. - - Command shape applied from a ``LogEntryType.RING_REGISTRY`` entry:: - - {"ring_id": "jobs", - "founding_voters": ["m1", "m2", "m3"], - "status": "active"} - - Or, to destroy:: - - {"ring_id": "jobs", "status": "destroyed"} - - On each commit ``on_change(ring_id, founding_voters, status)`` - fires; ``RaftService`` wires this to bring up or tear down the - named ring's per-ring Raft group inside the publish daemon. - - Snapshot/restore round-trip through the same multi-SM envelope - used by :class:`MembershipStateMachine`, registered under name - ``"ring_registry_sm"``. - """ - - def __init__(self, on_change=None): - # ring_id -> {"founding_voters": [...], "status": "active"|"destroyed"} - self._rings = {} - self._version = -1 - self.on_change = on_change - - # ------------------------------------------------------------------ - # BaseStateMachine interface - # ------------------------------------------------------------------ - - def apply(self, cmd, client_id=None, sequence_num=None, index=-1): - """ - Apply a committed RING_REGISTRY entry. - - Status defaults to ``"active"`` so the common create case is - a two-field commit; founding voters are sorted to canonicalise - the on-disk representation. ``ring_id`` is required. - """ - if not isinstance(cmd, dict): - log.warning("RingRegistryStateMachine: ignoring non-dict cmd %r", cmd) - return - ring_id = cmd.get("ring_id") - if not ring_id: - log.warning( - "RingRegistryStateMachine: ignoring entry without ring_id: %r", - cmd, - ) - return - status = cmd.get("status", RING_STATUS_ACTIVE) - if status not in RING_STATUS_VALID: - log.warning( - "RingRegistryStateMachine: ignoring unknown status %r " - "(expected one of %s)", - status, - RING_STATUS_VALID, - ) - return - existing = self._rings.get(ring_id) or {} - # Preserve the existing founding_voters when the incoming - # entry omits them — destroy commits ride this path so the - # audit trail keeps "who founded this ring." ``cmd.get`` is - # checked against ``None`` rather than truthiness so an - # explicit empty list still wins (operator-driven correction). - if "founding_voters" in cmd and cmd.get("founding_voters") is not None: - founders = sorted(cmd["founding_voters"] or []) - else: - founders = existing.get("founding_voters", []) - # Destruction of a never-registered ring is a no-op write to - # the registry — keep the entry so the lifecycle is auditable. - self._rings[ring_id] = { - "founding_voters": founders, - "status": status, - } - self._version = index - if self.on_change is not None: - self.on_change(ring_id, founders, status) - - def get_snapshot(self): - """Return the JSON-serialisable registry.""" - return { - "rings": {ring_id: dict(entry) for ring_id, entry in self._rings.items()}, - "version": self._version, - } - - def restore_snapshot(self, data): - """Restore from a snapshot dict (as produced by :meth:`get_snapshot`).""" - if isinstance(data, (bytes, bytearray)): - data = json.loads(data.decode()) - if not isinstance(data, dict): - return - rings = data.get("rings", {}) - if isinstance(rings, dict): - self._rings = { - ring_id: { - "founding_voters": sorted(entry.get("founding_voters", []) or []), - "status": entry.get("status", RING_STATUS_ACTIVE), - } - for ring_id, entry in rings.items() - if isinstance(entry, dict) - } - self._version = data.get("version", -1) - - # ------------------------------------------------------------------ - # Query API - # ------------------------------------------------------------------ - - def rings(self): - """Return the full ring_id -> entry dict. Copy; callers may mutate.""" - return {ring_id: dict(entry) for ring_id, entry in self._rings.items()} - - def active_rings(self): - """Return a sorted list of ring ids whose status is ``"active"``.""" - return sorted( - ring_id - for ring_id, entry in self._rings.items() - if entry.get("status") == RING_STATUS_ACTIVE - ) - - def get(self, ring_id): - """Return the registry entry for *ring_id*, or ``None`` if unknown.""" - entry = self._rings.get(ring_id) - return dict(entry) if entry is not None else None - - @property - def registry_version(self): - """Log index of the most recently applied RING_REGISTRY entry, or -1.""" - return self._version - - def __repr__(self): - return ( - f"" - ) - - -class RoutingStateMachine(BaseStateMachine): - """ - Cluster-log data-type -> ring mapping. - - For each Salt data type that a master writes to a cache (e.g. - ``"jobs"``), the routing table answers "which ring owns this - data?". A mapping of ``None`` means *broadcast* — no ring is - consulted and every master writes the data unconditionally - (the pre-multi-ring default). - - Command shape applied from a ``LogEntryType.ROUTE`` entry:: - - {"data_type": "jobs", "ring_id": "jobs_ring"} - - Or, to clear a route back to broadcast:: - - {"data_type": "jobs", "ring_id": None} - - On each commit ``on_change(data_type, ring_id)`` fires; - ``RaftService`` wires this so the local routing table used by the - gate sites in ``salt/master.py`` stays in sync without IPC. - - Snapshot/restore round-trip through the same multi-SM envelope - used by :class:`MembershipStateMachine`, registered under name - ``"routing_sm"``. - """ - - def __init__(self, on_change=None): - # data_type -> ring_id or None - self._routes = {} - self._version = -1 - self.on_change = on_change - - # ------------------------------------------------------------------ - # BaseStateMachine interface - # ------------------------------------------------------------------ - - def apply(self, cmd, client_id=None, sequence_num=None, index=-1): - """Apply a committed ROUTE entry.""" - if not isinstance(cmd, dict): - log.warning("RoutingStateMachine: ignoring non-dict cmd %r", cmd) - return - data_type = cmd.get("data_type") - if not data_type: - log.warning( - "RoutingStateMachine: ignoring entry without data_type: %r", - cmd, - ) - return - # Use a sentinel so we can distinguish "ring_id absent" (treat as - # a clear-to-broadcast) from "ring_id explicitly None". Both - # map to broadcast semantically, so we accept either; the more - # natural form for an operator is to send ``"ring_id": None``. - ring_id = cmd.get("ring_id") - self._routes[data_type] = ring_id - self._version = index - if self.on_change is not None: - self.on_change(data_type, ring_id) - - def get_snapshot(self): - """Return the JSON-serialisable routing table.""" - return { - "routes": dict(self._routes), - "version": self._version, - } - - def restore_snapshot(self, data): - """Restore from a snapshot dict (as produced by :meth:`get_snapshot`).""" - if isinstance(data, (bytes, bytearray)): - data = json.loads(data.decode()) - if not isinstance(data, dict): - return - routes = data.get("routes", {}) - if isinstance(routes, dict): - self._routes = dict(routes) - self._version = data.get("version", -1) - - # ------------------------------------------------------------------ - # Query API - # ------------------------------------------------------------------ - - def routes(self): - """Return a copy of the data_type -> ring_id mapping.""" - return dict(self._routes) - - def get(self, data_type, default=None): - """Return the ring_id for *data_type*, or *default* if unrouted.""" - return self._routes.get(data_type, default) - - @property - def routing_version(self): - """Log index of the most recently applied ROUTE entry, or -1.""" - return self._version - - def __repr__(self): - return ( - f"" - ) - - -class CounterStateMachine(BaseStateMachine): - """Simple state machine that counts applied commands with exactly-once logic.""" - - def __init__(self): - """Initialize the counter and sessions.""" - self.count = 0 - # client_id -> last_sequence_num - self.sessions = {} - - def apply(self, cmd, client_id=None, sequence_num=None): - """Increment the counter for each applied command (accepts bytes or strings).""" - if client_id is not None and sequence_num is not None: - last_seq = self.sessions.get(client_id, -1) - if sequence_num <= last_seq: - # Duplicate request, do not execute - return self.count - self.sessions[client_id] = sequence_num - - self.count += 1 - return self.count - - def get_snapshot(self): - """Return the current count and sessions as a JSON-encoded snapshot.""" - return json.dumps({"count": self.count, "sessions": self.sessions}).encode( - "utf-8" - ) - - def restore_snapshot(self, data): - """Restore the counter and sessions from a snapshot.""" - if not isinstance(data, dict): - log.debug( - "CounterStateMachine.restore_snapshot expected dict, got %s", type(data) - ) - # If it's bytes, it should have been decoded by Node, but let's be safe - if isinstance(data, (bytes, bytearray)): - try: - data = json.loads(data.decode("utf-8")) - except (ValueError, UnicodeDecodeError): - data = {} - else: - data = {} - - self.count = data.get("count", 0) - self.sessions = data.get("sessions", {}) diff --git a/salt/cluster/consensus/raft/node.py b/salt/cluster/consensus/raft/node.py deleted file mode 100644 index 088b674d4581..000000000000 --- a/salt/cluster/consensus/raft/node.py +++ /dev/null @@ -1,1445 +0,0 @@ -""" -Raft node: elections, log replication callbacks, and peer RPC surface. - -The :class:`Peer` / :class:`ManualPeer` boundary and -``register_schedule_timeout`` / ``register_peer_factory`` hooks keep transport -and timers out of the core algorithm. - -This module is intentionally **not** asyncio-based: the core stays -synchronous with callbacks. Salt-side consensus glue should prefer asyncio -for I/O where we control it, and adapt into these callbacks. -""" - -import functools -import logging -import threading -import time - -from salt.cluster.consensus.raft.log import Log, LogEntryType, MembershipStateMachine -from salt.cluster.consensus.raft.util import gettimeout - -log = logging.getLogger(__name__) - - -class NoOpLock: - def __enter__(self): - return self - - def __exit__(self, *args): - pass - - def acquire(self, *args, **kwargs): - return True - - def release(self, *args, **kwargs): - pass - - -NOOPLOCK = NoOpLock() - - -class CandidacyError(Exception): - pass - - -class Vote: - def __init__(self, voter_id, term, granted=False): - self.voter_id = voter_id - self.term = term - self.granted = granted - - @property - def node_id(self): - return self.voter_id - - def info(self): - return {"voter_id": self.voter_id, "term": self.term, "granted": self.granted} - - -class Peer: - """Interface for interacting with a remote node.""" - - def __init__(self, node, node_id=None, voting=True): - """Initialize the peer with node and optional voting status.""" - self.node = node - self._node_id = node_id or getattr( - node, "node_id", getattr(node, "address", "mock") - ) - self.voting = voting - - @property - def address(self): - """Return the network address of the peer.""" - return getattr(self.node, "address", self._node_id) - - @property - def node_id(self): - """Return the unique ID of the peer.""" - return self._node_id - - def request_vote(self, callback, node_id, term, last_log_term, last_log_index): - """Issue a RequestVote RPC.""" - granted, our_term, lc_addr = self.node.request_vote( - node_id, term, last_log_term=last_log_term, last_log_index=last_log_index - ) - if callback: - callback(self.node_id, granted, our_term) - - def pre_request_vote(self, callback, node_id, term, last_log_term, last_log_index): - """Issue a Pre-RequestVote RPC.""" - granted, our_term, lc_addr = self.node.pre_request_vote( - node_id, term, last_log_term=last_log_term, last_log_index=last_log_index - ) - if callback: - callback(self.node_id, granted, our_term) - - def append_entries( - self, - callback, - leader_id, - term, - prev_log_term, - prev_log_index, - leader_commit, - *entries, - **kwargs, - ): - """Issue an AppendEntries RPC.""" - # Convert *entries to a list for the target method - actual_entries = list(entries) - - success, our_term, last_idx, conflict_term, lc_addr = self.node.append_entries( - leader_id, - term, - prev_log_term, - prev_log_index, - leader_commit, - *actual_entries, - **kwargs, - ) - if callback: - # Term, prev_log_term, prev_log_index, sent_log_index, node_id, ourterm, success, conflict_index, conflict_term, *entries - sent_log_index = ( - prev_log_index + len(actual_entries) - if prev_log_index is not None - else len(actual_entries) - 1 - ) - callback( - term, - prev_log_term, - prev_log_index, - sent_log_index, - self.node_id, - our_term, - success, - last_idx, # conflict_index or last_index - conflict_term, - *actual_entries, - ) - - def install_snapshot( - self, - callback, - leader_id, - term, - last_included_index, - last_included_term, - data, - **kwargs, - ): - """Issue an InstallSnapshot RPC.""" - our_term, lc_addr = self.node.install_snapshot( - leader_id, term, last_included_index, last_included_term, data, **kwargs - ) - if callback: - callback(self.node_id, our_term) - - -class ManualPeer: - """Mock peer for unit tests that queues requests.""" - - def __init__(self, node, node_id=None, voting=True): - self.node = node - self.node_id = node_id or getattr(node, "node_id", "mock") - self.address = getattr(node, "address", self.node_id) - self.voting = voting - self.requests = [] - - def request_vote( - self, callback, candidate_id, term, last_log_term=None, last_log_index=None - ): - self.requests.append( - ("rv", candidate_id, term, callback, last_log_term, last_log_index) - ) - - def pre_request_vote( - self, callback, candidate_id, term, last_log_term=None, last_log_index=None - ): - self.requests.append( - ("prv", candidate_id, term, callback, last_log_term, last_log_index) - ) - - def append_entries( - self, - callback, - leader_id, - term, - prev_log_term, - prev_log_index, - leader_commit, - *entries, - **kwargs, - ): - self.requests.append( - ( - "ae", - leader_id, - term, - callback, - prev_log_index, - prev_log_term, - leader_commit, - list(entries), - kwargs.get("leader_client_address"), - ) - ) - - def install_snapshot( - self, - callback, - leader_id, - term, - last_included_index, - last_included_term, - data, - **kwargs, - ): - self.requests.append( - ( - "is", - leader_id, - term, - callback, - last_included_index, - last_included_term, - data, - ) - ) - - def handle_all_requests(self): - while self.requests: - req = self.requests.pop(0) - kind = req[0] - if kind == "rv": - # candidate_id, term, callback, last_log_term, last_log_index - res = self.node.request_vote( - req[1], req[2], last_log_term=req[4], last_log_index=req[5] - ) - req[3](self.node_id, res[0], res[1]) - elif kind == "prv": - # candidate_id, term, callback, last_log_term, last_log_index - res = self.node.pre_request_vote( - req[1], req[2], last_log_term=req[4], last_log_index=req[5] - ) - req[3](self.node_id, res[0], res[1]) - elif kind == "ae": - # leader_id, term, callback, prev_log_index, prev_log_term, leader_commit, entries, lc_addr - res = self.node.handle_append_entries( - req[1], - req[2], - req[5], - req[4], - req[6], - *req[7], - leader_client_address=req[8], - ) - sent_log_index = ( - req[4] + len(req[7]) if req[4] is not None else len(req[7]) - 1 - ) - req[3]( - req[2], - req[5], - req[4], - sent_log_index, - self.node_id, - res[1], - res[0], - res[2], - res[3], - *req[7], - ) - elif kind == "is": - # leader_id, term, callback, last_index, last_term, data - res = self.node.install_snapshot(req[1], req[2], req[4], req[5], req[6]) - req[3](self.node_id, res[0]) - - def drop_requests(self): - self.requests = [] - - -def lock(func): - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - with self._lock: - return func(self, *args, **kwargs) - - return wrapper - - -class NodeState: - START = "start" - FOLLOWER = "follower" - CANDIDATE = "candidate" - LEADER = "leader" - - def __init__(self): - self._state = self.START - - def become_candidate(self): - if self._state == self.START: - raise RuntimeError("State must be follower first") - if self._state != self.FOLLOWER and self._state != self.CANDIDATE: - raise RuntimeError("Not follower") - self._state = self.CANDIDATE - - def become_leader(self): - if self._state == self.START: - raise RuntimeError("State must be follower first") - if self._state != self.CANDIDATE and self._state != self.LEADER: - raise RuntimeError(f"Not candidate ({self._state})") - self._state = self.LEADER - - def become_follower(self): - self._state = self.FOLLOWER - - def __str__(self): - return self._state - - def __repr__(self): - return f"" - - def __eq__(self, other): - if isinstance(other, str): - return self._state == other - return self._state == getattr(other, "_state", None) - - -class Candidacy: - def __init__(self, term, peers): - self.term = term - self.peers = set(peers) - self.votes = {} - - def handle_reply(self, node_id, term, result): - if term != self.term: - raise CandidacyError(f"Term {term} does not match ours {self.term}") - if node_id not in self.peers: - raise CandidacyError(f"{node_id} is not a peer") - if node_id in self.votes: - raise CandidacyError(f"Already received a reply from this peer: {node_id}") - self.votes[node_id] = bool(result) - - def elected(self): - v_votes = [v for v in self.votes.values() if v is True] - # Include self - return (len(v_votes) + 1) >= (len(self.peers) + 1) // 2 + 1 - - -class Node: - def __init__( - self, - address, - storage=None, - peers=None, - _follower_min=150, - _follower_max=300, - _candidate_min=150, - _candidate_max=300, - _leader_beacon_min=50, - _leader_beacon_max=100, - state_machine=None, - membership_sm=None, - max_log_size=None, - max_voters=None, - voting=True, - **kwargs, - ): - self.address = address - self.node_id = kwargs.get("node_id", address) - self.client_address = kwargs.get("client_address") - self.peers = peers or [] - self.storage = storage - # True if this node participates in quorum; False means learner/observer. - self.voting = voting - # Optional upper bound on voter count. ``None`` preserves the - # original behaviour where every caught-up learner is promoted - # to voter. When set, the leader's auto-promotion path checks - # the cap before proposing the CONFIG entry; learners that - # arrive after the cap is reached stay non-voting indefinitely. - self.max_voters = max_voters - - # Membership state machine: applies CONFIG entries to track the committed - # voter/learner sets. It is the authoritative query store for committed - # membership; it does NOT drive on_config_change (that is called directly - # from apply_entries so the eager log_add path and commit path stay in sync). - if membership_sm is None: - membership_sm = MembershipStateMachine() - self.membership_sm = membership_sm - - # Use local variable to avoid property collision. ``membership_sm`` is - # registered alongside the application SM so its state survives log - # compaction (otherwise CONFIG entries that were truncated would leave - # the membership SM empty after restart). - sm = state_machine or ( - getattr(self.storage, "state_machine", None) if storage else None - ) - self.log = Log( - storage=storage, - state_machine=sm, - max_log_size=max_log_size, - state_machines={"membership_sm": self.membership_sm}, - ) - - self.state = NodeState() - self._term = 0 - self._voted_for = None - # ``_leader`` is the backing store for the ``leader`` property. - # Initialise directly to bypass the property setter (no storage - # write before storage is wired below). - self._leader = None - self.vote = None - self.leader_client_address_map = {} - - self._follower_min = _follower_min - self._follower_max = _follower_max - self._candidate_min = _candidate_min - self._candidate_max = _candidate_max - self._leader_beacon_min = _leader_beacon_min - self._leader_beacon_max = _leader_beacon_max - - self._lock = kwargs.get("_lock", NOOPLOCK) - self._schedule_timeout_method = None - self._peer_factory = None - - # Per-peer last-contact tracking for voter health detection. Set - # by the leader's AppendEntries reply handler each time a peer - # acknowledges replication. Consumed by - # ``RaftService._check_voter_health`` to decide when a voter has - # been silent long enough to warrant demotion (Ongaro §6.4). - # Note: each leader observes contact for itself; on leadership - # change the new leader starts fresh and does its own observation. - self._peer_last_contact = {} - - self.last_followed = self.get_now() - self._follower_timeout = None - self._candidate_timeout = None - self._leader_beacon_timeout = None - - self._pre_candidacy = None - self.candidacy = None - self.native_engine = None - - self.next_index = {} - self.match_index = {} - self._applied_config_index = -1 # index of the most recently applied CONFIG - - if storage: - st = storage.load_state() - if isinstance(st, dict): - self._term = st.get("term", 0) - self._voted_for = st.get("voted_for") - self._leader = st.get("leader_id") - else: - self._term, self._voted_for = st - if self._voted_for: - self.vote = Vote(self._voted_for, self._term, granted=True) - - @property - def term(self): - return self._term - - @term.setter - def term(self, val): - if val != self._term: - self._term = val - if self.storage: - self.storage.save_state( - self._term, self._voted_for, leader_id=self._leader - ) - - @property - def voted_for(self): - return self._voted_for - - @voted_for.setter - def voted_for(self, val): - if val != self._voted_for: - self._voted_for = val - if self.storage: - self.storage.save_state( - self._term, self._voted_for, leader_id=self._leader - ) - - @property - def leader(self): - """The most recently observed leader id, or ``None`` if unknown. - - Persisted alongside ``term`` and ``voted_for`` via ``save_state`` - so ``cluster.members`` can answer "who is the leader" without - IPC. Not used in any Raft safety check — leader identity is - derived from incoming AppendEntries; this is purely an - observability hint. - """ - return self._leader - - @leader.setter - def leader(self, val): - if val != self._leader: - self._leader = val - if self.storage: - self.storage.save_state( - self._term, self._voted_for, leader_id=self._leader - ) - - @property - def vote(self): - if self._voted_for: - return Vote(self._voted_for, self.term, granted=True) - return None - - @vote.setter - def vote(self, val): - if val: - self.voted_for = val.voter_id - else: - self.voted_for = None - - @property - def follower_timeout(self): - return getattr(self, "_follower_timeout_val", None) - - @property - def leader_beacon_timeout(self): - return getattr(self, "_leader_timeout_val", None) - - @property - def candidate_timeout(self): - return getattr(self, "_candidate_timeout_val", None) - - def get_now(self): - if self._schedule_timeout_method: - scheduler = getattr(self._schedule_timeout_method, "__self__", None) - if scheduler and hasattr(scheduler, "time"): - return scheduler.time - return time.monotonic() - - def register_schedule_timeout(self, method): - self._schedule_timeout_method = method - - def register_peer_factory(self, factory): - self._peer_factory = factory - - def register_membership_sm(self, sm): - """ - Replace the membership state machine. - - The SM is the authoritative query store for committed membership - (``current_voters()``, ``current_learners()``). Side-effects on - ``Node.peers`` / ``Node.voting`` are driven directly by - ``apply_entries`` -> ``on_config_change``, not by the SM's - ``on_change`` callback, to avoid double-applying eager leader updates. - """ - self.membership_sm = sm - # Keep the Log's snapshot registry in sync so future snapshots include - # the replacement SM rather than the one set up at __init__. - if self.log is not None: - self.log.register_state_machine("membership_sm", sm) - - def reconcile_membership(self): - """ - Re-apply the current ``membership_sm`` voter/learner state to the rest - of the Node. - - :meth:`MembershipStateMachine.restore_snapshot` is a pure store - operation — it does not invoke ``on_change``. After a snapshot - restore (Node startup with a saved snapshot, or - :meth:`install_snapshot` from a leader) the SM holds the right - committed view but ``Node.peers`` / ``Node.voting`` and any - downstream ``on_change`` hook (e.g. ``RaftService._on_ready`` / - ring rebuild) are stale because the CONFIG entries that originally - flipped them have been compacted away. - - Calling this after every restore re-runs ``on_config_change`` for - the side-effects on the local peer table, then invokes the wired - ``on_change`` hook so RaftService and any future SM observers see - the same committed view they would see after a fresh CONFIG apply. - - Idempotent: calling it on a Node whose peer table already matches - the SM is a no-op modulo a redundant ``on_change`` fire. When the - SM is empty (no compacted snapshot, no apply yet) it is a no-op - because there's nothing to reconcile. - """ - if self.membership_sm is None: - return - voters = self.membership_sm.current_voters() - learners = self.membership_sm.current_learners() - if not voters and not learners: - # Nothing committed yet (e.g. fresh node before founding CONFIG). - return - # Update Node.peers / Node.voting in place from the restored view. - self.on_config_change(voters, learners) - # Also notify the SM's on_change observer (RaftService wires this - # for the cluster-ready hook and, post-ring-stage-0, ring rebuild). - on_change = getattr(self.membership_sm, "on_change", None) - if on_change is not None: - on_change(voters, learners) - - def schedule_timeout(self, delay, callback): - if not self._schedule_timeout_method: - raise RuntimeError("Register a scheduling method first") - return self._schedule_timeout_method(delay, callback) - - @lock - def become_candidate(self): - self.state.become_candidate() - self.term += 1 - log.info("Node %s BECOMING CANDIDATE for term %s", self.node_id, self.term) - self.voted_for = self.node_id - self.last_followed = self.get_now() - - voters = [p.node_id for p in self.peers if getattr(p, "voting", True)] - self.candidacy = Candidacy(self.term, voters) - - timeout = gettimeout(self._candidate_min, self._candidate_max) - self._candidate_timeout_val = timeout - if self._candidate_timeout: - self._candidate_timeout.cancel() - self._candidate_timeout = self.schedule_timeout(timeout, self.become_candidate) - - if not voters: - self.become_leader() - else: - last_log_term = self.log.last_term - last_log_index = self.log.index - for peer in self.peers: - if getattr(peer, "voting", True): - peer.request_vote( - self.request_vote_reply, - self.node_id, - self.term, - last_log_term, - last_log_index, - ) - - def request_votes(self): - """Helper for tests or manual triggers.""" - if self.state != NodeState.CANDIDATE: - raise RuntimeError("Not a candidate") - # Reuse logic from become_candidate or just trigger RPCs - last_log_term = self.log.last_term - last_log_index = self.log.index - for peer in self.peers: - if getattr(peer, "voting", True): - peer.request_vote( - self.request_vote_reply, - self.node_id, - self.term, - last_log_term, - last_log_index, - ) - - @lock - def request_vote_reply(self, peer_id, granted, term): - if term > self.term: - self.become_follower(term) - return - - if ( - self.state != NodeState.CANDIDATE - or not self.candidacy - or self.candidacy.term != term - ): - return - - self.candidacy.handle_reply(peer_id, term, granted) - if self.candidacy.elected(): - self.become_leader() - - @lock - def become_leader(self): - self.state.become_leader() - log.info("Node %s BECOMING LEADER for term %s", self.node_id, self.term) - self.leader = self.node_id - if self._candidate_timeout: - self._candidate_timeout.cancel() - self._candidate_timeout = None - - if self.native_engine: - self.native_engine.become_leader(self.term) - - self.next_index = {p.node_id: self.log.index + 1 for p in self.peers} - self.match_index = {p.node_id: -1 for p in self.peers} - self.schedule_heartbeat() - - def schedule_heartbeat(self): - with self._lock: - if self.state == NodeState.LEADER: - self.leader_beacon() - timeout = gettimeout(self._leader_beacon_min, self._leader_beacon_max) - self._leader_timeout_val = timeout - if self._leader_beacon_timeout: - self._leader_beacon_timeout.cancel() - self._leader_beacon_timeout = self.schedule_timeout( - timeout, self.schedule_heartbeat - ) - - def leader_beacon(self): - for peer in self.peers: - if self.native_engine: - peer.send_heartbeat(self.term, self.commit_index) - else: - self.send_append_entries(peer, entries=[]) - - def send_append_entries(self, peer, entries=None): - ni = self.next_index.get(peer.node_id, self.log.index + 1) - prev_idx = ni - 1 - prev_entry = self.log.get(prev_idx) - prev_term = prev_entry.term if prev_entry else self.log.last_included_term - - if entries is None: - entries = [e for e in self.log.entries if e.index >= ni] - - peer.append_entries( - self.append_entries_reply, - self.node_id, - self.term, - prev_term, - prev_idx, - self.log.commit_index, - *entries, - leader_client_address=self.client_address, - ) - - @lock - def append_entries_reply( - self, - sent_term, - sent_prev_term, - sent_prev_index, - sent_log_index, - peer_id, - term, - success, - *args, - ): - if term > self.term: - self.become_follower(term) - return - - if self.state != NodeState.LEADER: - return - - # Record liveness of every replying peer regardless of success - # bit. A reply with success=False still proves the peer is up - # and reachable (the log just mismatched); only true silence - # indicates a failed voter. - self._peer_last_contact[peer_id] = self.get_now() - - if success: - self.match_index[peer_id] = max( - self.match_index.get(peer_id, -1), sent_log_index - ) - self.next_index[peer_id] = self.match_index[peer_id] + 1 - - # Learner promotion: once a learner has caught up to the leader's - # log, propose a CONFIG entry to promote it. The peer stays - # non-voting until that entry is *applied* (see apply_entries). - # - # When ``max_voters`` is set, hold the promotion once the cap - # is reached. The learner keeps receiving log entries and - # cluster events; it just doesn't count toward quorum. An - # operator (or a future auto-replacement path) can later - # demote a voter to make room. - for p in self.peers: - if p.node_id == peer_id and not p.voting: - if self.match_index[peer_id] >= self.log.index: - current_voter_count = 1 + sum( - 1 for px in self.peers if px.voting - ) - if ( - self.max_voters is not None - and current_voter_count >= self.max_voters - ): - break - voters = ( - [self.node_id] - + [px.node_id for px in self.peers if px.voting] - + [peer_id] - ) - learners = [ - px.node_id - for px in self.peers - if not px.voting and px.node_id != peer_id - ] - self.log_add( - {"voters": voters, "learners": learners}, - entry_type=LogEntryType.CONFIG, - ) - - self.advance_commit_index() - else: - self.next_index[peer_id] = max(0, self.next_index.get(peer_id, 1) - 1) - - def advance_commit_index(self): - matches = sorted([m for m in self.match_index.values()] + [self.log.index]) - voters = [p for p in self.peers if getattr(p, "voting", True)] - quorum = (len(voters) + 1) // 2 + 1 - if len(matches) >= quorum: - q_idx = matches[-quorum] - if q_idx > self.log.commit_index: - entry = self.log.get(q_idx) - if entry and entry.term == self.term: - self.log.commit(q_idx) - self.apply_entries() - - def apply_entries(self): - while self.log.last_applied < self.log.commit_index: - new_applied = self.log.last_applied + 1 - entry = self.log.get(new_applied) - if entry: - if entry.type == LogEntryType.COMMAND: - if self.state_machine: - self.state_machine.apply( - entry.cmd, - client_id=entry.client_id, - sequence_num=entry.sequence_num, - ) - elif entry.type == LogEntryType.CONFIG: - cmd = entry.cmd - voters = cmd.get("voters", []) if isinstance(cmd, dict) else cmd - learners = cmd.get("learners", []) if isinstance(cmd, dict) else [] - # Always update the SM's committed view (query authority). - if self.membership_sm is not None: - self.membership_sm.apply(cmd, index=entry.index) - # Only call on_config_change when this committed entry is - # strictly newer than what the eager log_add path already - # applied. This prevents an older committed CONFIG from - # clobbering a newer CONFIG that the leader optimistically - # applied to its peer list when it wrote the entry. - if entry.index >= self._applied_config_index: - self._applied_config_index = entry.index - self.on_config_change(voters, learners) - elif entry.type == LogEntryType.RING_CONFIG: - # Ring policy commit (members source + replication - # factor). Applied to the registered ``ring_sm`` - # if any; on_change inside the SM drives - # ``ring_membership.rebuild`` via RaftService. - ring_sm = self.log._extra_state_machines.get("ring_sm") - if ring_sm is not None: - ring_sm.apply(entry.cmd, index=entry.index) - elif entry.type == LogEntryType.RING_REGISTRY: - # Multi-ring registry: the cluster log records - # which rings exist and their founding voters. - # Applied to ``ring_registry_sm`` if registered; - # the SM's on_change fires per-ring lifecycle in - # RaftService (slice 3 of the multi-ring rollout). - registry_sm = self.log._extra_state_machines.get("ring_registry_sm") - if registry_sm is not None: - registry_sm.apply(entry.cmd, index=entry.index) - elif entry.type == LogEntryType.ROUTE: - # Data-type -> ring routing. Applied to - # ``routing_sm`` if registered; the SM's on_change - # drives the local routing table that gate sites - # consult. - routing_sm = self.log._extra_state_machines.get("routing_sm") - if routing_sm is not None: - routing_sm.apply(entry.cmd, index=entry.index) - self.log.last_applied = new_applied - - @lock - def become_follower(self, term=None): - if term is not None: - if term < self.term: - raise RuntimeError("Term lower than ours") - if term > self.term: - self.term = term - self.voted_for = None - - self.state.become_follower() - self.leader = None - log.info("Node %s BECOMING FOLLOWER for term %s", self.node_id, self.term) - - self._pre_candidacy = None - self.candidacy = None - - if self._leader_beacon_timeout: - self._leader_beacon_timeout.cancel() - self._leader_beacon_timeout = None - - if self.native_engine: - self.native_engine.become_follower(self.term) - - self.last_followed = self.get_now() - self.schedule_follower_timeout() - - def schedule_follower_timeout(self): - if self._follower_timeout: - self._follower_timeout.cancel() - timeout = gettimeout(self._follower_min, self._follower_max) - self._follower_timeout_val = timeout - - def _cb(): - self._follower_timeout = None - self.follower_timeout_callback() - - self._follower_timeout = self.schedule_timeout(timeout, _cb) - - def follower_timeout_callback(self): - with self._lock: - if self.state == NodeState.FOLLOWER: - now = self.get_now() - if now - self.last_followed < self._follower_min * 0.001: - self.schedule_follower_timeout() - return - if not self.voting: - # Learner/observer — reset the timer and wait for promotion. - self.schedule_follower_timeout() - return - self.start_pre_vote() - - @lock - def start_pre_vote(self): - if self.state != NodeState.FOLLOWER: - return - voters = [p.node_id for p in self.peers if getattr(p, "voting", True)] - if not voters: - self.become_candidate() - return - self._pre_candidacy = Candidacy(self.term + 1, voters) - last_log_term = self.log.last_term - last_log_index = self.log.index - for peer in self.peers: - if getattr(peer, "voting", True): - peer.pre_request_vote( - self.pre_request_vote_reply, - self.node_id, - self.term + 1, - last_log_term, - last_log_index, - ) - # Re-arm the follower timeout so a fresh attempt fires if no peer - # replies (e.g. peers not yet up at startup). Without this, an - # isolated voter is stuck after a single failed pre-vote attempt - # — once a peer comes online no append-entries arrive to wake it - # up because nobody is leader yet. If the pre-vote *does* elect, - # the candidate path immediately moves us out of FOLLOWER state - # and follower_timeout_callback short-circuits, so this re-arm - # is harmless on the success path. - self.schedule_follower_timeout() - - @lock - def pre_request_vote_reply(self, peer_id, granted, term): - if term > self.term + 1: - self.become_follower(term - 1) - return - if self.state == NodeState.FOLLOWER and self._pre_candidacy: - # Tests might pass older term (e.g. 0 when we pre-vote for 1) - # We care about whether the vote is granted for OUR current pre-vote attempt - self._pre_candidacy.handle_reply(peer_id, self._pre_candidacy.term, granted) - if self._pre_candidacy.elected(): - self._pre_candidacy = None - self.become_candidate() - - @lock - def pre_request_vote( - self, address, term, last_log_term=None, last_log_index=None, **kwargs - ): - """ - Evaluate a pre-vote request *without* mutating local state. - - Pre-vote is the disturb-protection layer of Raft (Ongaro - thesis §9.6): a candidate asks "would you grant a real vote?" - before bumping the term and disrupting the cluster. The - receiver MUST answer with a hypothetical decision without - changing its own term, voted_for, leader, or follower-timer - state — otherwise the very disturbance pre-vote is supposed - to prevent leaks back in. - - Concrete failure mode of the previous (state-mutating) version: - under CPU stress one survivor's election timer fires - repeatedly, each pre-vote bumps the other survivor's term and - called ``become_follower()``, which reset ``last_followed`` - and restarted the follower timer. The other survivor then - never fired its OWN election timer (it was being kept "fresh" - by the disturb), so two survivors both stayed quiet -> no - re-election -> test fails. Reproduced 5/20 fail under - stress-ng on debian-12; with this fix it's expected to - converge. - """ - llt = last_log_term if last_log_term is not None else kwargs.get("last_term") - lli = last_log_index if last_log_index is not None else kwargs.get("last_index") - - now = self.get_now() - lease_active = (now - self.last_followed) < self._follower_min * 0.001 - - llt_val = llt if llt is not None else 0 - lli_val = lli if lli is not None else -1 - log_ok = llt_val > self.log.last_term or ( - llt_val == self.log.last_term and lli_val >= self.log.index - ) - - # Grant if (a) the proposed term is in the future relative to - # ours and (b) the candidate's log is at least as up-to-date - # and (c) we haven't heard from a leader recently (the - # disturb-protection lease). Note: we deliberately use - # ``term > self.term`` rather than the previous ``term == - # self.term + 1`` because we don't update term here — the - # candidate may legitimately be one or several terms ahead of - # us if we were partitioned, and the question is just whether - # we'd grant if asked for real. - granted = (term > self.term) and log_ok and not lease_active - return granted, self.term, self.leader_client_address - - @lock - def request_vote( - self, address, term, last_log_term=None, last_log_index=None, **kwargs - ): - llt = last_log_term if last_log_term is not None else kwargs.get("last_term") - lli = last_log_index if last_log_index is not None else kwargs.get("last_index") - - if term > self.term: - self.become_follower(term) - - now = self.get_now() - my_last_term = self.log.last_term - my_last_index = self.log.index - llt_val = llt if llt is not None else 0 - lli_val = lli if lli is not None else -1 - log_ok = llt_val > my_last_term or ( - llt_val == my_last_term and lli_val >= my_last_index - ) - - granted = ( - (term == self.term) - and (self.voted_for is None or self.voted_for == address) - and log_ok - ) - - if granted: - self.voted_for = address - self.last_followed = now - return granted, self.term, self.leader_client_address - - @lock - def handle_append_entries( - self, - leader_id, - term, - prev_log_term, - prev_log_index, - leader_commit, - *entries, - **kwargs, - ): - if term < self.term: - return ( - False, - self.term, - self.log.index, - self.term, - self.leader_client_address, - ) - if term > self.term: - # Observability: silent term advances are confusing for operators - # (the BECOMING FOLLOWER log only fires via Node.become_follower, - # not this AppendEntries-driven path). One INFO per transition. - log.info( - "Node %s advancing term %s -> %s on AppendEntries from %s", - self.node_id, - self.term, - term, - leader_id, - ) - self.term = term - - self.state.become_follower() - self.leader = leader_id - self.last_followed = self.get_now() - self.schedule_follower_timeout() - - lca = kwargs.get("leader_client_address") - if lca: - self.leader_client_address_map[leader_id] = lca - - # Log matching - if prev_log_index >= 0: - e = self.log.get(prev_log_index) - if not e or e.term != prev_log_term: - return ( - False, - self.term, - self.log.index, - self.term, - self.leader_client_address, - ) - - curr_idx = prev_log_index + 1 - for entry in entries: - # Handle normalized entry formats (LogEntry, tuple/list, or _asdict() dict) - if isinstance(entry, dict): - e_term = entry.get("term", self.term) - e_cmd = entry.get("cmd", entry) - e_type = entry.get("type", LogEntryType.COMMAND) - else: - e_term = getattr( - entry, - "term", - entry[0] if isinstance(entry, (list, tuple)) else self.term, - ) - e_cmd = getattr( - entry, - "cmd", - entry[2] if isinstance(entry, (list, tuple)) else entry, - ) - e_type = getattr( - entry, - "type", - ( - entry[4] - if isinstance(entry, (list, tuple)) - else LogEntryType.COMMAND - ), - ) - - # Use log.add with explicit index to trigger conflict detection and truncation - self.log.add(e_term, e_cmd, index=curr_idx, entry_type=e_type) - - if e_type == LogEntryType.CONFIG: - voters = e_cmd.get("voters", []) if isinstance(e_cmd, dict) else e_cmd - learners = e_cmd.get("learners", []) if isinstance(e_cmd, dict) else [] - self._applied_config_index = curr_idx - self.on_config_change(voters, learners=learners) - - curr_idx += 1 - - if leader_commit > self.log.commit_index: - self.log.commit(min(leader_commit, self.log.index)) - self.apply_entries() - - return True, self.term, self.log.index, self.term, self.leader_client_address - - def append_entries(self, *args, **kwargs): - return self.handle_append_entries(*args, **kwargs) - - @lock - def log_add( - self, data, entry_type=LogEntryType.COMMAND, client_id=None, sequence_num=None - ): - if self.state != NodeState.LEADER: - raise NotLeader() - index = self.log.append( - self.term, - data, - entry_type=entry_type, - client_id=client_id, - sequence_num=sequence_num, - ) - if entry_type == LogEntryType.CONFIG: - voters = data.get("voters", []) if isinstance(data, dict) else data - learners = data.get("learners", []) if isinstance(data, dict) else [] - self._applied_config_index = index - self.on_config_change(voters, learners=learners) - for peer in self.peers: - self.send_append_entries(peer) - return index - - def append(self, data, client_id=None, sequence_num=None): - return self.log_add(data, client_id=client_id, sequence_num=sequence_num) - - # ------------------------------------------------------------------ - # Auto-replacement primitives (Ongaro thesis §6.4 single-server) - # ------------------------------------------------------------------ - # - # These methods are the leader-side mechanics for swapping a failed - # voter out and a healthy learner in. Each is a *one-step* membership - # change: the resulting CONFIG entry differs from the previous one by - # a single membership transition. Single-step changes are safe - # without joint consensus iff quorum overlap holds between old and - # new configurations — which is what makes the safety check below - # load-bearing. - - @lock - def propose_voter_demotion(self, peer_id, min_voters=3): - """ - Demote a voter to learner by writing a CONFIG entry. - - Returns ``True`` if a CONFIG entry was proposed, ``False`` if - any precondition failed (with a logged reason). Preconditions: - - * This node must be leader. - * ``peer_id`` must currently be a voter in the membership SM. - * After removal, the remaining voter set must still satisfy - ``len(voters) >= min_voters``. This is the safety floor that - protects against accidentally stalling a small cluster. - - The demoted voter is **moved** to learners rather than removed - entirely so that ``propose_voter_promotion_to_replace`` (or a - future re-promotion path) can pick it up cheaply when it - recovers. - """ - if self.state != NodeState.LEADER: - log.warning( - "propose_voter_demotion(%s) refused: this node is not leader", - peer_id, - ) - return False - current_voters = list(self.membership_sm.current_voters()) - current_learners = list(self.membership_sm.current_learners()) - if peer_id not in current_voters: - log.warning( - "propose_voter_demotion(%s) refused: not in current voters", - peer_id, - ) - return False - new_voters = [v for v in current_voters if v != peer_id] - if len(new_voters) < min_voters: - log.warning( - "propose_voter_demotion(%s) refused: would leave %d voters " - "(below cluster_min_voters=%d)", - peer_id, - len(new_voters), - min_voters, - ) - return False - new_learners = sorted(set(current_learners) | {peer_id}) - log.info( - "propose_voter_demotion(%s): voters=%s learners=%s", - peer_id, - new_voters, - new_learners, - ) - self.log_add( - {"voters": new_voters, "learners": new_learners}, - entry_type=LogEntryType.CONFIG, - ) - return True - - @lock - def propose_voter_promotion_to_replace(self, replacement_id): - """ - Promote a caught-up learner to voter. - - Returns ``True`` if a CONFIG entry was proposed, ``False`` if - any precondition failed. Preconditions: - - * This node must be leader. - * ``replacement_id`` must currently be a learner in the - membership SM. - * The learner must be caught up: ``match_index[replacement_id] - >= self.log.index``. - * ``cluster_max_voters`` (the existing cap) must allow another - voter — counted as ``len(current_voters) < max_voters``. - - This is the symmetric counterpart of ``propose_voter_demotion``; - the leader's health watchdog calls this immediately after a - successful demotion to keep the voter set sized. - """ - if self.state != NodeState.LEADER: - log.warning( - "propose_voter_promotion_to_replace(%s) refused: " - "this node is not leader", - replacement_id, - ) - return False - current_voters = list(self.membership_sm.current_voters()) - current_learners = list(self.membership_sm.current_learners()) - if replacement_id not in current_learners: - log.warning( - "propose_voter_promotion_to_replace(%s) refused: not a learner", - replacement_id, - ) - return False - if self.match_index.get(replacement_id, -1) < self.log.index: - log.warning( - "propose_voter_promotion_to_replace(%s) refused: learner " - "has not caught up (match_index=%s, log.index=%s)", - replacement_id, - self.match_index.get(replacement_id, -1), - self.log.index, - ) - return False - if self.max_voters is not None and len(current_voters) >= self.max_voters: - log.warning( - "propose_voter_promotion_to_replace(%s) refused: voter set " - "already at cluster_max_voters=%s", - replacement_id, - self.max_voters, - ) - return False - new_voters = sorted(set(current_voters) | {replacement_id}) - new_learners = [l for l in current_learners if l != replacement_id] - log.info( - "propose_voter_promotion_to_replace(%s): voters=%s learners=%s", - replacement_id, - new_voters, - new_learners, - ) - self.log_add( - {"voters": new_voters, "learners": new_learners}, - entry_type=LogEntryType.CONFIG, - ) - return True - - def on_config_change(self, voters, learners=None): - # Update this node's own voting status. - if voters and self.node_id in voters: - self.voting = True - elif learners and self.node_id in learners: - self.voting = False - - if self._peer_factory: - new_peers = [] - existing_peers = {p.node_id: p for p in self.peers} - - for addr in voters: - if addr != self.node_id: - if addr in existing_peers: - p = existing_peers[addr] - p.voting = True - new_peers.append(p) - else: - new_peers.append(self._peer_factory(addr, voting=True)) - for addr in learners or []: - if addr != self.node_id: - if addr in existing_peers: - p = existing_peers[addr] - p.voting = False - new_peers.append(p) - else: - new_peers.append(self._peer_factory(addr, voting=False)) - self.peers = new_peers - else: - # No factory — update voting flags on existing peers in-place. - voter_set = set(voters or []) - learner_set = set(learners or []) - for p in self.peers: - if p.node_id in voter_set: - p.voting = True - elif p.node_id in learner_set: - p.voting = False - - def info(self): - with self._lock: - info = { - "node_id": self.node_id, - "address": self.address, - "term": self.term, - "state": str(self.state), - "voting": self.voting, - "leader": self.leader, - "leader_client_address": self.leader_client_address, - "last_index": self.log.index, - "commit_index": self.log.commit_index, - "last_applied": self.log.last_applied, - } - if self.membership_sm is not None: - info["membership"] = { - "voters": self.membership_sm.current_voters(), - "learners": self.membership_sm.current_learners(), - "version": self.membership_sm.membership_version, - } - return info - - @property - def leader_client_address(self): - if self.state == NodeState.LEADER: - return self.client_address - return self.leader_client_address_map.get(self.leader) - - def install_snapshot(self, leader_id, term, last_index, last_term, data, **kwargs): - with self._lock: - if term < self.term: - return self.term, self.leader_client_address - self.term = term - self.become_follower() - self.leader = leader_id - self.last_followed = self.get_now() - self.schedule_follower_timeout() - - if self.log.last_included_index >= last_index: - return self.term, self.node_id - - # Keep entries that follow the snapshot - self.log.entries = [e for e in self.log.entries if e.index > last_index] - self.log.last_included_index = last_index - self.log.last_included_term = last_term - - # Dispatch to every registered SM (state_machine + membership_sm). - # Legacy single-SM payloads still flow to state_machine via - # restore_state_machines_from_data's fallback path. - self.log.restore_state_machines_from_data(data) - - # restore_snapshot is a pure store; reconcile so Node.peers / - # Node.voting and any wired on_change hook re-converge with the - # restored membership SM (CONFIG entries it derived from were - # compacted away). - self.reconcile_membership() - - self.log.commit_index = max(self.log.commit_index, last_index) - self.log.last_applied = max(self.log.last_applied, last_index) - self.apply_entries() - return self.term, self.node_id - - @lock - def install_snapshot_reply(self, peer_id, term): - if term > self.term: - self.become_follower(term) - - def candidacy_timeout_callback(self, candidacy): - with self._lock: - if self.state == NodeState.CANDIDATE and self.candidacy == candidacy: - if candidacy.term < self.term: - self.candidacy = None - else: - self.become_candidate() - elif self.candidacy and self.candidacy == candidacy: - self.candidacy = None - - def __repr__(self): - return f"" - - @property - def commit_index(self): - return self.log.commit_index - - @commit_index.setter - def commit_index(self, val): - self.log.commit_index = val - self.apply_entries() - - # Check if we should snapshot now that commit_index advanced and entries were applied - if self.log.max_log_size and len(self.log.entries) >= self.log.max_log_size: - if self.log.entries and self.log.commit_index >= self.log.entries[0].index: - self.log.snapshot() - - @property - def last_applied(self): - return self.log.last_applied - - @last_applied.setter - def last_applied(self, val): - self.log.last_applied = val - - @property - def state_machine(self): - return self.log.state_machine - - -class NotLeader(Exception): - pass - - -class LockingNode(Node): - def __init__(self, *args, **kwargs): - kwargs.setdefault("_lock", threading.RLock()) - super().__init__(*args, **kwargs) diff --git a/salt/cluster/consensus/raft/scheduler.py b/salt/cluster/consensus/raft/scheduler.py deleted file mode 100644 index 6e0de3e08ec5..000000000000 --- a/salt/cluster/consensus/raft/scheduler.py +++ /dev/null @@ -1,177 +0,0 @@ -""" -Timeout scheduling for the Raft node. - -Provides manual, threaded, and asynchronous schedulers so election and -heartbeat timers stay outside the core state machine. Production Salt -runs the consensus event loop inside ``MasterPubServerChannel._publish_daemon`` -(Tornado on top of asyncio) and uses :class:`AsyncTimeoutScheduler`; tests -use :class:`ManualTimeoutScheduler` for deterministic time. -""" - -import asyncio -import logging -import threading -import time - -log = logging.getLogger(__name__) - - -class TimeoutHandle: - def __init__(self, scheduler, handle, callback): - self.scheduler = scheduler - self.handle = handle # Can be time (float) or asyncio.Handle/Task - self.callback = callback - self.cancelled = False - - def cancel(self): - if self.cancelled: - return - self.cancelled = True - - if hasattr(self.handle, "cancel"): - # asyncio Handle or Task - self.handle.cancel() - else: - # Manual/Threaded float time - lock = getattr(self.scheduler, "_lock", None) - if lock: - with lock: - self._do_manual_cancel() - else: - self._do_manual_cancel() - - def _do_manual_cancel(self): - if hasattr(self.scheduler, "timeouts"): - if self.handle in self.scheduler.timeouts: - if self.scheduler.timeouts[self.handle] == self.callback: - self.scheduler.timeouts.pop(self.handle) - - -class TimeoutScheduler: - def __init__(self): - self.timeouts = {} - - def schedule(self, timeout, callback): - t = time.monotonic() + timeout - # Avoid clobbering an existing timeout scheduled for the exact same - # instant (millisecond-granularity randoms collide easily under the - # manual-clock tests). Nudge forward by a tiny epsilon until unique. - while t in self.timeouts: - t += 1e-9 - self.timeouts[t] = callback - return TimeoutHandle(self, t, callback) - - def process_timeouts(self): - for t in list(self.timeouts.keys()): - if time.monotonic() > t: - cb = self.timeouts.pop(t) - cb() - - -class ManualTimeoutScheduler(TimeoutScheduler): - def __init__(self): - super().__init__() - self.timeouts = {} - self.time = 0 - - def schedule(self, timeout, callback): - t = self.time + timeout - # Same collision avoidance as the base scheduler; the manual clock - # doesn't advance between successive schedule() calls, so identical - # (self.time, timeout) pairs would otherwise silently overwrite one - # another and drop callbacks (or duplicate them into the wrong slot). - while t in self.timeouts: - t += 1e-9 - self.timeouts[t] = callback - return TimeoutHandle(self, t, callback) - - def advance_clock_to_next_timeout(self): - if not self.timeouts: - return - self.time = sorted(self.timeouts.keys())[0] - return True - - def process_timeouts(self): - for t in sorted(list(self.timeouts.keys())): - if self.time >= t: - cb = self.timeouts.pop(t) - cb() - - def process_existing_timeouts(self): - for t in sorted(list(self.timeouts.keys())): - cb = self.timeouts.pop(t) - cb() - - -class AsyncTimeoutScheduler: - def __init__(self, loop=None): - self.loop = loop or asyncio.get_event_loop() - - def schedule(self, timeout, callback): - # We need a handle that we can check for cancellation inside the wrapper - class State: - cancelled = False - - state = State() - - def _wrapper(): - if state.cancelled: - return - if asyncio.iscoroutinefunction(callback): - self.loop.create_task(callback()) - else: - callback() - - inner_handle = self.loop.call_later(timeout, _wrapper) - - # Create a custom handle that cancels both the state and the timer - class AsyncHandle: - def cancel(self): - state.cancelled = True - inner_handle.cancel() - - return TimeoutHandle(self, AsyncHandle(), callback) - - def stop(self): - pass - - -class ThreadedTimeoutScheduler: - def __init__(self): - self.timeouts = {} - self._lock = threading.Lock() - self._running = threading.Event() - self._thread = None - - def start(self): - self._running.set() - self._thread = threading.Thread(target=self._run, daemon=True) - self._thread.start() - - def stop(self): - self._running.clear() - if self._thread: - self._thread.join(timeout=1.0) - - def schedule(self, timeout, callback): - with self._lock: - t = time.monotonic() + timeout - while t in self.timeouts: - t += 1e-9 - self.timeouts[t] = callback - return TimeoutHandle(self, t, callback) - - def _run(self): - while self._running.is_set(): - now = time.monotonic() - to_call = [] - with self._lock: - for t in list(self.timeouts.keys()): - if now >= t: - to_call.append(self.timeouts.pop(t)) - for cb in to_call: - try: - cb() - except Exception: # pylint: disable=broad-except - log.exception("Error in timeout callback") - time.sleep(0.01) diff --git a/salt/cluster/consensus/raft/util.py b/salt/cluster/consensus/raft/util.py deleted file mode 100644 index dcb708c8cb0c..000000000000 --- a/salt/cluster/consensus/raft/util.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Small helpers for the Raft package (random election jitter, optional socket -checks, dynamic class loading). -""" - -import functools -import logging -import random -import socket -import string - -log = logging.getLogger(__name__) - - -def log_generator(size=6, chars=string.ascii_uppercase + string.digits): - """Generate a random string of specified size.""" - return "".join(random.choice(chars) for _ in range(size)) - - -def gettimeout(_min, _max): - """Return a random timeout in seconds within the specified millisecond range.""" - return random.randint(_min, _max) * 0.001 - - -def is_socket_closed(sock: socket.socket) -> bool: - """Check non-blockingly if a TCP socket has been closed by the peer.""" - try: - # this will try to read bytes without blocking and also without removing them from buffer (peek only) - data = sock.recv(16, socket.MSG_DONTWAIT | socket.MSG_PEEK) - if len(data) == 0: - log.warning("Empty data") - return True - except BlockingIOError: - return False # socket is open and reading from it would block - except ConnectionResetError: - log.warning("Connection reset") - return True # socket was closed for some other reason - except OSError as exc: - if exc.errno == 107: # Transport endpoint is not connected - log.warning("Endpoint not connected") - return False - elif exc.errno == 9: # Bad File Descriptor - log.warning("Bad file descripor") - return True - log.exception("unexpected exception when checking if a socket is closed") - return False - except Exception: # pylint: disable=broad-except - log.exception("unexpected exception when checking if a socket is closed") - return False - return False - - -def log_exceptions_async(func): - """Log unhandled exceptions in asynchronous functions as a decorator.""" - - @functools.wraps(func) - async def wrapped(*args, **kwargs): - try: - return await func(*args, **kwargs) - except Exception: - log.exception("Unhandled exception in %r", func) - raise - - return wrapped - - -def log_exceptions(func): - """Log unhandled exceptions in synchronous functions as a decorator.""" - - @functools.wraps(func) - def wrapped(*args, **kwargs): - try: - return func(*args, **kwargs) - except Exception: - log.exception("Unhandled exception in %r", func) - raise - - return wrapped - - -def load_class(path): - """ - Dynamically load a class from a string path. - - Example: ``salt.cluster.consensus.raft.log.CounterStateMachine``. - """ - import importlib - - try: - module_path, class_name = path.rsplit(".", 1) - module = importlib.import_module(module_path) - return getattr(module, class_name) - except (ImportError, AttributeError, ValueError) as e: - raise ImportError(f"Failed to load class from {path}: {e}") diff --git a/salt/cluster/consensus/rpc.py b/salt/cluster/consensus/rpc.py deleted file mode 100644 index ee24a3e7ced6..000000000000 --- a/salt/cluster/consensus/rpc.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -Raft RPC wire layer for the Salt cluster channel. - -Tags follow the existing ``cluster/raft/`` naming convention so they -are multiplexed over ``cluster_pool_port`` by ``handle_pool_publish`` in -``salt.channel.server.MasterPubServerChannel`` alongside the existing -``cluster/peer`` and ``cluster/event`` traffic. - -Each Raft RPC is packed as a plain dict via ``salt.payload`` (msgpack) and -wrapped inside the event envelope understood by the pool puller: - - tag : "cluster/raft/" - data : {"src": , "rpc_id": , - "raft_group_id": , "payload": } - -``raft_group_id`` identifies which Raft group the RPC belongs to so a -single master process can host multiple coexisting groups (the main -cluster group plus per-ring groups). Older envelopes that pre-date -multi-ring support omit the field and are interpreted as the -``"cluster"`` group. -""" - -import logging - -import salt.payload -import salt.utils.event - -log = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Tag constants -# --------------------------------------------------------------------------- - -REQUEST_VOTE = "cluster/raft/request-vote" -REQUEST_VOTE_REPLY = "cluster/raft/request-vote-reply" -PRE_REQUEST_VOTE = "cluster/raft/pre-request-vote" -PRE_REQUEST_VOTE_REPLY = "cluster/raft/pre-request-vote-reply" -APPEND_ENTRIES = "cluster/raft/append-entries" -APPEND_ENTRIES_REPLY = "cluster/raft/append-entries-reply" -INSTALL_SNAPSHOT = "cluster/raft/install-snapshot" -INSTALL_SNAPSHOT_REPLY = "cluster/raft/install-snapshot-reply" - -ALL_TAGS = frozenset( - { - REQUEST_VOTE, - REQUEST_VOTE_REPLY, - PRE_REQUEST_VOTE, - PRE_REQUEST_VOTE_REPLY, - APPEND_ENTRIES, - APPEND_ENTRIES_REPLY, - INSTALL_SNAPSHOT, - INSTALL_SNAPSHOT_REPLY, - } -) - - -def is_raft_tag(tag): - """Return True if *tag* is a Raft RPC tag we own.""" - return tag.startswith("cluster/raft/") - - -# --------------------------------------------------------------------------- -# Pack / unpack helpers -# --------------------------------------------------------------------------- - - -def pack(tag, src, rpc_id, payload, raft_group_id="cluster"): - """ - Serialise a Raft RPC into the bytes the pool puller expects. - - :param tag: One of the ``cluster/raft/*`` constants above. - :param src: Sender node-id (``opts["interface"]``) — - matches the cluster-wide identity used by - ``RaftService`` and the ``cluster_peers`` - opt; not the daemon's ``opts["id"]``. - :param rpc_id: Opaque correlation string chosen by the - caller. - :param payload: Dict of RPC-specific fields. - :param raft_group_id: Identifier of the Raft group this RPC - belongs to. ``"cluster"`` (default) is the - main cluster group; named rings (e.g. - ``"jobs"``) get their own group ids. - :returns: Raw bytes ready for ``pusher.publish()``. - """ - data = { - "src": src, - "rpc_id": rpc_id, - "raft_group_id": raft_group_id, - "payload": payload, - } - return salt.utils.event.SaltEvent.pack(tag, data) - - -def unpack(raw): - """ - Deserialise raw bytes from the pool puller back into - ``(tag, src, rpc_id, raft_group_id, payload)``. - - Envelopes that pre-date the multi-ring extension omit the - ``raft_group_id`` field; those are interpreted as the main - cluster group (``"cluster"``). - - :raises ValueError: if the envelope is missing required keys. - """ - tag, data = salt.utils.event.SaltEvent.unpack(raw) - try: - return ( - tag, - data["src"], - data["rpc_id"], - data.get("raft_group_id", "cluster"), - data["payload"], - ) - except KeyError as exc: - raise ValueError( - f"Malformed Raft RPC envelope (missing {exc}): {data!r}" - ) from exc diff --git a/salt/cluster/consensus/service.py b/salt/cluster/consensus/service.py deleted file mode 100644 index 5a07e10beba3..000000000000 --- a/salt/cluster/consensus/service.py +++ /dev/null @@ -1,1145 +0,0 @@ -""" -RaftService — lifecycle manager for the Raft node inside a Salt master. - -Responsibilities ----------------- -* Construct and own the ``Node`` instance. -* Wire ``AsyncTimeoutScheduler`` to the asyncio event loop already running - inside ``MasterPubServerChannel._publish_daemon``. -* Build one ``SaltPeer`` per entry in ``opts["cluster_peers"]``, keyed to - the per-peer ``PublishServer`` pushers that ``_publish_daemon`` already - created. -* Construct ``RaftDispatcher`` and hand it to - ``MasterPubServerChannel._raft_dispatcher`` so that - ``handle_pool_publish`` can route inbound Raft RPCs. -* Start the Raft election timer and, when elected leader, drive periodic - heartbeats. -* Handle dynamic peer joins: when a new master completes the Salt-level - cluster join, ``notify_peer_joined`` adds it as a non-voting learner. - The leader will automatically promote it to voter once its log catches up. - -Threading / concurrency model ------------------------------- -``RaftService`` is created and runs entirely inside the -``EventPublisher`` subprocess owned by ``MasterPubServerChannel``. All -methods that touch the ``Node`` are called from the asyncio event loop -that Tornado wraps; the Raft core remains synchronous. - -Usage (inside ``_publish_daemon``) ------------------------------------ -:: - - service = RaftService(opts, aio_loop, peer_pushers) - service.attach(channel) # sets channel._raft_dispatcher - service.start() # begins election timer -""" - -import logging - -from salt.cluster.consensus.peer import RaftDispatcher, SaltPeer -from salt.cluster.consensus.raft import AsyncTimeoutScheduler, Node -from salt.cluster.consensus.raft.log import ( - RING_MEMBERS_VOTERS, - RING_STATUS_ACTIVE, - RING_STATUS_DESTROYED, - RING_STATUS_VALID, - LogEntryType, - RingConfigStateMachine, - RingRegistryStateMachine, - RoutingStateMachine, -) -from salt.cluster.consensus.raft.node import NodeState -from salt.cluster.consensus.storage import SaltStorage - -log = logging.getLogger(__name__) - -# Heartbeat interval sent by the leader (seconds). Must be well below the -# follower election timeout floor (~0.15 s per gettimeout defaults). -_HEARTBEAT_INTERVAL = 0.05 - - -class RaftService: - """ - Owns the Raft ``Node`` for one Salt master process. - - :param opts: Salt master opts dict. - :param loop: The *asyncio* event loop running in this process. - :param peer_pushers: ``dict[peer_addr, PublishServer]`` - the pushers - ``_publish_daemon`` already created, keyed by the - peer's interface address (``opts["cluster_peers"]`` - entry). - """ - - def __init__(self, opts, loop, peer_pushers, voting=True, on_ready=None): - self.opts = opts - self.loop = loop - self._peer_pushers = dict(peer_pushers) # addr -> PublishServer (mutable copy) - self._on_ready = on_ready - - # Use the interface address as the Raft node-id so it matches the - # keys in cluster_peers and the peer_pushers dict. opts["id"] is - # the hostname which remote masters do not share; the interface - # address is the consistent cluster-wide identity. - node_id = opts["interface"] - storage = SaltStorage(node_id, opts) - # voting=False means this node joined dynamically and must wait for a - # CONFIG entry from the leader before participating in elections. - # - # The default Node election window of 150–300 ms is too tight for - # multi-process Salt masters running over real sockets — a single - # delayed heartbeat in CI can cause a follower to step up and fight - # the existing leader for the term. At ``_HEARTBEAT_INTERVAL`` = - # 50 ms the rule of thumb is election >= 10× heartbeat, so default - # to 750–1500 ms here. ``cluster_election_min`` / - # ``cluster_election_max`` opts let deployments tune further. - election_min = opts.get("cluster_election_min", 750) - election_max = opts.get("cluster_election_max", 1500) - # ``cluster_max_log_size`` (default ``None``) gates Raft log - # compaction. When unset, the log keeps every committed entry - # forever and snapshots never fire — fine for small clusters - # but pathological for any long-running deployment. When set, - # the membership SM round-trips through the snapshot envelope - # (raft.snapshot.v1); CONSENSUS_BUGS.md #1's fix and the - # reconcile_membership hook ensure peer state survives. - max_log_size = opts.get("cluster_max_log_size") - # ``cluster_max_voters`` (default ``None``) caps how many peers - # the leader will auto-promote out of the learner pool. When - # the cap is hit, additional joiners stay as non-voting log - # replicas indefinitely. - max_voters = opts.get("cluster_max_voters") - self._node = Node( - node_id, - storage=storage, - voting=voting, - _follower_min=election_min, - _follower_max=election_max, - max_log_size=max_log_size, - max_voters=max_voters, - ) - # ``_nodes`` is the multi-ring registry: keys are Raft group - # ids, values are the local ``Node`` instances. Slice 1 only - # carries the main cluster group; later slices spawn per-ring - # Nodes here as the cluster log commits RING_REGISTRY entries. - # ``self._node`` (singular) stays as a convenience handle to - # the cluster Node — every existing call site reads it through - # that name. - self._nodes = {"cluster": self._node} - self._scheduler = AsyncTimeoutScheduler(loop=loop) - self._node.register_schedule_timeout(self._scheduler.schedule) - - # Wire the membership SM's on_change so we can fire on_ready once - # this node appears in the committed voter set. - self._node.membership_sm.on_change = self._on_membership_change - - # Ring registry SM: cluster-log inventory of named rings (one - # per shardable cache). Slice 2 of the multi-ring rollout — - # bringing up / tearing down per-ring Raft groups is wired in - # slice 3; for now we just track and persist the registry so - # an operator can record the desired topology. - self._ring_registry_sm = RingRegistryStateMachine( - on_change=self._on_ring_registry_change - ) - self._node.log.register_state_machine( - "ring_registry_sm", self._ring_registry_sm - ) - - # Routing SM: cluster-log data-type -> ring mapping (e.g. - # "jobs" -> "jobs_ring", "events" -> None for broadcast). - # Gate sites consult the routing table once it's populated; - # absent entries mean broadcast, preserving today's behaviour. - self._routing_sm = RoutingStateMachine(on_change=self._on_route_change) - self._node.log.register_state_machine("routing_sm", self._routing_sm) - - # ``Node.__init__`` already loaded any snapshot, but only the - # state machines registered at construction time - # (``membership_sm``) saw the restore. Re-run the restore - # now that ``ring_sm`` / ``ring_registry_sm`` / ``routing_sm`` - # are registered so a master coming back from a snapshot - # rebuilds them from disk instead of starting empty (which - # would be silently wrong under log compaction). - if storage is not None: - try: - snap = storage.load_snapshot() - except Exception: # pylint: disable=broad-except - snap = None - if snap and "data" in snap: - self._node.log.restore_state_machines_from_data(snap["data"]) - - # The snapshot restore above populated the registry but did - # not fire its ``on_change`` (``restore_snapshot`` is a pure - # store). Drive bring-up for any active rings that this - # master was hosting before the restart so the per-ring - # ``Node`` instances reattach to the dispatcher. Idempotent - # — ``_bring_up_ring`` no-ops when the ring is already up. - for ring_id in self._ring_registry_sm.active_rings(): - entry = self._ring_registry_sm.get(ring_id) or {} - self._on_ring_registry_change( - ring_id, - entry.get("founding_voters", []), - entry.get("status", RING_STATUS_ACTIVE), - ) - - # Build SaltPeer objects - one per cluster peer (all voting at start). - peers = [ - SaltPeer(addr, pusher, node_id) for addr, pusher in peer_pushers.items() - ] - self._node.peers = peers - - # Register a peer factory so Node.on_config_change can create SaltPeers - # for addresses that appear in CONFIG entries (covers learner->voter path). - self._node.register_peer_factory(self._make_peer) - - # peer_pushers is keyed by interface address, matching the - # callback_node field written into RPC envelopes. Hand the - # dispatcher the full ``_nodes`` dict so that inbound RPCs are - # routed by ``raft_group_id``; passing ``self._nodes`` - # (instead of a bare ``self._node``) means subsequent - # ``register_ring_node`` calls are visible to the dispatcher - # without extra plumbing. - self._dispatcher = RaftDispatcher(self._nodes, node_id, self._peer_pushers) - - # If Node started from a saved snapshot the membership SM was - # restored before this on_change wiring existed, so the cluster- - # ready / peer-table side effects never fired. Reconcile now so - # _on_ready and on_config_change run for the restored view. - self._node.reconcile_membership() - - self._heartbeat_handle = None - # Voter health / auto-replacement state (Ongaro thesis §6.4). - # ``_recently_demoted`` is leader-local; a new leader on failover - # starts with an empty cooldown table and re-derives unhealthy - # peers from incoming AppendEntries replies. - self._recently_demoted = {} - self._voter_health_handle = None - - # ------------------------------------------------------------------ - # Membership change / readiness - # ------------------------------------------------------------------ - - def _on_membership_change(self, voters, learners): - """ - Called by ``MembershipStateMachine`` after every committed CONFIG entry. - - Fires ``on_ready`` (once) when this node's address first appears in the - committed voter set, signalling that it is a full participant and may - begin serving minion/CLI traffic. - - The default ``"cluster"`` ring is kept in lock-step with the - cluster voter set so pre-multi-ring callers - (``ring_membership.owns(opts, key)`` with no ring name) see a - meaningful answer. Per-ring sharding is driven separately by - each per-ring ``Node``'s own ``MembershipStateMachine``. - """ - # Lazy import keeps the consensus package independent of the - # ring module's load order. - import salt.cluster.ring_membership # pylint: disable=import-outside-toplevel - - # Keep the default "cluster" ring populated with the current - # voter set. Empty ``voters`` shouldn't happen in steady - # state but we tolerate it (an empty ring answers True for - # every owns() — broadcast semantics). - if voters: - salt.cluster.ring_membership.rebuild(list(voters)) - - if self._on_ready is None: - return - node_id = self._node.node_id - if node_id in voters: - log.info( - "RaftService: node %s is now a committed voter — marking cluster ready", - node_id, - ) - self._on_ready() - self._on_ready = None # fire only once - - def _on_ring_registry_change(self, ring_id, founding_voters, status): - """ - Called by :class:`RingRegistryStateMachine` after each - committed ``RING_REGISTRY`` entry. - - * ``status="active"`` and this master is in ``founding_voters``: - bring up the per-ring Raft group inside this process. The - per-ring ``Node`` shares the same asyncio loop, scheduler, - and peer transport as the cluster group; only the on-disk - state and Raft state machines are independent. - * ``status="destroyed"``: tear down the per-ring Node and - drop it from the dispatcher's routing table. - * Otherwise (e.g. an active entry that doesn't list this - master): nothing to do locally. Other masters will own the - ring. - """ - log.info( - "RaftService: ring registry committed — ring=%s status=%s " - "founding_voters=%s", - ring_id, - status, - list(founding_voters or []), - ) - if status == RING_STATUS_DESTROYED: - self._tear_down_ring(ring_id) - return - if self._node.node_id not in (founding_voters or []): - # Not a founder — this ring's data plane runs elsewhere. - return - self._bring_up_ring(ring_id, founding_voters) - - def _bring_up_ring(self, ring_id, founding_voters): - """ - Construct and register the per-ring Raft ``Node`` for this - master. Idempotent: if the ring is already up locally the - call is a no-op. - - The per-ring Node has its own :class:`SaltStorage` keyed by - ``ring_id``, its own ``MembershipStateMachine`` and - ``RingConfigStateMachine`` registered on its log, its own - :class:`SaltPeer` instances stamping ``raft_group_id`` into - outbound RPCs, and its own election + heartbeat path driven - by the shared scheduler. - """ - if ring_id == "cluster": - log.warning( - "RaftService: refusing to bring up a ring named 'cluster' — " - "reserved for the main cluster Raft group" - ) - return - if ring_id in self._nodes: - log.debug( - "RaftService: ring %s already up locally, skipping bring-up", - ring_id, - ) - return - - node_id = self._node.node_id - log.info( - "RaftService: bringing up ring %s with founders=%s", - ring_id, - sorted(founding_voters or []), - ) - storage = SaltStorage(node_id, self.opts, ring_id=ring_id) - # Election windows reuse the same opts as the cluster Node — - # if the operator tuned them for their environment, the - # per-ring nodes inherit the tuning automatically. - election_min = self.opts.get("cluster_election_min", 750) - election_max = self.opts.get("cluster_election_max", 1500) - ring_node = Node( - node_id, - storage=storage, - voting=True, - _follower_min=election_min, - _follower_max=election_max, - max_log_size=self.opts.get("cluster_max_log_size"), - max_voters=self.opts.get("cluster_max_voters"), - ) - ring_node.register_schedule_timeout(self._scheduler.schedule) - # Per-ring RingConfigStateMachine: each ring has its own - # members/replicas policy, persisted in its own snapshot - # envelope. - ring_config_sm = RingConfigStateMachine( - on_change=lambda m, r, _ring_id=ring_id: self._on_ring_config_change_for( - _ring_id, m, r - ) - ) - ring_node.log.register_state_machine("ring_sm", ring_config_sm) - # Wire the per-ring MembershipStateMachine's on_change so a - # committed CONFIG entry on the ring's log triggers a rebuild - # of the named HashRing. Without this, ring policy = - # ``"voters"`` would see the ring's voter set change without - # the local HashRing reflecting it. - ring_node.membership_sm.on_change = lambda voters, learners, _ring_id=ring_id: self._on_ring_membership_change_for( - _ring_id, voters, learners - ) - # ``Node.__init__`` ran the snapshot restore for - # ``membership_sm`` only. Replay it now that ``ring_sm`` is - # registered so a master that previously hosted this ring - # picks up its committed policy from disk instead of starting - # at the SM default. - try: - snap = storage.load_snapshot() - except Exception: # pylint: disable=broad-except - snap = None - if snap and "data" in snap: - ring_node.log.restore_state_machines_from_data(snap["data"]) - - # Build peers for this ring: every founder other than self. - peers = [] - for addr in sorted(founding_voters or []): - if addr == node_id: - continue - peers.append(self._make_peer(addr, voting=True, raft_group_id=ring_id)) - ring_node.peers = peers - # Peer factory for membership-change-driven additions. - ring_node.register_peer_factory( - lambda addr, voting=True, _ring_id=ring_id: self._make_peer( - addr, voting=voting, raft_group_id=_ring_id - ) - ) - - # Replay any persisted state. ``SaltStorage`` was loaded by - # ``Node.__init__`` already; reconcile fires on_change to - # rebuild peer flags. - ring_node.reconcile_membership() - - self._nodes[ring_id] = ring_node - # Mirror the registration into the dispatcher's routing - # table so inbound RPCs tagged with this ``raft_group_id`` - # land on the new Node. The dispatcher keeps its own dict - # (constructed from a copy at start time) — pushing the - # update explicitly keeps the two in sync without coupling - # them. - self._dispatcher.register_node(ring_id, ring_node) - - ring_node.become_follower() - - def _tear_down_ring(self, ring_id): - """ - Stop the per-ring ``Node`` and drop it from ``self._nodes``. - Idempotent. On-disk state is left in place so an operator - who re-creates the ring with the same id and founders can - recover historical state. - """ - if ring_id == "cluster": - log.warning("RaftService: refusing to tear down the cluster Raft group") - return - ring_node = self._nodes.pop(ring_id, None) - if ring_node is None: - return - self._dispatcher.unregister_node(ring_id) - # Drop the per-process ring snapshot so subsequent - # ``owns_for`` calls treat this master as a non-member of the - # destroyed ring. - import salt.cluster.ring_membership # pylint: disable=import-outside-toplevel - - salt.cluster.ring_membership.drop_ring(ring_id) - log.info("RaftService: tearing down ring %s", ring_id) - try: - ring_node.become_follower() - # Cancel any pending timers stored on the Node; the - # shared scheduler will simply not re-arm them since the - # Node is no longer in ``self._nodes`` and the heartbeat - # tick skips unregistered Nodes. - if getattr(ring_node, "_follower_timeout", None): - ring_node._follower_timeout.cancel() - ring_node._follower_timeout = None - if getattr(ring_node, "_leader_beacon_timeout", None): - ring_node._leader_beacon_timeout.cancel() - ring_node._leader_beacon_timeout = None - except Exception: # pylint: disable=broad-except - log.exception("RaftService: error stopping ring %s", ring_id) - - def _on_ring_membership_change_for(self, ring_id, voters, learners): - """ - Called by a per-ring :class:`MembershipStateMachine` after each - committed CONFIG entry on the *ring's* log. Mirrors the - cluster-side ``_on_membership_change`` but routes the rebuild - to the named ring. Re-applies the current ring policy so the - ``HashRing`` reflects the new voter set. - """ - ring_node = self._nodes.get(ring_id) - if ring_node is None: - return - ring_sm = ring_node.log._extra_state_machines.get("ring_sm") - if ring_sm is None: - return - self._on_ring_config_change_for(ring_id, ring_sm.members, ring_sm.replicas) - - def _on_ring_config_change_for(self, ring_id, members, replicas): - """ - Called by a per-ring :class:`RingConfigStateMachine` after a - ``RING_CONFIG`` commit on that ring's own log. - - Rebuilds the named ring's :class:`HashRing` from the per-ring - Raft group's committed voter set. ``"self"`` means the - local master is the only ring node (broadcast within the - ring); ``"voters"`` means the ring contains every committed - voter and the gate sites will hash by ring ownership. - """ - import salt.cluster.ring_membership # pylint: disable=import-outside-toplevel - - ring_node = self._nodes.get(ring_id) - if ring_node is None: - log.debug( - "RaftService: ring=%s policy commit observed but local " - "Node is not up — skipping rebuild", - ring_id, - ) - return - log.info( - "RaftService: ring=%s policy committed — members=%s replicas=%d", - ring_id, - members, - replicas, - ) - if members == RING_MEMBERS_VOTERS: - voters = ring_node.membership_sm.current_voters() - salt.cluster.ring_membership.rebuild(ring_id, voters, replicas=replicas) - else: # RING_MEMBERS_SELF (default) - salt.cluster.ring_membership.rebuild( - ring_id, [ring_node.node_id], replicas=replicas - ) - - def _on_route_change(self, data_type, ring_id): - """ - Called by :class:`RoutingStateMachine` after each committed - ``ROUTE`` entry. Updates the process-local routing snapshot - consulted by the gate sites in :mod:`salt.master` so a route - flip takes effect on every master without IPC. - """ - import salt.cluster.ring_membership # pylint: disable=import-outside-toplevel - - salt.cluster.ring_membership.set_route(data_type, ring_id) - if ring_id is None: - log.info( - "RaftService: route cleared — data_type=%s now broadcasts", - data_type, - ) - else: - log.info( - "RaftService: route committed — data_type=%s -> ring=%s", - data_type, - ring_id, - ) - - def propose_ring_create(self, ring_id, founding_voters, status=RING_STATUS_ACTIVE): - """ - Propose a ``RING_REGISTRY`` entry creating (or marking - destroyed) the named ring. - - Only valid on the leader. Slice 2 wires the cluster-log - replication path; the per-ring Raft group does not actually - come up until slice 3 attaches its lifecycle to - ``_on_ring_registry_change``. - - :param ring_id: Operator-chosen name for the ring. - :param founding_voters: List of master node-ids that will be - this ring's initial voter set. Sorted - deterministically before the entry is - appended. - :param status: ``"active"`` (default) or - ``"destroyed"``. - - :raises ValueError: if ``ring_id`` is empty or ``status`` is - unknown. - :raises RuntimeError: if this node is not currently the leader. - """ - if not ring_id: - raise ValueError("propose_ring_create requires a non-empty ring_id") - if status not in RING_STATUS_VALID: - raise ValueError( - f"Unknown ring status {status!r}; " - f"expected one of {RING_STATUS_VALID}" - ) - if self._node.state != NodeState.LEADER: - raise RuntimeError( - "propose_ring_create must run on the Raft leader; " - f"this node is in state {self._node.state}" - ) - founders = sorted(founding_voters or []) - cmd = { - "ring_id": ring_id, - "founding_voters": founders, - "status": status, - } - self._node.log_add(cmd, entry_type=LogEntryType.RING_REGISTRY) - - def propose_ring_destroy(self, ring_id): - """ - Propose a ``RING_REGISTRY`` entry marking *ring_id* as - destroyed. Idempotent at the registry level; the on_change - callback decides whether to tear down a per-ring Node based - on its previous status. - - The command omits ``founding_voters`` so the registry SM - preserves the original founder list as audit history — the - operator can still see who founded a ring after it's been - destroyed. - """ - if not ring_id: - raise ValueError("propose_ring_destroy requires a non-empty ring_id") - if self._node.state != NodeState.LEADER: - raise RuntimeError( - "propose_ring_destroy must run on the Raft leader; " - f"this node is in state {self._node.state}" - ) - cmd = {"ring_id": ring_id, "status": RING_STATUS_DESTROYED} - self._node.log_add(cmd, entry_type=LogEntryType.RING_REGISTRY) - - def propose_route(self, data_type, ring_id): - """ - Propose a ``ROUTE`` entry mapping *data_type* to *ring_id*. - - Pass ``ring_id=None`` to clear the route, returning the data - type to broadcast. Only valid on the leader. - - :param data_type: Logical cache identifier (e.g. ``"jobs"``). - :param ring_id: Ring name to route to, or ``None`` for - broadcast. - :raises ValueError: if ``data_type`` is empty. - :raises RuntimeError: if this node is not currently the leader. - """ - if not data_type: - raise ValueError("propose_route requires a non-empty data_type") - if self._node.state != NodeState.LEADER: - raise RuntimeError( - "propose_route must run on the Raft leader; " - f"this node is in state {self._node.state}" - ) - cmd = {"data_type": data_type, "ring_id": ring_id} - self._node.log_add(cmd, entry_type=LogEntryType.ROUTE) - - # ------------------------------------------------------------------ - # Peer factory (used by Node.on_config_change) - # ------------------------------------------------------------------ - - def _make_peer(self, addr, voting=True, raft_group_id="cluster"): - """ - Create a ``SaltPeer`` for *addr*. - - If we already have a pusher for that address (from ``_peer_pushers``) - it is reused; otherwise we create a new one using the cluster port. - Called by :meth:`Node.on_config_change` when a CONFIG log entry is - applied and a previously unknown address appears in the voter list. - - :param raft_group_id: Which Raft group this peer belongs to. - Defaults to ``"cluster"`` so the existing - cluster-Node call site is unchanged; per- - ring bring-up passes the ring's id so the - peer stamps every outbound RPC with the - ring's group id (and the dispatcher on the - receiver routes to the right Node). - """ - import salt.transport.tcp # pylint: disable=import-outside-toplevel - - pusher = self._peer_pushers.get(addr) - if pusher is None: - port = self.opts.get("cluster_port", 55596) - pusher = salt.transport.tcp.PublishServer( - self.opts, - pull_host=addr, - pull_port=port, - ) - self._peer_pushers[addr] = pusher - # Keep the dispatcher's pusher table in sync. - self._dispatcher._pushers[addr] = pusher - return SaltPeer( - addr, - pusher, - self.opts["interface"], - voting=voting, - raft_group_id=raft_group_id, - ) - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def attach(self, channel): - """ - Wire this service into ``MasterPubServerChannel``. - - Sets ``channel._raft_dispatcher`` so that ``handle_pool_publish`` - will route ``cluster/raft/*`` messages here. - """ - channel._raft_dispatcher = self._dispatcher - - def start(self): - """ - Start the Raft node as a follower and arm the election timer. - - Must be called from within the running asyncio event loop. - """ - log.info( - "RaftService: starting node %s with %d peer(s)", - self._node.node_id, - len(self._node.peers), - ) - self._node.become_follower() - self._schedule_heartbeat() - self._schedule_voter_health_check() - - def stop(self): - """Cancel scheduled callbacks and step the node down.""" - if self._heartbeat_handle is not None: - self._heartbeat_handle.cancel() - self._heartbeat_handle = None - if self._voter_health_handle is not None: - self._voter_health_handle.cancel() - self._voter_health_handle = None - log.info("RaftService: stopped node %s", self._node.node_id) - - # ------------------------------------------------------------------ - # Voter health watchdog (Ongaro thesis §6.4 single-server changes) - # ------------------------------------------------------------------ - - def _schedule_voter_health_check(self): - """Re-arm the periodic ``_check_voter_health`` timer.""" - interval = self.opts.get("cluster_voter_health_check_interval", 1.0) - self._voter_health_handle = self._scheduler.schedule( - interval, self._check_voter_health - ) - - def _check_voter_health(self): - """ - Periodic leader-side watchdog, run once per scheduled tick - across every Raft group hosted in this process (cluster + - per-ring). - - For each group where this master is the current leader: walk - the voter set, flag voters whose ``last_contact`` is older - than ``cluster_voter_timeout``, and — if - ``cluster_auto_replace_voters`` is True — propose a single - demotion + replacement promotion per tick (Ongaro thesis §6.4 - single-server change semantics). - - Per-group state: - - * ``self._recently_demoted`` is keyed by ``(group_id, - peer_id)`` so a cooldown on one ring doesn't bleed into - another. - * The on-disk sentinel ``cachedir/cluster-health.json`` is a - structured document with one entry per group — read by - ``cluster.members`` for the cluster group; per-ring - consumers can pull from the same file via ``rings.``. - - Safety: relies on each ``Node.propose_voter_demotion`` to - enforce the ``cluster_min_voters`` floor. The watchdog - never bypasses that. Idempotent on re-entry — a demotion - CONFIG already in flight leaves the demoted peer in - ``current_voters`` until commit, so the precondition check - inside ``propose_voter_demotion`` deduplicates. - """ - try: - self._voter_health_handle = None - now = self._node.get_now() - timeout = self.opts.get("cluster_voter_timeout", 10.0) - cooldown = self.opts.get("cluster_demote_cooldown", 60.0) - auto = self.opts.get("cluster_auto_replace_voters", False) - min_voters = self.opts.get("cluster_min_voters", 3) - - # Expire cooldown entries first so a fresh tick can - # promote a candidate that's just exited its cooldown. - self._recently_demoted = { - key: ts - for key, ts in self._recently_demoted.items() - if now - ts < cooldown - } - - per_group_unhealthy = {} - for group_id, node in list(self._nodes.items()): - if node is None: - continue - unhealthy = [] - if node.state == NodeState.LEADER: - voters = set(node.membership_sm.current_voters()) - for peer in node.peers: - if peer.node_id not in voters: - continue - last = node._peer_last_contact.get(peer.node_id) - if last is None: - continue - if now - last > timeout: - unhealthy.append(peer.node_id) - per_group_unhealthy[group_id] = unhealthy - - self._write_health_sentinel(per_group_unhealthy) - - if not auto: - return - - for group_id, unhealthy in per_group_unhealthy.items(): - if not unhealthy: - continue - node = self._nodes.get(group_id) - if node is None or node.state != NodeState.LEADER: - continue - # One demotion + replacement per group per tick. - target = unhealthy[0] - if node.propose_voter_demotion(target, min_voters=min_voters): - self._recently_demoted[(group_id, target)] = now - learners = node.membership_sm.current_learners() - for candidate in learners: - if (group_id, candidate) in self._recently_demoted: - continue - if node.match_index.get(candidate, -1) < node.log.index: - continue - node.propose_voter_promotion_to_replace(candidate) - break - except Exception: # pylint: disable=broad-except - log.exception("RaftService: voter health check failed") - finally: - self._schedule_voter_health_check() - - def _write_health_sentinel(self, per_group_unhealthy): - """ - Persist a structured health sentinel covering every Raft - group hosted in this process. - - Shape (a single JSON document at - ``cachedir/cluster-health.json``):: - - { - "updated_at": , - "unhealthy_voters": [, …], - "recently_demoted": [, …], - "rings": { - "": { - "unhealthy_voters": […], - "recently_demoted": […], - }, - … - } - } - - The top-level ``unhealthy_voters`` / ``recently_demoted`` - fields preserve the pre-multi-ring shape so the - ``cluster.members`` runner reads them unchanged. Per-ring - consumers reach into ``rings.`` for the same view per - ring. - """ - import json # pylint: disable=import-outside-toplevel - import os # pylint: disable=import-outside-toplevel - import time # pylint: disable=import-outside-toplevel - - import salt.utils.atomicfile # pylint: disable=import-outside-toplevel - - cachedir = self.opts.get("cachedir") - if not cachedir: - return - path = os.path.join(cachedir, "cluster-health.json") - - # Split the cooldown table by group for the per-ring view. - cooldown_by_group = {} - for (group_id, peer_id), _ts in self._recently_demoted.items(): - cooldown_by_group.setdefault(group_id, []).append(peer_id) - - cluster_unhealthy = per_group_unhealthy.get("cluster", []) - cluster_cooldown = cooldown_by_group.get("cluster", []) - - rings = {} - for group_id, unhealthy in per_group_unhealthy.items(): - if group_id == "cluster": - continue - rings[group_id] = { - "unhealthy_voters": sorted(unhealthy), - "recently_demoted": sorted(cooldown_by_group.get(group_id, [])), - } - - body = { - "updated_at": time.time(), - "unhealthy_voters": sorted(cluster_unhealthy), - "recently_demoted": sorted(cluster_cooldown), - "rings": rings, - } - # Atomic write so the every-N-seconds watchdog rewrite - # never overlaps an operator's ``cluster.members`` read on - # a torn-mid-write file. - try: - with salt.utils.atomicfile.atomic_open(path, "w") as fp: - json.dump(body, fp) - except OSError as exc: - log.warning( - "RaftService: could not write health sentinel %s: %s", path, exc - ) - - # ------------------------------------------------------------------ - # Operator overrides - # ------------------------------------------------------------------ - - def propose_voter_demotion(self, peer_id): - """ - Operator-facing entry point to demote a voter manually. - - Works regardless of ``cluster_auto_replace_voters`` so an operator - can force a known-bad voter out of the set even when auto- - replacement is disabled. Returns the same ``bool`` as - ``Node.propose_voter_demotion``. - - IPC story: this method runs inside the publish daemon's process, - which is the only place the ``Node`` is reachable today. A - future runner -> daemon command channel can call this directly; - until then it is callable from python hooks running inside the - master process. - """ - log.info("RaftService: operator-requested demotion of %s", peer_id) - min_voters = self.opts.get("cluster_min_voters", 3) - return self._node.propose_voter_demotion(peer_id, min_voters=min_voters) - - def propose_voter_promotion(self, peer_id): - """ - Operator-facing entry point to promote a learner to voter. - - Same operator-override semantics as ``propose_voter_demotion``. - Subject to the existing ``cluster_max_voters`` cap and the - caught-up precondition; both are enforced inside - ``Node.propose_voter_promotion_to_replace``. - """ - log.info("RaftService: operator-requested promotion of %s", peer_id) - return self._node.propose_voter_promotion_to_replace(peer_id) - - def notify_peer_joined(self, peer_addr): - """ - Called when a new master completes the Salt cluster join handshake. - - Adds *peer_addr* as a **non-voting learner** in the Raft cluster. - If this node is the current leader it immediately starts replicating - to the learner; once the learner's log catches up the leader will - automatically propose a CONFIG entry promoting it to voter - (see :meth:`Node.append_entries_reply`). - - If this node is a follower the learner peer is added to its peer - list so it will receive ``AppendEntries`` from whichever node - eventually becomes leader. - - :param peer_addr: The joining master's interface address - the same - value used as ``join_peer_id`` in the - ``cluster/peer/join-notify`` envelope. - """ - node_id = self._node.node_id - if peer_addr == node_id: - # This is the joining node learning about itself - nothing to do - # from the peer perspective; our own voting status is controlled - # by CONFIG entries from the leader. - return - - existing = {p.node_id for p in self._node.peers} - if peer_addr in existing: - log.debug( - "RaftService: peer %s already known, skipping notify_peer_joined", - peer_addr, - ) - return - - log.info("RaftService: adding learner peer %s", peer_addr) - - if self._node.state == NodeState.LEADER: - # Commit the founding CONFIG *before* the new learner becomes - # a known peer. ``_maybe_commit_founding_config`` derives its - # voter set from ``self._node.peers`` — if the new learner is - # already in that list it would be incorrectly inducted into - # the founding voter pool. No-op when log.index >= 0. - self._maybe_commit_founding_config() - - learner_peer = self._make_peer(peer_addr, voting=False) - self._node.peers.append(learner_peer) - - if self._node.state == NodeState.LEADER: - # Initialise replication tracking for the new learner. - self._node.next_index[peer_addr] = self._node.log.index + 1 - self._node.match_index[peer_addr] = -1 - # Persist the learner registration in a CONFIG entry so that a - # subsequent leader failover preserves the learner roster. Without - # this, new leaders rebuild ``peers`` only from the committed - # voter+learner sets in the membership SM; a learner that was only - # added in the previous leader's in-memory state would disappear - # and its subsequent RPC replies would trip CandidacyError ("X is - # not a peer") on the new leader. - # - # The cap on ``cluster_max_voters`` applies separately to the - # *promotion* CONFIG that fires once the learner catches up; - # this entry only registers the node as a learner, not a voter. - # - # Source the voter / learner sets from the leader's in-memory - # peer list rather than ``membership_sm.current_voters()``: - # ``on_config_change`` updates peer flags eagerly on the leader - # when ``log_add`` is called, but ``membership_sm.apply()`` - # only fires after commit. So immediately after the founding - # CONFIG is appended (but before quorum acks come in), the - # leader's ``peers`` already reflect the new view while the SM - # still has the empty pre-commit state. Reading from peers - # avoids the gap. - from salt.cluster.consensus.raft.log import ( - LogEntryType, # pylint: disable=import-outside-toplevel - ) - - voters = sorted( - {self._node.node_id} | {p.node_id for p in self._node.peers if p.voting} - ) - learners = sorted({p.node_id for p in self._node.peers if not p.voting}) - # Also fold in any committed learners from the SM that may not - # yet be peer entries (defensive — covers a state-sync restore - # path). - for known_learner in self._node.membership_sm.current_learners(): - if known_learner not in voters: - learners = sorted(set(learners) | {known_learner}) - - # Only emit the CONFIG when this call actually changes the - # registered set — idempotent against repeat joins. - current_voters_sm = self._node.membership_sm.current_voters() - current_learners_sm = self._node.membership_sm.current_learners() - already_known = ( - peer_addr in current_voters_sm or peer_addr in current_learners_sm - ) - if not already_known and peer_addr in learners: - try: - self._node.log_add( - {"voters": voters, "learners": learners}, - entry_type=LogEntryType.CONFIG, - ) - except Exception: # pylint: disable=broad-except - log.exception( - "RaftService: failed to persist learner registration for %s", - peer_addr, - ) - # Kick off replication immediately. - self._node.send_append_entries(learner_peer) - - @property - def node(self): - return self._node - - @property - def membership(self): - """The :class:`~salt.cluster.consensus.raft.log.MembershipStateMachine` for this node.""" - return self._node.membership_sm - - @property - def dispatcher(self): - return self._dispatcher - - # ------------------------------------------------------------------ - # Heartbeat - # ------------------------------------------------------------------ - - def _schedule_heartbeat(self): - self._heartbeat_handle = self.loop.call_later( - _HEARTBEAT_INTERVAL, self._heartbeat_tick - ) - - def _heartbeat_tick(self): - """ - Called periodically by the event loop. - - Iterates every Raft group hosted in this process — the - cluster group plus any per-ring groups — and heartbeats from - whichever ones consider themselves leader. On the *first* - heartbeat after winning an election with an empty log, - commits a founding CONFIG so the group's voter set is durably - recorded. - - A single tick services all groups: each group's heartbeat - load scales with peer count; even a master that hosts a dozen - rings still issues O(peers) sends per tick. - """ - try: - for ring_id, node in list(self._nodes.items()): - if node is None: - continue - if node.state != NodeState.LEADER: - continue - self._maybe_commit_founding_config(ring_id, node) - for peer in node.peers: - try: - ni = node.next_index.get(peer.node_id, node.log.index + 1) - # Send a heartbeat (empty) only when the peer - # is caught up. If it's behind, include the - # entries so it can advance — important for - # lagging learners. - entries = [] if ni > node.log.index else None - node.send_append_entries(peer, entries=entries) - except Exception: # pylint: disable=broad-except - log.exception( - "RaftService: error sending heartbeat to %s (ring=%s)", - peer.node_id, - ring_id, - ) - except Exception: # pylint: disable=broad-except - log.exception("RaftService: error in heartbeat tick") - finally: - self._schedule_heartbeat() - - def _maybe_commit_founding_config(self, ring_id="cluster", node=None): - """ - Propose the initial CONFIG entry for *node* when its log is - empty. - - ``ring_id`` defaults to ``"cluster"`` (and ``node`` to - ``self._node``) so the existing ``self._maybe_commit_founding_config()`` - call sites — heartbeat tick on the cluster Node, the - ``notify_peer_joined`` flow — keep working without changes. - Per-ring callers (multi-ring heartbeat tick) pass both. - - Records the founding voter set durably so that a node - recovering from storage can reconstruct membership without - relying solely on ``opts["cluster_peers"]`` (cluster group) - or the registry entry (per-ring groups). - - For the cluster group the bootstrap pool comes from - ``cluster_peers`` (the static list the operator configured). - For a per-ring group it comes from that ring's registry - entry on the cluster log, which records the founding voter - set chosen by the operator when the ring was created. - - No-ops if the log already has any entries (founding entry - already written, or this leader inherited a non-empty log). - """ - if node is None: - node = self._node - if node.log.index >= 0: - return - # Membership already populated (e.g. via state-sync or test - # seeding) — no need to synthesize a founding CONFIG. - if node.membership_sm.current_voters(): - return - from salt.cluster.consensus.raft.log import ( - LogEntryType, # pylint: disable=import-outside-toplevel - ) - - if ring_id == "cluster": - # Deterministic bootstrap pool: sorted set of - # {this node} ∪ peer addresses. Every prospective - # founder runs this code, but only the deterministic - # founder (lowest interface in the pool; see - # ``salt/master.py:920`` and - # ``salt/channel/server.py:2101``) actually writes the - # CONFIG. - bootstrap_pool = sorted({node.node_id, *[p.node_id for p in node.peers]}) - else: - # Per-ring founders come from the registry entry on the - # cluster log — the operator-chosen founding voters. - registry_entry = self._ring_registry_sm.get(ring_id) or {} - bootstrap_pool = sorted(registry_entry.get("founding_voters", [])) - if not bootstrap_pool: - # Registry entry already missing or destroyed — don't - # commit a spurious founding CONFIG. - return - - # ``cluster_max_voters`` (default ``None``) caps the founding - # voter set. Excess peers go into the learner set in the - # same CONFIG entry so they're still durably registered. - max_voters = self.opts.get("cluster_max_voters") - if max_voters is not None and len(bootstrap_pool) > max_voters: - voters = bootstrap_pool[:max_voters] - learners = bootstrap_pool[max_voters:] - else: - voters = bootstrap_pool - learners = [] - log.info( - "RaftService: committing founding CONFIG entry (ring=%s) " - "voters=%s learners=%s", - ring_id, - voters, - learners, - ) - try: - node.log_add( - {"voters": voters, "learners": learners}, - entry_type=LogEntryType.CONFIG, - ) - except Exception: # pylint: disable=broad-except - log.exception("RaftService: failed to propose founding CONFIG entry") - - -def build_peer_pushers(opts, pushers_list): - """ - Convert the flat ``pushers`` list from ``_publish_daemon`` into the - ``dict[addr, PublishServer]`` that ``RaftService`` expects. - - ``_publish_daemon`` builds ``self.pushers`` as a plain list of - ``PublishServer`` objects in the same order as - ``opts["cluster_peers"]``. This helper pairs them back up. - - :param opts: Salt master opts. - :param pushers_list: ``self.pushers`` from ``_publish_daemon``. - :returns: ``{peer_addr: PublishServer}`` - """ - peers = opts.get("cluster_peers", []) - return dict(zip(peers, pushers_list)) diff --git a/salt/cluster/consensus/storage.py b/salt/cluster/consensus/storage.py deleted file mode 100644 index ae84462c572e..000000000000 --- a/salt/cluster/consensus/storage.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -SaltStorage — ``salt.cache``-backed persistence for the Raft node. - -Implements :class:`salt.cluster.consensus.raft.BaseStorage` using the -``salt.cache.Cache`` abstraction so that whatever ``cache_driver`` the -operator has configured (``localfs`` today, ``mmapcache`` tomorrow) is used -automatically. - -Bank layout:: - - cluster/consensus/// — state + snapshot - state — {"term": int, "voted_for": str|None} - snapshot — {"data": base64-str, "index": int, "term": int} - - cluster/consensus///log/ — one cache key per log entry - — LogEntry.info() tuple - -The ```` segment exists so multiple Raft groups can coexist -on the same master. The default ``"cluster"`` value is the main -cluster Raft group; named rings (e.g. ``"jobs"``) get their own -sibling directory and run an independent Raft node out of one Salt -master process. - -Per-entry log keys keep ``append_log`` O(1) on a backend whose store -primitive is O(1) (mmap_cache). ``save_log`` (used for truncation and -recovery) flushes the log bank and re-writes each entry. -""" - -import base64 -import logging -import os -import threading - -import salt.cache -import salt.syspaths -from salt.cluster.consensus.raft.log import BaseStorage, LogEntry, LogEntryType - -log = logging.getLogger(__name__) - -# Keys written into the meta bank (state + snapshot share one bank so a -# single ``flush`` of the meta bank wipes all metadata; the log lives in -# its own bank so we can flush it independently for truncation). -_KEY_STATE = "state" -_KEY_SNAPSHOT = "snapshot" - - -class SaltStorage(BaseStorage): - """ - Raft persistence backed by ``salt.cache.Cache``. - - ``state`` and ``snapshot`` share the bank - ``cluster/consensus/``; log entries live in - ``cluster/consensus//log`` with one cache key per entry, - keyed by stringified Raft index. - - :param node_id: Raft node identifier (the master's interface address). - :param opts: Salt master opts dict — passed straight to - :class:`salt.cache.Cache`. - :param ring_id: Identifier of the Raft group this storage belongs - to. ``"cluster"`` (default) is the main cluster - Raft log; per-ring Raft groups pass their ring - name to isolate state on disk. - """ - - def __init__(self, node_id, opts, ring_id="cluster"): - self._node_id = node_id - self._ring_id = ring_id - self._meta_bank = f"cluster/consensus/{node_id}/{ring_id}" - self._log_bank = f"cluster/consensus/{node_id}/{ring_id}/log" - self._cache = salt.cache.Cache(opts) - # Retained so the localfs fsync helper can resolve the on-disk - # path of each bank/key. Cluster Raft consensus is correctness- - # critical and infrequent, so we always fsync committed writes; - # a knob would just invite a wrong setting. See _fsync_bank_key. - self._cachedir = opts.get("cachedir") or salt.syspaths.CACHE_DIR - self._lock = threading.RLock() - - # ------------------------------------------------------------------ - # Durability helper - # ------------------------------------------------------------------ - - def _fsync_bank_key(self, bank, key): - """ - Force the just-written ``cache.store(bank, key, ...)`` to disk. - - The cluster Raft log is correctness-critical (a voter that - crashes after voting must not re-vote in the same term; an - entry the leader has acked must survive a power loss). Write - volume is low, so we always fsync — there is no opt to flip. - - Currently only the ``localfs`` cache driver is supported here; - other drivers fall through silently (their durability profile - is up to the driver). ``localfs`` writes - ``//.p`` via temp-file + atomic rename, so - we fsync the file (its data) and the parent directory (the - rename's metadata). Directory fsync is a no-op on Windows; - the OSError is swallowed there. - """ - if getattr(self._cache, "driver", None) != "localfs": - return - bank_dir = os.path.join(self._cachedir, *bank.split("/")) - file_path = os.path.join(bank_dir, f"{key}.p") - try: - fd = os.open(file_path, os.O_RDONLY) - try: - os.fsync(fd) - finally: - os.close(fd) - except OSError as exc: - log.warning("SaltStorage: file fsync(%s) failed: %s", file_path, exc) - try: - dfd = os.open(bank_dir, os.O_RDONLY) - try: - os.fsync(dfd) - finally: - os.close(dfd) - except OSError as exc: - # Directory fsync is unsupported on Windows; data fsync above - # is the load-bearing call there. - log.debug("SaltStorage: dir fsync(%s) skipped: %s", bank_dir, exc) - - # ------------------------------------------------------------------ - # BaseStorage implementation - # ------------------------------------------------------------------ - - def save_state(self, term, voted_for, leader_id=None): - """ - Persist currentTerm and votedFor (Raft §5.2), plus the optional - ``leader_id`` of the most recently observed leader for this term. - - ``leader_id`` is not required for Raft safety — it's an - observability hint so a read-only consumer (``cluster.members``) - can answer "who is the leader" without IPC into the publish - daemon. Stored alongside ``term`` so it can be interpreted - relative to the most recent term. - """ - with self._lock: - payload = {"term": term, "voted_for": voted_for} - if leader_id is not None: - payload["leader_id"] = leader_id - self._cache.store(self._meta_bank, _KEY_STATE, payload) - self._fsync_bank_key(self._meta_bank, _KEY_STATE) - - def load_state(self): - """Return persisted state, or defaults if not yet written.""" - with self._lock: - data = self._cache.fetch(self._meta_bank, _KEY_STATE) - if not data: - return {"term": 0, "voted_for": None, "leader_id": None} - # Older state records may not have leader_id; default to None. - data.setdefault("leader_id", None) - return data - - def save_log(self, entries): - """ - Rewrite the entire log. - - Used by :class:`~salt.cluster.consensus.raft.log.Log` during - truncation (conflict resolution) and after :meth:`Log.clear`. We - flush the log bank to drop any indices not in *entries* and then - store each entry under its own key. - """ - with self._lock: - self._cache.flush(self._log_bank) - for entry in entries: - self._cache.store(self._log_bank, str(entry.index), entry.info()) - self._fsync_bank_key(self._log_bank, str(entry.index)) - - def append_log(self, entry): - """ - Append a single entry — O(1) on per-key backends. - - Stores under ``log_bank/`` so the hot append path - does not read or rewrite any other entry. Re-appending the same - index (rare; only happens on a leader-side overwrite that does - not also drive ``save_log``) is a benign overwrite. - """ - with self._lock: - self._cache.store(self._log_bank, str(entry.index), entry.info()) - self._fsync_bank_key(self._log_bank, str(entry.index)) - - def load_log(self): - """Return all persisted log entries as :class:`~.LogEntry` objects.""" - with self._lock: - keys = self._cache.list(self._log_bank) - if not keys: - return [] - try: - indices = sorted(int(k) for k in keys) - except (TypeError, ValueError): - log.warning( - "SaltStorage: ignoring non-integer keys in %s: %r", - self._log_bank, - keys, - ) - return [] - entries = [] - for idx in indices: - with self._lock: - raw = self._cache.fetch(self._log_bank, str(idx)) - if not raw: - log.warning( - "SaltStorage: log entry %d missing from %s", - idx, - self._log_bank, - ) - continue - entry = self._decode_entry(raw) - if entry is not None: - entries.append(entry) - return entries - - @staticmethod - def _decode_entry(raw): - """Reconstruct a :class:`LogEntry` from a cached ``info()`` payload.""" - if isinstance(raw, (list, tuple)): - term = raw[0] - idx = raw[1] - cmd = raw[2] - node_id = raw[3] if len(raw) > 3 else None - entry_type = raw[4] if len(raw) > 4 else LogEntryType.COMMAND - client_id = raw[5] if len(raw) > 5 else None - sequence_num = raw[6] if len(raw) > 6 else None - return LogEntry( - term, idx, cmd, node_id, entry_type, client_id, sequence_num - ) - if isinstance(raw, dict): - return LogEntry( - raw["term"], - raw["index"], - raw["cmd"], - raw.get("node_id"), - raw.get("type", LogEntryType.COMMAND), - raw.get("client_id"), - raw.get("sequence_num"), - ) - return None - - def save_snapshot(self, data, index, term): - """Persist a state-machine snapshot and its metadata.""" - if not isinstance(data, (bytes, memoryview)): - import json # pylint: disable=import-outside-toplevel - - data = json.dumps(data).encode("utf-8") - encoded = base64.b64encode(bytes(data)).decode("utf-8") - with self._lock: - self._cache.store( - self._meta_bank, - _KEY_SNAPSHOT, - {"data": encoded, "index": index, "term": term}, - ) - self._fsync_bank_key(self._meta_bank, _KEY_SNAPSHOT) - - def load_snapshot(self): - """Return the latest snapshot dict, or ``None`` if none exists.""" - with self._lock: - raw = self._cache.fetch(self._meta_bank, _KEY_SNAPSHOT) - if not raw or "data" not in raw: - return None - raw = dict(raw) - raw["data"] = base64.b64decode(raw["data"]) - return raw diff --git a/salt/cluster/file_sync.py b/salt/cluster/file_sync.py deleted file mode 100644 index 396ef51135ab..000000000000 --- a/salt/cluster/file_sync.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Helpers for replicating ``file_roots`` and ``pillar_roots`` between -cluster masters that don't share a filesystem. - -The cluster join-reply embeds the responder's current local state-tree -contents alongside the keys dump. A late-joining master applies the -contents into its own local roots before becoming a Raft learner so it -can serve states/pillars it would otherwise know nothing about. - -Used by :mod:`salt.channel.server` for the join handshake and (later) -by a ``cluster.sync_roots`` runner for ad-hoc updates after roots are -edited on a peer. -""" - -import logging -import os -from pathlib import Path - -import salt.utils.files - -log = logging.getLogger(__name__) - -# File and directory names skipped when collecting a roots tree. -# These are version-control artefacts and editor-temporary files that -# we never want to ship to peers. -_SKIP_DIR_NAMES = frozenset({".git", ".hg", ".svn", "__pycache__", ".tox"}) - - -def _is_skipped_path(rel_parts): - """Return ``True`` if any path component is on the skip list.""" - return any(part in _SKIP_DIR_NAMES for part in rel_parts) - - -def collect_root_tree(roots_map): - """ - Build a wire-friendly snapshot of a ``file_roots``/``pillar_roots`` - mapping. - - :param roots_map: ``{env: [path, path, ...]}`` from ``opts``. - :return: ``{env: [{"path": rel, "mode": int, "data": bytes}, ...]}``. - - Only regular files are included; symlinks, sockets and unreadable - entries are skipped. Multiple roots for the same env are flattened - in declaration order — earlier roots win on path conflicts. - """ - out = {} - for env, paths in (roots_map or {}).items(): - files = [] - seen = set() - for root in paths or []: - root_p = Path(root) - if not root_p.is_dir(): - continue - for sub in root_p.rglob("*"): - try: - if sub.is_symlink() or not sub.is_file(): - continue - rel_parts = sub.relative_to(root_p).parts - except (OSError, ValueError): - continue - if not rel_parts or _is_skipped_path(rel_parts): - continue - rel = "/".join(rel_parts) - if rel in seen: - continue - try: - data = sub.read_bytes() - mode = sub.stat().st_mode & 0o777 - except OSError as exc: - log.warning("file_sync: skipping unreadable %s: %s", sub, exc) - continue - files.append({"path": rel, "mode": mode, "data": data}) - seen.add(rel) - if files: - out[env] = files - return out - - -def apply_root_tree(roots_map, dump): - """ - Materialise *dump* (from :func:`collect_root_tree`) under the local - ``roots_map``. - - :param roots_map: ``{env: [path, path, ...]}`` from ``opts``. Files - for env *e* are written under ``roots_map[e][0]``; envs that are - absent or empty are skipped. - :param dump: the snapshot dict to apply. - :return: number of files written. - """ - written = 0 - for env, files in (dump or {}).items(): - roots = (roots_map or {}).get(env) - if not roots: - log.debug( - "file_sync: env %r not configured locally; skipping %d files", - env, - len(files), - ) - continue - target = Path(roots[0]) - target.mkdir(parents=True, exist_ok=True) - for entry in files: - rel = entry.get("path") if isinstance(entry, dict) else None - data = entry.get("data") if isinstance(entry, dict) else None - mode = entry.get("mode", 0o644) if isinstance(entry, dict) else 0o644 - if not rel or data is None: - continue - # msgpack round-trip via salt.payload may turn bytes back into - # str; coerce so binary files round-trip cleanly. - if isinstance(data, str): - data = data.encode("utf-8", errors="surrogateescape") - dst = target / rel - try: - dst.parent.mkdir(parents=True, exist_ok=True) - with salt.utils.files.fopen(dst, "wb") as fp: - fp.write(data) - os.chmod(dst, mode) - written += 1 - except OSError as exc: - log.warning("file_sync: write failed for %s: %s", dst, exc) - return written diff --git a/salt/cluster/healthchecks.py b/salt/cluster/healthchecks.py deleted file mode 100644 index 93676d32198e..000000000000 --- a/salt/cluster/healthchecks.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -File-sentinel health probes for Kubernetes (and other supervisors). - -Three independent sentinels live under ``/health/``: - -* ``startup`` — written once when ``Master.start`` finishes wiring up - every subprocess. Maps to a Kubernetes - ``startupProbe`` (``test -f /health/startup``). -* ``ready`` — written once when the master is willing to serve - traffic. For a clustered master that means the Raft - ``cluster_ready`` event has fired (the node committed - itself as a voter). For a non-clustered master we - write it at the same time as ``startup`` because there - is no cluster gate. Maps to a Kubernetes - ``readinessProbe``. -* ``alive`` — touched periodically from the parent process's - asyncio loop. If the loop wedges the mtime stops - advancing, so an exec probe ``test $(($(date +%s) - - $(stat -c %Y .../health/alive))) -lt 30`` flips - ``unready`` and Kubernetes restarts the pod. Maps to - a Kubernetes ``livenessProbe``. - -Why files (not HTTP): - - * No new listening port, no extra dependency, no thread/process for - a tiny web server. - * ``exec`` probes work everywhere (kubelet, docker-compose - healthchecks, systemd ``ExecStartPost`` waits). - * Easy to inspect by hand: ``ls -l /var/cache/salt/master/health/``. - -Why three sentinels (not one): - - * **Cardinal rule from etcd's incident history**: liveness must never - reflect cluster state. Making liveness depend on Raft leader - availability caused kubelet to SIGKILL pods *during* legitimate - leader elections, preventing the elections from completing. Each - sentinel answers a distinct question (Initialised? Routable? - Responsive?) so the answers can disagree. - -References: - * https://kubernetes.io/docs/concepts/configuration/liveness-readiness-startup-probes/ - * https://github.com/etcd-io/etcd/issues/13340 -""" - -import logging -import os -import pathlib -import shutil -import time - -log = logging.getLogger(__name__) - -#: Subdirectory under ``cachedir`` that holds the three sentinels. -HEALTH_DIR = "health" - -#: Sentinel filename for the startup probe. -STARTUP_SENTINEL = "startup" - -#: Sentinel filename for the readiness probe. -READY_SENTINEL = "ready" - -#: Sentinel filename for the liveness probe. -ALIVE_SENTINEL = "alive" - -#: Default interval in seconds between ``touch_alive`` heartbeats. Pair -#: with a Kubernetes ``livenessProbe`` ``periodSeconds: 15`` and a -#: staleness threshold of 30 s in the exec probe. -DEFAULT_ALIVE_INTERVAL = 5 - - -def health_dir(opts): - """ - Return the on-disk path of ``/health``. - - Returns ``None`` if ``opts`` has no ``cachedir`` (typical of unit - tests using a stripped opts dict); callers treat that as "no - health checks configured" and silently skip. This keeps the - helpers safe to wire into shared code paths like - ``_signal_cluster_ready`` that run in production *and* in tests - with hand-crafted opts. - """ - cachedir = opts.get("cachedir") - if not cachedir: - return None - return pathlib.Path(cachedir) / HEALTH_DIR - - -def reset_health_dir(opts): - """ - Wipe and recreate ``/health/``. - - Called once near the top of ``Master.start`` so a stale ``startup`` - or ``ready`` sentinel from a previous run cannot pass a probe before - the freshly-started master is actually ready. - - Errors are logged and swallowed: a missing health dir is *not* a - reason to refuse to start the master, so health-check writes are - best-effort. - """ - path = health_dir(opts) - if path is None: - return - try: - if path.exists(): - shutil.rmtree(path, ignore_errors=True) - path.mkdir(parents=True, exist_ok=True) - except OSError as exc: - log.warning("healthchecks: could not reset %s: %s", path, exc) - - -def _write_sentinel(opts, name, body=""): - """Write a sentinel file atomically. Best-effort; logs and returns on error.""" - base = health_dir(opts) - if base is None: - return - path = base / name - try: - path.parent.mkdir(parents=True, exist_ok=True) - # Atomic-ish: write to ``.tmp`` then rename. Avoids a probe - # observing a half-written file even though our payloads are - # tiny. - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(body, encoding="utf-8") - tmp.replace(path) - except OSError as exc: - log.warning("healthchecks: could not write %s: %s", path, exc) - - -def mark_startup_complete(opts): - """ - Write ``/health/startup`` once master init is done. - - Body is the unix epoch second the master finished bootstrapping — - handy for ``kubectl describe`` and post-mortems. - """ - _write_sentinel(opts, STARTUP_SENTINEL, body=str(int(time.time()))) - log.info("healthchecks: startup sentinel written at %s", health_dir(opts)) - - -def mark_cluster_ready(opts): - """ - Write ``/health/ready`` when the master is willing to serve - traffic. Idempotent — a second call is harmless. - """ - _write_sentinel(opts, READY_SENTINEL, body=str(int(time.time()))) - log.info("healthchecks: readiness sentinel written at %s", health_dir(opts)) - - -def touch_alive(opts): - """ - Refresh the mtime of ``/health/alive``. - - The exec probe compares ``time.time() - stat(alive).st_mtime`` to a - threshold (recommend 3× ``DEFAULT_ALIVE_INTERVAL``). Stops - advancing if the parent process's asyncio loop wedges, which is the - exact failure mode liveness is supposed to catch. - """ - base = health_dir(opts) - if base is None: - return - path = base / ALIVE_SENTINEL - try: - path.parent.mkdir(parents=True, exist_ok=True) - # ``open(...) + close()`` would work but pathlib.Path.touch is - # one syscall on Python 3.10+. - path.touch(exist_ok=True) - # ``Path.touch`` on Python < 3.10 only updates atime/mtime if - # the file already exists *and* doesn't always bump them on - # every filesystem. Force a definite mtime update via - # os.utime so ``stat -c %Y`` always advances. - now = time.time() - os.utime(path, (now, now)) - except OSError as exc: - log.warning("healthchecks: could not touch %s: %s", path, exc) - - -def is_clustered(opts): - """``True`` if the master is configured for cluster mode.""" - return bool(opts.get("cluster_id") and opts.get("cluster_peers") is not None) diff --git a/salt/cluster/migration.py b/salt/cluster/migration.py deleted file mode 100644 index b3618086ade9..000000000000 --- a/salt/cluster/migration.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Multi-ring migration helpers shared between the runner subprocess -and the publish daemon. - -Both surfaces need the same "drop unowned keys" logic: - -* ``salt.runners.cluster.shed_unowned`` runs it from the operator's - ``salt-run`` invocation. -* ``salt.channel.server.MasterPubServerChannel`` runs it on the - daemon side when a peer's ``cluster/peer/shed-request`` event - arrives (the fan-out path triggered by - ``cluster.shed_unowned_all``). - -Centralising the implementation here avoids the runner and the -daemon drifting apart on bank layout, cascade rules, or storage -replay quirks. Both call sites pass their own ``__opts__`` dict in -explicitly so the helper has no loader dependency. -""" - -import logging -import os -import time - -log = logging.getLogger(__name__) - - -SHED_STATUS_FILENAME = "cluster-shed-status.json" - - -def perform_shed( - opts, - ring, - banks=("jobs/loads", "jobs/minions", "jobs/endtimes", "jobs/nocache"), - subbank_template="jobs/returns/{key}", - driver=None, - dry_run=False, -): - """ - Drop the cache entries this master no longer owns under *ring*. - - Mirrors the operator-runner contract of - ``salt.runners.cluster.shed_unowned`` but takes *opts* as an - explicit argument so the publish daemon can call it without the - loader's ``__opts__`` injection. - - Returns the same structured dict :func:`shed_unowned` returns - (status / dropped / kept / subbanks_dropped / dry_run / ring). - A "skipped" status carries a ``reason`` field; an "ok" status - means the walk completed. - """ - # Lazy imports — this module is loaded by the runner subprocess - # which doesn't always have consensus deps available. - import salt.cache # pylint: disable=import-outside-toplevel - from salt.cluster.consensus.raft.log import ( # pylint: disable=import-outside-toplevel - RING_STATUS_ACTIVE, - Log, - LogEntryType, - MembershipStateMachine, - RingRegistryStateMachine, - ) - from salt.cluster.consensus.storage import ( # pylint: disable=import-outside-toplevel - SaltStorage, - ) - from salt.cluster.ring import HashRing # pylint: disable=import-outside-toplevel - - if not ring: - raise ValueError("perform_shed requires a non-empty 'ring'") - if not banks: - raise ValueError("perform_shed requires at least one bank") - - node_id = opts.get("interface") or opts.get("id") or "unknown" - - # Cluster registry replay — same shape the runner uses. - cluster_storage = SaltStorage(node_id, opts, ring_id="cluster") - registry_sm = RingRegistryStateMachine() - Log( - storage=cluster_storage, - state_machines={"ring_registry_sm": registry_sm}, - ) - for entry in cluster_storage.load_log(): - if entry.type == LogEntryType.RING_REGISTRY: - registry_sm.apply(entry.cmd, index=entry.index) - registry_entry = registry_sm.get(ring) - if not registry_entry or registry_entry.get("status") != RING_STATUS_ACTIVE: - return { - "status": "skipped", - "reason": f"ring {ring!r} is not active in the registry", - "ring": ring, - "dropped": 0, - "kept": 0, - "subbanks_dropped": 0, - "dry_run": dry_run, - } - if node_id not in registry_entry.get("founding_voters", []): - return { - "status": "skipped", - "reason": ( - f"this master ({node_id}) is not a founding voter of ring {ring!r}" - ), - "ring": ring, - "dropped": 0, - "kept": 0, - "subbanks_dropped": 0, - "dry_run": dry_run, - } - - # Per-ring membership replay. - ring_storage = SaltStorage(node_id, opts, ring_id=ring) - ring_membership_sm = MembershipStateMachine() - Log( - storage=ring_storage, - state_machines={"membership_sm": ring_membership_sm}, - ) - for entry in ring_storage.load_log(): - if entry.type == LogEntryType.CONFIG: - ring_membership_sm.apply(entry.cmd, index=entry.index) - voters = ring_membership_sm.current_voters() or registry_entry.get( - "founding_voters", [] - ) - if not voters: - return { - "status": "skipped", - "reason": f"ring {ring!r} has no committed voters yet", - "ring": ring, - "dropped": 0, - "kept": 0, - "subbanks_dropped": 0, - "dry_run": dry_run, - } - - hash_ring = HashRing() - hash_ring.rebuild(voters) - - if driver is None: - driver = opts.get("cache") or opts.get("keys.cache_driver") - cache = salt.cache.Cache(opts, driver=driver) - - primary_bank = banks[0] - unowned_primary_keys = [] - dropped, kept = 0, 0 - for idx, bank in enumerate(banks): - try: - keys = list(cache.list(bank)) - except Exception: # pylint: disable=broad-except - continue - for key in keys: - if hash_ring.owns(key, node_id): - kept += 1 - continue - if idx == 0: - unowned_primary_keys.append(key) - if not dry_run: - try: - cache.flush(bank, key) - except Exception: # pylint: disable=broad-except - continue - dropped += 1 - - subbanks_dropped = 0 - if subbank_template and unowned_primary_keys: - for key in unowned_primary_keys: - subbank = subbank_template.format(key=key) - if not dry_run: - try: - cache.flush(subbank) - except Exception: # pylint: disable=broad-except - continue - subbanks_dropped += 1 - - log.info( - "perform_shed: ring=%s dropped=%d kept=%d subbanks_dropped=%d " - "dry_run=%s (primary_bank=%s)", - ring, - dropped, - kept, - subbanks_dropped, - dry_run, - primary_bank, - ) - return { - "status": "ok", - "ring": ring, - "dropped": dropped, - "kept": kept, - "subbanks_dropped": subbanks_dropped, - "dry_run": dry_run, - } - - -def write_shed_status(opts, result, source): - """ - Persist a shed result for ``cluster.shed_status`` to surface. - - *source* explains who triggered the shed: - - * ``"runner"`` — this master ran ``cluster.shed_unowned`` - directly. - * ``"runner_originator"`` — this master is the originator of a - ``cluster.shed_unowned_all`` fan-out and ran its local pass. - * ``"peer_request"`` — a peer's ``shed-request`` event reached - this master. - - The sentinel is rewritten on every shed run. Operators - inspecting it always see the most-recent result. - - Writes are atomic — tmp file + rename — so a concurrent reader - or a second writer mid-write never sees a partial JSON document. - The bug this fixes: ``shed_unowned_all`` fan-out occasionally - arrives twice at the same peer (see the ``self.pushers`` - duplication note in MULTI_RING_DESIGN.md), and the second write - used to overwrite mid-stream and produce invalid JSON. - """ - import json # pylint: disable=import-outside-toplevel - - import salt.utils.atomicfile # pylint: disable=import-outside-toplevel - - cachedir = opts.get("cachedir") - if not cachedir: - return - path = os.path.join(cachedir, SHED_STATUS_FILENAME) - body = dict(result) - body["source"] = source - body["updated_at"] = time.time() - try: - with salt.utils.atomicfile.atomic_open(path, "w") as fp: - json.dump(body, fp) - except OSError as exc: - log.warning("shed-status: failed to write sentinel %s: %s", path, exc) diff --git a/salt/cluster/ring.py b/salt/cluster/ring.py deleted file mode 100644 index e65281054cfe..000000000000 --- a/salt/cluster/ring.py +++ /dev/null @@ -1,352 +0,0 @@ -""" -Consistent hash ring with virtual nodes (VNodes). - -The ring maps arbitrary keys (JIDs, minion IDs, cache bank names) to the -physical node that owns them. Virtual nodes prevent hotspots by distributing -each physical node to ``vnodes`` evenly-spaced points around the ring before -any real nodes are added, and then placing each physical node's actual token -set through xxhash so the distribution is deterministic given the same node -set. - -Ring position encoding ----------------------- -Positions are 64-bit unsigned integers derived from -``xxhash.xxh3_64_intdigest``. The ring is modelled as the integer range -``[0, 2**64)``, wrapping around. A key is owned by the first node whose -position is >= the key's hash (clockwise successor), with wrap-around to the -lowest-position node when no successor exists. - -VNode token derivation ----------------------- -For physical node *n* and replica index *r* (0 … vnodes-1), the token is:: - - xxhash.xxh3_64_intdigest(f"{n}#vnode{r}".encode()) - -This is deterministic, cheap, and produces good distribution. - -Single-node fast-path ---------------------- -When the ring contains exactly one physical node every key maps to that node -without any binary-search overhead. This matches the roadmap goal of making -the single-node case a first-class citizen before multi-node Raft is wired up. - -Thread safety -------------- -``HashRing`` uses an ``RLock``. ``get_owner`` / ``get_replicas`` acquire only -a read-path (non-mutating) lock section; ``add_node`` / ``remove_node`` / -``rebuild`` hold the lock for the full mutation. -""" - -import bisect -import logging -import threading - -# ``xxhash`` is the canonical ring-hash backend. Importing optionally -# keeps ``salt.master`` startable on installs where xxhash is missing -# (notably Windows NSIS upgrades from 3007.14, which never shipped -# xxhash and don't pull it in on upgrade): an empty/self-only ring's -# ``owns()`` returns ``True`` without ever hashing, so the broadcast -# path keeps working. Cluster sharding (``add_node``, ``get_owner``, -# ``get_replicas``, ``rebuild``) raises a clear error if xxhash is -# genuinely needed but missing. -try: - import xxhash as _xxhash -except ImportError: # pragma: no cover - exercised on Windows upgrade only - _xxhash = None - -log = logging.getLogger(__name__) - -# Default number of virtual nodes (tokens) per physical node. -# 150 tokens/node gives a coefficient of variation < 10 % for typical cluster -# sizes (1-20 nodes). -DEFAULT_VNODES = 150 - -_RING_SIZE = 1 << 64 # 2**64 hash space - -_XXHASH_MISSING_MSG = ( - "salt.cluster.ring requires the 'xxhash' Python package for ring " - "operations beyond a single-node ring. Install xxhash (>=3.0) and " - "restart the master." -) - - -def _token(node_id: str, replica: int) -> int: - """Return the ring position for *node_id* replica *replica*.""" - if _xxhash is None: - raise RuntimeError(_XXHASH_MISSING_MSG) - return _xxhash.xxh3_64_intdigest(f"{node_id}#vnode{replica}".encode()) - - -def _key_hash(key) -> int: - """Hash an arbitrary key to a ring position.""" - if _xxhash is None: - raise RuntimeError(_XXHASH_MISSING_MSG) - if isinstance(key, str): - key = key.encode() - return _xxhash.xxh3_64_intdigest(key) - - -class HashRing: - """ - Consistent hash ring. - - :param nodes: Initial iterable of node ID strings. - :param vnodes: Number of virtual nodes (tokens) per physical node. - Higher values improve distribution at the cost of memory - (``len(nodes) * vnodes * ~50 bytes``). - :param replicas: Number of distinct owners returned by ``get_replicas``. - Must be <= number of physical nodes in the ring. - """ - - def __init__(self, nodes=(), vnodes=DEFAULT_VNODES, replicas=1): - if vnodes < 1: - raise ValueError(f"vnodes must be >= 1, got {vnodes}") - if replicas < 1: - raise ValueError(f"replicas must be >= 1, got {replicas}") - self._vnodes = vnodes - self._replicas = replicas - self._lock = threading.RLock() - - # Sorted list of token positions (int). - self._ring: list[int] = [] - # token position -> physical node ID - self._token_map: dict[int, str] = {} - # set of physical node IDs currently in the ring - self._nodes: set[str] = set() - - for node in nodes: - self._add_node_locked(node) - - # ------------------------------------------------------------------ - # Internal helpers (call under lock) - # ------------------------------------------------------------------ - - def _add_node_locked(self, node_id: str) -> None: - if node_id in self._nodes: - return - self._nodes.add(node_id) - for r in range(self._vnodes): - tok = _token(node_id, r) - if tok not in self._token_map: - bisect.insort(self._ring, tok) - self._token_map[tok] = node_id - else: - # Collision: walk forward until a free slot is found. - # Collisions are astronomically rare with xxh3-64. - shifted = (tok + 1) % _RING_SIZE - while shifted in self._token_map and shifted != tok: - shifted = (shifted + 1) % _RING_SIZE - if shifted != tok: - bisect.insort(self._ring, shifted) - self._token_map[shifted] = node_id - - def _remove_node_locked(self, node_id: str) -> None: - if node_id not in self._nodes: - return - self._nodes.discard(node_id) - dead_tokens = [t for t, n in self._token_map.items() if n == node_id] - for tok in dead_tokens: - del self._token_map[tok] - idx = bisect.bisect_left(self._ring, tok) - if idx < len(self._ring) and self._ring[idx] == tok: - del self._ring[idx] - - def _find_owner_locked(self, key_hash: int) -> str | None: - """Return the node ID of the clockwise successor of *key_hash*.""" - if not self._ring: - return None - idx = bisect.bisect(self._ring, key_hash) - if idx == len(self._ring): - idx = 0 # wrap around - return self._token_map[self._ring[idx]] - - # ------------------------------------------------------------------ - # Public mutation API - # ------------------------------------------------------------------ - - def add_node(self, node_id: str) -> None: - """Add *node_id* to the ring.""" - with self._lock: - self._add_node_locked(node_id) - log.debug( - "HashRing: added node %s (%d physical nodes)", node_id, len(self._nodes) - ) - - def remove_node(self, node_id: str) -> None: - """Remove *node_id* from the ring.""" - with self._lock: - self._remove_node_locked(node_id) - log.debug( - "HashRing: removed node %s (%d physical nodes)", node_id, len(self._nodes) - ) - - def rebuild(self, nodes) -> None: - """ - Atomically replace the ring contents with *nodes*. - - This is the primary hook called from ``MembershipStateMachine.on_change`` - whenever a Raft CONFIG entry is committed. - """ - new_nodes = set(nodes) - with self._lock: - old_nodes = set(self._nodes) - for n in old_nodes - new_nodes: - self._remove_node_locked(n) - for n in new_nodes - old_nodes: - self._add_node_locked(n) - log.info( - "HashRing: rebuilt with %d node(s): %s", - len(new_nodes), - sorted(new_nodes), - ) - - # ------------------------------------------------------------------ - # Public query API - # ------------------------------------------------------------------ - - @property - def is_clustered(self) -> bool: - """ - Return ``True`` if the ring has been populated via :meth:`rebuild`. - - A standalone (non-clustered) master never calls ``rebuild()``, so its - ring stays empty and ``is_clustered`` remains ``False``. - - Use this to short-circuit routing logic that must not fire when Raft - is not running:: - - if ring.is_clustered and ring.get_owner(jid) != my_id: - # shunt to cluster bus - - Or use :meth:`owns` which handles both cases in one call. - """ - with self._lock: - return bool(self._nodes) - - def owns(self, key, node_id: str) -> bool: - """ - Return ``True`` if *node_id* should process *key*. - - * **Standalone master** (ring empty, Raft not running): always ``True`` - — every master owns all of its own keys. - * **Clustered master** (ring populated via :meth:`rebuild`): ``True`` - only when *node_id* is the consistent-hash owner of *key*. - - This is the intended call site for ``master.py``:: - - if not ring.owns(load["jid"], self.opts["interface"]): - # shunt to cluster bus - - ``opts["interface"]`` is used because that's the cluster-wide node - identity throughout the consensus layer (matches ``cluster_peers`` - and the keys in :class:`HashRing`'s internal node set populated by - ``rebuild``). ``opts["id"]`` is the local hostname which other - masters do not share. - """ - with self._lock: - if not self._nodes: - return True - n = len(self._nodes) - if n == 1: - return next(iter(self._nodes)) == node_id - return self._find_owner_locked(_key_hash(key)) == node_id - - def get_owner(self, key) -> str | None: - """ - Return the node ID that owns *key*. - - *key* may be a ``str`` or ``bytes``. Returns ``None`` if the ring is - empty. - - Single-node fast-path: if only one physical node is present the lock - is acquired once and the node is returned immediately without any - bisect. - """ - with self._lock: - n = len(self._nodes) - if n == 0: - return None - if n == 1: - return next(iter(self._nodes)) - return self._find_owner_locked(_key_hash(key)) - - def get_replicas(self, key, count: int | None = None) -> list[str]: - """ - Return up to *count* distinct physical nodes starting from the owner - of *key* and walking clockwise. - - *count* defaults to ``self._replicas``. If fewer physical nodes exist - than requested, all nodes are returned. - - The first element is always the primary owner (same as ``get_owner``). - """ - n_want = count if count is not None else self._replicas - with self._lock: - n_phys = len(self._nodes) - if n_phys == 0: - return [] - n_want = min(n_want, n_phys) - if n_phys == 1: - return [next(iter(self._nodes))] - - h = _key_hash(key) - idx = bisect.bisect(self._ring, h) - if idx == len(self._ring): - idx = 0 - - seen: set[str] = set() - result: list[str] = [] - ring_len = len(self._ring) - steps = 0 - while len(result) < n_want and steps < ring_len: - node = self._token_map[self._ring[(idx + steps) % ring_len]] - if node not in seen: - seen.add(node) - result.append(node) - steps += 1 - return result - - def node_count(self) -> int: - """Return the number of physical nodes currently in the ring.""" - with self._lock: - return len(self._nodes) - - def nodes(self) -> list[str]: - """Return a sorted list of physical node IDs.""" - with self._lock: - return sorted(self._nodes) - - def token_count(self) -> int: - """Return the total number of tokens (vnodes * physical nodes, approx).""" - with self._lock: - return len(self._ring) - - def distribution(self) -> dict[str, int]: - """ - Return a dict mapping each physical node to its token count. - - Useful for verifying VNode distribution balance in tests and - diagnostics. - """ - with self._lock: - result: dict[str, int] = {n: 0 for n in self._nodes} - for node in self._token_map.values(): - result[node] = result.get(node, 0) + 1 - return result - - # ------------------------------------------------------------------ - # Dunder helpers - # ------------------------------------------------------------------ - - def __len__(self) -> int: - return self.node_count() - - def __contains__(self, node_id: str) -> bool: - with self._lock: - return node_id in self._nodes - - def __repr__(self) -> str: - return ( - f"HashRing(nodes={self.nodes()!r}, vnodes={self._vnodes}, " - f"replicas={self._replicas})" - ) diff --git a/salt/cluster/ring_membership.py b/salt/cluster/ring_membership.py deleted file mode 100644 index 0e806336cbc4..000000000000 --- a/salt/cluster/ring_membership.py +++ /dev/null @@ -1,341 +0,0 @@ -""" -High-level ownership query for cluster-distributed state. - -Wraps per-process :class:`salt.cluster.ring.HashRing` instances with a -named-registry API the rest of the cluster code reaches for: - -* :func:`get_ring(name)` — fetch (or lazily create) the named ring. -* :func:`rebuild(name, voters, replicas=1)` — replace a ring's - contents (called from - :meth:`salt.cluster.consensus.service.RaftService._on_ring_config_change_for` - for per-ring policy commits). -* :func:`owns_for(opts, data_type, key)` — multi-ring gate: consult - the routing table, then ask that ring whether this master owns - *key*. -* :func:`owns(opts, key)` — legacy single-ring gate that targets the - ``"cluster"`` named ring. Pre-multi-ring callers keep working with - no changes; new gate sites use :func:`owns_for` instead. - -Multi-ring semantics --------------------- -For multi-ring deployments each Salt cache has its own -:class:`HashRing` keyed by a ring name (e.g. ``"jobs"``, -``"events"``). ``_RINGS`` is the per-process registry — a subprocess -inherits the parent's registry at fork time and keeps it for its -lifetime. Rings are only rebuilt in the publish daemon (where the -per-ring Raft groups live); other subprocesses see whatever the -parent had at fork. - -Routing -------- -``_ROUTING`` is a per-process snapshot of the cluster-log -:class:`RoutingStateMachine` (data_type -> ring_id-or-None). -Populated by ``RaftService`` on each committed ``ROUTE`` entry so -gate sites can decide quickly without IPC. A data type with no -routing entry is broadcast — every master keeps acting as the owner. - -Why not pass a ring instance everywhere ---------------------------------------- -The receivers we gate (``EventMonitor.handle_event`` at master.py) -live across a ``SignalHandlingProcess`` boundary from the place that -owns Raft membership (``RaftService`` inside the publish daemon). A -single instance can't be smuggled across a fork without a shared- -memory backing store. A module-level registry per process is the -smallest abstraction that survives both the current shape and any -future move to a shared-memory ring. -""" - -import logging -import threading - -from salt.cluster.ring import HashRing - -log = logging.getLogger(__name__) - - -# Per-process registry of named rings. Module load creates an empty -# dict; ``RaftService`` populates entries as rings come up. A -# subprocess inherits this dict at fork time. -_RINGS = {} - -# Per-process routing snapshot: data_type -> ring_id or None. ``None`` -# (or a missing entry) means broadcast — every master is the owner -# for that data type. -_ROUTING = {} - -_LOCK = threading.RLock() - - -# The canonical name for the legacy single-ring path so pre-multi-ring -# callers of ``owns(opts, key)`` route to a stable place. -DEFAULT_RING = "cluster" - - -def get_ring(name=DEFAULT_RING): - """ - Return this process's :class:`HashRing` for the named ring. - - Creates an empty ring on first reference so callers never have to - null-check. Use only from code that needs ring-internals - (diagnostics, tests). Production call sites should prefer - :func:`owns_for` so the ownership decision flows through one - place. - """ - with _LOCK: - ring = _RINGS.get(name) - if ring is None: - ring = HashRing() - _RINGS[name] = ring - return ring - - -def owns(opts, key, ring=DEFAULT_RING): - """ - Return ``True`` if this master should process *key* locally. - - *opts* must carry ``"interface"`` — the cluster-wide node identity - matching :func:`rebuild`'s input and ``cluster_peers``. *ring* is - the name of the ring to consult (defaults to the legacy - ``"cluster"`` ring so pre-multi-ring call sites keep working). - - Empty rings answer ``True`` for every key, so a master that has - not had its ring populated (subprocess pre-fork, or one without a - routing entry) keeps the broadcast behaviour. - """ - node_id = opts.get("interface") - if node_id is None: - # Defensive: opts without an interface (some test fixtures) - # act as a standalone master — own everything. - return True - return get_ring(ring).owns(key, node_id) - - -def owns_for(opts, data_type, key): - """ - Multi-ring ownership gate. - - Consults the routing table for *data_type*: - - * No entry, or entry mapping to ``None``: broadcast — every - master owns everything (returns ``True``). - * Entry maps to a ring this master *does not* host locally: - returns ``False``. Per the design the operator must arrange - for traffic to reach a ring member; non-members no-op writes. - * Entry maps to a ring this master hosts: defers to that ring's - :meth:`HashRing.owns` answer. - - Used by gate sites in :mod:`salt.master` to decide whether to - persist a job/event/etc. write locally. - - When the answer is ``False`` because this master isn't a ring - member, the call increments a per-(data_type, ring) drop counter - surfaced via :func:`drop_stats` so operators can detect a - misconfigured load balancer — i.e. traffic for a routed data - type landing on masters that aren't in the ring. - """ - ring_id = _ROUTING.get(data_type) - if ring_id is None: - return True # broadcast - with _LOCK: - ring = _RINGS.get(ring_id) - if ring is None or not ring.nodes(): - # This master is not in the ring (no local Node) or the ring - # is still empty. Non-member masters no-op writes for routed - # data — the operator is expected to route traffic at the - # load balancer. Count the drop so a misconfig is visible. - _record_drop(data_type, ring_id, "not_a_member") - return False - node_id = opts.get("interface") - if node_id is None: - return True - if ring.owns(key, node_id): - return True - # Owned by some other ring member. This is the expected sharding - # path, not a misconfig — counted separately so operators can - # tell shedding from drops. - _record_drop(data_type, ring_id, "other_ring_member") - return False - - -# Per-process drop counters — populated by ``owns_for`` when it -# answers False, queried by the ``cluster.routes`` runner so -# operators can spot misconfigured routing without tailing logs. -# Keyed by ``(data_type, ring_id, reason)``; reason is -# "not_a_member" (this master isn't in the named ring at all) or -# "other_ring_member" (the key hashed to a sibling — expected -# under sharded routing, included for completeness). -_DROP_STATS = {} - - -def _record_drop(data_type, ring_id, reason): - """ - Bump the drop counter for the given (data_type, ring, reason) bucket - and emit a rate-limited log line so a misconfigured deployment is - visible in the master log without operator intervention. - - Rate limit: one log line per (data_type, ring_id, reason) bucket - every ``_DROP_LOG_RATE_SECONDS`` (60s default). Counters keep - advancing on every drop; the log line carries the cumulative - count so an operator scanning logs can see the magnitude even - between rate-limited windows. - - Only the ``not_a_member`` reason logs at WARNING — that's the - misconfig signal. ``other_ring_member`` is the expected - sharded-traffic path and would drown out the warning, so it's - counter-only. - """ - import time # pylint: disable=import-outside-toplevel - - key = (data_type, ring_id, reason) - now = time.monotonic() - log_now = False - count = 0 - with _LOCK: - _DROP_STATS[key] = _DROP_STATS.get(key, 0) + 1 - count = _DROP_STATS[key] - if reason == "not_a_member": - last = _DROP_LAST_LOG.get(key, 0.0) - if now - last >= _DROP_LOG_RATE_SECONDS: - _DROP_LAST_LOG[key] = now - log_now = True - if log_now: - log.warning( - "ring_membership: dropping %s write — this master is not in " - "ring %r (count=%d). Operator likely needs to route traffic " - "for data_type=%s to a ring member.", - data_type, - ring_id, - count, - data_type, - ) - - -# Rate-limit knob. 60 s is fast enough for an operator running a -# checklist to spot the warning, slow enough that a busy master -# under sustained misrouting doesn't spam the log. -_DROP_LOG_RATE_SECONDS = 60.0 - -# Last-log-time tracking, parallel to _DROP_STATS but consumed by -# the rate limiter, not the operator surface. -_DROP_LAST_LOG = {} - - -def drop_stats(): - """ - Return a snapshot of the per-process drop counters. - - Shape:: - - { - "": { - "ring_id": "", - "not_a_member": int, - "other_ring_member": int, - }, - ... - } - - ``not_a_member`` is the field operators should care about — a - rising count means traffic for a routed data type is landing on - masters that aren't in the ring. ``other_ring_member`` is - expected to rise steadily under sharded routing and isn't a - misconfig signal on its own. - """ - with _LOCK: - snapshot = dict(_DROP_STATS) - result = {} - for (data_type, ring_id, reason), count in snapshot.items(): - bucket = result.setdefault( - data_type, - {"ring_id": ring_id, "not_a_member": 0, "other_ring_member": 0}, - ) - bucket[reason] = count - return result - - -def rebuild(name_or_voters, voters=None, replicas=1): - """ - Replace the named ring's contents. - - Two call shapes: - - * ``rebuild(voters)`` (legacy single-ring) — targets the - ``"cluster"`` ring for backward compatibility. - * ``rebuild(name, voters, replicas=N)`` (multi-ring) — names the - ring explicitly. - - Idempotent: rebuilding to the same voter set is cheap and emits - no spurious log noise. - - *voters* is the committed voter list — learners are excluded - because they don't yet hold replica state to be the canonical - owner of anything. - """ - if voters is None: - # Legacy shape: rebuild(voters_list) - name = DEFAULT_RING - voters_list = name_or_voters - else: - name = name_or_voters - voters_list = voters - with _LOCK: - ring = _RINGS.get(name) - if ring is None: - ring = HashRing(replicas=replicas) - _RINGS[name] = ring - elif replicas != ring._replicas: - # Replica count changed — rebuild a new ring instead of - # silently keeping the old factor. - ring = HashRing(replicas=replicas) - _RINGS[name] = ring - ring.rebuild(voters_list) - - -def set_route(data_type, ring_id): - """ - Update the per-process routing snapshot. - - Called by ``RaftService`` after each committed ``ROUTE`` entry so - gate sites in this process see the new mapping without IPC. Set - *ring_id* to ``None`` to clear the route (broadcast). - """ - with _LOCK: - if ring_id is None: - _ROUTING.pop(data_type, None) - else: - _ROUTING[data_type] = ring_id - - -def get_routes(): - """ - Return a copy of the per-process routing snapshot. Diagnostic / - test helper. - """ - with _LOCK: - return dict(_ROUTING) - - -def drop_ring(name): - """ - Remove the named ring from the registry. Called when ``RaftService`` - tears down a per-ring Raft group so subsequent ``owns_for`` calls - treat this master as a non-member of the destroyed ring. - """ - with _LOCK: - _RINGS.pop(name, None) - - -def reset(): - """ - Replace the registry with a fresh empty one. - - Test-only escape hatch — production code never calls this. Pytest - fixtures that build and tear down a cluster within the same - process need to reset the registry between tests so leftover - voters from one test don't bleed into the next. - """ - with _LOCK: - _RINGS.clear() - _ROUTING.clear() - _DROP_STATS.clear() - _DROP_LAST_LOG.clear() diff --git a/salt/cluster/state_sync.py b/salt/cluster/state_sync.py deleted file mode 100644 index 874916c83116..000000000000 --- a/salt/cluster/state_sync.py +++ /dev/null @@ -1,468 +0,0 @@ -""" -Paged bulk state-sync for cluster joiners. - -The cluster join handshake (``cluster/peer/join`` -> -``cluster/peer/join-reply``) carries the cluster's identity material -(``cluster_aes``, ``cluster.pem``, peer pubs), but the joining master -also needs the *content* the cluster has accumulated: accepted / -denied minion keys, the file_roots tree, and the pillar_roots tree. -That content can run from a few KB on a fresh cluster to tens of MB -on a production deployment with thousands of minions and a large -SLS tree. - -To keep the join-reply itself small and to give the joiner partial- -progress + per-channel failure isolation, the state-sync runs on -*four independent streams*, each chunked by its own budget: - -============== ================== ======================= -channel chunked by per-chunk budget -============== ================== ======================= -``keys`` entry count ``DEFAULT_KEY_CHUNK_COUNT`` -``denied_keys`` entry count ``DEFAULT_KEY_CHUNK_COUNT`` -``file_roots`` cumulative bytes ``DEFAULT_ROOTS_CHUNK_BYTES`` -``pillar_roots`` cumulative bytes ``DEFAULT_ROOTS_CHUNK_BYTES`` -============== ================== ======================= - -Wire format ------------ -The responder allocates a session id, names it in the join-reply's -``state_sync_session`` field, then publishes a series of -``cluster/peer/state-sync-chunk`` events to the joiner. Each event -payload is a Crypticle-encrypted dict (encrypted under the cluster -session AES key the joiner just received in the same join-reply):: - - { - "session": str, # matches join-reply state_sync_session - "channel": str, # one of ALL_CHANNELS - "seq": int, # 0-indexed sequence within this channel - "total": int, # total chunks for this channel (-1 if unknown) - "eof": bool, # True on the final chunk for this channel - "items": list, # channel-specific entries - } - -A channel with no data still emits one chunk with ``items=[]`` and -``eof=True``, so receivers can use the ``eof`` flag uniformly. - -Receiver state machine ----------------------- -:class:`StateSyncSession` is held by the joiner's -``MasterPubServerChannel`` and tracks per-channel ``eof`` flags. The -caller provides an ``on_complete`` callback that fires when all four -channels have either eof'd or the deadline expires (whichever comes -first); the channel server uses that callback to call -``_start_raft_as_learner`` only after bulk sync is at rest. -""" - -import logging -import secrets -import time - -import salt.cache -import salt.exceptions - -log = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Channel names + chunking knobs -# --------------------------------------------------------------------------- - -KEYS_CHANNEL = "keys" -DENIED_CHANNEL = "denied_keys" -FILE_ROOTS_CHANNEL = "file_roots" -PILLAR_ROOTS_CHANNEL = "pillar_roots" - -# Channel prefix for arbitrary cache banks (multi-ring migration). A -# channel string ``"bank:jobs/loads"`` names the cache bank that -# carries the payload; the receiver routes by prefix to -# :func:`install_bank_chunk`. Used by -# ``cluster.collect_from_peers`` for caches other than the four -# join-time channels above. -BANK_CHANNEL_PREFIX = "bank:" - - -def bank_channel(bank): - """Return the wire channel name for *bank*.""" - return f"{BANK_CHANNEL_PREFIX}{bank}" - - -def bank_from_channel(channel): - """Return the bank name a ``bank:`` channel was made from, or None.""" - if not channel or not channel.startswith(BANK_CHANNEL_PREFIX): - return None - return channel[len(BANK_CHANNEL_PREFIX) :] - - -ALL_CHANNELS = ( - KEYS_CHANNEL, - DENIED_CHANNEL, - FILE_ROOTS_CHANNEL, - PILLAR_ROOTS_CHANNEL, -) - -# Default count per chunk for cache-key channels. Tuned so a 200-entry -# minion-key chunk (avg pub key ~500 bytes -> ~100 KB) fits comfortably in -# one Crypticle-encrypted message without dominating heartbeat bandwidth. -DEFAULT_KEY_CHUNK_COUNT = 200 - -# Default per-chunk byte budget for file-tree channels. 1 MB is a -# pragmatic compromise: small enough that a TCP retransmit is cheap, big -# enough that a typical SLS tree fits in a handful of chunks. -DEFAULT_ROOTS_CHUNK_BYTES = 1 * 1024 * 1024 - -# Default deadline (seconds) the joiner waits for all four channels to -# eof before falling back to event-driven replication. -DEFAULT_RECEIVE_TIMEOUT = 30 - - -def new_session_id(): - """Return a fresh session id (URL-safe, no fixed length).""" - return secrets.token_urlsafe(16) - - -# --------------------------------------------------------------------------- -# Sender-side: chunk generators -# --------------------------------------------------------------------------- - - -def _by_count(items, n): - """Yield successive lists of *items* of size up to *n*.""" - chunk = [] - for item in items: - chunk.append(item) - if len(chunk) >= n: - yield chunk - chunk = [] - if chunk: - yield chunk - - -def iter_keys_chunks(opts, channel, count=DEFAULT_KEY_CHUNK_COUNT, key_filter=None): - """ - Yield ``items`` lists for the ``keys`` or ``denied_keys`` channel. - - Each item is ``{"id": minion_id, "value": cache_value}`` — a - self-contained record the receiver hands straight to ``cache.store``. - - A bank with no entries (or whose entries are all filtered out) still - yields one empty list so the caller can emit a single eof chunk. - - :param key_filter: Optional ``callable(minion_id) -> bool``. When - present, only entries whose id passes the - filter are emitted. Used by the multi-ring - ``cluster.collect_from_peers`` runner so a peer - only sends back the keys the requester asked - for, rather than its entire bank. - """ - if channel not in (KEYS_CHANNEL, DENIED_CHANNEL): - raise ValueError(f"iter_keys_chunks: unsupported channel {channel!r}") - cache = salt.cache.Cache(opts, driver=opts["keys.cache_driver"]) - try: - dump = cache.list_all(channel, include_data=True) - except (AttributeError, salt.exceptions.SaltCacheError): - dump = {} - pairs = list((dump or {}).items()) - if key_filter is not None: - pairs = [(mid, value) for mid, value in pairs if key_filter(mid)] - items = [{"id": mid, "value": value} for mid, value in pairs] - if not items: - yield [] - return - yield from _by_count(items, count) - - -def iter_root_chunks(roots_map, byte_budget=DEFAULT_ROOTS_CHUNK_BYTES): - """ - Yield ``items`` lists for the ``file_roots`` / ``pillar_roots`` channel. - - Each item is ``{"env": str, "path": str, "mode": int, "data": bytes}`` - — flattened across envs so the receiver can apply each entry without - needing to track env boundaries within a chunk. - - Chunks are bounded by *byte_budget*: a chunk is closed when adding - the next entry would exceed the budget *and* the chunk already holds - at least one entry. A single file larger than the budget gets its - own chunk on its own. - - An empty / missing roots map yields one empty list so the caller can - emit a single eof chunk. - """ - # Lazy import to avoid a circular dependency at module load. - from salt.cluster.file_sync import ( # pylint: disable=import-outside-toplevel - collect_root_tree, - ) - - dump = collect_root_tree(roots_map) - if not dump: - yield [] - return - - chunk = [] - chunk_bytes = 0 - for env, files in dump.items(): - for entry in files: - entry_bytes = len(entry.get("data") or b"") - if chunk and chunk_bytes + entry_bytes > byte_budget: - yield chunk - chunk = [] - chunk_bytes = 0 - chunk.append( - { - "env": env, - "path": entry["path"], - "mode": entry.get("mode", 0o644), - "data": entry["data"], - } - ) - chunk_bytes += entry_bytes - if chunk: - yield chunk - - -def iter_bank_chunks(opts, bank, count=DEFAULT_KEY_CHUNK_COUNT, key_filter=None): - """ - Yield ``items`` lists for an arbitrary :class:`salt.cache.Cache` - bank. - - Each item is ``{"key": str, "value": any}`` — the bank name is - carried separately in the wire channel - (``BANK_CHANNEL_PREFIX + bank``) so a single channel maps to a - single bank on the receiver. - - Used by :func:`salt.runners.cluster.collect_from_peers` to pull - arbitrary operator-routed caches (e.g. the salt_cache returner's - ``jobs/loads``) from peers. Mirrors :func:`iter_keys_chunks`'s - contract: an empty bank still yields a single empty list so the - eof flag fires uniformly. - """ - cache_driver = opts.get("cache") or opts.get("keys.cache_driver") - cache = salt.cache.Cache(opts, driver=cache_driver) - pairs = [] - try: - # Prefer the bulk list_all interface — drivers that implement - # it avoid the N+1 fetch. - dump = cache.list_all(bank, include_data=True) - pairs = list((dump or {}).items()) - except (AttributeError, salt.exceptions.SaltCacheError): - # Fallback for drivers that don't expose list_all (e.g. - # custom plugin caches). - try: - for key in cache.list(bank): - value = cache.fetch(bank, key) - pairs.append((key, value)) - except salt.exceptions.SaltCacheError: - pairs = [] - if key_filter is not None: - pairs = [(k, v) for k, v in pairs if key_filter(k)] - items = [{"key": k, "value": v} for k, v in pairs] - if not items: - yield [] - return - yield from _by_count(items, count) - - -# --------------------------------------------------------------------------- -# Receiver-side: install one chunk -# --------------------------------------------------------------------------- - - -def install_keys_chunk(opts, channel, items): - """ - Apply a single chunk of ``keys`` / ``denied_keys`` entries. - - Returns the number of entries successfully written. - """ - if channel not in (KEYS_CHANNEL, DENIED_CHANNEL): - raise ValueError(f"install_keys_chunk: unsupported channel {channel!r}") - cache = salt.cache.Cache(opts, driver=opts["keys.cache_driver"]) - written = 0 - for entry in items or []: - if not isinstance(entry, dict): - continue - mid = entry.get("id") - value = entry.get("value") - if not mid or value is None: - continue - try: - cache.store(channel, mid, value) - written += 1 - except Exception: # pylint: disable=broad-except - log.exception("state-sync: failed to install %s entry for %s", channel, mid) - return written - - -def install_bank_chunk(opts, bank, items): - """ - Apply a single chunk of generic bank entries. - - Each item is ``{"key": str, "value": any}``; the receiver writes - via ``cache.store(bank, key, value)``. Idempotent — receiving - the same chunk twice overwrites but doesn't break. Returns the - number of entries successfully written. - """ - if not bank: - raise ValueError("install_bank_chunk: bank is required") - cache_driver = opts.get("cache") or opts.get("keys.cache_driver") - cache = salt.cache.Cache(opts, driver=cache_driver) - written = 0 - for entry in items or []: - if not isinstance(entry, dict): - continue - key = entry.get("key") - if key is None: - continue - try: - cache.store(bank, key, entry.get("value")) - written += 1 - except Exception: # pylint: disable=broad-except - log.exception( - "state-sync: failed to install %s/%s", - bank, - key, - ) - return written - - -def install_root_chunk(roots_map, items): - """ - Apply a single chunk of ``file_roots`` / ``pillar_roots`` entries. - - Reuses :func:`salt.cluster.file_sync.apply_root_tree` by re-grouping - the flat ``items`` list back into ``{env: [entry, ...]}`` shape. - - Returns the number of files successfully written. - """ - from salt.cluster.file_sync import ( # pylint: disable=import-outside-toplevel - apply_root_tree, - ) - - grouped = {} - for entry in items or []: - if not isinstance(entry, dict): - continue - env = entry.get("env") - path = entry.get("path") - if not env or not path: - continue - grouped.setdefault(env, []).append( - { - "path": path, - "mode": entry.get("mode", 0o644), - "data": entry.get("data"), - } - ) - return apply_root_tree(roots_map, grouped) - - -# --------------------------------------------------------------------------- -# Receiver-side: per-session state machine -# --------------------------------------------------------------------------- - - -class StateSyncSession: - """ - Tracks per-channel completion for one bulk state-sync. - - Used by the joiner's ``MasterPubServerChannel``: a session is created - when the join-reply arrives, each inbound - ``cluster/peer/state-sync-chunk`` calls :meth:`record_chunk`, and the - *on_complete* callback fires exactly once when all four channels have - eof'd (or :meth:`force_complete` is called by a watchdog timer). - - :param session_id: opaque session identifier from the join-reply. - :param on_complete: zero-arg callable invoked once when the session - finishes (either all eofs received or forced). - :param channels: iterable of channel names that must all eof for - *on_complete* to fire. Defaults to :data:`ALL_CHANNELS`. - """ - - def __init__(self, session_id, on_complete, channels=ALL_CHANNELS): - self.session_id = session_id - self._on_complete = on_complete - self._channels = tuple(channels) - # Per-channel state: eof flag + count of chunks installed - self._state = { - ch: {"eof": False, "chunks": 0, "items": 0} for ch in self._channels - } - self._completed = False - self.created_at = time.monotonic() - - def record_chunk(self, channel, seq, eof, items_installed): - """ - Record one chunk's arrival. Fires *on_complete* if this was the - last outstanding eof. - """ - if channel not in self._state: - log.warning( - "state-sync session %s: unknown channel %r (chunks=%d, eof=%s)", - self.session_id, - channel, - seq, - eof, - ) - return - st = self._state[channel] - st["chunks"] += 1 - st["items"] += int(items_installed) - if eof: - if st["eof"]: - log.warning( - "state-sync session %s: duplicate eof on %s (seq=%d)", - self.session_id, - channel, - seq, - ) - st["eof"] = True - self._maybe_complete() - - def _maybe_complete(self): - if self._completed: - return - if all(self._state[ch]["eof"] for ch in self._channels): - self._completed = True - log.info( - "state-sync session %s complete: %s", - self.session_id, - {ch: self._state[ch] for ch in self._channels}, - ) - try: - self._on_complete() - except Exception: # pylint: disable=broad-except - log.exception( - "state-sync session %s: on_complete callback failed", - self.session_id, - ) - - def force_complete(self): - """ - Fire *on_complete* regardless of outstanding eofs. - - The watchdog timer calls this when the per-session deadline - elapses; the joiner then proceeds with whatever data arrived and - relies on event-driven replication for the rest. - """ - if self._completed: - return - missing = [ch for ch in self._channels if not self._state[ch]["eof"]] - log.warning( - "state-sync session %s deadline reached; forcing complete with " - "channels still pending: %s", - self.session_id, - missing, - ) - self._completed = True - try: - self._on_complete() - except Exception: # pylint: disable=broad-except - log.exception( - "state-sync session %s: on_complete callback failed (forced)", - self.session_id, - ) - - @property - def completed(self): - return self._completed - - def status(self): - """Return a serialisable snapshot of per-channel progress (for logs).""" - return dict(self._state) diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 42253f176a85..677696a0b444 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -23,7 +23,6 @@ import salt.utils.files import salt.utils.immutabletypes as immutabletypes import salt.utils.network -import salt.utils.optsdict import salt.utils.path import salt.utils.platform import salt.utils.stringutils @@ -38,7 +37,6 @@ DFLT_LOG_FMT_CONSOLE, DFLT_LOG_FMT_JID, DFLT_LOG_FMT_LOGFILE, - DFLT_LOG_FMT_MINION_ID, ) try: @@ -164,7 +162,7 @@ def _gather_buffer_space(): "always_verify_signature": bool, # The name of the file in the masters pki-directory that holds the pre-calculated signature of # the masters public-key - "master_pubkey_signature": (type(None), str), + "master_pubkey_signature": str, # Instead of computing the signature for each auth-reply, use a pre-calculated signature. # The master_pubkey_signature must also be set for this. "master_use_pubkey_signature": bool, @@ -200,54 +198,6 @@ def _gather_buffer_space(): "cluster_pki_dir": str, # The port required to be open for a master cluster to properly function "cluster_pool_port": int, - # Optional SHA-256 hex fingerprint of the shared cluster public key. - # When set, a joining master rejects any discover-reply whose - # ``cluster_pub`` does not hash to this value. See the ``cluster_secret`` - # docs and the master-cluster tutorial for the trust model. - "cluster_pub_fingerprint": str, - # Shared pre-shared string that authenticates a master joining an - # existing cluster at runtime. - "cluster_secret": str, - # When True, cluster masters do NOT share ``cluster_pki_dir`` / - # ``cachedir`` between members. In this mode keys.cache_driver - # defaults to mmap_key (so cache files are deterministic per-bank - # and can be sync'd as opaque blobs) and joining masters request a - # bulk state-sync from an existing peer before becoming Raft voters. - "cluster_isolated_filesystem": bool, - # Maximum number of in-memory Raft log entries before the log - # compacts into a snapshot. ``None`` (the default) disables - # compaction — fine for small clusters but unbounded growth at - # scale. Setting to a positive integer triggers - # ``Log.snapshot()`` whenever the log reaches the threshold; - # the snapshot envelope (``raft.snapshot.v1``) carries every - # registered state machine so membership survives compaction. - "cluster_max_log_size": (type(None), int), - # Upper bound on the number of voting peers in the cluster Raft - # group. ``None`` (the default) preserves today's behaviour: - # every master that joins is promoted to a voter once its log - # catches up. Setting a positive integer caps the voter set; - # late joiners that arrive after the cap is hit stay as - # non-voting learners indefinitely. Learners still receive - # log replication and cluster events, so they remain useful - # for handling minion traffic — they just don't count toward - # election or commit quorum. - "cluster_max_voters": (type(None), int), - # Auto-replacement of failed voters (Ongaro thesis §6.4 single-server - # changes). When ``cluster_auto_replace_voters`` is True the leader - # watches each voter's last_contact timestamp; a voter silent for - # ``cluster_voter_timeout`` seconds becomes a candidate for - # demotion. The leader proposes a CONFIG entry moving it to the - # learner set; the existing replacement-promotion path then - # promotes a caught-up learner to fill the slot, subject to - # ``cluster_max_voters``. ``cluster_min_voters`` is a floor that - # refuses demotion if it would shrink the voter set below safety; - # ``cluster_demote_cooldown`` blocks immediate re-promotion of a - # node that flaps. Default is opt-in (False) until field-tested. - "cluster_voter_health_check_interval": float, - "cluster_voter_timeout": float, - "cluster_min_voters": int, - "cluster_demote_cooldown": float, - "cluster_auto_replace_voters": bool, # Use a module function to determine the unique identifier. If this is # set and 'id' is not set, it will allow invocation of a module function # to determine the value of 'id'. For simple invocations without function @@ -400,10 +350,6 @@ def _gather_buffer_space(): "log_fmt_console": str, # The format for a given log file "log_fmt_logfile": (tuple, str), - # The format for JIDs prior to formatting into log lines as %(jid)s - "log_fmt_jid": (type(None), str), - # The format for minion_ids prior to formatting into log lines as %(minion_id)s - "log_fmt_minion_id": (type(None), str), # A dictionary of logging levels "log_granular_levels": dict, # The maximum number of bytes a single log file may contain before @@ -458,8 +404,6 @@ def _gather_buffer_space(): "state_auto_order": bool, # Fire events as state chunks are processed by the state compiler "state_events": bool, - # Limit the number of states that can be running in parallel - "state_max_parallel": int, # The number of seconds a minion should wait before retry when attempting authentication "acceptance_wait_time": float, # The number of seconds a minion should wait before giving up during authentication @@ -511,8 +455,6 @@ def _gather_buffer_space(): "return_retry_tries": int, # Configures amount of retries for Syndic to Master of Masters "syndic_retries": int, - # Top-level pillar key for per-type resource configuration (default: resources) - "resource_pillar_key": str, # Specify one or more returners in which all events will be sent to. Requires that the returners # in question have an event_return(event) function! "event_return": (list, str), @@ -570,13 +512,12 @@ def _gather_buffer_space(): # The number of MWorker processes for a master to startup. This number needs to scale up as # the number of connected minions increases. "worker_threads": int, - # Enable worker pool routing for mworkers - "worker_pools_enabled": bool, - # Worker pool configuration (dict of pool_name -> {worker_count, commands}) - "worker_pools": dict, # The port for the master to listen to returns on. The minion needs to connect to this port # to send returns. "ret_port": int, + # The number of hours to keep jobs around in the job cache on the master + # This option is deprecated by keep_jobs_seconds + "keep_jobs": int, # The number of seconds to keep jobs around in the job cache on the master "keep_jobs_seconds": int, # If the returner supports `clean_old_jobs`, then at cleanup time, @@ -636,7 +577,6 @@ def _gather_buffer_space(): "git_pillar_refspecs": list, "git_pillar_includes": bool, "git_pillar_verify_config": bool, - "git_pillar_proxy": str, # NOTE: gitfs_base, gitfs_fallback, gitfs_mountpoint, and gitfs_root omitted # here because their values could conceivably be loaded as non-string types, # which is OK because gitfs will normalize them to strings. But rather than @@ -654,7 +594,6 @@ def _gather_buffer_space(): "gitfs_ref_types": list, "gitfs_refspecs": list, "gitfs_disable_saltenv_mapping": bool, - "gitfs_proxy": str, "hgfs_remotes": list, "hgfs_mountpoint": str, "hgfs_root": str, @@ -825,7 +764,6 @@ def _gather_buffer_space(): "winrepo_remotes": list, "winrepo_remotes_ng": list, "winrepo_ssl_verify": bool, - "winrepo_proxy": str, "winrepo_user": str, "winrepo_password": str, "winrepo_insecure_auth": bool, @@ -873,13 +811,6 @@ def _gather_buffer_space(): "auth_timeout": int, # The number of attempts to authenticate to a master before giving up "auth_tries": int, - # Cap on the AsyncAuth outer retry loop on the minion. When the master - # answers sign_in() with a ``retry`` sentinel (key not yet accepted, - # AES rotation in flight, multi-master probe), the minion sleeps and - # retries. Set this to a positive integer to bail out with - # ``SaltClientError`` after that many outer attempts. Default is ``0`` - # which preserves the pre-3006.26 behavior of retrying forever. - "auth_retries": int, # The number of attempts to connect to a master before giving up. # Set this to -1 for unlimited attempts. This allows for a master to have # downtime and the minion to reconnect to it later when it comes back up. @@ -985,9 +916,6 @@ def _gather_buffer_space(): # Thin and minimal Salt extra modules "thin_extra_mods": str, "min_extra_mods": str, - "thin_exclude_saltexts": bool, - "thin_saltext_allowlist": (type(None), list), - "thin_saltext_blocklist": list, # Default returners minion should use. List or comma-delimited string "return": (str, list), # TLS/SSL connection options. This could be set to a dictionary containing arguments @@ -997,10 +925,6 @@ def _gather_buffer_space(): # Note: to set enum arguments values like `cert_reqs` and `ssl_version` use constant names # without ssl module prefix: `CERT_REQUIRED` or `PROTOCOL_SSLv23`. "ssl": (dict, bool, type(None)), - # Disable redundant AES encryption when TLS is active with validated certificates - "disable_aes_with_tls": bool, - # Use the native OS certificate store instead of the bundled certifi CA bundle - "use_os_truststore": bool, # Controls how a multi-function job returns its data. If this is False, # it will return its data using a dictionary with the function name as # the key. This is compatible with legacy systems. If this is True, it @@ -1051,8 +975,6 @@ def _gather_buffer_space(): "schedule": dict, # Whether to fire auth events "auth_events": bool, - # Specify auth events to add autosign_grains to - "auth_events_autosign_grains": list, # Whether to fire Minion data cache refresh events "minion_data_cache_events": bool, # Enable calling ssh minions from the salt master @@ -1097,28 +1019,10 @@ def _gather_buffer_space(): "signing_algorithm": str, # Master publish channel signing "publish_signing_algorithm": str, - # RSA encryption used for cluster peer-to-peer messages - "cluster_encryption_algorithm": str, - # the cache driver to be used to manage keys for both minion and master - "keys.cache_driver": (type(None), str), "request_server_ttl": int, "request_server_aes_session": int, # Minimum authentication protocol version to accept from minions "minimum_auth_version": int, - # optional cache driver for pillar cache - "pillar.cache_driver": (type(None), str), - # optional cache driver for eauth_tokens cache - "eauth_tokens.cache_driver": (type(None), str), - # eauth tokens cluster id override - "eauth_tokens.cluster_id": (type(None), str), - # OpenTelemetry tracing configuration block. Disabled by default; - # when enabled, salt daemons emit W3C-TraceContext-propagated spans - # via an OTLP exporter. - "tracing": dict, - # OpenTelemetry metrics configuration block. Disabled by default; - # when enabled, salt daemons emit counters, histograms and - # observable gauges via OTLP push or a Prometheus pull endpoint. - "metrics": dict, } ) @@ -1244,10 +1148,6 @@ def _gather_buffer_space(): "git_pillar_passphrase": "", "git_pillar_refspecs": _DFLT_REFSPECS, "git_pillar_includes": True, - "git_pillar_proxy": "", - "git_pillar_depth": 1, - "git_pillar_ref_types": ["branch", "tag", "sha"], - "git_pillar_disable_saltenv_mapping": False, "gitfs_remotes": [], "gitfs_mountpoint": "", "gitfs_root": "", @@ -1267,8 +1167,6 @@ def _gather_buffer_space(): "gitfs_ref_types": ["branch", "tag", "sha"], "gitfs_refspecs": _DFLT_REFSPECS, "gitfs_disable_saltenv_mapping": False, - "gitfs_proxy": "", - "gitfs_depth": 1, "unique_jid": False, "hash_type": DEFAULT_HASH_TYPE, "optimization_order": [0, 1, 2], @@ -1310,7 +1208,6 @@ def _gather_buffer_space(): "log_fmt_console": DFLT_LOG_FMT_CONSOLE, "log_fmt_logfile": DFLT_LOG_FMT_LOGFILE, "log_fmt_jid": DFLT_LOG_FMT_JID, - "log_fmt_minion_id": DFLT_LOG_FMT_MINION_ID, "log_granular_levels": {}, "log_rotate_max_bytes": 0, "log_rotate_backup_count": 0, @@ -1330,7 +1227,6 @@ def _gather_buffer_space(): "state_events": False, "state_aggregate": False, "state_queue": False, - "state_max_parallel": 0, "snapper_states": False, "snapper_states_config": "root", "acceptance_wait_time": 10, @@ -1352,7 +1248,6 @@ def _gather_buffer_space(): "return_retry_timer": 5, "return_retry_timer_max": 10, "return_retry_tries": 3, - "resource_pillar_key": "resources", "syndic_retries": 3, "random_reauth_delay": 10, "winrepo_source_dir": "salt://win/repo-ng/", @@ -1369,7 +1264,6 @@ def _gather_buffer_space(): "winrepo_branch": "master", "winrepo_fallback": "", "winrepo_ssl_verify": True, - "winrepo_proxy": "", "winrepo_user": "", "winrepo_password": "", "winrepo_insecure_auth": False, @@ -1377,7 +1271,6 @@ def _gather_buffer_space(): "winrepo_pubkey": "", "winrepo_passphrase": "", "winrepo_refspecs": _DFLT_REFSPECS, - "winrepo_depth": 1, "pidfile": os.path.join(salt.syspaths.PIDFILE_DIR, "salt-minion.pid"), "range_server": "range:80", "reactor_refresh_interval": 60, @@ -1397,7 +1290,6 @@ def _gather_buffer_space(): "transport": "zeromq", "auth_timeout": 5, "auth_tries": 7, - "auth_retries": 0, "master_tries": _MASTER_TRIES, "master_tops_first": False, "auth_safemode": False, @@ -1424,7 +1316,6 @@ def _gather_buffer_space(): "proxy_port": 0, "minion_jid_queue_hwm": 100, "ssl": None, - "disable_aes_with_tls": False, "multifunc_ordered": False, "beacons_before_connect": False, "scheduler_before_connect": False, @@ -1440,83 +1331,9 @@ def _gather_buffer_space(): "global_state_conditions": None, "reactor_niceness": None, "fips_mode": False, - "use_os_truststore": False, "features": {}, "encryption_algorithm": "OAEP-SHA1", "signing_algorithm": "PKCS1v15-SHA1", - "keys.cache_driver": "localfs_key", - "pillar.cache_driver": None, - "tracing": { - "enabled": False, - "exporter": "otlp-http", - "endpoint": "", - "service_name": "", - "sampler": "parent_based", - "sampler_arg": 1.0, - "resource_attributes": {}, - "insecure": True, - "headers": {}, - }, - "metrics": { - "enabled": False, - "exporter": "otlp-http", - "endpoint": "", - "service_name": "", - "resource_attributes": {}, - "insecure": True, - "headers": {}, - "export_interval_seconds": 60, - "prometheus": { - "host": "127.0.0.1", - "port": 9464, - }, - "histogram_boundaries": { - "salt.job.duration": [ - 1, - 5, - 10, - 25, - 50, - 100, - 250, - 500, - 1000, - 2500, - 5000, - 10000, - 30000, - 60000, - ], - "salt.minion.exec.duration": [ - 1, - 5, - 10, - 25, - 50, - 100, - 250, - 500, - 1000, - 2500, - 5000, - 10000, - ], - "salt.master.requests.duration": [ - 1, - 5, - 10, - 25, - 50, - 100, - 250, - 500, - 1000, - 2500, - 5000, - 10000, - ], - }, - }, } ) @@ -1529,8 +1346,6 @@ def _gather_buffer_space(): "auth_mode": 1, "user": _MASTER_USER, "worker_threads": 5, - "worker_pools_enabled": True, - "worker_pools": {}, "sock_dir": os.path.join(salt.syspaths.SOCK_DIR, "master"), "sock_pool_size": 1, "ret_port": 4506, @@ -1592,10 +1407,6 @@ def _gather_buffer_space(): "git_pillar_refspecs": _DFLT_REFSPECS, "git_pillar_includes": True, "git_pillar_verify_config": True, - "git_pillar_proxy": "", - "git_pillar_depth": 1, - "git_pillar_ref_types": ["branch", "tag", "sha"], - "git_pillar_disable_saltenv_mapping": False, "gitfs_remotes": [], "gitfs_mountpoint": "", "gitfs_root": "", @@ -1615,8 +1426,6 @@ def _gather_buffer_space(): "gitfs_ref_types": ["branch", "tag", "sha"], "gitfs_refspecs": _DFLT_REFSPECS, "gitfs_disable_saltenv_mapping": False, - "gitfs_proxy": "", - "gitfs_depth": 1, "hgfs_remotes": [], "hgfs_mountpoint": "", "hgfs_root": "", @@ -1734,7 +1543,6 @@ def _gather_buffer_space(): "log_fmt_console": DFLT_LOG_FMT_CONSOLE, "log_fmt_logfile": DFLT_LOG_FMT_LOGFILE, "log_fmt_jid": DFLT_LOG_FMT_JID, - "log_fmt_minion_id": DFLT_LOG_FMT_MINION_ID, "log_granular_levels": {}, "log_rotate_max_bytes": 0, "log_rotate_backup_count": 0, @@ -1761,7 +1569,6 @@ def _gather_buffer_space(): "state_auto_order": True, "state_events": False, "state_aggregate": False, - "state_max_parallel": 0, "search": "", "loop_interval": 60, "nodegroups": {}, @@ -1787,7 +1594,6 @@ def _gather_buffer_space(): "winrepo_branch": "master", "winrepo_fallback": "", "winrepo_ssl_verify": True, - "winrepo_proxy": "", "winrepo_user": "", "winrepo_password": "", "winrepo_insecure_auth": False, @@ -1795,7 +1601,6 @@ def _gather_buffer_space(): "winrepo_pubkey": "", "winrepo_passphrase": "", "winrepo_refspecs": _DFLT_REFSPECS, - "winrepo_depth": 1, "syndic_wait": 5, "jinja_env": {}, "jinja_sls_env": {}, @@ -1834,7 +1639,7 @@ def _gather_buffer_space(): "max_minions": 0, "master_sign_key_name": "master_sign", "master_sign_pubkey": False, - "master_pubkey_signature": None, + "master_pubkey_signature": "master_pubkey_signature", "master_use_pubkey_signature": False, "zmq_filtering": False, "zmq_monitor": False, @@ -1852,11 +1657,7 @@ def _gather_buffer_space(): "memcache_debug": False, "thin_extra_mods": "", "min_extra_mods": "", - "thin_exclude_saltexts": False, - "thin_saltext_allowlist": None, - "thin_saltext_blocklist": [], "ssl": None, - "disable_aes_with_tls": False, "extmod_whitelist": {}, "extmod_blacklist": {}, "clean_dynamic_modules": True, @@ -1869,12 +1670,10 @@ def _gather_buffer_space(): "discovery": False, "schedule": {}, "auth_events": True, - "auth_events_pend_autosign_grains": False, "minion_data_cache_events": True, "enable_ssh_minions": False, "netapi_allow_raw_shell": False, "fips_mode": False, - "use_os_truststore": False, "detect_remote_minions": False, "remote_minions_port": 22, "pass_variable_prefix": "", @@ -1888,97 +1687,11 @@ def _gather_buffer_space(): "cluster_peers": [], "cluster_pki_dir": None, "cluster_pool_port": 4520, - "cluster_pub_fingerprint": None, - "cluster_secret": None, - "cluster_isolated_filesystem": False, - "cluster_max_log_size": None, - "cluster_max_voters": None, - "cluster_voter_health_check_interval": 1.0, - "cluster_voter_timeout": 10.0, - "cluster_min_voters": 3, - "cluster_demote_cooldown": 60.0, - "cluster_auto_replace_voters": False, "features": {}, "publish_signing_algorithm": "PKCS1v15-SHA1", - "cluster_encryption_algorithm": "OAEP-SHA1", - "keys.cache_driver": "localfs_key", "request_server_aes_session": 0, "request_server_ttl": 0, "minimum_auth_version": 3, - "pillar.cache_driver": None, - "eauth_tokens.cache_driver": None, - "eauth_tokens.cluster_id": None, - "tracing": { - "enabled": False, - "exporter": "otlp-http", - "endpoint": "", - "service_name": "", - "sampler": "parent_based", - "sampler_arg": 1.0, - "resource_attributes": {}, - "insecure": True, - "headers": {}, - }, - "metrics": { - "enabled": False, - "exporter": "otlp-http", - "endpoint": "", - "service_name": "", - "resource_attributes": {}, - "insecure": True, - "headers": {}, - "export_interval_seconds": 60, - "prometheus": { - "host": "127.0.0.1", - "port": 9464, - }, - "histogram_boundaries": { - "salt.job.duration": [ - 1, - 5, - 10, - 25, - 50, - 100, - 250, - 500, - 1000, - 2500, - 5000, - 10000, - 30000, - 60000, - ], - "salt.minion.exec.duration": [ - 1, - 5, - 10, - 25, - 50, - 100, - 250, - 500, - 1000, - 2500, - 5000, - 10000, - ], - "salt.master.requests.duration": [ - 1, - 5, - 10, - 25, - 50, - 100, - 250, - 500, - 1000, - 2500, - 5000, - 10000, - ], - }, - }, } ) @@ -2042,7 +1755,6 @@ def _gather_buffer_space(): "log_fmt_console": DFLT_LOG_FMT_CONSOLE, "log_fmt_logfile": DFLT_LOG_FMT_LOGFILE, "log_fmt_jid": DFLT_LOG_FMT_JID, - "log_fmt_minion_id": DFLT_LOG_FMT_MINION_ID, "log_granular_levels": {}, "log_rotate_max_bytes": 0, "log_rotate_backup_count": 0, @@ -2680,10 +2392,7 @@ def minion_config( apply_sdb(opts) _validate_opts(opts) salt.features.setup_features(opts) - # Convert to OptsDict for memory efficiency - return salt.utils.optsdict.OptsDict.from_dict( - opts, name=f"minion_config:role={role}" - ) + return opts def mminion_config(path, overrides, ignore_config_errors=True): @@ -2696,6 +2405,7 @@ def mminion_config(path, overrides, ignore_config_errors=True): opts["grains"].destroy() opts["grains"] = salt.loader.grains(opts) opts["pillar"] = {} + salt.features.setup_features(opts) return opts @@ -2782,12 +2492,8 @@ def proxy_config( apply_sdb(opts) _validate_opts(opts) - salt.features.setup_features(opts) - # Convert to OptsDict for memory efficiency - return salt.utils.optsdict.OptsDict.from_dict( - opts, name="minion_config:role=master" - ) + return opts def syndic_config( @@ -2798,6 +2504,7 @@ def syndic_config( minion_defaults=None, master_defaults=None, ): + if minion_defaults is None: minion_defaults = DEFAULT_MINION_OPTS.copy() @@ -2863,56 +2570,31 @@ def syndic_config( if should_prepend_root_dir(config_key, opts): prepend_root_dirs.append(config_key) prepend_root_dir(opts, prepend_root_dirs) + salt.features.setup_features(opts) return opts -def apply_sdb(opts, sdb_opts=None, _visited=None): +def apply_sdb(opts, sdb_opts=None): """ Recurse for sdb:// links for opts """ + # Late load of SDB to keep CLI light + import salt.utils.sdb + if sdb_opts is None: sdb_opts = opts - if _visited is None: - _visited = set() - - # Track visited objects to prevent circular references - # For OptsDict proxies, track the parent OptsDict to avoid new proxy instances - # from being treated as new objects (which causes infinite recursion) - try: - from salt.utils.optsdict import DictProxy, ListProxy - - if isinstance(sdb_opts, (DictProxy, ListProxy)): - # Track the parent OptsDict instead of the proxy - parent = object.__getattribute__(sdb_opts, "_parent") - obj_id = id(parent) - else: - obj_id = id(sdb_opts) - except (ImportError, AttributeError): - # Fallback if optsdict not available or not a proxy - obj_id = id(sdb_opts) - - if obj_id in _visited: - return sdb_opts - _visited.add(obj_id) - if isinstance(sdb_opts, str) and sdb_opts.startswith("sdb://"): - # Late load of SDB to keep CLI light - import salt.utils.sdb - return salt.utils.sdb.sdb_get(sdb_opts, opts) elif isinstance(sdb_opts, dict): - # Create a list of items to avoid modifying dict during iteration - # This is especially important for OptsDict which has special iteration behavior - items = list(sdb_opts.items()) - for key, value in items: + for key, value in sdb_opts.items(): if value is None: continue - sdb_opts[key] = apply_sdb(opts, value, _visited) + sdb_opts[key] = apply_sdb(opts, value) elif isinstance(sdb_opts, list): for key, value in enumerate(sdb_opts): if value is None: continue - sdb_opts[key] = apply_sdb(opts, value, _visited) + sdb_opts[key] = apply_sdb(opts, value) return sdb_opts @@ -3141,6 +2823,7 @@ def cloud_config( prepend_root_dirs.append("log_file") prepend_root_dir(opts, prepend_root_dirs) + salt.features.setup_features(opts) # Return the final options return opts @@ -3225,6 +2908,7 @@ def old_to_new(opts): ) for provider in providers: + provider_config = {} for opt, val in opts.items(): if provider in opt: @@ -3467,6 +3151,7 @@ def apply_cloud_providers_config(overrides, defaults=None): handled_providers.add(details["driver"]) for entry in val: + if "driver" not in entry: entry["driver"] = f"-only-extendable-{ext_count}" ext_count += 1 @@ -3626,9 +3311,7 @@ def get_cloud_config_value(name, vm_, opts, default=None, search_global=True): # Let's get the value from the profile, if present if "profile" in vm_ and vm_["profile"] is not None: if name in opts["profiles"][vm_["profile"]]: - if isinstance(value, dict) and isinstance( - opts["profiles"][vm_["profile"]][name], dict - ): + if isinstance(value, dict): value.update(opts["profiles"][vm_["profile"]][name].copy()) else: value = deepcopy(opts["profiles"][vm_["profile"]][name]) @@ -4241,11 +3924,6 @@ def apply_minion_config( f"Please specify one of {','.join(salt.crypt.VALID_SIGNING_ALGORITHMS)}." ) - # Store original `cachedir` value, before overriding, - # to make overriding more accurate. - if "__cachedir" not in opts: - opts["__cachedir"] = opts["cachedir"] - return opts @@ -4333,8 +4011,7 @@ def master_config( opts["nodegroups"] = salt.utils.data.repack_dictlist(opts["nodegroups"]) apply_sdb(opts) salt.features.setup_features(opts) - # Convert to OptsDict for memory efficiency - return salt.utils.optsdict.OptsDict.from_dict(opts, name="master_config") + return opts def apply_master_config(overrides=None, defaults=None): @@ -4463,7 +4140,7 @@ def apply_master_config(overrides=None, defaults=None): if "cluster_id" not in opts: opts["cluster_id"] = None if opts["cluster_id"] is not None: - if not opts.get("cluster_peers", None) and not opts.get("cluster_secret", None): + if not opts.get("cluster_peers", None): log.warning("Cluster id defined without defining cluster peers") opts["cluster_peers"] = [] if not opts.get("cluster_pki_dir", None): @@ -4519,25 +4196,6 @@ def apply_master_config(overrides=None, defaults=None): ) opts["worker_threads"] = 3 - # Handle worker pools configuration - if opts.get("worker_pools_enabled", True): - from salt.config.worker_pools import ( - get_worker_pools_config, - validate_worker_pools_config, - ) - - # Get effective worker pools config (handles backward compat) - effective_pools = get_worker_pools_config(opts) - if effective_pools is not None: - opts["worker_pools"] = effective_pools - - # Validate the configuration - try: - validate_worker_pools_config(opts) - except ValueError as exc: - log.error("Worker pools configuration error: %s", exc) - raise - opts.setdefault("pillar_source_merging_strategy", "smart") # Make sure hash_type is lowercase @@ -4553,16 +4211,6 @@ def apply_master_config(overrides=None, defaults=None): f"Please specify one of {','.join(salt.crypt.VALID_SIGNING_ALGORITHMS)}." ) - if ( - opts["cluster_encryption_algorithm"] - not in salt.crypt.VALID_ENCRYPTION_ALGORITHMS - ): - raise salt.exceptions.SaltConfigurationError( - f"The cluster encryption algorithm '{opts['cluster_encryption_algorithm']}' is not valid. " - f"Please specify one of {','.join(salt.crypt.VALID_ENCRYPTION_ALGORITHMS)}." - ) - - salt.features.setup_features(opts) return opts @@ -4636,7 +4284,7 @@ def client_config(path, env_var="SALT_CLIENT_CONFIG", defaults=None): # Return the client options _validate_opts(opts) salt.features.setup_features(opts) - return salt.utils.optsdict.OptsDict.from_dict(opts, name="client_config") + return opts def api_config(path): @@ -4659,6 +4307,7 @@ def api_config(path): ) prepend_root_dir(opts, ["api_pidfile", "api_logfile", "log_file", "pidfile"]) + salt.features.setup_features(opts) return opts diff --git a/salt/config/worker_pools.py b/salt/config/worker_pools.py deleted file mode 100644 index 96cc18653483..000000000000 --- a/salt/config/worker_pools.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -Default worker-pool configuration and validation for the Salt master. - -Worker pools partition the master's MWorkers into named groups and route -specific commands to specific groups, so a slow workload cannot starve -time-critical traffic (for example ``_auth``). See the -:ref:`tunable worker pools ` topic guide for the -user-facing overview. - -This module contains three things: - -* :data:`DEFAULT_WORKER_POOLS`, the configuration used when the operator - provides no explicit ``worker_pools`` stanza and no ``worker_threads`` - override. -* :func:`validate_worker_pools_config`, called from master configuration - processing to enforce structural and security invariants before the master - is allowed to start. -* :func:`get_worker_pools_config`, which resolves the effective pool layout - from the master opts, handling backward compatibility with - ``worker_threads`` and the ``worker_pools_enabled=False`` legacy switch. - -The pool dictionary shape is:: - - { - "": { - "worker_count": = 1>, - "commands": ["", ..., "*"?], - }, - ... - } - -``commands`` entries are either exact command names (for example ``_auth``) -or the catchall marker ``"*"``. Exactly one pool must use ``"*"``, and no -command may be claimed by more than one pool. -""" - -# Default worker pool routing configuration. -# -# Two pools: a single-worker ``auth`` pool that handles minion authentication -# (``_auth``) and a four-worker ``default`` pool that catches everything -# else. Total worker count matches the long-standing ``worker_threads`` -# default of 5, so existing deployments see the same number of MWorker -# processes — the only change is that one of those workers is dedicated to -# auth, so a slow workload (e.g. a slow ext_pillar) cannot starve out -# minion authentication. -# -# The auth pool is sized to one worker on purpose: ``salt.cache.Cache`` -# stores minion key state under ``keys/`` and ``denied_keys/``, and -# concurrent auth workers writing the same minion id could race on the -# pending → accepted → denied transitions. Operators that have audited -# the cache backend for atomic store semantics can raise this. -# -# The master falls back to this value only when the operator sets neither -# ``worker_pools`` nor ``worker_threads``. -DEFAULT_WORKER_POOLS = { - "auth": { - "worker_count": 1, - "commands": ["_auth"], - }, - "default": { - "worker_count": 4, - "commands": ["*"], - }, -} - - -def validate_worker_pools_config(opts): - """ - Validate the effective worker-pool configuration at master startup. - - Called during master configuration processing. Returns ``True`` when - the configuration is acceptable; raises :class:`ValueError` with a - consolidated multi-line message listing every problem the validator - found. The accumulated reporting style lets operators fix their config - in a single pass instead of discovering errors one at a time. - - The following invariants are enforced: - - * ``worker_pools`` is a non-empty dictionary. - * Pool names are non-empty strings, contain no path separators - (``/`` or ``\\``), do not begin with ``..``, and contain no null - byte. These rules exist purely to prevent pool names from being - abused to steer IPC sockets or logs out of the master's runtime - directories. - * Each pool value is a dictionary containing an integer - ``worker_count >= 1`` and a non-empty list of string ``commands``. - * No command string is claimed by more than one pool. - * Exactly one pool uses the ``"*"`` catchall entry so that any - command not listed explicitly has a well-defined destination. - - When ``worker_pools_enabled`` is ``False`` validation is skipped; the - master runs in the legacy single-queue MWorker mode where pool routing - does not apply. - - :param dict opts: The master configuration dictionary. - :returns: ``True`` when the configuration is valid. - :raises ValueError: If the configuration is invalid. The exception - message lists every detected error. - """ - if not opts.get("worker_pools_enabled", True): - # Legacy mode, no validation needed - return True - - # Get the effective worker pools (handles defaults and backward compat) - worker_pools = get_worker_pools_config(opts) - - # If pools are disabled, no validation needed - if worker_pools is None: - return True - - errors = [] - - # 1. Validate pool structure - if not isinstance(worker_pools, dict): - errors.append("worker_pools must be a dictionary") - raise ValueError("\n".join(errors)) - - if not worker_pools: - errors.append("worker_pools cannot be empty") - raise ValueError("\n".join(errors)) - - # 2. Validate each pool - cmd_to_pool = {} - catchall_pool = None - - for pool_name, pool_config in worker_pools.items(): - # Validate pool name format (security-focused: block path traversal only) - if not isinstance(pool_name, str): - errors.append(f"Pool name must be a string, got {type(pool_name).__name__}") - continue - - if not pool_name: - errors.append("Pool name cannot be empty") - continue - - # Security: block path traversal attempts - if "/" in pool_name or "\\" in pool_name: - errors.append( - f"Pool name '{pool_name}' is invalid. Pool names cannot contain " - "path separators (/ or \\) to prevent path traversal attacks." - ) - continue - - # Security: block relative path components - if ( - pool_name == ".." - or pool_name.startswith("../") - or pool_name.startswith("..\\") - ): - errors.append( - f"Pool name '{pool_name}' is invalid. Pool names cannot be or start with " - "'../' to prevent path traversal attacks." - ) - continue - - # Security: block null bytes - if "\x00" in pool_name: - errors.append("Pool name contains null byte, which is not allowed.") - continue - - if not isinstance(pool_config, dict): - errors.append(f"Pool '{pool_name}': configuration must be a dictionary") - continue - - # Check worker_count - worker_count = pool_config.get("worker_count") - if not isinstance(worker_count, int) or worker_count < 1: - errors.append( - f"Pool '{pool_name}': worker_count must be integer >= 1, " - f"got {worker_count}" - ) - - # Check commands list - commands = pool_config.get("commands", []) - if not isinstance(commands, list): - errors.append(f"Pool '{pool_name}': commands must be a list") - continue - - if not commands: - errors.append(f"Pool '{pool_name}': commands list cannot be empty") - continue - - # Check for duplicate command mappings and catchall - for cmd in commands: - if not isinstance(cmd, str): - errors.append(f"Pool '{pool_name}': command '{cmd}' must be a string") - continue - - if cmd == "*": - # Found catchall pool - if catchall_pool is not None: - errors.append( - f"Multiple pools have catchall ('*'): " - f"'{catchall_pool}' and '{pool_name}'. " - "Only one pool can use catchall." - ) - catchall_pool = pool_name - continue - - if cmd in cmd_to_pool: - errors.append( - f"Command '{cmd}' mapped to multiple pools: " - f"'{cmd_to_pool[cmd]}' and '{pool_name}'" - ) - else: - cmd_to_pool[cmd] = pool_name - - # 3. Require exactly one catchall pool - if catchall_pool is None: - errors.append( - "No catchall pool ('*') found. One pool must include '*' in its " - "commands so every command has a routing destination." - ) - - if errors: - raise ValueError( - "Worker pools configuration validation failed:\n - " - + "\n - ".join(errors) - ) - - return True - - -def get_worker_pools_config(opts): - """ - Resolve the effective worker-pool configuration from master opts. - - Resolution order, first match wins: - - 1. ``worker_pools_enabled`` is ``False`` — returns ``None`` to signal - the legacy non-pooled code path. - 2. ``worker_pools`` is set and non-empty — returned verbatim. The - operator is fully in charge of pool layout. - 3. ``worker_threads`` is set — returns a synthesized single-pool - configuration whose ``worker_count`` matches ``worker_threads`` and - whose ``commands`` is the catchall ``["*"]``. This is the upgrade - path that keeps pre-3008.0 configurations byte-for-byte compatible. - 4. Neither is set — returns :data:`DEFAULT_WORKER_POOLS`. - - :param dict opts: The master configuration dictionary. - :returns: The resolved pool layout, or ``None`` when pooling is - explicitly disabled. - :rtype: dict or None - """ - # If pools explicitly disabled, return None (legacy mode) - if not opts.get("worker_pools_enabled", True): - return None - - # Check if worker_pools is explicitly configured AND not empty - if "worker_pools" in opts and opts["worker_pools"]: - return opts["worker_pools"] - - # Backward compatibility: convert worker_threads to single catchall pool - if "worker_threads" in opts: - worker_count = opts["worker_threads"] - return { - "default": { - "worker_count": worker_count, - "commands": ["*"], - } - } - - # Use default configuration - return DEFAULT_WORKER_POOLS diff --git a/salt/crypt.py b/salt/crypt.py index bbdc7b2e248f..d54dc8a4371c 100644 --- a/salt/crypt.py +++ b/salt/crypt.py @@ -4,7 +4,6 @@ authenticating peers """ -import asyncio import base64 import binascii import copy @@ -23,19 +22,15 @@ import uuid import weakref -import tornado.concurrent -import tornado.ioloop +import tornado.gen -import salt.cache import salt.channel.client import salt.defaults.exitcodes import salt.payload -import salt.utils.asynchronous import salt.utils.crypt import salt.utils.decorators import salt.utils.event import salt.utils.files -import salt.utils.platform import salt.utils.rsax931 import salt.utils.sdb import salt.utils.stringutils @@ -136,7 +131,7 @@ def dropfile(cachedir, user=None, master_id=""): with salt.utils.files.fopen(dfn_next, "w+") as fp_: fp_.write(master_id) os.chmod(dfn_next, stat.S_IRUSR) - if user and not salt.utils.platform.is_windows(): + if user: try: import pwd @@ -147,9 +142,30 @@ def dropfile(cachedir, user=None, master_id=""): os.rename(dfn_next, dfn) -def _write_private(keydir, keyname, key, passphrase=None): +def gen_keys(keydir, keyname, keysize, user=None, passphrase=None, e=65537): + """ + Generate a RSA public keypair for use with salt + + :param str keydir: The directory to write the keypair to + :param str keyname: The type of salt server for whom this key should be written. (i.e. 'master' or 'minion') + :param int keysize: The number of bits in the key + :param str user: The user on the system who should own this keypair + :param str passphrase: The passphrase which should be used to encrypt the private key + + :rtype: str + :return: Path on the filesystem to the RSA private key + """ base = os.path.join(keydir, keyname) priv = f"{base}.pem" + pub = f"{base}.pub" + + gen = rsa.generate_private_key(e, keysize) + + if os.path.isfile(priv): + # Between first checking and the generation another process has made + # a key! Use the winner's key + return priv + # Do not try writing anything, if directory has no permissions. if not os.access(keydir, os.W_OK): raise OSError( @@ -157,10 +173,7 @@ def _write_private(keydir, keyname, key, passphrase=None): os.path.abspath(keydir), getpass.getuser() ) ) - if pathlib.Path(priv).exists(): - # XXX - # raise RuntimeError() - log.error("Key should not exist") + with salt.utils.files.set_umask(0o277): with salt.utils.files.fopen(priv, "wb+") as f: if passphrase: @@ -171,25 +184,14 @@ def _write_private(keydir, keyname, key, passphrase=None): else: enc = serialization.NoEncryption() _format = serialization.PrivateFormat.TraditionalOpenSSL - pem = key.private_bytes( + pem = gen.private_bytes( encoding=serialization.Encoding.PEM, format=_format, encryption_algorithm=enc, ) f.write(pem) - -def _write_public(keydir, keyname, key): - base = os.path.join(keydir, keyname) - pub = f"{base}.pub" - # Do not try writing anything, if directory has no permissions. - if not os.access(keydir, os.W_OK): - raise OSError( - 'Write access denied to "{}" for user "{}".'.format( - os.path.abspath(keydir), getpass.getuser() - ) - ) - pubkey = key.public_key() + pubkey = gen.public_key() with salt.utils.files.fopen(pub, "wb+") as f: pem = pubkey.public_bytes( encoding=serialization.Encoding.PEM, @@ -197,71 +199,6 @@ def _write_public(keydir, keyname, key): ) f.write(pem) - -def gen_keys(keysize, passphrase=None, e=65537): - """ - Generate a RSA public keypair for use with salt - - :param int keysize: The number of bits in the key - :param str passphrase: The passphrase which should be used to encrypt the private key - - :rtype: tuple(str, str) - :return: Private and public key strings as tuple - """ - gen = rsa.generate_private_key(e, keysize) - - if passphrase: - enc = serialization.BestAvailableEncryption(passphrase.encode()) - _format = serialization.PrivateFormat.TraditionalOpenSSL - if fips_enabled(): - _format = serialization.PrivateFormat.PKCS8 - else: - enc = serialization.NoEncryption() - _format = serialization.PrivateFormat.TraditionalOpenSSL - priv_pem = gen.private_bytes( - encoding=serialization.Encoding.PEM, - format=_format, - encryption_algorithm=enc, - ) - - pubkey = gen.public_key() - pub_pem = pubkey.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - - return ( - salt.utils.stringutils.to_str(priv_pem), - salt.utils.stringutils.to_str(pub_pem), - ) - - -def write_keys(keydir, keyname, keysize, user=None, passphrase=None, e=65537): - """ - Generate and write a RSA public keypair for use with salt - - :param str keydir: The directory to write the keypair to - :param str keyname: The type of salt server for whom this key should be written. (i.e. 'master' or 'minion') - :param int keysize: The number of bits in the key - :param str user: The user on the system who should own this keypair - :param str passphrase: The passphrase which should be used to encrypt the private key - - :rtype: str - :return: Path on the filesystem to the RSA private key - """ - base = os.path.join(keydir, keyname) - priv = f"{base}.pem" - pub = f"{base}.pub" - - gen = rsa.generate_private_key(e, keysize) - - if os.path.isfile(priv): - # Between first checking and the generation another process has made - # a key! Use the winner's key - return priv - - _write_private(keydir, keyname, gen, passphrase) - _write_public(keydir, keyname, gen) os.chmod(priv, 0o400) if user: try: @@ -278,22 +215,6 @@ def write_keys(keydir, keyname, keysize, user=None, passphrase=None, e=65537): class BaseKey: - @classmethod - def from_file(cls, path, *args, **kwargs): - with salt.utils.files.fopen(path, "rb") as fp: - key = fp.read() - - return cls(key, *args, **kwargs) - - @classmethod - def from_str(cls, key_str, *args, **kwargs): - key_bytes = salt.utils.stringutils.to_bytes(key_str) - - return cls(key_bytes, *args, **kwargs) - - @classmethod - def from_bytes(cls, key_bytes, *args, **kwargs): - return cls(key_bytes, *args, **kwargs) @staticmethod def parse_padding_for_signing(algorithm): @@ -335,18 +256,9 @@ def _enforce_fips(algorithm): class PrivateKey(BaseKey): - def __init__(self, key_bytes, passphrase=None): - log.debug("Loading private key") - if passphrase: - password = passphrase.encode() - else: - password = None - try: - self.key = serialization.load_pem_private_key(key_bytes, password=password) - except ValueError: - raise InvalidKeyError("Encountered bad RSA private key") - except cryptography.exceptions.UnsupportedAlgorithm: - raise InvalidKeyError("Unsupported key algorithm") + + def __init__(self, path, passphrase=None): + self.key = get_rsa_key(path, passphrase) def encrypt(self, data): pem = self.key.private_bytes( @@ -383,37 +295,20 @@ def decrypt(self, data, algorithm=OAEP_SHA1): except cryptography.exceptions.UnsupportedAlgorithm: raise UnsupportedAlgorithm(f"Unsupported algorithm: {algorithm}") - def write_private(self, keydir, name, passphrase=None): - _write_private(keydir, name, self.key, passphrase) - - def write_public(self, keydir, name): - _write_public(keydir, name, self.key) - - def public_key(self): - """ - proxy to PrivateKey.public_key() - """ - return self.key.public_key() - class PublicKey(BaseKey): - def __init__(self, key_bytes): - log.debug("Loading public key") - try: - self.key = serialization.load_pem_public_key(key_bytes) - except ValueError: - raise InvalidKeyError("Encountered bad RSA public key") - except cryptography.exceptions.UnsupportedAlgorithm: - raise InvalidKeyError("Unsupported key algorithm") + def __init__(self, path): + with salt.utils.files.fopen(path, "rb") as fp: + try: + self.key = serialization.load_pem_public_key(fp.read()) + except ValueError as exc: + raise InvalidKeyError("Invalid key") def encrypt(self, data, algorithm=OAEP_SHA1): _padding = self.parse_padding_for_encryption(algorithm) _hash = self.parse_hash(algorithm) self._enforce_fips(algorithm) - if type(data) == "bytes": - bdata = data - else: - bdata = salt.utils.stringutils.to_bytes(data) + bdata = salt.utils.stringutils.to_bytes(data) try: return self.key.encrypt( bdata, @@ -456,49 +351,66 @@ def decrypt(self, data): return verifier.verify(data) -class PrivateKeyString(PrivateKey): - # pylint: disable=super-init-not-called - def __init__(self, data, password=None): - self.key = serialization.load_pem_private_key( - data.encode(), - password=password, - ) - - # pylint: enable=super-init-not-called - - -class PublicKeyString(PublicKey): - # pylint: disable=super-init-not-called - def __init__(self, data): +@salt.utils.decorators.memoize +def _get_key_with_evict(path, timestamp, passphrase): + """ + Load a private key from disk. `timestamp` above is intended to be the + timestamp of the file's last modification. This fn is memoized so if it is + called with the same path and timestamp (the file's last modified time) the + second time the result is returned from the memoization. If the file gets + modified then the params are different and the key is loaded from disk. + """ + log.debug("salt.crypt._get_key_with_evict: Loading private key") + if passphrase: + password = passphrase.encode() + else: + password = None + with salt.utils.files.fopen(path, "rb") as f: try: - self.key = serialization.load_pem_public_key(data.encode()) + return serialization.load_pem_private_key( + f.read(), + password=password, + ) except ValueError: - raise InvalidKeyError("Invalid key") - - # pylint: enable=super-init-not-called + raise InvalidKeyError("Encountered bad RSA public key") + except cryptography.exceptions.UnsupportedAlgorithm: + raise InvalidKeyError("Unsupported key algorithm") -@salt.utils.decorators.memoize def get_rsa_key(path, passphrase): """ - Read a private key off the disk. we memoize the constructed private key - based on the input args. + Read a private key off the disk. Poor man's simple cache in effect here, + we memoize the result of calling _get_rsa_with_evict. This means the first + time _get_key_with_evict is called with a path and a timestamp the result + is cached. If the file (the private key) does not change then its + timestamp will not change and the next time the result is returned from the + cache. If the key DOES change the next time _get_rsa_with_evict is called + it is called with different parameters and the fn is run fully to retrieve + the key from disk. """ - return PrivateKey.from_file(path, passphrase).key + log.debug("salt.crypt.get_rsa_key: Loading private key") + return _get_key_with_evict(path, str(os.path.getmtime(path)), passphrase) def get_rsa_pub_key(path): """ - Return a public key from bytes + Read a public key off the disk. """ - return PublicKey.from_file(path).key + log.debug("salt.crypt.get_rsa_pub_key: Loading public key") + try: + with salt.utils.files.fopen(path, "rb") as fp: + return serialization.load_pem_public_key(fp.read()) + except ValueError: + raise InvalidKeyError("Encountered bad RSA public key") + except cryptography.exceptions.UnsupportedAlgorithm: + raise InvalidKeyError("Unsupported key algorithm") def sign_message(privkey_path, message, passphrase=None, algorithm=PKCS1v15_SHA1): """ Use Crypto.Signature.PKCS1_v1_5 to sign a message. Returns the signature. """ - return PrivateKey.from_file(privkey_path, passphrase).sign(message, algorithm) + return PrivateKey(privkey_path, passphrase).sign(message, algorithm) def verify_signature(pubkey_path, message, signature, algorithm=PKCS1v15_SHA1): @@ -506,8 +418,39 @@ def verify_signature(pubkey_path, message, signature, algorithm=PKCS1v15_SHA1): Use Crypto.Signature.PKCS1_v1_5 to verify the signature on a message. Returns True for valid signature. """ - log.debug("Loading public key") - return PublicKey.from_file(pubkey_path).verify(message, signature, algorithm) + log.debug("salt.crypt.verify_signature: Loading public key") + return PublicKey(pubkey_path).verify(message, signature, algorithm) + + +def gen_signature(priv_path, pub_path, sign_path, passphrase=None): + """ + creates a signature for the given public-key with + the given private key and writes it to sign_path + """ + + with salt.utils.files.fopen(pub_path) as fp_: + mpub_64 = clean_key(fp_.read()) + + mpub_sig = sign_message(priv_path, mpub_64, passphrase) + mpub_sig_64 = binascii.b2a_base64(mpub_sig) + if os.path.isfile(sign_path): + return False + log.trace( + "Calculating signature for %s with %s", + os.path.basename(pub_path), + os.path.basename(priv_path), + ) + + if os.path.isfile(sign_path): + log.trace( + "Signature file %s already exists, please remove it first and try again", + sign_path, + ) + else: + with salt.utils.files.fopen(sign_path, "wb+") as sig_f: + sig_f.write(salt.utils.stringutils.to_bytes(mpub_sig_64)) + log.trace("Wrote signature to %s", sign_path) + return True def pwdata_decrypt(rsa_key, pwdata): @@ -527,151 +470,68 @@ class MasterKeys(dict): It also generates a signing key-pair if enabled with master_sign_key_name. """ - def __init__(self, opts, autocreate=True): + def __init__(self, opts): super().__init__() self.opts = opts - self.cache = salt.cache.Cache(opts, driver=self.opts["keys.cache_driver"]) - - # we need to differentiate this here because in a multi-master setup, - # if the driver is localfs, each master's key can be different but - # exist with the same name (master.pem); but with a different driver - # the state is shared across all masters, so it would be impossible to - # represent that setup unless the key used is unique (e.g the master - # id). - # when get_keys(name='master') runs it will duplicate the keys to - # ${id}.pem/pub to avoid this scenario. at some point in the future - # master.pem/pub can be removed - self.master_id = self.opts["id"].removesuffix("_master") + self.master_pub_path = os.path.join(self.opts["pki_dir"], "master.pub") + self.master_rsa_path = os.path.join(self.opts["pki_dir"], "master.pem") + key_pass = salt.utils.sdb.sdb_get(self.opts["key_pass"], self.opts) + self.master_key = self.__get_keys(passphrase=key_pass) self.cluster_pub_path = None self.cluster_rsa_path = None self.cluster_key = None - # XXX - if self.opts["cluster_id"]: + if self.opts.get("cluster_id"): self.cluster_pub_path = os.path.join( self.opts["cluster_pki_dir"], "cluster.pub" ) self.cluster_rsa_path = os.path.join( self.opts["cluster_pki_dir"], "cluster.pem" ) - if self.opts["cluster_pki_dir"] != self.opts["pki_dir"]: - # ``cluster_peers`` is configured with bare master names (the - # hostnames or IPs that other masters reach this node on), so - # the shared peer pubkey must be stored under the same bare - # name. ``apply_master_config`` appends ``_master`` to - # ``opts["id"]`` when the operator does not configure ``id`` - # explicitly; strip it back off so the file the cluster - # channel server looks up matches what gets written here. - # See https://github.com/saltstack/salt/issues/68462. - self.cluster_shared_path = os.path.join( - self.opts["cluster_pki_dir"], - "peers", - f"{self.master_id}.pub", - ) - # Note: cluster_key setup is handled in _setup_keys() after - # master keys are initialized. Calling it here would fail because - # the master key has not been generated yet when autocreate=True, - # and because self.__get_keys does not exist. - self.pub_signature = None - - # set names for the signing key-pairs - self.pubkey_signature = None - self.master_pubkey_signature = ( - opts.get("master_pubkey_signature") or f"{opts['id']}_pubkey_signature" - ) - - if autocreate: - self._setup_keys() - - @property - def master_pub_path(self): - # Canonical on-disk location of this master's public key. The symlink - # is created by _setup_keys when the localfs_key driver is in use. - return os.path.join(self.opts["pki_dir"], "master.pub") - - @property - def master_rsa_path(self): - # Canonical on-disk location of this master's private key. - return os.path.join(self.opts["pki_dir"], "master.pem") - - # We need __setstate__ and __getstate__ to avoid pickling errors since - # some of the member variables correspond to Cython objects which are - # not picklable. - # These methods are only used when pickling so will not be used on - # non-Windows platforms. - def __setstate__(self, state): - self.__init__(state["opts"]) - - def __getstate__(self): - return {"opts": self.opts} - - def _setup_keys(self): - # it's important to init this even if cluster_id is enabled so that on - # initial start the master's non cluster key is generated - key_pass = salt.utils.sdb.sdb_get(self.opts["key_pass"], self.opts) - - if self.cache.contains("master_keys", f"{self.master_id}.pem"): - self.master_key = self.key = self.find_or_create_keys( - name=self.master_id, passphrase=key_pass - ) - else: - self.master_key = self.key = self.find_or_create_keys( - name="master", passphrase=key_pass + # ``cluster_peers`` is configured with bare master names (the + # hostnames or IPs that other masters reach this node on), so + # the shared peer pubkey must be stored under the same bare + # name. ``apply_master_config`` appends ``_master`` to + # ``opts["id"]`` when the operator does not configure ``id`` + # explicitly; strip it back off so the file the cluster + # channel server looks up matches what gets written here. + # See https://github.com/saltstack/salt/issues/68462. + self.cluster_shared_path = os.path.join( + self.opts["cluster_pki_dir"], + "peers", + f"{self.opts['id'].removesuffix('_master')}.pub", ) - - # facilitate migrating to pem named off the master id instead of master.pem - if not self.cache.contains("master_keys", f"{self.master_id}.pem"): - priv = self.cache.fetch("master_keys", "master.pem") - pub = self.cache.fetch("master_keys", "master.pub") - self.cache.store("master_keys", f"{self.master_id}.pem", priv) - self.cache.store("master_keys", f"{self.master_id}.pub", pub) - self.cache.flush("master_keys", "master.pem") - self.cache.flush("master_keys", "master.pub") - - # lets create symlinks in case a user downgrades back to a previous version - if self.opts["keys.cache_driver"] == "localfs_key": - os.symlink( - os.path.join(self.opts["pki_dir"], f"{self.master_id}.pem"), - os.path.join(self.opts["pki_dir"], "master.pem"), - ) - os.symlink( - os.path.join(self.opts["pki_dir"], f"{self.master_id}.pub"), - os.path.join(self.opts["pki_dir"], "master.pub"), - ) - - if self.opts["cluster_id"]: self.check_master_shared_pub() key_pass = salt.utils.sdb.sdb_get(self.opts["cluster_key_pass"], self.opts) - self.cluster_key = self.key = self.find_or_create_keys( + self.cluster_key = self.__get_keys( name="cluster", passphrase=key_pass, + pki_dir=self.opts["cluster_pki_dir"], ) + self.pub_signature = None - if self.opts["master_sign_pubkey"]: - # if only the signature is available, use that - if self.opts["master_use_pubkey_signature"]: - if self.opts["keys.cache_driver"] == "localfs_key": - sig_path = os.path.join( - self.opts["pki_dir"], self.master_pubkey_signature - ) - else: - sig_path = f"{self.opts['keys.cache_driver']}:master_keys/{self.master_pubkey_signature}" + # set names for the signing key-pairs + if opts["master_sign_pubkey"]: - if self.cache.contains("master_keys", self.master_pubkey_signature): - self.pubkey_signature = clean_key( - self.cache.fetch("master_keys", self.master_pubkey_signature) - ) + # if only the signature is available, use that + if opts["master_use_pubkey_signature"]: + self.sig_path = os.path.join( + self.opts["pki_dir"], opts["master_pubkey_signature"] + ) + if os.path.isfile(self.sig_path): + with salt.utils.files.fopen(self.sig_path) as fp_: + self.pub_signature = clean_key(fp_.read()) log.info( "Read %s's signature from %s", - self.master_pubkey_signature, - sig_path, + os.path.basename(self.pub_path), + self.opts["master_pubkey_signature"], ) else: log.error( "Signing the master.pub key with a signature is " "enabled but no signature file found at the defined " "location %s", - sig_path, + self.sig_path, ) log.error( "The signature-file may be either named differently " @@ -685,51 +545,74 @@ def _setup_keys(self): key_pass = salt.utils.sdb.sdb_get( self.opts["signing_key_pass"], self.opts ) - self.sign_key = self.find_or_create_keys( - name=self.opts["master_sign_key_name"], passphrase=key_pass + self.pub_sign_path = os.path.join( + self.opts["pki_dir"], opts["master_sign_key_name"] + ".pub" + ) + self.rsa_sign_path = os.path.join( + self.opts["pki_dir"], opts["master_sign_key_name"] + ".pem" ) + self.sign_key = self.__get_keys(name=opts["master_sign_key_name"]) - def find_or_create_keys( - self, name=None, passphrase=None, keysize=None, cache=None, force=False - ): - """ - Returns a key object for a key in the pki-dir - If it does not exist, creates it - """ - if not name: - raise ValueError("name must be defined for a key") + # We need __setstate__ and __getstate__ to avoid pickling errors since + # some of the member variables correspond to Cython objects which are + # not picklable. + # These methods are only used when pickling so will not be used on + # non-Windows platforms. + def __setstate__(self, state): + self.__init__(state["opts"]) - if not cache: - cache = self.cache + def __getstate__(self): + return {"opts": self.opts} - path = name + ".pem" - # try to make the error messaging more obvious - if self.opts["keys.cache_driver"] == "localfs_key": - path = os.path.join(self.cache._kwargs["cachedir"], name + ".pem") - else: - path = f"{self.opts['keys.cache_driver']}:master_keys/{self.master_id}.pub" + @property + def key(self): + if self.cluster_key: + return self.cluster_key + return self.master_key - if force or not cache.contains("master_keys", f"{name}.pem"): - log.info("Generating key-pair for %s", path) - (priv, pub) = gen_keys( - keysize or self.opts["keysize"], - passphrase, - ) + @property + def pub_path(self): + if self.cluster_pub_path: + return self.cluster_pub_path + return self.master_pub_path - cache.store("master_keys", f"{name}.pem", priv) - cache.store("master_keys", f"{name}.pub", pub) - else: - priv = cache.fetch("master_keys", f"{name}.pem") + @property + def rsa_path(self): + if self.cluster_rsa_path: + return self.cluster_rsa_path + return self.master_rsa_path + + def __key_exists(self, name="master", passphrase=None, pki_dir=None): + if pki_dir is None: + pki_dir = self.opts["pki_dir"] + path = os.path.join(pki_dir, name + ".pem") + return os.path.exists(path) + def __get_keys(self, name="master", passphrase=None, pki_dir=None): + """ + Returns a key object for a key in the pki-dir + """ + if pki_dir is None: + pki_dir = self.opts["pki_dir"] + path = os.path.join(pki_dir, name + ".pem") + if not self.__key_exists(name, passphrase, pki_dir): + log.info("Generating %s keys: %s", name, pki_dir) + gen_keys( + pki_dir, + name, + self.opts["keysize"], + self.opts.get("user"), + passphrase, + ) try: - key = PrivateKey.from_str(priv, passphrase) - except InvalidKeyError: + key = PrivateKey(path, passphrase) + except InvalidKeyError as e: message = f"Unable to read key: {path}; key contains unsupported algorithm" - except ValueError: + except ValueError as e: message = f"Unable to read key: {path}; file may be corrupt" - except TypeError: + except TypeError as e: message = f"Unable to read key: {path}; passphrase may be incorrect" - except cryptography.exceptions.UnsupportedAlgorithm: + except cryptography.exceptions.UnsupportedAlgorithm as e: message = f"Unable to read key: {path}; key contains unsupported algorithm" else: log.debug("Loaded %s key: %s", name, path) @@ -737,29 +620,46 @@ def find_or_create_keys( log.error(message) raise MasterExit(message) - def get_pub_str(self): + def get_pub_str(self, name="master"): """ Return the string representation of a public key in the pki-directory """ - if self.opts["cluster_id"]: - key = "cluster.pub" + if self.cluster_pub_path: + path = self.cluster_pub_path else: - key = f"{self.master_id}.pub" - + path = self.master_pub_path # XXX We should always have a key present when this is called, if not # it's an error. # if not os.path.isfile(path): # raise RuntimeError(f"The key {path} does not exist.") - if not self.cache.contains("master_keys", key): + if not os.path.isfile(path): pubkey = self.key.public_key() - key_bytes = pubkey.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - self.cache.store("master_keys", key, key_bytes) + with salt.utils.files.fopen(path, "wb+") as f: + f.write( + pubkey.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) + with salt.utils.files.fopen(path) as rfh: + return clean_key(rfh.read()) + + def get_ckey_paths(self): + return self.cluster_pub_path, self.cluster_rsa_path + + def get_mkey_paths(self): + return self.pub_path, self.rsa_path - return clean_key(self.cache.fetch("master_keys", key)) + def get_sign_paths(self): + return self.pub_sign_path, self.rsa_sign_path + + def pubkey_signature(self): + """ + returns the base64 encoded signature from the signature file + or None if the master has its own signing keys + """ + return self.pub_signature def check_master_shared_pub(self): """ @@ -769,125 +669,19 @@ def check_master_shared_pub(self): to the shared location. Otherwise validate the shared key matches our key. Failed validation raises MasterExit """ - if self.opts["keys.cache_driver"] == "localfs_key": - shared_path = os.path.join( - self.opts["cluster_pki_dir"], "peers", f"{self.master_id}.pub" - ) - else: - shared_path = f"{self.opts['keys.cache_driver']}:master_keys/peers/{self.master_id}.pub" - - shared_pub = self.cache.fetch("master_keys", f"peers/{self.master_id}.pub") - # the non-clustered master key can live in both places depending on if - # a shared backend or not. see comment in __init__ - master_pub = self.cache.fetch("master_keys", f"{self.master_id}.pub") - if not master_pub: - master_pub = self.cache.fetch("master_keys", "master.pub") - - if shared_pub: - if master_pub and shared_pub != master_pub: + shared_pub = pathlib.Path(self.cluster_shared_path) + master_pub = pathlib.Path(self.master_pub_path) + if shared_pub.exists(): + if shared_pub.read_bytes() != master_pub.read_bytes(): message = ( - f"Shared key does not match, remove it to continue: {shared_path}" + f"Shared key does not match, remove it to continue: {shared_pub}" ) log.error(message) raise MasterExit(message) - elif master_pub: - # permissions - log.debug("Writing shared key %s", shared_path) - self.cache.store("master_keys", f"peers/{self.master_id}.pub", master_pub) - - def gen_signature(self, priv=None, pub=None, sign_path=None, algorithm=None): - """ - creates a signature for the given public-key with - the given private key and writes it to sign_path - """ - # we need to replace the path if sign_path is specified - if sign_path: - if self.opts["keys.cache_driver"] != "localfs_key": - log.error( - "You seem to be calling salt.crypt.MasterKeys.gen_signature() with a signature-path override, but are not using localfs_key. This probably isn't doing what you intended" - ) - cache = salt.cache.Cache( - self.opts, - driver=self.opts["keys.cache_driver"], - pki_dir=pathlib.Path(sign_path).parent, - ) else: - cache = self.cache - - if cache.contains("master_keys", self.master_pubkey_signature): - log.error( - "%s already exists at expected location", - sign_path or self.master_pubkey_signature, - ) - return False - - if not priv: - priv = self.sign_key - - if not pub: - pub = priv.public_key() - - # Sign with the algorithm the master is already configured to use for - # its outbound signed payloads. ``publish_signing_algorithm`` is the - # opt operators set (to ``PKCS1v15-SHA224``) to make signed traffic - # FIPS-legal, so honoring it keeps this pre-compute path aligned with - # the rest of the auth flow instead of hard-coding a runtime default. - if algorithm is None: - algorithm = self.opts["publish_signing_algorithm"] - - pub_pem = pub.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - - # ``get_pub_str()`` transmits the pub key through ``clean_key()``, which - # strips the trailing newline that ``public_bytes(PEM)`` emits per - # RFC 7468. Sign the same bytes the minion will verify against, - # otherwise ``verify_signature`` fails when - # ``master_use_pubkey_signature`` is set. See #66259. - pub_pem = salt.utils.stringutils.to_bytes(clean_key(pub_pem.decode())) - - mpub_sig = priv.sign(pub_pem, algorithm=algorithm) - mpub_sig_64 = binascii.b2a_base64(mpub_sig) - - log.trace("Calculating signature for %s with %s", pub, priv) - - cache.store("master_keys", self.master_pubkey_signature, mpub_sig_64) - return True - - def sign(self, *args, **kwargs): - """ - proxy to PrivateKey.sign - """ - return self.key.sign(*args, **kwargs) - - def decrypt(self, *args, **kwargs): - """ - proxy to PrivateKey.decrypt - """ - return self.key.decrypt(*args, **kwargs) - - def encrypt(self, *args, **kwargs): - """ - proxy to PrivateKey.encrypt - """ - return self.key.encrypt(*args, **kwargs) - - def fetch(self, name): - """ - fetch from keystore, unmarshalling to object if possible - """ - key = self.cache.fetch("master_keys", name) - - if not key: - return - - if name.endswith(".pem"): - return PrivateKey.from_str(key) - elif name.endswith(".pub"): - return PublicKey.from_str(key) - else: - return key + # permissions + log.debug("Writing shared key %s", shared_pub) + shared_pub.write_bytes(master_pub.read_bytes()) def _auth_singleton_key(opts): @@ -983,30 +777,16 @@ def __singleton_init__(self, opts, io_loop=None): """ self.opts = opts self.token = salt.utils.stringutils.to_bytes(Crypticle.generate_key_string()) - self.cache = salt.cache.Cache(opts, driver=opts["keys.cache_driver"]) self.pub_path = os.path.join(self.opts["pki_dir"], "minion.pub") self.rsa_path = os.path.join(self.opts["pki_dir"], "minion.pem") self._private_key = None - # Initialize ``_creds`` so ``_authenticate`` can safely check it even - # when a sibling ``AsyncAuth`` populates ``creds_map`` between our - # construction and the ``key not in AsyncAuth.creds_map`` check in - # the coroutine. Without this pre-assignment the else-branch below - # falls through to ``self.authenticate()`` and ``_authenticate`` - # later raises ``AttributeError`` on ``self._creds["aes"]`` (see - # issue #67947). - self._creds = None if self.opts["__role"] == "syndic": self.mpub = "syndic_master.pub" else: self.mpub = "minion_master.pub" if not os.path.isfile(self.pub_path): self.get_keys() - if io_loop is None: - self.io_loop = salt.utils.asynchronous.aioloop( - tornado.ioloop.IOLoop.current() - ) - else: - self.io_loop = salt.utils.asynchronous.aioloop(io_loop) + self.io_loop = io_loop or tornado.ioloop.IOLoop.current() key = self.__key(self.opts) # TODO: if we already have creds for this key, lets just re-use if key in AsyncAuth.creds_map: @@ -1073,19 +853,20 @@ def authenticate(self, callback=None): else: future = tornado.concurrent.Future() self._authenticate_future = future - self.io_loop.create_task(self._authenticate()) + self.io_loop.add_callback(self._authenticate) if callback is not None: def handle_future(future): response = future.result() - self.io_loop.call_soon(callback, response) + self.io_loop.add_callback(callback, response) future.add_done_callback(handle_future) return future - async def _authenticate(self): + @tornado.gen.coroutine + def _authenticate(self): """ Authenticate with the master, this method breaks the functional paradigm, it will update the master information from a fresh sign @@ -1105,22 +886,9 @@ async def _authenticate(self): self.opts, crypt="clear", io_loop=self.io_loop ) as channel: error = None - attempts = 0 - # ``auth_retries`` caps the outer retry loop introduced for - # issue #69442. It defaults to ``0`` which preserves the - # pre-3006.26 behavior of retrying forever; set it to a - # positive integer to bail out with ``SaltClientError`` after - # that many attempts. This is intentionally opt-in on the - # 3006.x LTS branch so an upgrade does not silently change - # failure modes for long-disconnected minions. - auth_retries = self.opts.get("auth_retries", 0) while True: - # Give up a little time between connection attempts - # to allow the IOLoop to run any other scheduled tasks. - await asyncio.sleep(0.1) - attempts += 1 try: - creds = await self.sign_in(channel=channel) + creds = yield self.sign_in(channel=channel) except SaltClientError as exc: error = exc break @@ -1128,11 +896,6 @@ async def _authenticate(self): if self.opts.get("detect_mode") is True: error = SaltClientError("Detect mode is on") break - if auth_retries > 0 and attempts >= auth_retries: - error = SaltClientError( - f"Failed to authenticate with the master after {attempts} attempts" - ) - break if self.opts.get("caller"): # We have a list of masters, so we should break # and try the next one in the list. @@ -1153,7 +916,7 @@ async def _authenticate(self): log.info( "Waiting %s seconds before retry.", acceptance_wait_time ) - await asyncio.sleep(acceptance_wait_time) + yield tornado.gen.sleep(acceptance_wait_time) if acceptance_wait_time < acceptance_wait_time_max: acceptance_wait_time += acceptance_wait_time log.debug( @@ -1188,10 +951,7 @@ async def _authenticate(self): else: key = self.__key(self.opts) new_aes, changed_aes, changed_session = False, False, False - # ``self._creds is None`` covers the first-authentication case - # even when a sibling ``AsyncAuth`` for the same key raced us - # into ``creds_map``. See issue #67947. - if key not in AsyncAuth.creds_map or self._creds is None: + if key not in AsyncAuth.creds_map: new_aes = True log.debug("%s Got new master aes key.", self) else: @@ -1210,31 +970,24 @@ async def _authenticate(self): self._authenticate_future.set_result( True ) # mark the sign-in as complete - # Notify the bus about creds change. - # Fire synchronously on the role's event bus (no io_loop) so the - # IPC publish completes before the `with` block tears the event - # session down. When fired via fire_event_async with io_loop set, - # the publish runs on the *calling* (sub)process io_loop and the - # parent minion's handle_event consumer can miss the - # salt/auth/creds update before the next master publish arrives, - # which gets silently dropped at AES decrypt because creds_map - # still holds the previous key (observed on macOS integration - # zeromq tests during state.apply-driven re-auth). + # Notify the bus about creds change if self.opts.get("auth_events") is True: with salt.utils.event.get_event( self.opts.get("__role"), opts=self.opts, listen=False, + io_loop=self.io_loop, ) as event: try: - event.fire_event( + yield event.fire_event_async( {"key": key, "creds": creds}, salt.utils.event.tagify(prefix="auth", suffix="creds"), ) except Exception as exc: # pylint: disable=broad-except log.error("Error firing auth creds event: %s", exc) - async def sign_in(self, timeout=60, safe=True, tries=1, channel=None): + @tornado.gen.coroutine + def sign_in(self, timeout=60, safe=True, tries=1, channel=None): """ Send a sign in request to the master, sets the key information and returns a dict containing the master publish interface to bind to @@ -1269,13 +1022,13 @@ async def sign_in(self, timeout=60, safe=True, tries=1, channel=None): sign_in_payload = self.minion_sign_in_payload() try: - payload = await channel.send(sign_in_payload, tries=tries, timeout=timeout) + payload = yield channel.send(sign_in_payload, tries=tries, timeout=timeout) except SaltReqTimeoutError as e: if safe: log.warning("SaltReqTimeoutError: %s", e) - return "retry" + raise tornado.gen.Return("retry") if self.opts.get("detect_mode") is True: - return "retry" + raise tornado.gen.Return("retry") else: raise SaltClientError( "Attempt to authenticate with the salt master failed with timeout" @@ -1284,7 +1037,8 @@ async def sign_in(self, timeout=60, safe=True, tries=1, channel=None): finally: if close_channel: channel.close() - return self.handle_signin_response(sign_in_payload, payload) + ret = self.handle_signin_response(sign_in_payload, payload) + raise tornado.gen.Return(ret) def handle_signin_response(self, sign_in_payload, payload): auth = {} @@ -1328,7 +1082,7 @@ def handle_signin_response(self, sign_in_payload, payload): ) master_pubkey_path = os.path.join(self.opts["pki_dir"], self.mpub) - if os.path.exists(master_pubkey_path) and not PublicKey.from_file( + if os.path.exists(master_pubkey_path) and not PublicKey( master_pubkey_path ).verify( clear_signed_data, @@ -1349,7 +1103,7 @@ def handle_signin_response(self, sign_in_payload, payload): "The Salt Master has rejected this minion's public " "key.\nTo repair this issue, delete the public key " "for this minion on the Salt Master.\nThe Salt " - "Minion will attempt to re-authenticate." + "Minion will attempt to re-authenicate." ) return "retry" else: @@ -1416,16 +1170,13 @@ def get_keys(self): if not os.path.exists(self.rsa_path): log.info("Generating keys: %s", self.opts["pki_dir"]) - (priv, pub) = gen_keys(self.opts["keysize"]) - - # the cache bank is called master keys but the codepath is shared - # on master/minion for interacting with pki - self.cache.store("master_keys", "minion.pem", priv) - self.cache.store("master_keys", "minion.pub", pub) - else: - priv = self.cache.fetch("master_keys", "minion.pem") - - self._private_key = PrivateKey.from_str(priv, None) + gen_keys( + self.opts["pki_dir"], + "minion", + self.opts["keysize"], + self.opts.get("user"), + ) + self._private_key = PrivateKey(self.rsa_path, None) return self._private_key @salt.utils.decorators.memoize @@ -1465,7 +1216,7 @@ def minion_sign_in_payload(self): payload["autosign_grains"] = autosign_grains try: pubkey_path = os.path.join(self.opts["pki_dir"], self.mpub) - pub = PublicKey.from_file(pubkey_path) + pub = PublicKey(pubkey_path) payload["token"] = pub.encrypt( self.token, self.opts["encryption_algorithm"] ) @@ -1511,9 +1262,8 @@ def decrypt_aes(self, payload, master_pub=True): m_path = os.path.join(self.opts["pki_dir"], self.mpub) if os.path.exists(m_path): try: - mkey = PublicKey.from_file(m_path) + mkey = PublicKey(m_path) except Exception: # pylint: disable=broad-except - log.exception("Something unexpected occured loading master pub-key") return "", "" digest = hashlib.sha256(key_str).hexdigest() digest = salt.utils.stringutils.to_bytes(digest) @@ -1825,7 +1575,6 @@ def __singleton_init__(self, opts, io_loop=None): :rtype: Auth """ self.opts = opts - self.cache = salt.cache.Cache(opts, driver=opts["keys.cache_driver"]) self.token = salt.utils.stringutils.to_bytes(Crypticle.generate_key_string()) self.pub_path = os.path.join(self.opts["pki_dir"], "minion.pub") self.rsa_path = os.path.join(self.opts["pki_dir"], "minion.pem") @@ -1916,9 +1665,7 @@ def authenticate(self, _=None): # TODO: remove unused var self._crypticle = Crypticle(self.opts, creds["aes"]) self._session_crypticle = Crypticle(self.opts, creds["session"]) - def sign_in( - self, timeout=60, safe=True, tries=1, channel=None - ): # pylint: disable=invalid-overridden-method + def sign_in(self, timeout=60, safe=True, tries=1, channel=None): """ Send a sign in request to the master, sets the key information and returns a dict containing the master publish interface to bind to @@ -2104,129 +1851,3 @@ def loads(self, data, raw=False, nonce=None): return {} self.serial = serial return payload - - -class TLSAwareCrypticle(Crypticle): - """ - Extension of Crypticle that can skip AES encryption when TLS is active. - - This class provides the TLS encryption optimization feature. It maintains - backward compatibility by falling back to AES encryption when TLS - requirements are not met. - """ - - TLS_MARKER = b"tls_opt::" - - def __init__(self, opts, key_string, key_size=192, serial=0): - super().__init__(opts, key_string, key_size, serial) - self.opts = opts - - def dumps(self, obj, nonce=None, peer_cert=None, claimed_id=None): - """ - Serialize and conditionally encrypt a python object. - - If TLS optimization is enabled and all security requirements are met, - this will skip AES encryption and only serialize the object. - - Args: - obj: Object to serialize - nonce: Optional nonce for verification - peer_cert: Peer's SSL certificate (DER format bytes) - claimed_id: The minion ID claimed in the message - - Returns: - bytes: Encrypted or plaintext serialized data - """ - import salt.transport.tls_util - - # Check if we can skip AES encryption - if salt.transport.tls_util.can_skip_aes_encryption( - self.opts, peer_cert=peer_cert, claimed_id=claimed_id - ): - # TLS optimization active - skip AES encryption - log.debug("TLS optimization: skipping AES encryption for %s", claimed_id) - if nonce: - plaintext = ( - self.TLS_MARKER - + self.PICKLE_PAD - + nonce.encode() - + salt.payload.dumps(obj) - ) - else: - plaintext = self.TLS_MARKER + self.PICKLE_PAD + salt.payload.dumps(obj) - return plaintext - else: - # Fall back to standard AES encryption - return super().dumps(obj, nonce=nonce) - - def loads(self, data, raw=False, nonce=None, peer_cert=None, claimed_id=None): - """ - Conditionally decrypt and un-serialize a python object. - - Detects whether the data was encrypted with AES or sent via TLS-only. - - Args: - data: Data to decrypt and deserialize - raw: Whether to return raw deserialized data - nonce: Optional nonce for verification - peer_cert: Peer's SSL certificate (DER format bytes) - claimed_id: The minion ID claimed in the message - - Returns: - Deserialized python object - """ - import salt.transport.tls_util - - # Check if data has TLS marker (was sent without AES) - if data.startswith(self.TLS_MARKER): - # Verify TLS optimization is valid for this connection - if not salt.transport.tls_util.can_skip_aes_encryption( - self.opts, peer_cert=peer_cert, claimed_id=claimed_id - ): - log.warning( - "Received TLS-optimized message but TLS requirements not met. Rejecting." - ) - return {} - - log.debug("TLS optimization: skipping AES decryption for %s", claimed_id) - # Remove TLS marker - data = data[len(self.TLS_MARKER) :] - - # Verify integrity marker - if not data.startswith(self.PICKLE_PAD): - return {} - data = data[len(self.PICKLE_PAD) :] - - # Handle nonce if present - if nonce: - ret_nonce = data[:32].decode() - data = data[32:] - if ret_nonce != nonce: - from salt.exceptions import SaltClientError - - raise SaltClientError( - f"Nonce verification error {ret_nonce} {nonce}" - ) - - # Deserialize payload - payload = salt.payload.loads(data, raw=raw) - - # Handle serial number check - if isinstance(payload, dict): - if "serial" in payload: - serial = payload.pop("serial") - if serial <= self.serial: - log.critical( - "A message with an invalid serial was received.\n" - "this serial: %d\n" - "last serial: %d\n" - "The minion will not honor this request.", - serial, - self.serial, - ) - return {} - self.serial = serial - return payload - else: - # Standard AES-encrypted message - return super().loads(data, raw=raw, nonce=nonce) diff --git a/salt/daemons/masterapi.py b/salt/daemons/masterapi.py index d9b1d47c8ca0..71db68a4eb25 100644 --- a/salt/daemons/masterapi.py +++ b/salt/daemons/masterapi.py @@ -682,7 +682,7 @@ def _mine_get(self, load, skip_verify=False): minions = _res["minions"] minion_side_acl = {} # Cache minion-side ACL for minion in minions: - mine_data = self.cache.fetch("mine", minion) + mine_data = self.cache.fetch(f"minions/{minion}", "mine") if not isinstance(mine_data, dict): continue for function in functions_allowed: @@ -733,8 +733,8 @@ def _mine(self, load, skip_verify=False): if self.opts.get("minion_data_cache", False) or self.opts.get( "enforce_mine_cache", False ): - ckey = load["id"] - cbank = "mine" + cbank = "minions/{}".format(load["id"]) + ckey = "mine" new_data = load["data"] if not load.get("clear", False): data = self.cache.fetch(cbank, ckey) @@ -752,8 +752,8 @@ def _mine_delete(self, load): if self.opts.get("minion_data_cache", False) or self.opts.get( "enforce_mine_cache", False ): - cbank = "mine" - ckey = load["id"] + cbank = "minions/{}".format(load["id"]) + ckey = "mine" try: data = self.cache.fetch(cbank, ckey) if not isinstance(data, dict): @@ -774,7 +774,7 @@ def _mine_flush(self, load, skip_verify=False): if self.opts.get("minion_data_cache", False) or self.opts.get( "enforce_mine_cache", False ): - return self.cache.flush("mine", load["id"]) + return self.cache.flush("minions/{}".format(load["id"]), "mine") return True def _file_recv(self, load): @@ -847,7 +847,11 @@ def _pillar(self, load): ) data = pillar.compile_pillar() if self.opts.get("minion_data_cache", False): - self.cache.store("grains", load["id"], load["grains"]) + self.cache.store( + "minions/{}".format(load["id"]), + "data", + {"grains": load["grains"], "pillar": data}, + ) if self.opts.get("minion_data_cache_events") is True: self.event.fire_event( {"comment": "Minion data cache refresh"}, @@ -870,19 +874,15 @@ def _minion_event(self, load): event_data = event["data"] else: event_data = event - # Fire pretagged event first (for syndics) before blacklist check - # This allows syndics to forward events like salt/job/*/new with - # the syndic/ prefix, bypassing the minion event blacklist + if not valid_minion_tag(event["tag"]): + log.warning("Filtering blacklisted event tag %s", event["tag"]) + continue + self.event.fire_event(event_data, event["tag"]) # old dup event if load.get("pretag") is not None: self.event.fire_event( event_data, salt.utils.event.tagify(event["tag"], base=load["pretag"]), ) - # Check blacklist for original tag - if not valid_minion_tag(event["tag"]): - log.warning("Filtering blacklisted event tag %s", event["tag"]) - continue - self.event.fire_event(event_data, event["tag"]) # old dup event else: tag = load["tag"] self.event.fire_event(load, tag) diff --git a/salt/engines/docker_events.py b/salt/engines/docker_events.py new file mode 100644 index 000000000000..3bcabedc8ba8 --- /dev/null +++ b/salt/engines/docker_events.py @@ -0,0 +1,113 @@ +""" +Send events from Docker events +:Depends: Docker API >= 1.22 +""" + +import logging +import traceback + +import salt.utils.event +import salt.utils.json + +try: + import docker # pylint: disable=import-error,no-name-in-module + import docker.utils # pylint: disable=import-error,no-name-in-module + + HAS_DOCKER_PY = True +except ImportError: + HAS_DOCKER_PY = False + +log = logging.getLogger(__name__) # pylint: disable=invalid-name + +# Default timeout as of docker-py 1.0.0 +CLIENT_TIMEOUT = 60 + +# Define the module's virtual name +__virtualname__ = "docker_events" + +__deprecated__ = ( + 3009, + "docker", + "https://github.com/saltstack/saltext-docker", +) + + +def __virtual__(): + """ + Only load if docker libs are present + """ + if not HAS_DOCKER_PY: + return (False, "Docker_events engine could not be imported") + return True + + +def start( + docker_url="unix://var/run/docker.sock", + timeout=CLIENT_TIMEOUT, + tag="salt/engines/docker_events", + filters=None, +): + """ + Scan for Docker events and fire events + + Example Config + + .. code-block:: yaml + + engines: + - docker_events: + docker_url: unix://var/run/docker.sock + filters: + event: + - start + - stop + - die + - oom + + The config above sets up engines to listen + for events from the Docker daemon and publish + them to the Salt event bus. + + For filter reference, see https://docs.docker.com/engine/reference/commandline/events/ + """ + + if __opts__.get("__role") == "master": + fire_master = salt.utils.event.get_master_event( + __opts__, __opts__["sock_dir"] + ).fire_event + else: + fire_master = None + + def fire(tag, msg): + """ + How to fire the event + """ + if fire_master: + fire_master(msg, tag) + else: + __salt__["event.send"](tag, msg) + + try: + # docker-py 2.0 renamed this client attribute + client = docker.APIClient(base_url=docker_url, timeout=timeout) + except AttributeError: + # pylint: disable=not-callable + client = docker.Client(base_url=docker_url, timeout=timeout) + # pylint: enable=not-callable + + try: + events = client.events(filters=filters) + for event in events: + data = salt.utils.json.loads( + event.decode(__salt_system_encoding__, errors="replace") + ) + # https://github.com/docker/cli/blob/master/cli/command/system/events.go#L109 + # https://github.com/docker/engine-api/blob/master/types/events/events.go + # Each output includes the event type, actor id, name and action. + # status field can be ommited + if data["Action"]: + fire("{}/{}".format(tag, data["Action"]), data) + else: + fire("{}/{}".format(tag, data["status"]), data) + except Exception: # pylint: disable=broad-except + traceback.print_exc() diff --git a/salt/engines/fluent.py b/salt/engines/fluent.py new file mode 100644 index 000000000000..9b7367d7df4f --- /dev/null +++ b/salt/engines/fluent.py @@ -0,0 +1,91 @@ +""" +An engine that reads messages from the salt event bus and pushes +them onto a fluent endpoint. + +.. versionadded:: 3000 + +:Configuration: + +All arguments are optional + + Example configuration of default settings + + .. code-block:: yaml + + engines: + - fluent: + host: localhost + port: 24224 + app: engine + + Example fluentd configuration + + .. code-block:: none + + + @type forward + port 24224 + + + + @type file + path /var/log/td-agent/saltstack + + +:depends: fluent-logger +""" + +import logging + +import salt.utils.event + +try: + from fluent import event, sender +except ImportError: + sender = None + +log = logging.getLogger(__name__) + +__virtualname__ = "fluent" + + +def __virtual__(): + return ( + __virtualname__ + if sender is not None + else (False, "fluent-logger not installed") + ) + + +def start(host="localhost", port=24224, app="engine"): + """ + Listen to salt events and forward them to fluent + + args: + host (str): Host running fluentd agent. Default is localhost + port (int): Port of fluentd agent. Default is 24224 + app (str): Text sent as fluentd tag. Default is "engine". This text is appended + to "saltstack." to form a fluentd tag, ex: "saltstack.engine" + """ + SENDER_NAME = "saltstack" + + sender.setup(SENDER_NAME, host=host, port=port) + + if __opts__.get("id").endswith("_master"): + event_bus = salt.utils.event.get_master_event( + __opts__, __opts__["sock_dir"], listen=True + ) + else: + event_bus = salt.utils.event.get_event( + "minion", + opts=__opts__, + sock_dir=__opts__["sock_dir"], + listen=True, + ) + log.info("Fluent engine started") + + with event_bus: + while True: + salt_event = event_bus.get_event_block() + if salt_event: + event.Event(app, salt_event) diff --git a/salt/engines/http_logstash.py b/salt/engines/http_logstash.py new file mode 100644 index 000000000000..e3a96ae83563 --- /dev/null +++ b/salt/engines/http_logstash.py @@ -0,0 +1,99 @@ +""" +HTTP Logstash engine +========================== + +An engine that reads messages from the salt event bus and pushes +them onto a logstash endpoint via HTTP requests. + +.. versionchanged:: 2018.3.0 + +.. note:: + By default, this engine take everything from the Salt bus and exports into + Logstash. + For a better selection of the events that you want to publish, you can use + the ``tags`` and ``funs`` options. + +:configuration: Example configuration + + .. code-block:: yaml + + engines: + - http_logstash: + url: http://blabla.com/salt-stuff + tags: + - salt/job/*/new + - salt/job/*/ret/* + funs: + - probes.results + - bgp.config +""" + +import fnmatch + +import salt.utils.event +import salt.utils.http +import salt.utils.json + +_HEADERS = {"Content-Type": "application/json"} + + +def _logstash(url, data): + """ + Issues HTTP queries to the logstash server. + """ + result = salt.utils.http.query( + url, + "POST", + header_dict=_HEADERS, + data=salt.utils.json.dumps(data), + decode=True, + status=True, + opts=__opts__, + ) + return result + + +def start(url, funs=None, tags=None): + """ + Listen to salt events and forward them to logstash. + + url + The Logstash endpoint. + + funs: ``None`` + A list of functions to be compared against, looking into the ``fun`` + field from the event data. This option helps to select the events + generated by one or more functions. + If an event does not have the ``fun`` field in the data section, it + will be published. For a better selection, consider using the ``tags`` + option. + By default, this option accepts any event to be submitted to Logstash. + + tags: ``None`` + A list of pattern to compare the event tag against. + By default, this option accepts any event to be submitted to Logstash. + """ + if __opts__.get("id").endswith("_master"): + instance = "master" + else: + instance = "minion" + with salt.utils.event.get_event( + instance, + sock_dir=__opts__["sock_dir"], + opts=__opts__, + ) as event_bus: + while True: + event = event_bus.get_event(full=True) + if event: + publish = True + if tags and isinstance(tags, list): + found_match = False + for tag in tags: + if fnmatch.fnmatch(event["tag"], tag): + found_match = True + publish = found_match + if funs and "fun" in event["data"]: + if not event["data"]["fun"] in funs: + publish = False + if publish: + _logstash(url, event["data"]) diff --git a/salt/engines/ircbot.py b/salt/engines/ircbot.py new file mode 100644 index 000000000000..fc1241bc85be --- /dev/null +++ b/salt/engines/ircbot.py @@ -0,0 +1,351 @@ +""" +IRC Bot engine + +.. versionadded:: 2017.7.0 + +Example Configuration + +.. code-block:: yaml + + engines: + - ircbot: + nick: + username: + password: + host: irc.oftc.net + port: 7000 + channels: + - salt-test + - '##something' + use_ssl: True + use_sasl: True + disable_query: True + allow_hosts: + - salt/engineer/.* + allow_nicks: + - gtmanfred + +Available commands on irc are: + +ping + return pong + +echo + return targeted at the user who sent the commands + +event [, ] + fire event on the master or minion event stream with the tag `salt/engines/ircbot/` and a data object with a + list of everything else sent in the message + +Example of usage + +.. code-block:: text + + 08:33:57 @gtmanfred > !ping + 08:33:57 gtmanbot > gtmanfred: pong + 08:34:02 @gtmanfred > !echo ping + 08:34:02 gtmanbot > ping + 08:34:17 @gtmanfred > !event test/tag/ircbot irc is useful + 08:34:17 gtmanbot > gtmanfred: TaDa! + +.. code-block:: text + + [DEBUG ] Sending event: tag = salt/engines/ircbot/test/tag/ircbot; data = {'_stamp': '2016-11-28T14:34:16.633623', 'data': ['irc', 'is', 'useful']} + +""" + +import base64 +import logging +import re +import socket +import ssl +from collections import namedtuple + +import tornado.ioloop +import tornado.iostream + +import salt.utils.event + +log = logging.getLogger(__name__) + + +# Nothing listening here +Event = namedtuple("Event", "source code line") +PrivEvent = namedtuple("PrivEvent", "source nick user host code channel command line") + + +class IRCClient: + def __init__( + self, + nick, + host, + port=6667, + username=None, + password=None, + channels=None, + use_ssl=False, + use_sasl=False, + char="!", + allow_hosts=False, + allow_nicks=False, + disable_query=True, + ): + self.nick = nick + self.host = host + self.port = port + self.username = username or nick + self.password = password + self.channels = channels or [] + self.ssl = use_ssl + self.sasl = use_sasl + self.char = char + self.allow_hosts = allow_hosts + self.allow_nicks = allow_nicks + self.disable_query = disable_query + self.io_loop = tornado.ioloop.IOLoop() + self._connect() + + def _connect(self): + _sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) + if self.ssl is True: + self._stream = tornado.iostream.SSLIOStream( + _sock, ssl_options={"cert_reqs": ssl.CERT_NONE} + ) + else: + self._stream = tornado.iostream.IOStream(_sock) + self._stream.set_close_callback(self.on_closed) + self._stream.connect((self.host, self.port), self.on_connect) + + def read_messages(self): + self._stream.read_until("\r\n", self._message) + + @staticmethod + def _event(line): + log.debug("Received: %s", line) + search = re.match( + "^(?:(?P:[^ ]+) )?(?P[^ ]+)(?: (?P.*))?$", line + ) + source, code, line = ( + search.group("source"), + search.group("code"), + search.group("line"), + ) + return Event(source, code, line) + + def _allow_host(self, host): + if isinstance(self.allow_hosts, bool): + return self.allow_hosts + else: + return any([re.match(match, host) for match in self.allow_hosts]) + + def _allow_nick(self, nick): + if isinstance(self.allow_nicks, bool): + return self.allow_nicks + else: + return any([re.match(match, nick) for match in self.allow_nicks]) + + def _privmsg(self, event): + search = re.match( + "^:(?P[^!]+)!(?P[^@]+)@(?P.*)$", event.source + ) + nick, user, host = ( + search.group("nick"), + search.group("user"), + search.group("host"), + ) + search = re.match( + "^(?P[^ ]+) :(?:{}(?P[^ ]+)(?: (?P.*))?)?$".format( + self.char + ), + event.line, + ) + if search: + channel, command, line = ( + search.group("channel"), + search.group("command"), + search.group("line"), + ) + if self.disable_query is True and not channel.startswith("#"): + return + if channel == self.nick: + channel = nick + privevent = PrivEvent( + event.source, nick, user, host, event.code, channel, command, line + ) + if (self._allow_nick(nick) or self._allow_host(host)) and hasattr( + self, f"_command_{command}" + ): + getattr(self, f"_command_{command}")(privevent) + + def _command_echo(self, event): + message = f"PRIVMSG {event.channel} :{event.line}" + self.send_message(message) + + def _command_ping(self, event): + message = f"PRIVMSG {event.channel} :{event.nick}: pong" + self.send_message(message) + + def _command_event(self, event): + if __opts__.get("__role") == "master": + fire_master = salt.utils.event.get_master_event( + __opts__, __opts__["sock_dir"] + ).fire_event + else: + fire_master = None + + def fire(tag, msg): + """ + How to fire the event + """ + if fire_master: + fire_master(msg, tag) + else: + __salt__["event.send"](tag, msg) + + args = event.line.split(" ") + tag = args[0] + if len(args) > 1: + payload = {"data": args[1:]} + else: + payload = {"data": []} + + fire("salt/engines/ircbot/" + tag, payload) + message = f"PRIVMSG {event.channel} :{event.nick}: TaDa!" + self.send_message(message) + + def _message(self, raw): + raw = raw.rstrip(b"\r\n").decode("utf-8") + event = self._event(raw) + + if event.code == "PING": + tornado.ioloop.IOLoop.current().spawn_callback( + self.send_message, f"PONG {event.line}" + ) + elif event.code == "PRIVMSG": + tornado.ioloop.IOLoop.current().spawn_callback(self._privmsg, event) + self.read_messages() + + def join_channel(self, channel): + if not channel.startswith("#"): + channel = "#" + channel + self.send_message(f"JOIN {channel}") + + def on_connect(self): + logging.info("on_connect") + if self.sasl is True: + self.send_message("CAP REQ :sasl") + self.send_message(f"NICK {self.nick}") + self.send_message("USER saltstack 0 * :saltstack") + if self.password: + if self.sasl is True: + authstring = base64.b64encode( + "{0}\x00{0}\x00{1}".format(self.username, self.password).encode() + ) + self.send_message("AUTHENTICATE PLAIN") + self.send_message(f"AUTHENTICATE {authstring}") + self.send_message("CAP END") + else: + self.send_message( + "PRIVMSG NickServ :IDENTIFY {} {}".format( + self.username, self.password + ) + ) + for channel in self.channels: + self.join_channel(channel) + self.read_messages() + + def on_closed(self): + logging.info("on_closed") + + def send_message(self, line): + if isinstance(line, str): + line = line.encode("utf-8") + log.debug("Sending: %s", line) + self._stream.write(line + b"\r\n") + + +def start( + nick, + host, + port=6667, + username=None, + password=None, + channels=None, + use_ssl=False, + use_sasl=False, + char="!", + allow_hosts=False, + allow_nicks=False, + disable_query=True, +): + """ + IRC Bot for interacting with salt. + + nick + Nickname of the connected Bot. + + host + irc server (example - irc.oftc.net). + + port + irc port. Default: 6667 + + password + password for authenticating. If not provided, user will not authenticate on the irc server. + + channels + channels to join. + + use_ssl + connect to server using ssl. Default: False + + use_sasl + authenticate using sasl, instead of messaging NickServ. Default: False + + .. note:: This will allow the bot user to be fully authenticated before joining any channels + + char + command character to look for. Default: ! + + allow_hosts + hostmasks allowed to use commands on the bot. Default: False + True to allow all + False to allow none + List of regexes to allow matching + + allow_nicks + Nicks that are allowed to use commands on the bot. Default: False + True to allow all + False to allow none + List of regexes to allow matching + + disable_query + Disable commands from being sent through private queries. Require they be sent to a channel, so that all + communication can be controlled by access to the channel. Default: True + + .. warning:: Unauthenticated Access to event stream + + This engine sends events calls to the event stream without authenticating them in salt. Authentication will + need to be configured and enforced on the irc server or enforced in the irc channel. The engine only accepts + commands from channels, so non authenticated users could be banned or quieted in the channel. + + /mode +q $~a # quiet all users who are not authenticated + /mode +r # do not allow unauthenticated users into the channel + + It would also be possible to add a password to the irc channel, or only allow invited users to join. + """ + client = IRCClient( + nick, + host, + port, + username, + password, + channels or [], + use_ssl, + use_sasl, + char, + allow_hosts, + allow_nicks, + disable_query, + ) + client.io_loop.start() diff --git a/salt/engines/junos_syslog.py b/salt/engines/junos_syslog.py new file mode 100644 index 000000000000..17bd49c79b70 --- /dev/null +++ b/salt/engines/junos_syslog.py @@ -0,0 +1,402 @@ +""" +Junos Syslog Engine +========================== + +.. versionadded:: 2017.7.0 + + +:depends: pyparsing, twisted + + +An engine that listens to syslog message from Junos devices, +extract event information and generate message on SaltStack bus. + +The event topic sent to salt is dynamically generated according to the topic title +specified by the user. The incoming event data (from the junos device) consists +of the following fields: + +1. hostname +2. hostip +3. daemon +4. event +5. severity +6. priority +7. timestamp +8. message +9. pid +10. raw (the raw event data forwarded from the device) + +The topic title can consist of any of the combination of above fields, +but the topic has to start with 'jnpr/syslog'. +So, we can have different combinations: + + - jnpr/syslog/hostip/daemon/event + - jnpr/syslog/daemon/severity + +The corresponding dynamic topic sent on salt event bus would look something like: + + - jnpr/syslog/1.1.1.1/mgd/UI_COMMIT_COMPLETED + - jnpr/syslog/sshd/7 + +The default topic title is 'jnpr/syslog/hostname/event'. + +The user can choose the type of data they wants of the event bus. +Like, if one wants only events pertaining to a particular daemon, they can +specify that in the configuration file: + +.. code-block:: yaml + + daemon: mgd + +One can even have a list of daemons like: + +.. code-block:: yaml + + daemon: + - mgd + - sshd + +Example configuration (to be written in master config file) + +.. code-block:: yaml + + engines: + - junos_syslog: + port: 9999 + topic: jnpr/syslog/hostip/daemon/event + daemon: + - mgd + - sshd + +For junos_syslog engine to receive events, syslog must be set on the junos device. +This can be done via following configuration: + +.. code-block:: bash + + set system syslog host port 516 any any + +Below is a sample syslog event which is received from the junos device: + +.. code-block:: bash + + '<30>May 29 05:18:12 bng-ui-vm-9 mspd[1492]: No chassis configuration found' + +The source for parsing the syslog messages is taken from: +https://gist.github.com/leandrosilva/3651640#file-xlog-py +""" + +import logging +import re +import time + +import salt.utils.event as event + +try: + from pyparsing import ( + Combine, + LineEnd, + Optional, + Regex, + StringEnd, + Suppress, + Word, + alphas, + delimitedList, + nums, + string, + ) + from twisted.internet import reactor, threads # pylint: disable=no-name-in-module + from twisted.internet.protocol import ( # pylint: disable=no-name-in-module + DatagramProtocol, + ) + + HAS_TWISTED_AND_PYPARSING = True +except ImportError: + HAS_TWISTED_AND_PYPARSING = False + + # Fallback class + class DatagramProtocol: + pass + + +# logging.basicConfig(level=logging.DEBUG) +log = logging.getLogger(__name__) + +__virtualname__ = "junos_syslog" + + +def __virtual__(): + """ + Load only if twisted and pyparsing libs are present. + """ + if not HAS_TWISTED_AND_PYPARSING: + return ( + False, + "junos_syslog could not be loaded." + " Make sure you have twisted and pyparsing python libraries.", + ) + return True + + +class _Parser: + def __init__(self): + ints = Word(nums) + EOL = LineEnd().suppress() + + # ip address of device + ipAddress = Optional(delimitedList(ints, ".", combine=True) + Suppress(":")) + + # priority + priority = Suppress("<") + ints + Suppress(">") + + # timestamp + month = Word(string.ascii_uppercase, string.ascii_lowercase, exact=3) + day = ints + hour = Combine(ints + ":" + ints + ":" + ints) + + timestamp = month + day + hour + + # hostname + hostname = Word(alphas + nums + "_" + "-" + ".") + + # daemon + daemon = ( + Word(alphas + nums + "/" + "-" + "_" + ".") + + Optional(Suppress("[") + ints + Suppress("]")) + + Suppress(":") + ) + + # message + message = Regex(".*") + + # pattern build + self.__pattern = ( + ipAddress + priority + timestamp + hostname + daemon + message + StringEnd() + | EOL + ) + + self.__pattern_without_daemon = ( + ipAddress + priority + timestamp + hostname + message + StringEnd() | EOL + ) + + def parse(self, line): + try: + parsed = self.__pattern.parseString(line) + except Exception: # pylint: disable=broad-except + try: + parsed = self.__pattern_without_daemon.parseString(line) + except Exception: # pylint: disable=broad-except + return + if len(parsed) == 6: + payload = {} + payload["priority"] = int(parsed[0]) + payload["severity"] = payload["priority"] & 0x07 + payload["facility"] = payload["priority"] >> 3 + payload["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S") + payload["hostname"] = parsed[4] + payload["daemon"] = "unknown" + payload["message"] = parsed[5] + payload["event"] = "SYSTEM" + payload["raw"] = line + return payload + elif len(parsed) == 7: + payload = {} + payload["priority"] = int(parsed[0]) + payload["severity"] = payload["priority"] & 0x07 + payload["facility"] = payload["priority"] >> 3 + payload["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S") + payload["hostname"] = parsed[4] + payload["daemon"] = parsed[5] + payload["message"] = parsed[6] + payload["event"] = "SYSTEM" + obj = re.match(r"(\w+): (.*)", payload["message"]) + if obj: + payload["message"] = obj.group(2) + payload["raw"] = line + return payload + elif len(parsed) == 8: + payload = {} + payload["priority"] = int(parsed[0]) + payload["severity"] = payload["priority"] & 0x07 + payload["facility"] = payload["priority"] >> 3 + payload["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S") + payload["hostname"] = parsed[4] + payload["daemon"] = parsed[5] + payload["pid"] = parsed[6] + payload["message"] = parsed[7] + payload["event"] = "SYSTEM" + obj = re.match(r"(\w+): (.*)", payload["message"]) + if obj: + payload["event"] = obj.group(1) + payload["message"] = obj.group(2) + payload["raw"] = line + return payload + elif len(parsed) == 9: + payload = {} + payload["hostip"] = parsed[0] + payload["priority"] = int(parsed[1]) + payload["severity"] = payload["priority"] & 0x07 + payload["facility"] = payload["priority"] >> 3 + payload["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S") + payload["hostname"] = parsed[5] + payload["daemon"] = parsed[6] + payload["pid"] = parsed[7] + payload["message"] = parsed[8] + payload["event"] = "SYSTEM" + obj = re.match(r"(\w+): (.*)", payload["message"]) + if obj: + payload["event"] = obj.group(1) + payload["message"] = obj.group(2) + payload["raw"] = line + return payload + + +class _SyslogServerFactory(DatagramProtocol): + def __init__(self, options): + self.options = options + self.obj = _Parser() + data = [ + "hostip", + "priority", + "severity", + "facility", + "timestamp", + "hostname", + "daemon", + "pid", + "message", + "event", + ] + if "topic" in self.options: + # self.title = 'jnpr/syslog' + # To remove the stray '/', if not removed splitting the topic + # won't work properly. Eg: '/jnpr/syslog/event' won't be split + # properly if the starting '/' is not stripped + self.options["topic"] = options["topic"].strip("/") + topics = options["topic"].split("/") + self.title = topics + if len(topics) < 2 or topics[0] != "jnpr" or topics[1] != "syslog": + log.debug( + "The topic specified in configuration should start with " + '"jnpr/syslog". Using the default topic.' + ) + self.title = ["jnpr", "syslog", "hostname", "event"] + else: + for i in range(2, len(topics)): + if topics[i] not in data: + log.debug( + "Please check the topic specified. Only the following " + "keywords can be specified in the topic: hostip, priority, " + "severity, facility, timestamp, hostname, daemon, pid, " + "message, event. Using the default topic." + ) + self.title = ["jnpr", "syslog", "hostname", "event"] + break + # We are done processing the topic. All other arguments are the + # filters given by the user. While processing the filters we don't + # explicitly ignore the 'topic', but delete it here itself. + del self.options["topic"] + else: + self.title = ["jnpr", "syslog", "hostname", "event"] + + def parseData(self, data, host, port, options): + """ + This function will parse the raw syslog data, dynamically create the + topic according to the topic specified by the user (if specified) and + decide whether to send the syslog data as an event on the master bus, + based on the constraints given by the user. + + :param data: The raw syslog event data which is to be parsed. + :param host: The IP of the host from where syslog is forwarded. + :param port: Port of the junos device from which the data is sent + :param options: kwargs provided by the user in the configuration file. + :return: The result dictionary which contains the data and the topic, + if the event is to be sent on the bus. + + """ + data = self.obj.parse(data.decode()) + data["hostip"] = host + log.debug( + "Junos Syslog - received %s from %s, sent from port %s", data, host, port + ) + + send_this_event = True + for key in options: + if key in data: + if isinstance(options[key], (str, int)): + if str(options[key]) != str(data[key]): + send_this_event = False + break + elif isinstance(options[key], list): + for opt in options[key]: + if str(opt) == str(data[key]): + break + else: + send_this_event = False + break + else: + raise Exception("Arguments in config not specified properly") + else: + raise Exception( + "Please check the arguments given to junos engine in the " + "configuration file" + ) + + if send_this_event: + if "event" in data: + topic = "jnpr/syslog" + + for i in range(2, len(self.title)): + topic += "/" + str(data[self.title[i]]) + log.debug( + "Junos Syslog - sending this event on the bus: %s from %s", + data, + host, + ) + result = {"send": True, "data": data, "topic": topic} + return result + else: + raise Exception("The incoming event data could not be parsed properly.") + else: + result = {"send": False} + return result + + def send_event_to_salt(self, result): + """ + This function identifies whether the engine is running on the master + or the minion and sends the data to the master event bus accordingly. + + :param result: It's a dictionary which has the final data and topic. + + """ + if result["send"]: + data = result["data"] + topic = result["topic"] + # If the engine is run on master, get the event bus and send the + # parsed event. + if __opts__["__role"] == "master": + event.get_master_event(__opts__, __opts__["sock_dir"]).fire_event( + data, topic + ) + # If the engine is run on minion, use the fire_master execution + # module to send event on the master bus. + else: + __salt__["event.fire_master"](data=data, tag=topic) + + def handle_error(self, err_msg): + """ + Log the error messages. + """ + log.error(err_msg.getErrorMessage) + + def datagramReceived(self, data, connection_details): + (host, port) = connection_details + d = threads.deferToThread(self.parseData, data, host, port, self.options) + d.addCallbacks(self.send_event_to_salt, self.handle_error) + + +def start(port=516, **kwargs): + + log.info("Starting junos syslog engine (port %s)", port) + reactor.listenUDP(port, _SyslogServerFactory(kwargs)) + reactor.run() diff --git a/salt/engines/libvirt_events.py b/salt/engines/libvirt_events.py new file mode 100644 index 000000000000..f09a298dc472 --- /dev/null +++ b/salt/engines/libvirt_events.py @@ -0,0 +1,759 @@ +""" +An engine that listens for libvirt events and resends them to the salt event bus. + +The minimal configuration is the following and will listen to all events on the +local hypervisor and send them with a tag starting with ``salt/engines/libvirt_events``: + +.. code-block:: yaml + + engines: + - libvirt_events + +Note that the automatically-picked libvirt connection will depend on the value +of ``uri_default`` in ``/etc/libvirt/libvirt.conf``. To force using another +connection like the local LXC libvirt driver, set the ``uri`` property as in the +following example configuration. + +.. code-block:: yaml + + engines: + - libvirt_events: + uri: lxc:/// + tag_prefix: libvirt + filters: + - domain/lifecycle + - domain/reboot + - pool + +Filters is a list of event types to relay to the event bus. Items in this list +can be either one of the main types (``domain``, ``network``, ``pool``, +``nodedev``, ``secret``), ``all`` or a more precise filter. These can be done +with values like /. The possible values are in the +CALLBACK_DEFS constant. If the filters list contains ``all``, all +events will be relayed. + +Be aware that the list of events increases with libvirt versions, for example +network events have been added in libvirt 1.2.1 and storage events in 2.0.0. + +Running the engine on non-root +------------------------------ + +Running this engine as non-root requires a special attention, which is surely +the case for the master running as user `salt`. The engine is likely to fail +to connect to libvirt with an error like this one: + + [ERROR ] authentication unavailable: no polkit agent available to authenticate action 'org.libvirt.unix.monitor' + + +To fix this, the user running the engine, for example the salt-master, needs +to have the rights to connect to libvirt in the machine polkit config. +A polkit rule like the following one will allow `salt` user to connect to libvirt: + +.. code-block:: javascript + + polkit.addRule(function(action, subject) { + if (action.id.indexOf("org.libvirt") == 0 && + subject.user == "salt") { + return polkit.Result.YES; + } + }); + +:depends: libvirt 1.0.0+ python binding + +.. versionadded:: 2019.2.0 +""" + +import logging +import urllib.parse + +import salt.utils.event + +log = logging.getLogger(__name__) + + +try: + import libvirt +except ImportError: + libvirt = None # pylint: disable=invalid-name + + +def __virtual__(): + """ + Only load if libvirt python binding is present + """ + if libvirt is None: + msg = "libvirt module not found" + elif libvirt.getVersion() < 1000000: + msg = "libvirt >= 1.0.0 required" + else: + msg = "" + return not bool(msg), msg + + +REGISTER_FUNCTIONS = { + "domain": "domainEventRegisterAny", + "network": "networkEventRegisterAny", + "pool": "storagePoolEventRegisterAny", + "nodedev": "nodeDeviceEventRegisterAny", + "secret": "secretEventRegisterAny", +} + +# Handle either BLOCK_JOB or BLOCK_JOB_2, but prefer the latter +if hasattr(libvirt, "VIR_DOMAIN_EVENT_ID_BLOCK_JOB_2"): + BLOCK_JOB_ID = "VIR_DOMAIN_EVENT_ID_BLOCK_JOB_2" +else: + BLOCK_JOB_ID = "VIR_DOMAIN_EVENT_ID_BLOCK_JOB" + +CALLBACK_DEFS = { + "domain": ( + ("lifecycle", None), + ("reboot", None), + ("rtc_change", None), + ("watchdog", None), + ("graphics", None), + ("io_error", "VIR_DOMAIN_EVENT_ID_IO_ERROR_REASON"), + ("control_error", None), + ("disk_change", None), + ("tray_change", None), + ("pmwakeup", None), + ("pmsuspend", None), + ("balloon_change", None), + ("pmsuspend_disk", None), + ("device_removed", None), + ("block_job", BLOCK_JOB_ID), + ("tunable", None), + ("agent_lifecycle", None), + ("device_added", None), + ("migration_iteration", None), + ("job_completed", None), + ("device_removal_failed", None), + ("metadata_change", None), + ("block_threshold", None), + ), + "network": (("lifecycle", None),), + "pool": ( + ("lifecycle", "VIR_STORAGE_POOL_EVENT_ID_LIFECYCLE"), + ("refresh", "VIR_STORAGE_POOL_EVENT_ID_REFRESH"), + ), + "nodedev": ( + ("lifecycle", "VIR_NODE_DEVICE_EVENT_ID_LIFECYCLE"), + ("update", "VIR_NODE_DEVICE_EVENT_ID_UPDATE"), + ), + "secret": (("lifecycle", None), ("value_changed", None)), +} + + +def _compute_subprefix(attr): + """ + Get the part before the first '_' or the end of attr including + the potential '_' + """ + return "".join((attr.split("_")[0], "_" if len(attr.split("_")) > 1 else "")) + + +def _get_libvirt_enum_string(prefix, value): + """ + Convert the libvirt enum integer value into a human readable string. + + :param prefix: start of the libvirt attribute to look for. + :param value: integer to convert to string + """ + attributes = [ + attr[len(prefix) :] for attr in libvirt.__dict__ if attr.startswith(prefix) + ] + + # Filter out the values starting with a common base as they match another enum + prefixes = [_compute_subprefix(p) for p in attributes] + counts = {p: prefixes.count(p) for p in prefixes} + sub_prefixes = [ + p + for p, count in counts.items() + if count > 1 or (p.endswith("_") and p[:-1] in prefixes) + ] + filtered = [ + attr for attr in attributes if _compute_subprefix(attr) not in sub_prefixes + ] + + for candidate in filtered: + if value == getattr(libvirt, "".join((prefix, candidate))): + name = candidate.lower().replace("_", " ") + return name + return "unknown" + + +def _get_domain_event_detail(event, detail): + """ + Convert event and detail numeric values into a tuple of human readable strings + """ + event_name = _get_libvirt_enum_string("VIR_DOMAIN_EVENT_", event) + if event_name == "unknown": + return event_name, "unknown" + + prefix = f"VIR_DOMAIN_EVENT_{event_name.upper()}_" + detail_name = _get_libvirt_enum_string(prefix, detail) + + return event_name, detail_name + + +def _salt_send_event(opaque, conn, data): + """ + Convenience function adding common data to the event and sending it + on the salt event bus. + + :param opaque: the opaque data that is passed to the callback. + This is a dict with 'prefix', 'object' and 'event' keys. + :param conn: libvirt connection + :param data: additional event data dict to send + """ + tag_prefix = opaque["prefix"] + object_type = opaque["object"] + event_type = opaque["event"] + + # Prepare the connection URI to fit in the tag + # qemu+ssh://user@host:1234/system -> qemu+ssh/user@host:1234/system + uri = urllib.parse.urlparse(conn.getURI()) + uri_tag = [uri.scheme] + if uri.netloc: + uri_tag.append(uri.netloc) + path = uri.path.strip("/") + if path: + uri_tag.append(path) + uri_str = "/".join(uri_tag) + + # Append some common data + all_data = {"uri": conn.getURI()} + all_data.update(data) + + tag = "/".join((tag_prefix, uri_str, object_type, event_type)) + + # Actually send the event in salt + if __opts__.get("__role") == "master": + salt.utils.event.get_master_event(__opts__, __opts__["sock_dir"]).fire_event( + all_data, tag + ) + else: + __salt__["event.send"](tag, all_data) + + +def _salt_send_domain_event(opaque, conn, domain, event, event_data): + """ + Helper function send a salt event for a libvirt domain. + + :param opaque: the opaque data that is passed to the callback. + This is a dict with 'prefix', 'object' and 'event' keys. + :param conn: libvirt connection + :param domain: name of the domain related to the event + :param event: name of the event + :param event_data: additional event data dict to send + """ + data = { + "domain": { + "name": domain.name(), + "id": domain.ID(), + "uuid": domain.UUIDString(), + }, + "event": event, + } + data.update(event_data) + _salt_send_event(opaque, conn, data) + + +def _domain_event_lifecycle_cb(conn, domain, event, detail, opaque): + """ + Domain lifecycle events handler + """ + event_str, detail_str = _get_domain_event_detail(event, detail) + + _salt_send_domain_event( + opaque, + conn, + domain, + opaque["event"], + {"event": event_str, "detail": detail_str}, + ) + + +def _domain_event_reboot_cb(conn, domain, opaque): + """ + Domain reboot events handler + """ + _salt_send_domain_event(opaque, conn, domain, opaque["event"], {}) + + +def _domain_event_rtc_change_cb(conn, domain, utcoffset, opaque): + """ + Domain RTC change events handler + """ + _salt_send_domain_event( + opaque, conn, domain, opaque["event"], {"utcoffset": utcoffset} + ) + + +def _domain_event_watchdog_cb(conn, domain, action, opaque): + """ + Domain watchdog events handler + """ + _salt_send_domain_event( + opaque, + conn, + domain, + opaque["event"], + {"action": _get_libvirt_enum_string("VIR_DOMAIN_EVENT_WATCHDOG_", action)}, + ) + + +def _domain_event_io_error_cb(conn, domain, srcpath, devalias, action, reason, opaque): + """ + Domain I/O Error events handler + """ + _salt_send_domain_event( + opaque, + conn, + domain, + opaque["event"], + { + "srcPath": srcpath, + "dev": devalias, + "action": _get_libvirt_enum_string("VIR_DOMAIN_EVENT_IO_ERROR_", action), + "reason": reason, + }, + ) + + +def _domain_event_graphics_cb( + conn, domain, phase, local, remote, auth, subject, opaque +): + """ + Domain graphics events handler + """ + prefix = "VIR_DOMAIN_EVENT_GRAPHICS_" + + def get_address(addr): + """ + transform address structure into event data piece + """ + return { + "family": _get_libvirt_enum_string(f"{prefix}_ADDRESS_", addr["family"]), + "node": addr["node"], + "service": addr["service"], + } + + _salt_send_domain_event( + opaque, + conn, + domain, + opaque["event"], + { + "phase": _get_libvirt_enum_string(prefix, phase), + "local": get_address(local), + "remote": get_address(remote), + "authScheme": auth, + "subject": [{"type": item[0], "name": item[1]} for item in subject], + }, + ) + + +def _domain_event_control_error_cb(conn, domain, opaque): + """ + Domain control error events handler + """ + _salt_send_domain_event(opaque, conn, domain, opaque["event"], {}) + + +def _domain_event_disk_change_cb(conn, domain, old_src, new_src, dev, reason, opaque): + """ + Domain disk change events handler + """ + _salt_send_domain_event( + opaque, + conn, + domain, + opaque["event"], + { + "oldSrcPath": old_src, + "newSrcPath": new_src, + "dev": dev, + "reason": _get_libvirt_enum_string("VIR_DOMAIN_EVENT_DISK_", reason), + }, + ) + + +def _domain_event_tray_change_cb(conn, domain, dev, reason, opaque): + """ + Domain tray change events handler + """ + _salt_send_domain_event( + opaque, + conn, + domain, + opaque["event"], + { + "dev": dev, + "reason": _get_libvirt_enum_string("VIR_DOMAIN_EVENT_TRAY_CHANGE_", reason), + }, + ) + + +def _domain_event_pmwakeup_cb(conn, domain, reason, opaque): + """ + Domain wakeup events handler + """ + _salt_send_domain_event( + opaque, conn, domain, opaque["event"], {"reason": "unknown"} # currently unused + ) + + +def _domain_event_pmsuspend_cb(conn, domain, reason, opaque): + """ + Domain suspend events handler + """ + _salt_send_domain_event( + opaque, conn, domain, opaque["event"], {"reason": "unknown"} # currently unused + ) + + +def _domain_event_balloon_change_cb(conn, domain, actual, opaque): + """ + Domain balloon change events handler + """ + _salt_send_domain_event(opaque, conn, domain, opaque["event"], {"actual": actual}) + + +def _domain_event_pmsuspend_disk_cb(conn, domain, reason, opaque): + """ + Domain disk suspend events handler + """ + _salt_send_domain_event( + opaque, conn, domain, opaque["event"], {"reason": "unknown"} # currently unused + ) + + +def _domain_event_block_job_cb(conn, domain, disk, job_type, status, opaque): + """ + Domain block job events handler + """ + _salt_send_domain_event( + opaque, + conn, + domain, + opaque["event"], + { + "disk": disk, + "type": _get_libvirt_enum_string("VIR_DOMAIN_BLOCK_JOB_TYPE_", job_type), + "status": _get_libvirt_enum_string("VIR_DOMAIN_BLOCK_JOB_", status), + }, + ) + + +def _domain_event_device_removed_cb(conn, domain, dev, opaque): + """ + Domain device removal events handler + """ + _salt_send_domain_event(opaque, conn, domain, opaque["event"], {"dev": dev}) + + +def _domain_event_tunable_cb(conn, domain, params, opaque): + """ + Domain tunable events handler + """ + _salt_send_domain_event(opaque, conn, domain, opaque["event"], {"params": params}) + + +# pylint: disable=invalid-name +def _domain_event_agent_lifecycle_cb(conn, domain, state, reason, opaque): + """ + Domain agent lifecycle events handler + """ + _salt_send_domain_event( + opaque, + conn, + domain, + opaque["event"], + { + "state": _get_libvirt_enum_string( + "VIR_CONNECT_DOMAIN_EVENT_AGENT_LIFECYCLE_STATE_", state + ), + "reason": _get_libvirt_enum_string( + "VIR_CONNECT_DOMAIN_EVENT_AGENT_LIFECYCLE_REASON_", reason + ), + }, + ) + + +def _domain_event_device_added_cb(conn, domain, dev, opaque): + """ + Domain device addition events handler + """ + _salt_send_domain_event(opaque, conn, domain, opaque["event"], {"dev": dev}) + + +# pylint: disable=invalid-name +def _domain_event_migration_iteration_cb(conn, domain, iteration, opaque): + """ + Domain migration iteration events handler + """ + _salt_send_domain_event( + opaque, conn, domain, opaque["event"], {"iteration": iteration} + ) + + +def _domain_event_job_completed_cb(conn, domain, params, opaque): + """ + Domain job completion events handler + """ + _salt_send_domain_event(opaque, conn, domain, opaque["event"], {"params": params}) + + +def _domain_event_device_removal_failed_cb(conn, domain, dev, opaque): + """ + Domain device removal failure events handler + """ + _salt_send_domain_event(opaque, conn, domain, opaque["event"], {"dev": dev}) + + +def _domain_event_metadata_change_cb(conn, domain, mtype, nsuri, opaque): + """ + Domain metadata change events handler + """ + _salt_send_domain_event( + opaque, + conn, + domain, + opaque["event"], + { + "type": _get_libvirt_enum_string("VIR_DOMAIN_METADATA_", mtype), + "nsuri": nsuri, + }, + ) + + +def _domain_event_block_threshold_cb( + conn, domain, dev, path, threshold, excess, opaque +): + """ + Domain block threshold events handler + """ + _salt_send_domain_event( + opaque, + conn, + domain, + opaque["event"], + {"dev": dev, "path": path, "threshold": threshold, "excess": excess}, + ) + + +def _network_event_lifecycle_cb(conn, net, event, detail, opaque): + """ + Network lifecycle events handler + """ + + _salt_send_event( + opaque, + conn, + { + "network": {"name": net.name(), "uuid": net.UUIDString()}, + "event": _get_libvirt_enum_string("VIR_NETWORK_EVENT_", event), + "detail": "unknown", # currently unused + }, + ) + + +def _pool_event_lifecycle_cb(conn, pool, event, detail, opaque): + """ + Storage pool lifecycle events handler + """ + _salt_send_event( + opaque, + conn, + { + "pool": {"name": pool.name(), "uuid": pool.UUIDString()}, + "event": _get_libvirt_enum_string("VIR_STORAGE_POOL_EVENT_", event), + "detail": "unknown", # currently unused + }, + ) + + +def _pool_event_refresh_cb(conn, pool, opaque): + """ + Storage pool refresh events handler + """ + _salt_send_event( + opaque, + conn, + { + "pool": {"name": pool.name(), "uuid": pool.UUIDString()}, + "event": opaque["event"], + }, + ) + + +def _nodedev_event_lifecycle_cb(conn, dev, event, detail, opaque): + """ + Node device lifecycle events handler + """ + _salt_send_event( + opaque, + conn, + { + "nodedev": {"name": dev.name()}, + "event": _get_libvirt_enum_string("VIR_NODE_DEVICE_EVENT_", event), + "detail": "unknown", # currently unused + }, + ) + + +def _nodedev_event_update_cb(conn, dev, opaque): + """ + Node device update events handler + """ + _salt_send_event( + opaque, conn, {"nodedev": {"name": dev.name()}, "event": opaque["event"]} + ) + + +def _secret_event_lifecycle_cb(conn, secret, event, detail, opaque): + """ + Secret lifecycle events handler + """ + _salt_send_event( + opaque, + conn, + { + "secret": {"uuid": secret.UUIDString()}, + "event": _get_libvirt_enum_string("VIR_SECRET_EVENT_", event), + "detail": "unknown", # currently unused + }, + ) + + +def _secret_event_value_changed_cb(conn, secret, opaque): + """ + Secret value change events handler + """ + _salt_send_event( + opaque, + conn, + {"secret": {"uuid": secret.UUIDString()}, "event": opaque["event"]}, + ) + + +def _cleanup(cnx): + """ + Close the libvirt connection + + :param cnx: libvirt connection + """ + log.debug("Closing libvirt connection: %s", cnx.getURI()) + cnx.close() + + +def _callbacks_cleanup(cnx, callback_ids): + """ + Unregister all the registered callbacks + + :param cnx: libvirt connection + :param callback_ids: dictionary mapping a libvirt object type to an ID list + of callbacks to deregister + """ + for obj, ids in callback_ids.items(): + register_name = REGISTER_FUNCTIONS[obj] + deregister_name = register_name.replace("Reg", "Dereg") + deregister = getattr(cnx, deregister_name) + for callback_id in ids: + deregister(callback_id) + + +def _register_callback(cnx, tag_prefix, obj, event, real_id): + """ + Helper function registering a callback + + :param cnx: libvirt connection + :param tag_prefix: salt event tag prefix to use + :param obj: the libvirt object name for the event. Needs to + be one of the REGISTER_FUNCTIONS keys. + :param event: the event type name. + :param real_id: the libvirt name of an alternative event id to use or None + + :rtype integer value needed to deregister the callback + """ + libvirt_name = real_id + if real_id is None: + libvirt_name = f"VIR_{obj}_EVENT_ID_{event}".upper() + + if not hasattr(libvirt, libvirt_name): + log.warning('Skipping "%s/%s" events: libvirt too old', obj, event) + return None + + libvirt_id = getattr(libvirt, libvirt_name) + callback_name = f"_{obj}_event_{event}_cb" + callback = globals().get(callback_name, None) + if callback is None: + log.error("Missing function %s in engine", callback_name) + return None + + register = getattr(cnx, REGISTER_FUNCTIONS[obj]) + return register( + None, + libvirt_id, + callback, + {"prefix": tag_prefix, "object": obj, "event": event}, + ) + + +def _append_callback_id(ids, obj, callback_id): + """ + Helper function adding a callback ID to the IDs dict. + The callback ids dict maps an object to event callback ids. + + :param ids: dict of callback IDs to update + :param obj: one of the keys of REGISTER_FUNCTIONS + :param callback_id: the result of _register_callback + """ + if obj not in ids: + ids[obj] = [] + ids[obj].append(callback_id) + + +def start(uri=None, tag_prefix="salt/engines/libvirt_events", filters=None): + """ + Listen to libvirt events and forward them to salt. + + :param uri: libvirt URI to listen on. + Defaults to None to pick the first available local hypervisor + :param tag_prefix: the beginning of the salt event tag to use. + Defaults to 'salt/engines/libvirt_events' + :param filters: the list of event of listen on. Defaults to 'all' + """ + if filters is None: + filters = ["all"] + try: + libvirt.virEventRegisterDefaultImpl() + + cnx = libvirt.openReadOnly(uri) + log.debug("Opened libvirt uri: %s", cnx.getURI()) + + callback_ids = {} + all_filters = "all" in filters + + for obj, event_defs in CALLBACK_DEFS.items(): + for event, real_id in event_defs: + event_filter = "/".join((obj, event)) + if ( + event_filter not in filters + and obj not in filters + and not all_filters + ): + continue + registered_id = _register_callback(cnx, tag_prefix, obj, event, real_id) + if registered_id: + _append_callback_id(callback_ids, obj, registered_id) + + exit_loop = False + while not exit_loop: + exit_loop = libvirt.virEventRunDefaultImpl() < 0 + + except Exception as err: # pylint: disable=broad-except + log.exception(err) + finally: + _callbacks_cleanup(cnx, callback_ids) + _cleanup(cnx) diff --git a/salt/engines/logentries.py b/salt/engines/logentries.py new file mode 100644 index 000000000000..33c7bf8337a4 --- /dev/null +++ b/salt/engines/logentries.py @@ -0,0 +1,219 @@ +""" +An engine that sends events to the Logentries logging service. + +:maintainer: Jimmy Tang (jimmy_tang@rapid7.com) +:maturity: New +:depends: ssl, certifi +:platform: all + +.. versionadded:: 2016.3.0 + +To enable this engine the master and/or minion will need the following +python libraries + + ssl + certifi + +If you are running a new enough version of python then the ssl library +will be present already. + +You will also need the following values configured in the minion or +master config. + +:configuration: + + Example configuration + + .. code-block:: yaml + + engines: + - logentries: + endpoint: data.logentries.com + port: 10000 + token: 057af3e2-1c05-47c5-882a-5cd644655dbf + +The 'token' can be obtained from the Logentries service. + +To test this engine + + .. code-block:: bash + + salt '*' test.ping cmd.run uptime + +""" + +import logging +import random +import socket +import time +import uuid + +import salt.utils.event +import salt.utils.json + +try: + import certifi + + HAS_CERTIFI = True +except ImportError: + HAS_CERTIFI = False + +# This is here for older python installs, it is needed to setup an encrypted tcp connection +try: + import ssl + + HAS_SSL = True +except ImportError: # for systems without TLS support. + HAS_SSL = False + + +log = logging.getLogger(__name__) + + +def __virtual__(): + return True if HAS_CERTIFI and HAS_SSL else False + + +class PlainTextSocketAppender: + def __init__( + self, verbose=True, LE_API="data.logentries.com", LE_PORT=80, LE_TLS_PORT=443 + ): + + self.LE_API = LE_API + self.LE_PORT = LE_PORT + self.LE_TLS_PORT = LE_TLS_PORT + self.MIN_DELAY = 0.1 + self.MAX_DELAY = 10 + # Error message displayed when an incorrect Token has been detected + self.INVALID_TOKEN = ( + "\n\nIt appears the LOGENTRIES_TOKEN " + "parameter you entered is incorrect!\n\n" + ) + # Encoded unicode line separator + self.LINE_SEP = salt.utils.stringutils.to_str("\u2028") + + self.verbose = verbose + self._conn = None + + def open_connection(self): + self._conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._conn.connect((self.LE_API, self.LE_PORT)) + + def reopen_connection(self): + self.close_connection() + + root_delay = self.MIN_DELAY + while True: + try: + self.open_connection() + return + except Exception: # pylint: disable=broad-except + if self.verbose: + log.warning("Unable to connect to Logentries") + + root_delay *= 2 + if root_delay > self.MAX_DELAY: + root_delay = self.MAX_DELAY + + wait_for = root_delay + random.uniform(0, root_delay) + + try: + time.sleep(wait_for) + except KeyboardInterrupt: # pylint: disable=try-except-raise + raise + + def close_connection(self): + if self._conn is not None: + self._conn.close() + + def put(self, data): + # Replace newlines with Unicode line separator for multi-line events + multiline = data.replace("\n", self.LINE_SEP) + "\n" + # Send data, reconnect if needed + while True: + try: + self._conn.send(multiline) + except OSError: + self.reopen_connection() + continue + break + + self.close_connection() + + +try: + import ssl + + HAS_SSL = True +except ImportError: # for systems without TLS support. + SocketAppender = PlainTextSocketAppender + HAS_SSL = False +else: + + class TLSSocketAppender(PlainTextSocketAppender): + def open_connection(self): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock = ssl.wrap_socket( + sock=sock, + keyfile=None, + certfile=None, + server_side=False, + cert_reqs=ssl.CERT_REQUIRED, + ssl_version=getattr(ssl, "PROTOCOL_TLSv1_2", ssl.PROTOCOL_TLSv1), + ca_certs=certifi.where(), + do_handshake_on_connect=True, + suppress_ragged_eofs=True, + ) + sock.connect((self.LE_API, self.LE_TLS_PORT)) + self._conn = sock + + SocketAppender = TLSSocketAppender + + +def event_bus_context(opts): + if opts.get("id").endswith("_master"): + event_bus = salt.utils.event.get_master_event( + opts, opts["sock_dir"], listen=True + ) + else: + event_bus = salt.utils.event.get_event( + "minion", + opts=opts, + sock_dir=opts["sock_dir"], + listen=True, + ) + return event_bus + + +def start( + endpoint="data.logentries.com", + port=10000, + token=None, + tag="salt/engines/logentries", +): + """ + Listen to salt events and forward them to Logentries + """ + with event_bus_context(__opts__) as event_bus: + log.debug("Logentries engine started") + try: + val = uuid.UUID(token) + except ValueError: + log.warning("Not a valid logentries token") + + appender = SocketAppender(verbose=False, LE_API=endpoint, LE_PORT=port) + appender.reopen_connection() + + while True: + event = event_bus.get_event() + if event: + msg = " ".join( + ( + salt.utils.stringutils.to_str(token), + salt.utils.stringutils.to_str(tag), + salt.utils.json.dumps(event), + ) + ) + appender.put(msg) + + appender.close_connection() diff --git a/salt/engines/logstash_engine.py b/salt/engines/logstash_engine.py new file mode 100644 index 000000000000..d8baa9464577 --- /dev/null +++ b/salt/engines/logstash_engine.py @@ -0,0 +1,78 @@ +""" +An engine that reads messages from the salt event bus and pushes +them onto a logstash endpoint. + +.. versionadded:: 2015.8.0 + +:configuration: + + Example configuration + + .. code-block:: yaml + + engines: + - logstash: + host: log.my_network.com + port: 5959 + proto: tcp + +:depends: logstash +""" + +import logging + +import salt.utils.event + +try: + import logstash +except ImportError: + logstash = None + +log = logging.getLogger(__name__) + +__virtualname__ = "logstash" + + +def __virtual__(): + return ( + __virtualname__ + if logstash is not None + else (False, "python-logstash not installed") + ) + + +def event_bus_context(opts): + if opts.get("id").endswith("_master"): + event_bus = salt.utils.event.get_master_event( + opts, opts["sock_dir"], listen=True + ) + else: + event_bus = salt.utils.event.get_event( + "minion", + opts=opts, + sock_dir=opts["sock_dir"], + listen=True, + ) + return event_bus + + +def start(host, port=5959, tag="salt/engine/logstash", proto="udp"): + """ + Listen to salt events and forward them to logstash + """ + + if proto == "tcp": + logstashHandler = logstash.TCPLogstashHandler + elif proto == "udp": + logstashHandler = logstash.UDPLogstashHandler + + logstash_logger = logging.getLogger("python-logstash-logger") + logstash_logger.setLevel(logging.INFO) + logstash_logger.addHandler(logstashHandler(host, port, version=1)) + + with event_bus_context(__opts__) as event_bus: + log.debug("Logstash engine started") + while True: + event = event_bus.get_event() + if event: + logstash_logger.info(tag, extra=event) diff --git a/salt/engines/napalm_syslog.py b/salt/engines/napalm_syslog.py new file mode 100644 index 000000000000..e64f3c6b54b0 --- /dev/null +++ b/salt/engines/napalm_syslog.py @@ -0,0 +1,357 @@ +""" +NAPALM syslog engine +==================== + +.. versionadded:: 2017.7.0 + +An engine that takes syslog messages structured in +OpenConfig_ or IETF format +and fires Salt events. + +.. _OpenConfig: http://www.openconfig.net/ + +As there can be many messages pushed into the event bus, +the user is able to filter based on the object structure. + +Requirements +------------ + +- `napalm-logs`_ + +.. _`napalm-logs`: https://github.com/napalm-automation/napalm-logs + +This engine transfers objects from the napalm-logs library +into the event bus. The top dictionary has the following keys: + +- ``ip`` +- ``host`` +- ``timestamp`` +- ``os``: the network OS identified +- ``model_name``: the OpenConfig or IETF model name +- ``error``: the error name (consult the documentation) +- ``message_details``: details extracted from the syslog message +- ``open_config``: the OpenConfig model + +The napalm-logs transfers the messages via widely used transport +mechanisms such as: ZeroMQ (default), Kafka, etc. + +The user can select the right transport using the ``transport`` +option in the configuration. + +:configuration: Example configuration + + .. code-block:: yaml + + engines: + - napalm_syslog: + transport: zmq + address: 1.2.3.4 + port: 49018 + +:configuration: Configuration example, excluding messages from IOS-XR devices: + + .. code-block:: yaml + + engines: + - napalm_syslog: + transport: kafka + address: 1.2.3.4 + port: 49018 + os_blacklist: + - iosxr + +Event example: + +.. code-block:: json + + { + "_stamp": "2017-05-26T10:03:18.653045", + "error": "BGP_PREFIX_THRESH_EXCEEDED", + "host": "vmx01", + "ip": "192.168.140.252", + "message_details": { + "date": "May 25", + "host": "vmx01", + "message": "192.168.140.254 (External AS 65001): Configured maximum prefix-limit threshold(22) exceeded for inet-unicast nlri: 28 (instance master)", + "pri": "28", + "processId": "2957", + "processName": "rpd", + "tag": "BGP_PREFIX_THRESH_EXCEEDED", + "time": "20:50:41" + }, + "model_name": "openconfig_bgp", + "open_config": { + "bgp": { + "neighbors": { + "neighbor": { + "192.168.140.254": { + "afi_safis": { + "afi_safi": { + "inet": { + "afi_safi_name": "inet", + "ipv4_unicast": { + "prefix_limit": { + "state": { + "max_prefixes": 22 + } + } + }, + "state": { + "prefixes": { + "received": 28 + } + } + } + } + }, + "neighbor_address": "192.168.140.254", + "state": { + "peer_as": 65001 + } + } + } + } + } + }, + "os": "junos", + "timestamp": "1495741841" + } + +To consume the events and eventually react and deploy a configuration changes +on the device(s) firing the event, one is able to identify the minion ID, using +one of the following alternatives, but not limited to: + +- :mod:`Host grains ` to match the event tag +- :mod:`Host DNS grain ` to match the IP address in the event data +- :mod:`Hostname grains ` to match the event tag +- :ref:`Define static grains ` +- :ref:`Write a grains module ` +- :ref:`Targeting minions using pillar data ` - The user can + configure certain information in the Pillar data and then use it to identify + minions + +Master configuration example, to match the event and react: + +.. code-block:: yaml + + reactor: + - 'napalm/syslog/*/BGP_PREFIX_THRESH_EXCEEDED/*': + - salt://increase_prefix_limit_on_thresh_exceeded.sls + +Which matches the events having the error code ``BGP_PREFIX_THRESH_EXCEEDED`` +from any network operating system, from any host and reacts, executing the +``increase_prefix_limit_on_thresh_exceeded.sls`` reactor, found under +one of the :conf_master:`file_roots` paths. + +Reactor example: + +.. code-block:: yaml + + increase_prefix_limit_on_thresh_exceeded: + local.net.load_template: + - tgt: "hostname:{{ data['host'] }}" + - tgt_type: grain + - kwarg: + template_name: salt://increase_prefix_limit.jinja + openconfig_structure: {{ data['open_config'] }} + +The reactor in the example increases the BGP prefix limit +when triggered by an event as above. The minion is matched using the ``host`` +field from the ``data`` (which is the body of the event), compared to the +:mod:`hostname grain ` field. When the event +occurs, the reactor will execute the +:mod:`net.load_template ` function, +sending as arguments the template ``salt://increase_prefix_limit.jinja`` defined +by the user in their environment and the complete OpenConfig object under +the variable name ``openconfig_structure``. Inside the Jinja template, the user +can process the object from ``openconfig_structure`` and define the bussiness +logic as required. +""" + +import logging + +import salt.utils.event as event +import salt.utils.network +import salt.utils.stringutils +from salt.utils.zeromq import zmq + +try: + # pylint: disable=import-error + import napalm_logs + import napalm_logs.utils + + # pylint: enable=import-error + HAS_NAPALM_LOGS = True +except ImportError: + HAS_NAPALM_LOGS = False + + +log = logging.getLogger(__name__) + +__virtualname__ = "napalm_syslog" + + +def __virtual__(): + """ + Load only if napalm-logs is installed. + """ + if not HAS_NAPALM_LOGS or not zmq: + return ( + False, + "napalm_syslog could not be loaded. Please install " + "napalm-logs library and ZeroMQ.", + ) + return True + + +def _zmq(address, port, **kwargs): + context = zmq.Context() + socket = context.socket(zmq.SUB) + if salt.utils.network.is_ipv6(address): + socket.ipv6 = True + socket.connect(f"tcp://{address}:{port}") + socket.setsockopt(zmq.SUBSCRIBE, b"") + return socket.recv + + +def _get_transport_recv(name="zmq", address="0.0.0.0", port=49017, **kwargs): + if name not in TRANSPORT_FUN_MAP: + log.error("Invalid transport: %s. Falling back to ZeroMQ.", name) + name = "zmq" + return TRANSPORT_FUN_MAP[name](address, port, **kwargs) + + +TRANSPORT_FUN_MAP = {"zmq": _zmq, "zeromq": _zmq} + + +def start( + transport="zmq", + address="0.0.0.0", + port=49017, + auth_address="0.0.0.0", + auth_port=49018, + disable_security=False, + certificate=None, + os_whitelist=None, + os_blacklist=None, + error_whitelist=None, + error_blacklist=None, + host_whitelist=None, + host_blacklist=None, +): + """ + Listen to napalm-logs and publish events into the Salt event bus. + + transport: ``zmq`` + Choose the desired transport. + + .. note:: + Currently ``zmq`` is the only valid option. + + address: ``0.0.0.0`` + The address of the publisher, as configured on napalm-logs. + + port: ``49017`` + The port of the publisher, as configured on napalm-logs. + + auth_address: ``0.0.0.0`` + The address used for authentication + when security is not disabled. + + auth_port: ``49018`` + Port used for authentication. + + disable_security: ``False`` + Trust unencrypted messages. + Strongly discouraged in production. + + certificate: ``None`` + Absolute path to the SSL certificate. + + os_whitelist: ``None`` + List of operating systems allowed. By default everything is allowed. + + os_blacklist: ``None`` + List of operating system to be ignored. Nothing ignored by default. + + error_whitelist: ``None`` + List of errors allowed. + + error_blacklist: ``None`` + List of errors ignored. + + host_whitelist: ``None`` + List of hosts or IPs to be allowed. + + host_blacklist: ``None`` + List of hosts of IPs to be ignored. + """ + if not disable_security: + if not certificate: + log.critical("Please use a certificate, or disable the security.") + return + auth = napalm_logs.utils.ClientAuth( + certificate, address=auth_address, port=auth_port + ) + + transport_recv_fun = _get_transport_recv(name=transport, address=address, port=port) + if not transport_recv_fun: + log.critical("Unable to start the engine", exc_info=True) + return + master = False + if __opts__["__role"] == "master": + master = True + while True: + log.debug("Waiting for napalm-logs to send anything...") + raw_object = transport_recv_fun() + log.debug("Received from napalm-logs:") + log.debug(raw_object) + if not disable_security: + dict_object = auth.decrypt(raw_object) + else: + dict_object = napalm_logs.utils.unserialize(raw_object) + try: + event_os = dict_object["os"] + if os_blacklist or os_whitelist: + valid_os = salt.utils.stringutils.check_whitelist_blacklist( + event_os, whitelist=os_whitelist, blacklist=os_blacklist + ) + if not valid_os: + log.info("Ignoring NOS %s as per whitelist/blacklist", event_os) + continue + event_error = dict_object["error"] + if error_blacklist or error_whitelist: + valid_error = salt.utils.stringutils.check_whitelist_blacklist( + event_error, whitelist=error_whitelist, blacklist=error_blacklist + ) + if not valid_error: + log.info( + "Ignoring error %s as per whitelist/blacklist", event_error + ) + continue + event_host = dict_object.get("host") or dict_object.get("ip") + if host_blacklist or host_whitelist: + valid_host = salt.utils.stringutils.check_whitelist_blacklist( + event_host, whitelist=host_whitelist, blacklist=host_blacklist + ) + if not valid_host: + log.info( + "Ignoring messages from %s as per whitelist/blacklist", + event_host, + ) + continue + tag = "napalm/syslog/{os}/{error}/{host}".format( + os=event_os, error=event_error, host=event_host + ) + except KeyError as kerr: + log.warning("Missing keys from the napalm-logs object:", exc_info=True) + log.warning(dict_object) + continue # jump to the next object in the queue + log.debug("Sending event %s", tag) + log.debug(raw_object) + if master: + event.get_master_event(__opts__, __opts__["sock_dir"]).fire_event( + dict_object, tag + ) + else: + __salt__["event.send"](tag, dict_object) diff --git a/salt/engines/redis_sentinel.py b/salt/engines/redis_sentinel.py new file mode 100644 index 000000000000..0dfdd7ca727b --- /dev/null +++ b/salt/engines/redis_sentinel.py @@ -0,0 +1,124 @@ +""" +An engine that reads messages from the redis sentinel pubsub and sends reactor +events based on the channels they are subscribed to. + +.. versionadded:: 2016.3.0 + +:configuration: + + Example configuration + + .. code-block:: yaml + + engines: + - redis_sentinel: + hosts: + matching: 'board*' + port: 26379 + interface: eth2 + channels: + - '+switch-master' + - '+odown' + - '-odown' + +:depends: redis +""" + +import logging + +import salt.client + +try: + import redis +except ImportError: + redis = None + +log = logging.getLogger(__name__) + +__virtualname__ = "redis" + + +def __virtual__(): + return ( + __virtualname__ + if redis is not None + else (False, "redis python module is not installed") + ) + + +class Listener: + def __init__(self, host=None, port=None, channels=None, tag=None, password=None): + if host is None: + host = "localhost" + if port is None: + port = 26379 + if channels is None: + channels = ["*"] + if tag is None: + tag = "salt/engine/redis_sentinel" + super().__init__() + self.tag = tag + self.redis = redis.StrictRedis( + host=host, port=port, password=password, decode_responses=True + ) + self.pubsub = self.redis.pubsub() + self.pubsub.psubscribe(channels) + self.fire_master = salt.utils.event.get_master_event( + __opts__, __opts__["sock_dir"] + ).fire_event + + def work(self, item): + ret = {"channel": item["channel"]} + if isinstance(item["data"], int): + ret["code"] = item["data"] + elif item["channel"] == "+switch-master": + ret.update( + dict( + list( + zip( + ("master", "old_host", "old_port", "new_host", "new_port"), + item["data"].split(" "), + ) + ) + ) + ) + elif item["channel"] in ("+odown", "-odown"): + ret.update( + dict(list(zip(("master", "host", "port"), item["data"].split(" ")[1:]))) + ) + else: + ret = { + "channel": item["channel"], + "data": item["data"], + } + self.fire_master(ret, "{}/{}".format(self.tag, item["channel"])) + + def run(self): + log.debug("Start Listener") + for item in self.pubsub.listen(): + log.debug("Item: %s", item) + self.work(item) + + +def start(hosts, channels, tag=None, password=None): + if tag is None: + tag = "salt/engine/redis_sentinel" + with salt.client.LocalClient() as local: + ip_results = local.cmd( + hosts["matching"], "network.ip_addrs", [hosts["interface"]] + ) + if not ip_results: + log.error( + "redis_sentinel: no minions matched %r; cannot pick a listener " + "host. Check the 'matching' target in the engine config.", + hosts["matching"], + ) + return + client = Listener( + host=next(reversed(ip_results.values()))[0], + port=hosts["port"], + channels=channels, + tag=tag, + password=password, + ) + client.run() diff --git a/salt/engines/slack.py b/salt/engines/slack.py new file mode 100644 index 000000000000..0d331668904c --- /dev/null +++ b/salt/engines/slack.py @@ -0,0 +1,947 @@ +""" +An engine that reads messages from Slack and can act on them + +.. versionadded:: 2016.3.0 + +:depends: `slackclient `_ Python module + +.. important:: + This engine requires a bot user. To create a bot user, first go to the + **Custom Integrations** page in your Slack Workspace. Copy and paste the + following URL, and replace ``myworkspace`` with the proper value for your + workspace: + + ``https://myworkspace.slack.com/apps/manage/custom-integrations`` + + Next, click on the ``Bots`` integration and request installation. Once + approved by an admin, you will be able to proceed with adding the bot user. + Once the bot user has been added, you can configure it by adding an avatar, + setting the display name, etc. You will also at this time have access to + your API token, which will be needed to configure this engine. + + Finally, add this bot user to a channel by switching to the channel and + using ``/invite @mybotuser``. Keep in mind that this engine will process + messages from each channel in which the bot is a member, so it is + recommended to narrowly define the commands which can be executed, and the + Slack users which are allowed to run commands. + + +This engine has two boolean configuration parameters that toggle specific +features (both default to ``False``): + +1. ``control`` - If set to ``True``, then any message which starts with the + trigger string (which defaults to ``!`` and can be overridden by setting the + ``trigger`` option in the engine configuration) will be interpreted as a + Salt CLI command and the engine will attempt to run it. The permissions + defined in the various ``groups`` will determine if the Slack user is + allowed to run the command. The ``targets`` and ``default_target`` options + can be used to set targets for a given command, but the engine can also read + the following two keyword arguments: + + - ``target`` - The target expression to use for the command + + - ``tgt_type`` - The match type, can be one of ``glob``, ``list``, + ``pcre``, ``grain``, ``grain_pcre``, ``pillar``, ``nodegroup``, ``range``, + ``ipcidr``, or ``compound``. The default value is ``glob``. + + Here are a few examples: + + .. code-block:: text + + !test.ping target=* + !state.apply foo target=os:CentOS tgt_type=grain + !pkg.version mypkg target=role:database tgt_type=pillar + +2. ``fire_all`` - If set to ``True``, all messages which are not prefixed with + the trigger string will fired as events onto Salt's ref:`event bus + `. The tag for these veents will be prefixed with the string + specified by the ``tag`` config option (default: ``salt/engines/slack``). + + +The ``groups_pillar_name`` config option can be used to pull group +configuration from the specified pillar key. + +.. note:: + In order to use ``groups_pillar_name``, the engine must be running as a + minion running on the master, so that the ``Caller`` client can be used to + retrieve that minions pillar data, because the master process does not have + pillar data. + + +Configuration Examples +====================== + +.. versionchanged:: 2017.7.0 + Access control group support added + +This example uses a single group called ``default``. In addition, other groups +are being loaded from pillar data. The group names do not have any +significance, it is the users and commands defined within them that are used to +determine whether the Slack user has permission to run the desired command. + +.. code-block:: text + + engines: + - slack: + token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx' + control: True + fire_all: False + groups_pillar_name: 'slack_engine:groups_pillar' + groups: + default: + users: + - '*' + commands: + - test.ping + - cmd.run + - list_jobs + - list_commands + aliases: + list_jobs: + cmd: jobs.list_jobs + list_commands: + cmd: 'pillar.get salt:engines:slack:valid_commands target=saltmaster tgt_type=list' + default_target: + target: saltmaster + tgt_type: glob + targets: + test.ping: + target: '*' + tgt_type: glob + cmd.run: + target: saltmaster + tgt_type: list + +This example shows multiple groups applying to different users, with all users +having access to run test.ping. Keep in mind that when using ``*``, the value +must be quoted, or else PyYAML will fail to load the configuration. + +.. code-block:: text + + engines: + - slack: + groups_pillar: slack_engine_pillar + token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx' + control: True + fire_all: True + tag: salt/engines/slack + groups_pillar_name: 'slack_engine:groups_pillar' + groups: + default: + users: + - '*' + commands: + - test.ping + aliases: + list_jobs: + cmd: jobs.list_jobs + list_commands: + cmd: 'pillar.get salt:engines:slack:valid_commands target=saltmaster tgt_type=list' + gods: + users: + - garethgreenaway + commands: + - '*' + +""" + +import ast +import datetime +import itertools +import logging +import re +import time +import traceback + +import salt.client +import salt.loader +import salt.minion +import salt.output +import salt.runner +import salt.utils.args +import salt.utils.event +import salt.utils.http +import salt.utils.json +import salt.utils.slack +import salt.utils.yaml + +try: + import slackclient + + HAS_SLACKCLIENT = True +except ImportError: + HAS_SLACKCLIENT = False + +log = logging.getLogger(__name__) + +__virtualname__ = "slack" + + +def __virtual__(): + if not HAS_SLACKCLIENT: + return (False, "The 'slackclient' Python module could not be loaded") + return __virtualname__ + + +class SlackClient: + def __init__(self, token): + self.master_minion = salt.minion.MasterMinion(__opts__) + + self.sc = slackclient.SlackClient(token) + self.slack_connect = self.sc.rtm_connect() + + def get_slack_users(self, token): + """ + Get all users from Slack + """ + + ret = salt.utils.slack.query(function="users", api_key=token, opts=__opts__) + users = {} + if "message" in ret: + for item in ret["message"]: + if "is_bot" in item: + if not item["is_bot"]: + users[item["name"]] = item["id"] + users[item["id"]] = item["name"] + return users + + def get_slack_channels(self, token): + """ + Get all channel names from Slack + """ + + ret = salt.utils.slack.query( + function="rooms", + api_key=token, + # These won't be honored until https://github.com/saltstack/salt/pull/41187/files is merged + opts={"exclude_archived": True, "exclude_members": True}, + ) + channels = {} + if "message" in ret: + for item in ret["message"]: + channels[item["id"]] = item["name"] + return channels + + def get_config_groups(self, groups_conf, groups_pillar_name): + """ + get info from groups in config, and from the named pillar + + todo: add specification for the minion to use to recover pillar + """ + # Get groups + # Default to returning something that'll never match + ret_groups = { + "default": { + "users": set(), + "commands": set(), + "aliases": {}, + "default_target": {}, + "targets": {}, + } + } + + # allow for empty groups in the config file, and instead let some/all of this come + # from pillar data. + if not groups_conf: + use_groups = {} + else: + use_groups = groups_conf + # First obtain group lists from pillars, then in case there is any overlap, iterate over the groups + # that come from pillars. The configuration in files on disk/from startup + # will override any configs from pillars. They are meant to be complementary not to provide overrides. + log.debug("use_groups %s", use_groups) + try: + groups_gen = itertools.chain( + self._groups_from_pillar(groups_pillar_name).items(), use_groups.items() + ) + except AttributeError: + log.warning( + "Failed to get groups from %s: %s or from config: %s", + groups_pillar_name, + self._groups_from_pillar(groups_pillar_name), + use_groups, + ) + groups_gen = [] + for name, config in groups_gen: + log.info("Trying to get %s and %s to be useful", name, config) + ret_groups.setdefault( + name, + { + "users": set(), + "commands": set(), + "aliases": {}, + "default_target": {}, + "targets": {}, + }, + ) + try: + ret_groups[name]["users"].update(set(config.get("users", []))) + ret_groups[name]["commands"].update(set(config.get("commands", []))) + ret_groups[name]["aliases"].update(config.get("aliases", {})) + ret_groups[name]["default_target"].update( + config.get("default_target", {}) + ) + ret_groups[name]["targets"].update(config.get("targets", {})) + except (IndexError, AttributeError): + log.warning( + "Couldn't use group %s. Check that targets is a dictionary and not" + " a list", + name, + ) + + log.debug("Got the groups: %s", ret_groups) + return ret_groups + + def _groups_from_pillar(self, pillar_name): + """ + pillar_prefix is the pillar.get syntax for the pillar to be queried. + Group name is gotten via the equivalent of using + ``salt['pillar.get']('{}:{}'.format(pillar_prefix, group_name))`` + in a jinja template. + + returns a dictionary (unless the pillar is mis-formatted) + XXX: instead of using Caller, make the minion to use configurable so there could be some + restrictions placed on what pillars can be used. + """ + if pillar_name and __opts__["__role"] == "minion": + pillar_groups = __salt__["pillar.get"](pillar_name, {}) + log.debug("Got pillar groups %s from pillar %s", pillar_groups, pillar_name) + log.debug("pillar groups is %s", pillar_groups) + log.debug("pillar groups type is %s", type(pillar_groups)) + else: + pillar_groups = {} + return pillar_groups + + def fire(self, tag, msg): + """ + This replaces a function in main called 'fire' + + It fires an event into the salt bus. + """ + if __opts__.get("__role") == "master": + fire_master = salt.utils.event.get_master_event( + __opts__, __opts__["sock_dir"] + ).fire_master + else: + fire_master = None + + if fire_master: + fire_master(msg, tag) + else: + __salt__["event.send"](tag, msg) + + def can_user_run(self, user, command, groups): + """ + Break out the permissions into the following: + + Check whether a user is in any group, including whether a group has the '*' membership + + :type user: str + :param user: The username being checked against + + :type command: str + :param command: The command that is being invoked (e.g. test.ping) + + :type groups: dict + :param groups: the dictionary with groups permissions structure. + + :rtype: tuple + :returns: On a successful permitting match, returns 2-element tuple that contains + the name of the group that successfully matched, and a dictionary containing + the configuration of the group so it can be referenced. + + On failure it returns an empty tuple + + """ + log.info("%s wants to run %s with groups %s", user, command, groups) + for key, val in groups.items(): + if user not in val["users"]: + if "*" not in val["users"]: + continue # this doesn't grant permissions, pass + if (command not in val["commands"]) and ( + command not in val.get("aliases", {}).keys() + ): + if "*" not in val["commands"]: + continue # again, pass + log.info("Slack user %s permitted to run %s", user, command) + return ( + key, + val, + ) # matched this group, return the group + log.info("Slack user %s denied trying to run %s", user, command) + return () + + def commandline_to_list(self, cmdline_str, trigger_string): + """ + cmdline_str is the string of the command line + trigger_string is the trigger string, to be removed + """ + cmdline = salt.utils.args.shlex_split(cmdline_str[len(trigger_string) :]) + # Remove slack url parsing + # Translate target= + # to target=host.domain.net + cmdlist = [] + for cmditem in cmdline: + pattern = r"(?P.*)(<.*\|)(?P.*)(>)(?P.*)" + mtch = re.match(pattern, cmditem) + if mtch: + origtext = ( + mtch.group("begin") + mtch.group("url") + mtch.group("remainder") + ) + cmdlist.append(origtext) + else: + cmdlist.append(cmditem) + return cmdlist + + def control_message_target( + self, slack_user_name, text, loaded_groups, trigger_string + ): + """Returns a tuple of (target, cmdline,) for the response + + Raises IndexError if a user can't be looked up from all_slack_users + + Returns (False, False) if the user doesn't have permission + + These are returned together because the commandline and the targeting + interact with the group config (specifically aliases and targeting configuration) + so taking care of them together works out. + + The cmdline that is returned is the actual list that should be + processed by salt, and not the alias. + + """ + + # Trim the trigger string from the front + # cmdline = _text[1:].split(' ', 1) + cmdline = self.commandline_to_list(text, trigger_string) + permitted_group = self.can_user_run(slack_user_name, cmdline[0], loaded_groups) + log.debug( + "slack_user_name is %s and the permitted group is %s", + slack_user_name, + permitted_group, + ) + + if not permitted_group: + return (False, None, cmdline[0]) + if not slack_user_name: + return (False, None, cmdline[0]) + + # maybe there are aliases, so check on that + if cmdline[0] in permitted_group[1].get("aliases", {}).keys(): + use_cmdline = self.commandline_to_list( + permitted_group[1]["aliases"][cmdline[0]].get("cmd", ""), "" + ) + # Include any additional elements from cmdline + use_cmdline.extend(cmdline[1:]) + else: + use_cmdline = cmdline + target = self.get_target(permitted_group, cmdline, use_cmdline) + + # Remove target and tgt_type from commandline + # that is sent along to Salt + use_cmdline = [ + item + for item in use_cmdline + if all(not item.startswith(x) for x in ("target", "tgt_type")) + ] + + return (True, target, use_cmdline) + + def message_text(self, m_data): + """ + Raises ValueError if a value doesn't work out, and TypeError if + this isn't a message type + """ + if m_data.get("type") != "message": + raise TypeError("This is not a message") + # Edited messages have text in message + _text = m_data.get("text", None) or m_data.get("message", {}).get("text", None) + try: + log.info("Message is %s", _text) # this can violate the ascii codec + except UnicodeEncodeError as uee: + log.warning("Got a message that I could not log. The reason is: %s", uee) + + # Convert UTF to string + _text = salt.utils.json.dumps(_text) + _text = salt.utils.yaml.safe_load(_text) + + if not _text: + raise ValueError("_text has no value") + return _text + + def generate_triggered_messages( + self, token, trigger_string, groups, groups_pillar_name + ): + """ + slack_token = string + trigger_string = string + input_valid_users = set + input_valid_commands = set + + When the trigger_string prefixes the message text, yields a dictionary + of:: + + { + 'message_data': m_data, + 'cmdline': cmdline_list, # this is a list + 'channel': channel, + 'user': m_data['user'], + 'slack_client': sc + } + + else yields {'message_data': m_data} and the caller can handle that + + When encountering an error (e.g. invalid message), yields {}, the caller can proceed to the next message + + When the websocket being read from has given up all its messages, yields {'done': True} to + indicate that the caller has read all of the relevant data for now, and should continue + its own processing and check back for more data later. + + This relies on the caller sleeping between checks, otherwise this could flood + """ + all_slack_users = self.get_slack_users( + token + ) # re-checks this if we have an negative lookup result + all_slack_channels = self.get_slack_channels( + token + ) # re-checks this if we have an negative lookup result + + def just_data(m_data): + """Always try to return the user and channel anyway""" + if "user" not in m_data: + if "message" in m_data and "user" in m_data["message"]: + log.debug( + "Message was edited, " + "so we look for user in " + "the original message." + ) + user_id = m_data["message"]["user"] + elif "comment" in m_data and "user" in m_data["comment"]: + log.debug("Comment was added, so we look for user in the comment.") + user_id = m_data["comment"]["user"] + else: + user_id = m_data.get("user") + channel_id = m_data.get("channel") + if channel_id.startswith("D"): # private chate with bot user + channel_name = "private chat" + else: + channel_name = all_slack_channels.get(channel_id) + data = { + "message_data": m_data, + "user_id": user_id, + "user_name": all_slack_users.get(user_id), + "channel_name": channel_name, + } + if not data["user_name"]: + all_slack_users.clear() + all_slack_users.update(self.get_slack_users(token)) + data["user_name"] = all_slack_users.get(user_id) + if not data["channel_name"]: + all_slack_channels.clear() + all_slack_channels.update(self.get_slack_channels(token)) + data["channel_name"] = all_slack_channels.get(channel_id) + return data + + for sleeps in (5, 10, 30, 60): + if self.slack_connect: + break + else: + # see https://api.slack.com/docs/rate-limits + log.warning( + "Slack connection is invalid. Server: %s, sleeping %s", + self.sc.server, + sleeps, + ) + time.sleep( + sleeps + ) # respawning too fast makes the slack API unhappy about the next reconnection + else: + raise UserWarning( + "Connection to slack is still invalid, giving up: {}".format( + self.slack_connect + ) + ) # Boom! + while True: + msg = self.sc.rtm_read() + for m_data in msg: + try: + msg_text = self.message_text(m_data) + except (ValueError, TypeError) as msg_err: + log.debug( + "Got an error from trying to get the message text %s", msg_err + ) + yield {"message_data": m_data} # Not a message type from the API? + continue + + # Find the channel object from the channel name + channel = self.sc.server.channels.find(m_data["channel"]) + data = just_data(m_data) + if msg_text.startswith(trigger_string): + loaded_groups = self.get_config_groups(groups, groups_pillar_name) + if not data.get("user_name"): + log.error( + "The user %s can not be looked up via slack. What has" + " happened here?", + m_data.get("user"), + ) + channel.send_message( + "The user {} can not be looked up via slack. Not" + " running {}".format(data["user_id"], msg_text) + ) + yield {"message_data": m_data} + continue + (allowed, target, cmdline) = self.control_message_target( + data["user_name"], msg_text, loaded_groups, trigger_string + ) + log.debug("Got target: %s, cmdline: %s", target, cmdline) + if allowed: + yield { + "message_data": m_data, + "channel": m_data["channel"], + "user": data["user_id"], + "user_name": data["user_name"], + "cmdline": cmdline, + "target": target, + } + continue + else: + channel.send_message( + "{} is not allowed to use command {}.".format( + data["user_name"], cmdline + ) + ) + yield data + continue + else: + yield data + continue + yield {"done": True} + + def get_target(self, permitted_group, cmdline, alias_cmdline): + """ + When we are permitted to run a command on a target, look to see + what the default targeting is for that group, and for that specific + command (if provided). + + It's possible for None or False to be the result of either, which means + that it's expected that the caller provide a specific target. + + If no configured target is provided, the command line will be parsed + for target=foo and tgt_type=bar + + Test for this:: + + h = {'aliases': {}, 'commands': {'cmd.run', 'pillar.get'}, + 'default_target': {'target': '*', 'tgt_type': 'glob'}, + 'targets': {'pillar.get': {'target': 'you_momma', 'tgt_type': 'list'}}, + 'users': {'dmangot', 'jmickle', 'pcn'}} + f = {'aliases': {}, 'commands': {'cmd.run', 'pillar.get'}, + 'default_target': {}, 'targets': {},'users': {'dmangot', 'jmickle', 'pcn'}} + + g = {'aliases': {}, 'commands': {'cmd.run', 'pillar.get'}, + 'default_target': {'target': '*', 'tgt_type': 'glob'}, + 'targets': {}, 'users': {'dmangot', 'jmickle', 'pcn'}} + + Run each of them through ``get_configured_target(('foo', f), 'pillar.get')`` and confirm a valid target + + """ + # Default to targeting all minions with a type of glob + null_target = {"target": "*", "tgt_type": "glob"} + + def check_cmd_against_group(cmd): + """ + Validate cmd against the group to return the target, or a null target + """ + name, group_config = permitted_group + target = group_config.get("default_target") + if not target: # Empty, None, or False + target = null_target + if group_config.get("targets"): + if group_config["targets"].get(cmd): + target = group_config["targets"][cmd] + if not target.get("target"): + log.debug( + "Group %s is not configured to have a target for cmd %s.", name, cmd + ) + return target + + for this_cl in cmdline, alias_cmdline: + _, kwargs = self.parse_args_and_kwargs(this_cl) + if "target" in kwargs: + log.debug("target is in kwargs %s.", kwargs) + if "tgt_type" in kwargs: + log.debug("tgt_type is in kwargs %s.", kwargs) + return {"target": kwargs["target"], "tgt_type": kwargs["tgt_type"]} + return {"target": kwargs["target"], "tgt_type": "glob"} + + for this_cl in cmdline, alias_cmdline: + checked = check_cmd_against_group(this_cl[0]) + log.debug("this cmdline has target %s.", this_cl) + if checked.get("target"): + return checked + return null_target + + def format_return_text( + self, data, function, **kwargs + ): # pylint: disable=unused-argument + """ + Print out YAML using the block mode + """ + # emulate the yaml_out output formatter. It relies on a global __opts__ object which + # we can't obviously pass in + try: + try: + outputter = data[next(iter(data))].get("out") + except (StopIteration, AttributeError): + outputter = None + return salt.output.string_format( + {x: y["return"] for x, y in data.items()}, + out=outputter, + opts=__opts__, + ) + except Exception as exc: # pylint: disable=broad-except + import pprint + + log.exception( + "Exception encountered when trying to serialize %s", + pprint.pformat(data), + ) + return "Got an error trying to serialze/clean up the response" + + def parse_args_and_kwargs(self, cmdline): + """ + cmdline: list + + returns tuple of: args (list), kwargs (dict) + """ + # Parse args and kwargs + args = [] + kwargs = {} + + if len(cmdline) > 1: + for item in cmdline[1:]: + if "=" in item: + (key, value) = item.split("=", 1) + kwargs[key] = value + else: + args.append(item) + return (args, kwargs) + + def get_jobs_from_runner(self, outstanding_jids): + """ + Given a list of job_ids, return a dictionary of those job_ids that have + completed and their results. + + Query the salt event bus via the jobs runner. jobs.list_job will show + a job in progress, jobs.lookup_jid will return a job that has + completed. + + returns a dictionary of job id: result + """ + # Can't use the runner because of https://github.com/saltstack/salt/issues/40671 + runner = salt.runner.RunnerClient(__opts__) + source = __opts__.get("ext_job_cache") + if not source: + source = __opts__.get("master_job_cache") + + results = {} + for jid in outstanding_jids: + # results[jid] = runner.cmd('jobs.lookup_jid', [jid]) + if self.master_minion.returners[f"{source}.get_jid"](jid): + job_result = runner.cmd("jobs.list_job", [jid]) + jid_result = job_result.get("Result", {}) + jid_function = job_result.get("Function", {}) + # emulate lookup_jid's return, which is just minion:return + results[jid] = { + "data": salt.utils.json.loads(salt.utils.json.dumps(jid_result)), + "function": jid_function, + } + + return results + + def run_commands_from_slack_async( + self, message_generator, fire_all, tag, control, interval=1 + ): + """ + Pull any pending messages from the message_generator, sending each + one to either the event bus, the command_async or both, depending on + the values of fire_all and command + """ + + outstanding = {} # set of job_id that we need to check for + + while True: + log.trace("Sleeping for interval of %s", interval) + time.sleep(interval) + # Drain the slack messages, up to 10 messages at a clip + count = 0 + for msg in message_generator: + # The message_generator yields dicts. Leave this loop + # on a dict that looks like {'done': True} or when we've done it + # 10 times without taking a break. + log.trace("Got a message from the generator: %s", msg.keys()) + if count > 10: + log.warning( + "Breaking in getting messages because count is exceeded" + ) + break + if not msg: + count += 1 + log.warning("Skipping an empty message.") + continue # This one is a dud, get the next message + if msg.get("done"): + log.trace("msg is done") + break + if fire_all: + log.debug("Firing message to the bus with tag: %s", tag) + log.debug("%s %s", tag, msg) + self.fire("{}/{}".format(tag, msg["message_data"].get("type")), msg) + if control and (len(msg) > 1) and msg.get("cmdline"): + channel = self.sc.server.channels.find(msg["channel"]) + jid = self.run_command_async(msg) + log.debug("Submitted a job and got jid: %s", jid) + outstanding[jid] = ( + msg # record so we can return messages to the caller + ) + channel.send_message( + "@{}'s job is submitted as salt jid {}".format( + msg["user_name"], jid + ) + ) + count += 1 + start_time = time.time() + job_status = self.get_jobs_from_runner( + outstanding.keys() + ) # dict of job_ids:results are returned + log.trace( + "Getting %s jobs status took %s seconds", + len(job_status), + time.time() - start_time, + ) + for jid in job_status: + result = job_status[jid]["data"] + function = job_status[jid]["function"] + if result: + log.debug("ret to send back is %s", result) + # formatting function? + this_job = outstanding[jid] + channel = self.sc.server.channels.find(this_job["channel"]) + return_text = self.format_return_text(result, function) + return_prefix = ( + "@{}'s job `{}` (id: {}) (target: {}) returned".format( + this_job["user_name"], + this_job["cmdline"], + jid, + this_job["target"], + ) + ) + channel.send_message(return_prefix) + ts = time.time() + st = datetime.datetime.fromtimestamp(ts).strftime("%Y%m%d%H%M%S%f") + filename = f"salt-results-{st}.yaml" + r = self.sc.api_call( + "files.upload", + channels=channel.id, + filename=filename, + content=return_text, + ) + # Handle unicode return + log.debug("Got back %s via the slack client", r) + resp = salt.utils.yaml.safe_load(salt.utils.json.dumps(r)) + if "ok" in resp and resp["ok"] is False: + this_job["channel"].send_message( + "Error: {}".format(resp["error"]) + ) + del outstanding[jid] + + def run_command_async(self, msg): + """ + :type message_generator: generator of dict + :param message_generator: Generates messages from slack that should be run + + :type fire_all: bool + :param fire_all: Whether to also fire messages to the event bus + + :type tag: str + :param tag: The tag to send to use to send to the event bus + + :type interval: int + :param interval: time to wait between ending a loop and beginning the next + + """ + log.debug("Going to run a command asynchronous") + runner_functions = sorted(salt.runner.Runner(__opts__).functions) + # Parse args and kwargs + cmd = msg["cmdline"][0] + + args, kwargs = self.parse_args_and_kwargs(msg["cmdline"]) + + # Check for pillar string representation of dict and convert it to dict + if "pillar" in kwargs: + kwargs.update(pillar=ast.literal_eval(kwargs["pillar"])) + + # Check for target. Otherwise assume None + target = msg["target"]["target"] + # Check for tgt_type. Otherwise assume glob + tgt_type = msg["target"]["tgt_type"] + log.debug("target_type is: %s", tgt_type) + + if cmd in runner_functions: + runner = salt.runner.RunnerClient(__opts__) + log.debug("Command %s will run via runner_functions", cmd) + job_id_dict = runner.asynchronous(cmd, {"arg": args, "kwarg": kwargs}) + job_id = job_id_dict["jid"] + + # Default to trying to run as a client module. + else: + log.debug( + "Command %s will run via local.cmd_async, targeting %s", cmd, target + ) + log.debug("Running %s, %s, %s, %s, %s", target, cmd, args, kwargs, tgt_type) + # according to https://github.com/saltstack/salt-api/issues/164, tgt_type has changed to expr_form + with salt.client.LocalClient() as local: + job_id = local.cmd_async( + str(target), + cmd, + arg=args, + kwarg=kwargs, + tgt_type=str(tgt_type), + ) + log.info("ret from local.cmd_async is %s", job_id) + return job_id + + +def start( + token, + control=False, + trigger="!", + groups=None, + groups_pillar_name=None, + fire_all=False, + tag="salt/engines/slack", +): + """ + Listen to slack events and forward them to salt, new version + """ + + salt.utils.versions.warn_until( + 3008, + "This 'slack' engine will be deprecated and " + "will be replace by the slack_bolt engine. This new " + "engine will use the new Bolt library from Slack and requires " + "a Slack app and a Slack bot account.", + ) + + if (not token) or (not token.startswith("xoxb")): + time.sleep(2) # don't respawn too quickly + log.error("Slack bot token not found, bailing...") + raise UserWarning("Slack Engine bot token not configured") + + try: + client = SlackClient(token=token) + message_generator = client.generate_triggered_messages( + token, trigger, groups, groups_pillar_name + ) + client.run_commands_from_slack_async(message_generator, fire_all, tag, control) + except Exception: # pylint: disable=broad-except + raise Exception(f"{traceback.format_exc()}") diff --git a/salt/engines/slack_bolt_engine.py b/salt/engines/slack_bolt_engine.py new file mode 100644 index 000000000000..1e164c53b989 --- /dev/null +++ b/salt/engines/slack_bolt_engine.py @@ -0,0 +1,1090 @@ +""" +An engine that reads messages from Slack and can act on them + +.. versionadded:: 3006.0 + +:depends: `slack_bolt `_ Python module + +.. important:: + This engine requires a Slack app and a Slack Bot user. To create a + bot user, first go to the **Custom Integrations** page in your + Slack Workspace. Copy and paste the following URL, and log in with + account credentials with administrative privileges: + + ``https://api.slack.com/apps/new`` + + Next, click on the ``From scratch`` option from the ``Create an app`` popup. + Give your new app a unique name, eg. ``SaltSlackEngine``, select the workspace + where your app will be running, and click ``Create App``. + + Next, click on ``Socket Mode`` and then click on the toggle button for + ``Enable Socket Mode``. In the dialog give your Socket Mode Token a unique + name and then copy and save the app level token. This will be used + as the ``app_token`` parameter in the Slack engine configuration. + + Next, click on ``Event Subscriptions`` and ensure that ``Enable Events`` is in + the on position. Then add the following bot events, ``message.channel`` + and ``message.im`` to the ``Subcribe to bot events`` list. + + Next, click on ``OAuth & Permissions`` and then under ``Bot Token Scope``, click + on ``Add an OAuth Scope``. Ensure the following scopes are included: + + - ``channels:history`` + - ``channels:read`` + - ``chat:write`` + - ``commands`` + - ``files:read`` + - ``files:write`` + - ``im:history`` + - ``mpim:history`` + - ``usergroups:read`` + - ``users:read`` + + Once all the scopes have been added, click the ``Install to Workspace`` button + under ``OAuth Tokens for Your Workspace``, then click ``Allow``. Copy and save + the ``Bot User OAuth Token``, this will be used as the ``bot_token`` parameter + in the Slack engine configuration. + + Finally, add this bot user to a channel by switching to the channel and + using ``/invite @mybotuser``. Keep in mind that this engine will process + messages from each channel in which the bot is a member, so it is + recommended to narrowly define the commands which can be executed, and the + Slack users which are allowed to run commands. + + +This engine has two boolean configuration parameters that toggle specific +features (both default to ``False``): + +1. ``control`` - If set to ``True``, then any message which starts with the + trigger string (which defaults to ``!`` and can be overridden by setting the + ``trigger`` option in the engine configuration) will be interpreted as a + Salt CLI command and the engine will attempt to run it. The permissions + defined in the various ``groups`` will determine if the Slack user is + allowed to run the command. The ``targets`` and ``default_target`` options + can be used to set targets for a given command, but the engine can also read + the following two keyword arguments: + + - ``target`` - The target expression to use for the command + + - ``tgt_type`` - The match type, can be one of ``glob``, ``list``, + ``pcre``, ``grain``, ``grain_pcre``, ``pillar``, ``nodegroup``, ``range``, + ``ipcidr``, or ``compound``. The default value is ``glob``. + + Here are a few examples: + + .. code-block:: text + + !test.ping target=* + !state.apply foo target=os:CentOS tgt_type=grain + !pkg.version mypkg target=role:database tgt_type=pillar + +2. ``fire_all`` - If set to ``True``, all messages which are not prefixed with + the trigger string will fired as events onto Salt's ref:`event bus + `. The tag for these events will be prefixed with the string + specified by the ``tag`` config option (default: ``salt/engines/slack``). + + +The ``groups_pillar_name`` config option can be used to pull group +configuration from the specified pillar key. + +.. note:: + In order to use ``groups_pillar_name``, the engine must be running as a + minion running on the master, so that the ``Caller`` client can be used to + retrieve that minion's pillar data, because the master process does not have + pillar data. + + +Configuration Examples +====================== + +.. versionchanged:: 2017.7.0 + Access control group support added + +.. versionchanged:: 3006.0 + Updated to use slack_bolt Python library. + +This example uses a single group called ``default``. In addition, other groups +are being loaded from pillar data. The users and commands defined within these +groups are used to determine whether the Slack user has permission to run +the desired command. + +.. code-block:: text + + engines: + - slack_bolt: + app_token: "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + bot_token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx' + control: True + fire_all: False + groups_pillar_name: 'slack_engine:groups_pillar' + groups: + default: + users: + - '*' + commands: + - test.ping + - cmd.run + - list_jobs + - list_commands + aliases: + list_jobs: + cmd: jobs.list_jobs + list_commands: + cmd: 'pillar.get salt:engines:slack:valid_commands target=saltmaster tgt_type=list' + default_target: + target: saltmaster + tgt_type: glob + targets: + test.ping: + target: '*' + tgt_type: glob + cmd.run: + target: saltmaster + tgt_type: list + +This example shows multiple groups applying to different users, with all users +having access to run test.ping. Keep in mind that when using ``*``, the value +must be quoted, or else PyYAML will fail to load the configuration. + +.. code-block:: text + + engines: + - slack_bolt: + groups_pillar: slack_engine_pillar + app_token: "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + bot_token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx' + control: True + fire_all: True + tag: salt/engines/slack + groups_pillar_name: 'slack_engine:groups_pillar' + groups: + default: + users: + - '*' + commands: + - test.ping + aliases: + list_jobs: + cmd: jobs.list_jobs + list_commands: + cmd: 'pillar.get salt:engines:slack:valid_commands target=saltmaster tgt_type=list' + gods: + users: + - garethgreenaway + commands: + - '*' + +""" + +import ast +import collections +import datetime +import itertools +import logging +import re +import time +import traceback + +import salt.client +import salt.loader +import salt.minion +import salt.output +import salt.runner +import salt.utils.args +import salt.utils.event +import salt.utils.http +import salt.utils.json +import salt.utils.slack +import salt.utils.yaml + +try: + # pylint: disable=import-error + import slack_bolt + import slack_bolt.adapter.socket_mode + + # pylint: enable=import-error + + HAS_SLACKBOLT = True +except ImportError: + HAS_SLACKBOLT = False + +log = logging.getLogger(__name__) + +__virtualname__ = "slack_bolt" + + +def __virtual__(): + if not HAS_SLACKBOLT: + return (False, "The 'slack_bolt' Python module could not be loaded") + return __virtualname__ + + +class SlackClient: + def __init__(self, app_token, bot_token, trigger_string): + self.master_minion = salt.minion.MasterMinion(__opts__) + + self.app = slack_bolt.App(token=bot_token) + self.handler = slack_bolt.adapter.socket_mode.SocketModeHandler( + self.app, app_token + ) + self.handler.connect() + + self.app_token = app_token + self.bot_token = bot_token + + self.msg_queue = collections.deque() + + trigger_pattern = f"(^{trigger_string}.*)" + + # Register message_trigger when we see messages that start + # with the trigger string + self.app.message(re.compile(trigger_pattern))(self.message_trigger) + + def _run_until(self): + return True + + def message_trigger(self, message): + # Add the received message to the queue + self.msg_queue.append(message) + + def get_slack_users(self, token): + """ + Get all users from Slack + + :type user: str + :param token: The Slack token being used to allow Salt to interact with Slack. + """ + + ret = salt.utils.slack.query(function="users", api_key=token, opts=__opts__) + users = {} + if "message" in ret: + for item in ret["message"]: + if "is_bot" in item: + if not item["is_bot"]: + users[item["name"]] = item["id"] + users[item["id"]] = item["name"] + return users + + def get_slack_channels(self, token): + """ + Get all channel names from Slack + + :type token: str + :param token: The Slack token being used to allow Salt to interact with Slack. + """ + + ret = salt.utils.slack.query( + function="rooms", + api_key=token, + # These won't be honored until https://github.com/saltstack/salt/pull/41187/files is merged + opts={"exclude_archived": True, "exclude_members": True}, + ) + channels = {} + if "message" in ret: + for item in ret["message"]: + channels[item["id"]] = item["name"] + return channels + + def get_config_groups(self, groups_conf, groups_pillar_name): + """ + get info from groups in config, and from the named pillar + + :type group_conf: dict + :param group_conf: + The dictionary containing the groups, group members, + and the commands those group members have access to. + + :type groups_pillar_name: str + :param groups_pillar_name: + can be used to pull group configuration from the specified pillar key. + """ + # Get groups + # Default to returning something that'll never match + ret_groups = { + "default": { + "users": set(), + "commands": set(), + "aliases": {}, + "default_target": {}, + "targets": {}, + } + } + + # allow for empty groups in the config file, and instead let some/all of this come + # from pillar data. + if not groups_conf: + use_groups = {} + else: + use_groups = groups_conf + # First obtain group lists from pillars, then in case there is any overlap, iterate over the groups + # that come from pillars. The configuration in files on disk/from startup + # will override any configs from pillars. They are meant to be complementary not to provide overrides. + log.debug("use_groups %s", use_groups) + try: + groups_gen = itertools.chain( + self._groups_from_pillar(groups_pillar_name).items(), use_groups.items() + ) + except AttributeError: + log.warning( + "Failed to get groups from %s: %s or from config: %s", + groups_pillar_name, + self._groups_from_pillar(groups_pillar_name), + use_groups, + ) + groups_gen = [] + for name, config in groups_gen: + log.info("Trying to get %s and %s to be useful", name, config) + ret_groups.setdefault( + name, + { + "users": set(), + "commands": set(), + "aliases": {}, + "default_target": {}, + "targets": {}, + }, + ) + try: + ret_groups[name]["users"].update(set(config.get("users", []))) + ret_groups[name]["commands"].update(set(config.get("commands", []))) + ret_groups[name]["aliases"].update(config.get("aliases", {})) + ret_groups[name]["default_target"].update( + config.get("default_target", {}) + ) + ret_groups[name]["targets"].update(config.get("targets", {})) + except (IndexError, AttributeError): + log.warning( + "Couldn't use group %s. Check that targets is a dictionary and not" + " a list", + name, + ) + + log.debug("Got the groups: %s", ret_groups) + return ret_groups + + def _groups_from_pillar(self, pillar_name): + """ + + :type pillar_name: str + :param pillar_name: The pillar.get syntax for the pillar to be queried. + + returns a dictionary (unless the pillar is mis-formatted) + """ + if pillar_name and __opts__["__role"] == "minion": + pillar_groups = __salt__["pillar.get"](pillar_name, {}) + log.debug("Got pillar groups %s from pillar %s", pillar_groups, pillar_name) + log.debug("pillar groups is %s", pillar_groups) + log.debug("pillar groups type is %s", type(pillar_groups)) + else: + pillar_groups = {} + return pillar_groups + + def fire(self, tag, msg): + """ + This replaces a function in main called 'fire' + + It fires an event into the salt bus. + + :type tag: str + :param tag: The tag to use when sending events to the Salt event bus. + + :type msg: dict + :param msg: The msg dictionary to send to the Salt event bus. + + """ + if __opts__.get("__role") == "master": + fire_master = salt.utils.event.get_master_event( + __opts__, __opts__["sock_dir"] + ).fire_master + else: + fire_master = None + + if fire_master: + fire_master(msg, tag) + else: + __salt__["event.send"](tag, msg) + + def can_user_run(self, user, command, groups): + """ + Check whether a user is in any group, including whether a group has the '*' membership + + :type user: str + :param user: The username being checked against + + :type command: str + :param command: The command that is being invoked (e.g. test.ping) + + :type groups: dict + :param groups: the dictionary with groups permissions structure. + + :rtype: tuple + :returns: On a successful permitting match, returns 2-element tuple that contains + the name of the group that successfully matched, and a dictionary containing + the configuration of the group so it can be referenced. + + On failure it returns an empty tuple + + """ + log.info("%s wants to run %s with groups %s", user, command, groups) + for key, val in groups.items(): + if user not in val["users"]: + if "*" not in val["users"]: + continue # this doesn't grant permissions, pass + if (command not in val["commands"]) and ( + command not in val.get("aliases", {}).keys() + ): + if "*" not in val["commands"]: + continue # again, pass + log.info("Slack user %s permitted to run %s", user, command) + return ( + key, + val, + ) # matched this group, return the group + log.info("Slack user %s denied trying to run %s", user, command) + return () + + def commandline_to_list(self, cmdline_str, trigger_string): + """ + cmdline_str is the string of the command line + trigger_string is the trigger string, to be removed + """ + cmdline = salt.utils.args.shlex_split(cmdline_str[len(trigger_string) :]) + # Remove slack url parsing + # Translate target= + # to target=host.domain.net + cmdlist = [] + for cmditem in cmdline: + pattern = r"(?P.*)(<.*\|)(?P.*)(>)(?P.*)" + mtch = re.match(pattern, cmditem) + if mtch: + origtext = ( + mtch.group("begin") + mtch.group("url") + mtch.group("remainder") + ) + cmdlist.append(origtext) + else: + cmdlist.append(cmditem) + return cmdlist + + def control_message_target( + self, slack_user_name, text, loaded_groups, trigger_string + ): + """Returns a tuple of (target, cmdline,) for the response + + Raises IndexError if a user can't be looked up from all_slack_users + + Returns (False, False) if the user doesn't have permission + + These are returned together because the commandline and the targeting + interact with the group config (specifically aliases and targeting configuration) + so taking care of them together works out. + + The cmdline that is returned is the actual list that should be + processed by salt, and not the alias. + + """ + + # Trim the trigger string from the front + # cmdline = _text[1:].split(' ', 1) + cmdline = self.commandline_to_list(text, trigger_string) + permitted_group = self.can_user_run(slack_user_name, cmdline[0], loaded_groups) + log.debug( + "slack_user_name is %s and the permitted group is %s", + slack_user_name, + permitted_group, + ) + + if not permitted_group: + return (False, None, cmdline[0]) + if not slack_user_name: + return (False, None, cmdline[0]) + + # maybe there are aliases, so check on that + if cmdline[0] in permitted_group[1].get("aliases", {}).keys(): + use_cmdline = self.commandline_to_list( + permitted_group[1]["aliases"][cmdline[0]].get("cmd", ""), "" + ) + # Include any additional elements from cmdline + use_cmdline.extend(cmdline[1:]) + else: + use_cmdline = cmdline + target = self.get_target(permitted_group, cmdline, use_cmdline) + + # Remove target and tgt_type from commandline + # that is sent along to Salt + use_cmdline = [ + item + for item in use_cmdline + if all(not item.startswith(x) for x in ("target", "tgt_type")) + ] + + return (True, target, use_cmdline) + + def message_text(self, m_data): + """ + Raises ValueError if a value doesn't work out, and TypeError if + this isn't a message type + + :type m_data: dict + :param m_data: The message sent from Slack + + """ + if m_data.get("type") != "message": + raise TypeError("This is not a message") + # Edited messages have text in message + _text = m_data.get("text", None) or m_data.get("message", {}).get("text", None) + try: + log.info("Message is %s", _text) # this can violate the ascii codec + except UnicodeEncodeError as uee: + log.warning("Got a message that I could not log. The reason is: %s", uee) + + # Convert UTF to string + _text = salt.utils.json.dumps(_text) + _text = salt.utils.yaml.safe_load(_text) + + if not _text: + raise ValueError("_text has no value") + return _text + + def generate_triggered_messages( + self, token, trigger_string, groups, groups_pillar_name + ): + """ + slack_token = string + trigger_string = string + input_valid_users = set + input_valid_commands = set + + When the trigger_string prefixes the message text, yields a dictionary + of:: + + { + 'message_data': m_data, + 'cmdline': cmdline_list, # this is a list + 'channel': channel, + 'user': m_data['user'], + 'slack_client': sc + } + + else yields {'message_data': m_data} and the caller can handle that + + When encountering an error (e.g. invalid message), yields {}, the caller can proceed to the next message + + When the websocket being read from has given up all its messages, yields {'done': True} to + indicate that the caller has read all of the relevant data for now, and should continue + its own processing and check back for more data later. + + This relies on the caller sleeping between checks, otherwise this could flood + """ + all_slack_users = self.get_slack_users( + token + ) # re-checks this if we have an negative lookup result + all_slack_channels = self.get_slack_channels( + token + ) # re-checks this if we have an negative lookup result + + def just_data(m_data): + """Always try to return the user and channel anyway""" + user_id = None + user_name = None + if "user" not in m_data: + if "message" in m_data and "user" in m_data["message"]: + log.debug( + "Message was edited, " + "so we look for user in " + "the original message." + ) + user_id = m_data["message"]["user"] + elif "comment" in m_data and "user" in m_data["comment"]: + log.debug("Comment was added, so we look for user in the comment.") + user_id = m_data["comment"]["user"] + elif m_data.get("subtype") == "bot_message": + # Workflows and other bot-posted messages do not carry a + # ``user`` field. Fall back to the bot identity so the + # message can still be processed instead of crashing the + # engine with UnboundLocalError. See issue #68105. + log.debug( + "Message was posted by a bot/workflow, " + "so we use the bot id and username." + ) + user_id = m_data.get("bot_id") + user_name = m_data.get("username") + else: + user_id = m_data.get("user") + channel_id = m_data.get("channel") + if channel_id.startswith("D"): # private chate with bot user + channel_name = "private chat" + else: + channel_name = all_slack_channels.get(channel_id) + if user_name is None: + user_name = all_slack_users.get(user_id) + data = { + "message_data": m_data, + "user_id": user_id, + "user_name": user_name, + "channel_name": channel_name, + } + if not data["user_name"]: + all_slack_users.clear() + all_slack_users.update(self.get_slack_users(token)) + data["user_name"] = all_slack_users.get(user_id) + if not data["channel_name"]: + all_slack_channels.clear() + all_slack_channels.update(self.get_slack_channels(token)) + data["channel_name"] = all_slack_channels.get(channel_id) + return data + + for sleeps in (5, 10, 30, 60): + if self.handler: + break + else: + # see https://api.slack.com/docs/rate-limits + log.warning( + "Slack connection is invalid, sleeping %s", + sleeps, + ) + time.sleep( + sleeps + ) # respawning too fast makes the slack API unhappy about the next reconnection + else: + raise UserWarning( + "Connection to slack is still invalid, giving up: {}".format( + self.handler + ) + ) # Boom! + while self._run_until(): + while self.msg_queue: + msg = self.msg_queue.popleft() + try: + msg_text = self.message_text(msg) + except (ValueError, TypeError) as msg_err: + log.debug("Got an error trying to get the message text %s", msg_err) + yield {"message_data": msg} # Not a message type from the API? + continue + + # Find the channel object from the channel name + channel = msg["channel"] + data = just_data(msg) + if msg_text.startswith(trigger_string): + loaded_groups = self.get_config_groups(groups, groups_pillar_name) + if not data.get("user_name"): + log.error( + "The user %s can not be looked up via slack. What has" + " happened here?", + msg.get("user"), + ) + channel.send_message( + "The user {} can not be looked up via slack. Not" + " running {}".format(data["user_id"], msg_text) + ) + yield {"message_data": msg} + continue + (allowed, target, cmdline) = self.control_message_target( + data["user_name"], msg_text, loaded_groups, trigger_string + ) + if allowed: + ret = { + "message_data": msg, + "channel": msg["channel"], + "user": data["user_id"], + "user_name": data["user_name"], + "cmdline": cmdline, + "target": target, + } + yield ret + continue + else: + channel.send_message( + "{} is not allowed to use command {}.".format( + data["user_name"], cmdline + ) + ) + yield data + continue + else: + yield data + continue + yield {"done": True} + + def get_target(self, permitted_group, cmdline, alias_cmdline): + """ + When we are permitted to run a command on a target, look to see + what the default targeting is for that group, and for that specific + command (if provided). + + It's possible for ``None`` or ``False`` to be the result of either, which means + that it's expected that the caller provide a specific target. + + If no configured target is provided, the command line will be parsed + for target=foo and tgt_type=bar + + Test for this:: + + h = {'aliases': {}, 'commands': {'cmd.run', 'pillar.get'}, + 'default_target': {'target': '*', 'tgt_type': 'glob'}, + 'targets': {'pillar.get': {'target': 'you_momma', 'tgt_type': 'list'}}, + 'users': {'dmangot', 'jmickle', 'pcn'}} + f = {'aliases': {}, 'commands': {'cmd.run', 'pillar.get'}, + 'default_target': {}, 'targets': {},'users': {'dmangot', 'jmickle', 'pcn'}} + + g = {'aliases': {}, 'commands': {'cmd.run', 'pillar.get'}, + 'default_target': {'target': '*', 'tgt_type': 'glob'}, + 'targets': {}, 'users': {'dmangot', 'jmickle', 'pcn'}} + + Run each of them through ``get_configured_target(('foo', f), 'pillar.get')`` and confirm a valid target + + :type permitted_group: tuple + :param permitted_group: A tuple containing the group name and group configuration to check for permission. + + :type cmdline: list + :param cmdline: The command sent from Slack formatted as a list. + + :type alias_cmdline: str + :param alias_cmdline: An alias to a cmdline. + + """ + # Default to targeting all minions with a type of glob + null_target = {"target": "*", "tgt_type": "glob"} + + def check_cmd_against_group(cmd): + """ + Validate cmd against the group to return the target, or a null target + + :type cmd: list + :param cmd: The command sent from Slack formatted as a list. + """ + name, group_config = permitted_group + target = group_config.get("default_target") + if not target: # Empty, None, or False + target = null_target + if group_config.get("targets"): + if group_config["targets"].get(cmd): + target = group_config["targets"][cmd] + if not target.get("target"): + log.debug( + "Group %s is not configured to have a target for cmd %s.", name, cmd + ) + return target + + for this_cl in cmdline, alias_cmdline: + _, kwargs = self.parse_args_and_kwargs(this_cl) + if "target" in kwargs: + log.debug("target is in kwargs %s.", kwargs) + if "tgt_type" in kwargs: + log.debug("tgt_type is in kwargs %s.", kwargs) + return {"target": kwargs["target"], "tgt_type": kwargs["tgt_type"]} + return {"target": kwargs["target"], "tgt_type": "glob"} + + for this_cl in cmdline, alias_cmdline: + checked = check_cmd_against_group(this_cl[0]) + log.debug("this cmdline has target %s.", this_cl) + if checked.get("target"): + return checked + return null_target + + def format_return_text( + self, data, function, **kwargs + ): # pylint: disable=unused-argument + """ + Print out YAML using the block mode + + :type user: dict + :param token: The return data that needs to be formatted. + + :type user: str + :param token: The function that was used to generate the return data. + """ + # emulate the yaml_out output formatter. It relies on a global __opts__ object which + # we can't obviously pass in + try: + try: + outputter = data[next(iter(data))].get("out") + except (StopIteration, AttributeError): + outputter = None + return salt.output.string_format( + {x: y["return"] for x, y in data.items()}, + out=outputter, + opts=__opts__, + ) + except Exception as exc: # pylint: disable=broad-except + import pprint + + log.exception( + "Exception encountered when trying to serialize %s", + pprint.pformat(data), + ) + return "Got an error trying to serialze/clean up the response" + + def parse_args_and_kwargs(self, cmdline): + """ + + :type cmdline: list + :param cmdline: The command sent from Slack formatted as a list. + + returns tuple of: args (list), kwargs (dict) + """ + # Parse args and kwargs + args = [] + kwargs = {} + + if len(cmdline) > 1: + for item in cmdline[1:]: + if "=" in item: + (key, value) = item.split("=", 1) + kwargs[key] = value + else: + args.append(item) + return (args, kwargs) + + def get_jobs_from_runner(self, outstanding_jids): + """ + Given a list of job_ids, return a dictionary of those job_ids that have + completed and their results. + + Query the salt event bus via the jobs runner. jobs.list_job will show + a job in progress, jobs.lookup_jid will return a job that has + completed. + + :type outstanding_jids: list + :param outstanding_jids: The list of job ids to check for completion. + + returns a dictionary of job id: result + """ + # Can't use the runner because of https://github.com/saltstack/salt/issues/40671 + runner = salt.runner.RunnerClient(__opts__) + source = __opts__.get("ext_job_cache") + if not source: + source = __opts__.get("master_job_cache") + + results = {} + for jid in outstanding_jids: + # results[jid] = runner.cmd('jobs.lookup_jid', [jid]) + if self.master_minion.returners[f"{source}.get_jid"](jid): + job_result = runner.cmd("jobs.list_job", [jid]) + jid_result = job_result.get("Result", {}) + jid_function = job_result.get("Function", {}) + # emulate lookup_jid's return, which is just minion:return + results[jid] = { + "data": salt.utils.json.loads(salt.utils.json.dumps(jid_result)), + "function": jid_function, + } + + return results + + def run_commands_from_slack_async( + self, message_generator, fire_all, tag, control, interval=1 + ): + """ + Pull any pending messages from the message_generator, sending each + one to either the event bus, the command_async or both, depending on + the values of fire_all and command + + :type message_generator: generator of dict + :param message_generator: Generates messages from slack that should be run + + :type fire_all: bool + :param fire_all: Whether to also fire messages to the event bus + + :type control: bool + :param control: If set to True, whether Slack is allowed to control Salt. + + :type tag: str + :param tag: The tag to send to use to send to the event bus + + :type interval: int + :param interval: time to wait between ending a loop and beginning the next + """ + + outstanding = {} # set of job_id that we need to check for + + while self._run_until(): + log.trace("Sleeping for interval of %s", interval) + time.sleep(interval) + # Drain the slack messages, up to 10 messages at a clip + count = 0 + for msg in message_generator: + if msg: + # The message_generator yields dicts. Leave this loop + # on a dict that looks like {'done': True} or when we've done it + # 10 times without taking a break. + log.trace("Got a message from the generator: %s", msg.keys()) + if count > 10: + log.warning( + "Breaking in getting messages because count is exceeded" + ) + break + if not msg: + count += 1 + log.warning("Skipping an empty message.") + continue # This one is a dud, get the next message + if msg.get("done"): + log.trace("msg is done") + break + if fire_all: + log.debug("Firing message to the bus with tag: %s", tag) + log.debug("%s %s", tag, msg) + self.fire( + "{}/{}".format(tag, msg["message_data"].get("type")), msg + ) + if control and (len(msg) > 1) and msg.get("cmdline"): + jid = self.run_command_async(msg) + log.debug("Submitted a job and got jid: %s", jid) + outstanding[jid] = ( + msg # record so we can return messages to the caller + ) + text_msg = "@{}'s job is submitted as salt jid {}".format( + msg["user_name"], jid + ) + self.app.client.chat_postMessage( + channel=msg["channel"], text=text_msg + ) + count += 1 + start_time = time.time() + job_status = self.get_jobs_from_runner( + outstanding.keys() + ) # dict of job_ids:results are returned + log.trace( + "Getting %s jobs status took %s seconds", + len(job_status), + time.time() - start_time, + ) + for jid in job_status: + result = job_status[jid]["data"] + function = job_status[jid]["function"] + if result: + log.debug("ret to send back is %s", result) + # formatting function? + this_job = outstanding[jid] + channel = this_job["channel"] + return_text = self.format_return_text(result, function) + return_prefix = ( + "@{}'s job `{}` (id: {}) (target: {}) returned".format( + this_job["user_name"], + this_job["cmdline"], + jid, + this_job["target"], + ) + ) + self.app.client.chat_postMessage( + channel=channel, text=return_prefix + ) + ts = time.time() + st = datetime.datetime.fromtimestamp(ts).strftime("%Y%m%d%H%M%S%f") + filename = f"salt-results-{st}.yaml" + resp = self.app.client.files_upload( + channels=channel, + filename=filename, + content=return_text, + ) + # Handle unicode return + log.debug("Got back %s via the slack client", resp) + if "ok" in resp and resp["ok"] is False: + this_job["channel"].send_message( + "Error: {}".format(resp["error"]) + ) + del outstanding[jid] + + def run_command_async(self, msg): + """ + :type msg: dict + :param msg: The message dictionary that contains the command and all information. + + """ + log.debug("Going to run a command asynchronous") + runner_functions = sorted(salt.runner.Runner(__opts__).functions) + # Parse args and kwargs + cmd = msg["cmdline"][0] + + args, kwargs = self.parse_args_and_kwargs(msg["cmdline"]) + + # Check for pillar string representation of dict and convert it to dict + if "pillar" in kwargs: + kwargs.update(pillar=ast.literal_eval(kwargs["pillar"])) + + # Check for target. Otherwise assume None + target = msg["target"]["target"] + # Check for tgt_type. Otherwise assume glob + tgt_type = msg["target"]["tgt_type"] + log.debug("target_type is: %s", tgt_type) + + if cmd in runner_functions: + runner = salt.runner.RunnerClient(__opts__) + log.debug("Command %s will run via runner_functions", cmd) + job_id_dict = runner.asynchronous(cmd, {"arg": args, "kwarg": kwargs}) + job_id = job_id_dict["jid"] + + # Default to trying to run as a client module. + else: + log.debug( + "Command %s will run via local.cmd_async, targeting %s", cmd, target + ) + log.debug("Running %s, %s, %s, %s, %s", target, cmd, args, kwargs, tgt_type) + # according to https://github.com/saltstack/salt-api/issues/164, tgt_type has changed to expr_form + with salt.client.LocalClient() as local: + job_id = local.cmd_async( + str(target), + cmd, + arg=args, + kwarg=kwargs, + tgt_type=str(tgt_type), + ) + log.info("ret from local.cmd_async is %s", job_id) + return job_id + + +def start( + app_token, + bot_token, + control=False, + trigger="!", + groups=None, + groups_pillar_name=None, + fire_all=False, + tag="salt/engines/slack", +): + """ + Listen to slack events and forward them to salt, new version + + :type app_token: str + :param app_token: The Slack application token used by Salt to communicate with Slack. + + :type bot_token: str + :param bot_token: The Slack bot token used by Salt to communicate with Slack. + + :type control: bool + :param control: Determines whether or not commands sent from Slack with the trigger string will control Salt, defaults to False. + + :type trigger: str + :param trigger: The string that should preface all messages in Slack that should be treated as commands to send to Salt. + + :type group: str + :param group: The string that should preface all messages in Slack that should be treated as commands to send to Salt. + + :type groups_pillar: str + :param group_pillars: A pillar key that can be used to pull group configuration. + + :type fire_all: bool + :param fire_all: + If set to ``True``, all messages which are not prefixed with + the trigger string will fired as events onto Salt's ref:`event bus + `. The tag for these events will be prefixed with the string + specified by the ``tag`` config option (default: ``salt/engines/slack``). + + :type tag: str + :param tag: The tag to prefix all events sent to the Salt event bus. + """ + + if (not bot_token) or (not bot_token.startswith("xoxb")): + time.sleep(2) # don't respawn too quickly + log.error("Slack bot token not found, bailing...") + raise UserWarning("Slack Engine bot token not configured") + + try: + client = SlackClient( + app_token=app_token, bot_token=bot_token, trigger_string=trigger + ) + message_generator = client.generate_triggered_messages( + bot_token, trigger, groups, groups_pillar_name + ) + client.run_commands_from_slack_async(message_generator, fire_all, tag, control) + except Exception: # pylint: disable=broad-except + raise Exception(f"{traceback.format_exc()}") diff --git a/salt/engines/sqs_events.py b/salt/engines/sqs_events.py new file mode 100644 index 000000000000..bf17529e95db --- /dev/null +++ b/salt/engines/sqs_events.py @@ -0,0 +1,188 @@ +""" +An engine that continuously reads messages from SQS and fires them as events. + +Note that long polling is utilized to avoid excessive CPU usage. + +.. versionadded:: 2015.8.0 + +:depends: boto + +Configuration +============= + +This engine can be run on the master or on a minion. + +Example Config: + +.. code-block:: yaml + + sqs.keyid: GKTADJGHEIQSXMKKRBJ08H + sqs.key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs + sqs.message_format: json + +Explicit sqs credentials are accepted but this engine can also utilize +IAM roles assigned to the instance through Instance Profiles. Dynamic +credentials are then automatically obtained from AWS API and no further +configuration is necessary. More Information available at:: + + http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html + +If IAM roles are not (or for ``boto`` version < 2.5.1) used you need to +specify them either in a pillar or in the config file of the master or +minion, as appropriate: + +To deserialize the message from json: + +.. code-block:: yaml + + sqs.message_format: json + +It's also possible to specify key, keyid and region via a profile: + +.. code-block:: yaml + + sqs.keyid: GKTADJGHEIQSXMKKRBJ08H + sqs.key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs + +A region may also be specified in the configuration: + +.. code-block:: yaml + + sqs.region: us-east-1 + +If a region is not specified, the default is us-east-1. + +It's also possible to specify key, keyid and region via a profile: + +.. code-block:: yaml + + myprofile: + keyid: GKTADJGHEIQSXMKKRBJ08H + key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs + region: us-east-1 + +Additionally you can define cross account sqs: + +.. code-block:: yaml + + engines: + - sqs_events: + queue: prod + owner_acct_id: 111111111111 + +""" + +import logging +import time + +import salt.utils.event +import salt.utils.json + +try: + import boto.sqs + + HAS_BOTO = True +except ImportError: + HAS_BOTO = False + + +def __virtual__(): + if not HAS_BOTO: + return ( + False, + "Cannot import engine sqs_events because the required boto module is" + " missing", + ) + else: + return True + + +log = logging.getLogger(__name__) + + +def _get_sqs_conn(profile, region=None, key=None, keyid=None): + """ + Get a boto connection to SQS. + """ + if profile: + if isinstance(profile, str): + _profile = __opts__[profile] + elif isinstance(profile, dict): + _profile = profile + key = _profile.get("key", None) + keyid = _profile.get("keyid", None) + region = _profile.get("region", None) + + if not region: + region = __opts__.get("sqs.region", "us-east-1") + if not key: + key = __opts__.get("sqs.key", None) + if not keyid: + keyid = __opts__.get("sqs.keyid", None) + try: + conn = boto.sqs.connect_to_region( + region, aws_access_key_id=keyid, aws_secret_access_key=key + ) + except boto.exception.NoAuthHandlerFound: + log.error( + "No authentication credentials found when attempting to" + " make sqs_event engine connection to AWS." + ) + return None + return conn + + +def _process_queue( + q, + q_name, + fire_master, + tag="salt/engine/sqs", + owner_acct_id=None, + message_format=None, +): + if not q: + log.warning( + "failure connecting to queue: %s, waiting 10 seconds.", + ":".join([_f for _f in (str(owner_acct_id), q_name) if _f]), + ) + time.sleep(10) + else: + msgs = q.get_messages(wait_time_seconds=20) + for msg in msgs: + if message_format == "json": + fire_master( + tag=tag, data={"message": salt.utils.json.loads(msg.get_body())} + ) + else: + fire_master(tag=tag, data={"message": msg.get_body()}) + msg.delete() + + +def start(queue, profile=None, tag="salt/engine/sqs", owner_acct_id=None): + """ + Listen to sqs and fire message on event bus + """ + if __opts__.get("__role") == "master": + fire_master = salt.utils.event.get_master_event( + __opts__, __opts__["sock_dir"], listen=False + ).fire_event + else: + fire_master = __salt__["event.send"] + + message_format = __opts__.get("sqs.message_format", None) + + sqs = _get_sqs_conn(profile) + q = None + while True: + if not q: + q = sqs.get_queue(queue, owner_acct_id=owner_acct_id) + q.set_message_class(boto.sqs.message.RawMessage) + + _process_queue( + q, + queue, + fire_master, + tag=tag, + owner_acct_id=owner_acct_id, + message_format=message_format, + ) diff --git a/salt/engines/stalekey.py b/salt/engines/stalekey.py new file mode 100644 index 000000000000..acbef3e3c006 --- /dev/null +++ b/salt/engines/stalekey.py @@ -0,0 +1,144 @@ +""" +An engine that uses presence detection to keep track of which minions +have been recently connected and remove their keys if they have not been +connected for a certain period of time. + +Requires that the :conf_master:`minion_data_cache` option be enabled. + +.. versionadded:: 2017.7.0 + +:configuration: + + Example configuration: + + .. code-block:: yaml + + engines: + - stalekey: + interval: 3600 + expire: 86400 + +""" + +import logging +import os +import time + +import salt.config +import salt.key +import salt.utils.files +import salt.utils.minions +import salt.utils.msgpack +import salt.wheel + +log = logging.getLogger(__name__) + + +def __virtual__(): + if not __opts__.get("minion_data_cache"): + return (False, "stalekey engine requires minion_data_cache to be enabled") + return True + + +def _get_keys(): + """ + Get the keys + """ + with salt.key.get_key(__opts__) as keys: + minions = keys.all_keys() + return minions["minions"] + + +def _delete_keys(stale_keys, minions): + """ + Delete the keys + """ + wheel = salt.wheel.WheelClient(__opts__) + for k in stale_keys: + log.info("Removing stale key for %s", k) + wheel.cmd("key.delete", [salt.utils.stringutils.to_unicode(k)]) + del minions[k] + return minions + + +def _read_presence(presence_file): + """ + Read minion data from presence file + """ + error = False + minions = {} + if os.path.exists(presence_file): + try: + with salt.utils.files.fopen(presence_file, "rb") as f: + _minions = salt.utils.msgpack.load(f) + + # ensure all keys are unicode, not bytes. + for minion in _minions: + _minion = salt.utils.stringutils.to_unicode(minion) + minions[_minion] = _minions[minion] + + except OSError as e: + error = True + log.error("Could not open presence file %s: %s", presence_file, e) + + return error, minions + + +def _write_presence(presence_file, minions): + """ + Write minion data to presence file + """ + error = False + try: + with salt.utils.files.fopen(presence_file, "wb") as f: + salt.utils.msgpack.dump(minions, f) + except OSError as e: + error = True + log.error("Could not write to presence file %s: %s", presence_file, e) + return error + + +def start(interval=3600, expire=604800): + """ + Start the engine + """ + ck = salt.utils.minions.CkMinions(__opts__) + presence_file = "{}/presence.p".format(__opts__["cachedir"]) + wheel = salt.wheel.WheelClient(__opts__) + + while True: + log.debug("Checking for present minions") + minions = {} + error, minions = _read_presence(presence_file) + if error: + time.sleep(interval) + continue + + minion_keys = _get_keys() + now = time.time() + present = ck.connected_ids() + + # For our existing keys, check which are present + for m in minion_keys: + # If we have a key that's not in the presence file, + # it may be a new minion # It could also mean this + # is the first time this engine is running and no + # presence file was found + if m not in minions: + minions[m] = now + elif m in present: + minions[m] = now + + log.debug("Finished checking for present minions") + # Delete old keys + stale_keys = [] + for m, seen in minions.items(): + if now - expire > seen: + stale_keys.append(m) + + if stale_keys: + minions = _delete_keys(stale_keys, minions) + + error = _write_presence(presence_file, minions) + + time.sleep(interval) diff --git a/salt/exceptions.py b/salt/exceptions.py index 4fd3e1f2930e..b42b93c17d8b 100644 --- a/salt/exceptions.py +++ b/salt/exceptions.py @@ -354,12 +354,6 @@ class TokenAuthenticationError(SaltException): """ -class TokenExpiredError(SaltException): - """ - Thrown when token is expired - """ - - class SaltDeserializationError(SaltException): """ Thrown when salt cannot deserialize data. diff --git a/salt/executors/docker.py b/salt/executors/docker.py new file mode 100644 index 000000000000..ee19796bd68c --- /dev/null +++ b/salt/executors/docker.py @@ -0,0 +1,59 @@ +""" +Docker executor module + +.. versionadded:: 2019.2.0 + +Used with the docker proxy minion. +""" + +__virtualname__ = "docker" + +DOCKER_MOD_MAP = { + "state.sls": "docker.sls", + "state.apply": "docker.apply", + "state.highstate": "docker.highstate", +} + +__deprecated__ = ( + 3009, + "docker", + "https://github.com/saltstack/saltext-docker", +) + + +def __virtual__(): + if "proxy" not in __opts__: + return ( + False, + "Docker executor is only meant to be used with Docker Proxy Minions", + ) + if __opts__.get("proxy", {}).get("proxytype") != __virtualname__: + return False, f"Proxytype does not match: {__virtualname__}" + return True + + +def execute(opts, data, func, args, kwargs): + """ + Directly calls the given function with arguments + """ + if data["fun"] == "saltutil.find_job": + return __executors__["direct_call.execute"](opts, data, func, args, kwargs) + if data["fun"] in DOCKER_MOD_MAP: + return __executors__["direct_call.execute"]( + opts, + data, + __salt__[DOCKER_MOD_MAP[data["fun"]]], + [opts["proxy"]["name"]] + args, + kwargs, + ) + return __salt__["docker.call"](opts["proxy"]["name"], data["fun"], *args, **kwargs) + + +def allow_missing_func(function): # pylint: disable=unused-argument + """ + Allow all calls to be passed through to docker container. + + The docker call will use direct_call, which will return back if the module + was unable to be run. + """ + return True diff --git a/salt/executors/transactional_update.py b/salt/executors/transactional_update.py new file mode 100644 index 000000000000..6f36ed03b6fc --- /dev/null +++ b/salt/executors/transactional_update.py @@ -0,0 +1,132 @@ +""" +Transactional executor module + +.. versionadded:: 3004 + +""" + +import os + +import salt.utils.path + +# Functions that are mapped into an equivalent one in +# transactional_update module +DELEGATION_MAP = { + "state.single": "transactional_update.single", + "state.sls": "transactional_update.sls", + "state.apply": "transactional_update.apply", + "state.highstate": "transactional_update.highstate", +} + +# By default, all modules and functions are executed outside the +# transaction. The next two sets will enumerate the exceptions that +# will be routed to transactional_update.call() +DEFAULT_DELEGATED_MODULES = [ + "ansible", + "cabal", + "chef", + "cmd", + "composer", + "cp", + "cpan", + "cyg", + "file", + "freeze", + "nix", + "npm", + "pip", + "pkg", + "puppet", + "pyenv", + "rbenv", + "scp", +] +DEFAULT_DELEGATED_FUNCTIONS = [] + + +def __virtual__(): + if salt.utils.path.which("transactional-update"): + return True + else: + return (False, "transactional_update executor requires a transactional system") + + +def execute(opts, data, func, args, kwargs): + """Delegate into transactional_update module + + The ``transactional_update`` module support the execution of + functions inside a transaction, as support apply a state (via + ``apply``, ``sls``, ``single`` or ``highstate``). + + This execution module can be used to route some Salt modules and + functions to be executed inside the transaction snapshot. + + Add this executor in the minion configuration file: + + .. code-block:: yaml + + module_executors: + - transactional_update + - direct_call + + Or use the command line parameter: + + .. code-block:: bash + + salt-call --module-executors='[transactional_update, direct_call]' test.version + + You can also schedule a reboot if needed: + + .. code-block:: bash + + salt-call --module-executors='[transactional_update]' state.sls stuff activate_transaction=True + + There are some configuration parameters supported: + + .. code-block:: yaml + + # Replace the list of default modules that all the functions + # are delegated to `transactional_update.call()` + delegated_modules: [cmd, pkg] + + # Replace the list of default functions that are delegated to + # `transactional_update.call()` + delegated_functions: [pip.install] + + # Expand the default list of modules + add_delegated_modules: [ansible] + + # Expand the default list of functions + add_delegated_functions: [file.copy] + + """ + inside_transaction = os.environ.get("TRANSACTIONAL_UPDATE") + + fun = data["fun"] + module, _ = fun.split(".") + + delegated_modules = set(opts.get("delegated_modules", DEFAULT_DELEGATED_MODULES)) + delegated_functions = set( + opts.get("delegated_functions", DEFAULT_DELEGATED_FUNCTIONS) + ) + if "executor_opts" in data: + delegated_modules |= set(data["executor_opts"].get("add_delegated_modules", [])) + delegated_functions |= set( + data["executor_opts"].get("add_delegated_functions", []) + ) + else: + delegated_modules |= set(opts.get("add_delegated_modules", [])) + delegated_functions |= set(opts.get("add_delegated_functions", [])) + + if fun in DELEGATION_MAP and not inside_transaction: + result = __executors__["direct_call.execute"]( + opts, data, __salt__[DELEGATION_MAP[fun]], args, kwargs + ) + elif ( + module in delegated_modules or fun in delegated_functions + ) and not inside_transaction: + result = __salt__["transactional_update.call"](fun, *args, **kwargs) + else: + result = __executors__["direct_call.execute"](opts, data, func, args, kwargs) + + return result diff --git a/salt/features.py b/salt/features.py index a8b0fd5568e9..51761c24fcb4 100644 --- a/salt/features.py +++ b/salt/features.py @@ -25,7 +25,7 @@ def get(self, key, default=None): import salt.utils.versions salt.utils.versions.warn_until( - 3009, + 3008, "Please stop checking feature flags using 'salt.features' and instead " "check the 'features' keyword on the configuration dictionary. The " "'salt.features' module will go away in {version}.", diff --git a/salt/fileclient.py b/salt/fileclient.py index 9f36caf52aa7..6f820452a1c0 100644 --- a/salt/fileclient.py +++ b/salt/fileclient.py @@ -464,76 +464,6 @@ def get_dir(self, path, dest="", saltenv="base", gzip=None, cachedir=None): ret.sort() return ret - def _on_header(self, hdr, write_body, use_etag, dest_etag): - hdr = hdr.strip() - if write_body[1] is not False and ( - write_body[2] is None or (use_etag and write_body[3] is None) - ): - if not hdr and "Content-Type" not in write_body[1]: - # If write_body[0] is True, then we are not following a - # redirect (initial response was a 200 OK). So there is - # no need to reset write_body[0]. - if write_body[0] is not True: - # We are following a redirect, so we need to reset - # write_body[0] so that we properly follow it. - write_body[0] = None - # We don't need the HTTPHeaders object anymore - if not use_etag or write_body[3]: - write_body[1] = False - return - # Try to find out what content type encoding is used if - # this is a text file - write_body[1].parse_line(hdr) # pylint: disable=no-member - # Case insensitive Etag header checking below. Don't break case - # insensitivity unless you really want to mess with people's heads - # in the tests. Note: http.server and apache2 use "Etag" and nginx - # uses "ETag" as the header key. Yay standards! - if use_etag and "etag" in map(str.lower, write_body[1]): - etag = write_body[3] = [ - val for key, val in write_body[1].items() if key.lower() == "etag" - ][0] - with salt.utils.files.fopen(dest_etag, "w") as etagfp: - etag = etagfp.write(etag) - elif "Content-Type" in write_body[1]: - content_type = write_body[1].get( - "Content-Type" - ) # pylint: disable=no-member - if not content_type.startswith("text"): - write_body[2] = False - if not use_etag or write_body[3]: - write_body[1] = False - else: - encoding = "utf-8" - fields = content_type.split(";") - for field in fields: - if "encoding" in field: - encoding = field.split("encoding=")[-1] - write_body[2] = encoding - # We have found our encoding. Stop processing headers. - if not use_etag or write_body[3]: - write_body[1] = False - - # If write_body[0] is False, this means that this - # header is a 30x redirect, so we need to reset - # write_body[0] to None so that we parse the HTTP - # status code from the redirect target. Additionally, - # we need to reset write_body[2] so that we inspect the - # headers for the Content-Type of the URL we're - # following. - if write_body[0] is write_body[1] is False: - write_body[0] = write_body[2] = None - - # Check the status line of the HTTP request - if write_body[0] is None: - try: - hdr_response = parse_response_start_line(hdr) - except HTTPInputError: - log.debug("Unable to parse header: %r", hdr.strip()) - # Not the first line, do nothing - return - write_body[0] = hdr_response.code not in [301, 302, 303, 307] - write_body[1] = HTTPHeaders() - def get_url( self, url, @@ -657,14 +587,7 @@ def s3_opt(key, default=None): ftp_port = url_data.port if not ftp_port: ftp_port = 21 - # Pass an explicit timeout so an unreachable address family - # (e.g. an AAAA record with no working IPv6 route) does not - # cause the blocking connect() to hang indefinitely -- - # ``socket.create_connection`` with no timeout will wait for - # kernel TCP SYN retransmits to exhaust before falling - # through to the next getaddrinfo result. - ftp_timeout = self.opts.get("fileserver_ftp_timeout", 30) - ftp.connect(url_data.hostname, ftp_port, timeout=ftp_timeout) + ftp.connect(url_data.hostname, ftp_port) ftp.login(url_data.username, url_data.password) remote_file_path = url_data.path.lstrip("/") with salt.utils.files.fopen(dest, "wb") as fp_: @@ -759,6 +682,78 @@ def swift_opt(key, default): # both content encoding and etag are found. write_body = [None, False, None, None] + def on_header(hdr): + + if write_body[1] is not False and ( + write_body[2] is None or (use_etag and write_body[3] is None) + ): + if not hdr.strip() and "Content-Type" not in write_body[1]: + # If write_body[0] is True, then we are not following a + # redirect (initial response was a 200 OK). So there is + # no need to reset write_body[0]. + if write_body[0] is not True: + # We are following a redirect, so we need to reset + # write_body[0] so that we properly follow it. + write_body[0] = None + # We don't need the HTTPHeaders object anymore + if not use_etag or write_body[3]: + write_body[1] = False + return + # Try to find out what content type encoding is used if + # this is a text file + write_body[1].parse_line(hdr) # pylint: disable=no-member + # Case insensitive Etag header checking below. Don't break case + # insensitivity unless you really want to mess with people's heads + # in the tests. Note: http.server and apache2 use "Etag" and nginx + # uses "ETag" as the header key. Yay standards! + if use_etag and "etag" in map(str.lower, write_body[1]): + etag = write_body[3] = [ + val + for key, val in write_body[1].items() + if key.lower() == "etag" + ][0] + with salt.utils.files.fopen(dest_etag, "w") as etagfp: + etag = etagfp.write(etag) + elif "Content-Type" in write_body[1]: + content_type = write_body[1].get( + "Content-Type" + ) # pylint: disable=no-member + if not content_type.startswith("text"): + write_body[2] = False + if not use_etag or write_body[3]: + write_body[1] = False + else: + encoding = "utf-8" + fields = content_type.split(";") + for field in fields: + if "encoding" in field: + encoding = field.split("encoding=")[-1] + write_body[2] = encoding + # We have found our encoding. Stop processing headers. + if not use_etag or write_body[3]: + write_body[1] = False + + # If write_body[0] is False, this means that this + # header is a 30x redirect, so we need to reset + # write_body[0] to None so that we parse the HTTP + # status code from the redirect target. Additionally, + # we need to reset write_body[2] so that we inspect the + # headers for the Content-Type of the URL we're + # following. + if write_body[0] is write_body[1] is False: + write_body[0] = write_body[2] = None + + # Check the status line of the HTTP request + if write_body[0] is None: + try: + hdr = parse_response_start_line(hdr.strip()) + except HTTPInputError as exc: + log.trace("Unable to parse header: %r", hdr.strip()) + # Not the first line, do nothing + return + write_body[0] = hdr.code not in [301, 302, 303, 307] + write_body[1] = HTTPHeaders() + if no_cache: result = [] @@ -792,9 +787,7 @@ def on_chunk(chunk): fixed_url, stream=True, streaming_callback=on_chunk, - header_callback=lambda header: self._on_header( - header, write_body, use_etag, dest_etag - ), + header_callback=on_header, username=url_data.username, password=url_data.password, opts=self.opts, @@ -1261,7 +1254,10 @@ def get_file( if senv: saltenv = senv - hash_server = self.hash_file(path, saltenv) + if not salt.utils.platform.is_windows(): + hash_server, stat_server = self.hash_and_stat_file(path, saltenv) + else: + hash_server = self.hash_file(path, saltenv) # Check if file exists on server, before creating files and # directories @@ -1302,7 +1298,10 @@ def get_file( ) if dest2check and os.path.isfile(dest2check): - hash_local = self.hash_file(dest2check, saltenv) + if not salt.utils.platform.is_windows(): + hash_local, stat_local = self.hash_and_stat_file(dest2check, saltenv) + else: + hash_local = self.hash_file(dest2check, saltenv) if hash_local == hash_server: return dest2check diff --git a/salt/fileserver/gitfs.py b/salt/fileserver/gitfs.py index 266ce8203f14..3f947d2384ca 100644 --- a/salt/fileserver/gitfs.py +++ b/salt/fileserver/gitfs.py @@ -16,17 +16,14 @@ ``git`` also works here. Prior to the 2018.3.0 release, *only* ``git`` would work. -The Git fileserver backend supports three providers for the Python-to-git -interface: pygit2_, GitPython_, and ``gitcli`` (added in 3008.0). ``gitcli`` -shells out to the system ``git`` binary, so it does not require a Python git -library. When :conf_master:`gitfs_provider` is unset Salt picks the first -working provider in the order ``pygit2`` -> ``gitpython`` -> ``gitcli``; set -the option explicitly to override. - -.. versionchanged:: 3008.0 - Added the ``gitcli`` provider. On masters that have neither pygit2_ nor - GitPython_ installed, gitfs now falls back to ``gitcli`` instead of - failing with "No suitable gitfs provider module is installed". +The Git fileserver backend supports both pygit2_ and GitPython_, to provide the +Python interface to git. If both are present, the order of preference for which +one will be chosen is the same as the order in which they were listed: pygit2, +then GitPython. + +An optional master config parameter (:conf_master:`gitfs_provider`) can be used +to specify which provider should be used, in the event that compatible versions +of both pygit2_ and GitPython_ are installed. More detailed information on how to use GitFS can be found in the :ref:`GitFS Walkthrough `. @@ -43,9 +40,6 @@ as well as the git CLI utility. Instructions for installing GitPython can be found :ref:`here `. - To use ``gitcli`` for GitFS requires only the system ``git`` binary at - version 2.3.0 or newer; no Python library is needed. - To clear stale refs the git CLI utility must also be installed. .. _pygit2: https://github.com/libgit2/pygit2 @@ -70,13 +64,11 @@ "disable_saltenv_mapping", "ref_types", "update_interval", - "proxy", - "depth", ) PER_REMOTE_ONLY = ("all_saltenvs", "name", "saltenv") # Auth support (auth params can be global or per-remote, too) -AUTH_PROVIDERS = ("pygit2", "gitcli") +AUTH_PROVIDERS = ("pygit2",) AUTH_PARAMS = ("user", "password", "pubkey", "privkey", "passphrase", "insecure_auth") diff --git a/salt/fileserver/hgfs.py b/salt/fileserver/hgfs.py new file mode 100644 index 000000000000..48d175578861 --- /dev/null +++ b/salt/fileserver/hgfs.py @@ -0,0 +1,963 @@ +""" +Mercurial Fileserver Backend + +To enable, add ``hgfs`` to the :conf_master:`fileserver_backend` option in the +Master config file. + +.. code-block:: yaml + + fileserver_backend: + - hgfs + +.. note:: + ``hg`` also works here. Prior to the 2018.3.0 release, *only* ``hg`` would + work. + +After enabling this backend, branches, bookmarks, and tags in a remote +mercurial repository are exposed to salt as different environments. This +feature is managed by the :conf_master:`fileserver_backend` option in the salt +master config file. + +This fileserver has an additional option :conf_master:`hgfs_branch_method` that +will set the desired branch method. Possible values are: ``branches``, +``bookmarks``, or ``mixed``. If using ``branches`` or ``mixed``, the +``default`` branch will be mapped to ``base``. + + +.. versionchanged:: 2014.1.0 + The :conf_master:`hgfs_base` master config parameter was added, allowing + for a branch other than ``default`` to be used for the ``base`` + environment, and allowing for a ``base`` environment to be specified when + using an :conf_master:`hgfs_branch_method` of ``bookmarks``. + + +:depends: - mercurial + - python bindings for mercurial (``python-hglib``) +""" + +import copy +import errno +import fnmatch +import glob +import hashlib +import logging +import os +import shutil +from datetime import datetime + +import salt.fileserver +import salt.utils.data +import salt.utils.files +import salt.utils.gzip_util +import salt.utils.hashutils +import salt.utils.stringutils +import salt.utils.url +import salt.utils.versions +from salt.config import DEFAULT_HASH_TYPE +from salt.exceptions import FileserverConfigError +from salt.utils.event import tagify + +VALID_BRANCH_METHODS = ("branches", "bookmarks", "mixed") +PER_REMOTE_OVERRIDES = ("base", "branch_method", "mountpoint", "root") + + +# pylint: disable=import-error +try: + import hglib + + HAS_HG = True +except ImportError: + HAS_HG = False +# pylint: enable=import-error + + +log = logging.getLogger(__name__) + +# Define the module's virtual name +__virtualname__ = "hgfs" +__virtual_aliases__ = ("hg",) + + +def __virtual__(): + """ + Only load if mercurial is available + """ + if __virtualname__ not in __opts__["fileserver_backend"]: + return False + if not HAS_HG: + log.error( + "Mercurial fileserver backend is enabled in configuration " + "but could not be loaded, is hglib installed?" + ) + return False + if __opts__["hgfs_branch_method"] not in VALID_BRANCH_METHODS: + log.error( + "Invalid hgfs_branch_method '%s'. Valid methods are: %s", + __opts__["hgfs_branch_method"], + VALID_BRANCH_METHODS, + ) + return False + if salt.utils.path.which("hg") is None: + log.error("hgfs requested but hg executable is not available.") + return False + return __virtualname__ + + +def _all_branches(repo): + """ + Returns all branches for the specified repo + """ + # repo.branches() returns a list of 3-tuples consisting of + # (branch name, rev #, nodeid) + # Example: [('default', 4, '7c96229269fa')] + branches = [ + (salt.utils.stringutils.to_str(x[0]), x[1], salt.utils.stringutils.to_str(x[2])) + for x in repo.branches() + ] + return branches + + +def _get_branch(repo, name): + """ + Find the requested branch in the specified repo + """ + try: + return [x for x in _all_branches(repo) if x[0] == name][0] + except IndexError: + return False + + +def _all_bookmarks(repo): + """ + Returns all bookmarks for the specified repo + """ + # repo.bookmarks() returns a tuple containing the following: + # 1. A list of 3-tuples consisting of (bookmark name, rev #, nodeid) + # 2. The index of the current bookmark (-1 if no current one) + # Example: ([('mymark', 4, '7c96229269fa')], -1) + bookmarks = [ + (salt.utils.stringutils.to_str(x[0]), x[1], salt.utils.stringutils.to_str(x[2])) + for x in repo.bookmarks()[0] + ] + return bookmarks + + +def _get_bookmark(repo, name): + """ + Find the requested bookmark in the specified repo + """ + try: + return [x for x in _all_bookmarks(repo) if x[0] == name][0] + except IndexError: + return False + + +def _all_tags(repo): + """ + Returns all tags for the specified repo + """ + # repo.tags() returns a list of 4-tuples consisting of + # (tag name, rev #, nodeid, islocal) + # Example: [('1.0', 3, '3be15e71b31a', False), + # ('tip', 4, '7c96229269fa', False)] + # Avoid returning the special 'tip' tag. + return [ + ( + salt.utils.stringutils.to_str(x[0]), + x[1], + salt.utils.stringutils.to_str(x[2]), + x[3], + ) + for x in repo.tags() + if salt.utils.stringutils.to_str(x[0]) != "tip" + ] + + +def _get_tag(repo, name): + """ + Find the requested tag in the specified repo + """ + try: + return [x for x in _all_tags(repo) if x[0] == name][0] + except IndexError: + return False + + +def _get_ref(repo, name): + """ + Return ref tuple if ref is in the repo. + """ + if name == "base": + name = repo["base"] + if name == repo["base"] or name in envs(): + if repo["branch_method"] == "branches": + return _get_branch(repo["repo"], name) or _get_tag(repo["repo"], name) + elif repo["branch_method"] == "bookmarks": + return _get_bookmark(repo["repo"], name) or _get_tag(repo["repo"], name) + elif repo["branch_method"] == "mixed": + return ( + _get_branch(repo["repo"], name) + or _get_bookmark(repo["repo"], name) + or _get_tag(repo["repo"], name) + ) + return False + + +def _get_manifest(repo, ref): + """ + Get manifest for ref + """ + # repo.manifest() returns a list of 5-tuples consisting of + # ('b80de5d138758541c5f05265ad144ab9fa86d1db', '644', False, False, 'thing.sls') + manifest = [ + ( + salt.utils.stringutils.to_str(x[0]), + salt.utils.stringutils.to_str(x[1]), + x[2], + x[3], + salt.utils.stringutils.to_str(x[4]), + ) + for x in repo.manifest(rev=ref[1]) + ] + return manifest + + +def _failhard(): + """ + Fatal fileserver configuration issue, raise an exception + """ + raise FileserverConfigError("Failed to load hg fileserver backend") + + +def init(): + """ + Return a list of hglib objects for the various hgfs remotes + """ + bp_ = os.path.join(__opts__["cachedir"], "hgfs") + new_remote = False + repos = [] + + per_remote_defaults = {} + for param in PER_REMOTE_OVERRIDES: + per_remote_defaults[param] = str(__opts__[f"hgfs_{param}"]) + + for remote in __opts__["hgfs_remotes"]: + repo_conf = copy.deepcopy(per_remote_defaults) + if isinstance(remote, dict): + repo_url = next(iter(remote)) + per_remote_conf = { + key: str(val) + for key, val in salt.utils.data.repack_dictlist( + remote[repo_url] + ).items() + } + if not per_remote_conf: + log.error( + "Invalid per-remote configuration for hgfs remote %s. If " + "no per-remote parameters are being specified, there may " + "be a trailing colon after the URL, which should be " + "removed. Check the master configuration file.", + repo_url, + ) + _failhard() + + branch_method = per_remote_conf.get( + "branch_method", per_remote_defaults["branch_method"] + ) + if branch_method not in VALID_BRANCH_METHODS: + log.error( + "Invalid branch_method '%s' for remote %s. Valid " + "branch methods are: %s. This remote will be ignored.", + branch_method, + repo_url, + ", ".join(VALID_BRANCH_METHODS), + ) + _failhard() + + per_remote_errors = False + for param in (x for x in per_remote_conf if x not in PER_REMOTE_OVERRIDES): + log.error( + "Invalid configuration parameter '%s' for remote %s. " + "Valid parameters are: %s. See the documentation for " + "further information.", + param, + repo_url, + ", ".join(PER_REMOTE_OVERRIDES), + ) + per_remote_errors = True + if per_remote_errors: + _failhard() + + repo_conf.update(per_remote_conf) + else: + repo_url = remote + + if not isinstance(repo_url, str): + log.error( + "Invalid hgfs remote %s. Remotes must be strings, you may " + "need to enclose the URL in quotes", + repo_url, + ) + _failhard() + + try: + repo_conf["mountpoint"] = salt.utils.url.strip_proto( + repo_conf["mountpoint"] + ) + except TypeError: + # mountpoint not specified + pass + + hash_type = getattr(hashlib, __opts__.get("hash_type", DEFAULT_HASH_TYPE)) + repo_hash = hash_type(repo_url.encode("utf-8")).hexdigest() + rp_ = os.path.join(bp_, repo_hash) + if not os.path.isdir(rp_): + os.makedirs(rp_) + + if not os.listdir(rp_): + # Only init if the directory is empty. + client = hglib.init(rp_) + client.close() + new_remote = True + repo = None + try: + try: + repo = hglib.open(rp_) + except hglib.error.ServerError: + log.error( + "Cache path %s (corresponding remote: %s) exists but is not " + "a valid mercurial repository. You will need to manually " + "delete this directory on the master to continue to use this " + "hgfs remote.", + rp_, + repo_url, + ) + _failhard() + except Exception as exc: # pylint: disable=broad-except + log.error( + "Exception '%s' encountered while initializing hgfs remote %s", + exc, + repo_url, + ) + _failhard() + + try: + refs = repo.config(names=b"paths") + except hglib.error.CommandError: + refs = None + + # Do NOT put this if statement inside the except block above. Earlier + # versions of hglib did not raise an exception, so we need to do it + # this way to support both older and newer hglib. + if not refs: + # Write an hgrc defining the remote URL + hgconfpath = os.path.join(rp_, ".hg", "hgrc") + with salt.utils.files.fopen(hgconfpath, "w+") as hgconfig: + hgconfig.write("[paths]\n") + hgconfig.write( + salt.utils.stringutils.to_str(f"default = {repo_url}\n") + ) + + repo_conf.update( + { + "repo": repo, + "url": repo_url, + "hash": repo_hash, + "cachedir": rp_, + "lockfile": os.path.join( + __opts__["cachedir"], "hgfs", f"{repo_hash}.update.lk" + ), + } + ) + repos.append(repo_conf) + finally: + if repo: + repo.close() + + if new_remote: + remote_map = os.path.join(__opts__["cachedir"], "hgfs/remote_map.txt") + try: + with salt.utils.files.fopen(remote_map, "w+") as fp_: + timestamp = datetime.now().strftime("%d %b %Y %H:%M:%S.%f") + fp_.write(f"# hgfs_remote map as of {timestamp}\n") + for repo in repos: + fp_.write( + salt.utils.stringutils.to_str( + "{} = {}\n".format(repo["hash"], repo["url"]) + ) + ) + except OSError: + pass + else: + log.info("Wrote new hgfs_remote map to %s", remote_map) + + return repos + + +def _clear_old_remotes(): + """ + Remove cache directories for remotes no longer configured + """ + bp_ = os.path.join(__opts__["cachedir"], "hgfs") + try: + cachedir_ls = os.listdir(bp_) + except OSError: + cachedir_ls = [] + repos = init() + # Remove actively-used remotes from list + for repo in repos: + try: + cachedir_ls.remove(repo["hash"]) + except ValueError: + pass + to_remove = [] + for item in cachedir_ls: + if item in ("hash", "refs"): + continue + path = os.path.join(bp_, item) + if os.path.isdir(path): + to_remove.append(path) + failed = [] + if to_remove: + for rdir in to_remove: + try: + shutil.rmtree(rdir) + except OSError as exc: + log.error("Unable to remove old hgfs remote cachedir %s: %s", rdir, exc) + failed.append(rdir) + else: + log.debug("hgfs removed old cachedir %s", rdir) + for fdir in failed: + to_remove.remove(fdir) + return bool(to_remove), repos + + +def clear_cache(): + """ + Completely clear hgfs cache + """ + fsb_cachedir = os.path.join(__opts__["cachedir"], "hgfs") + list_cachedir = os.path.join(__opts__["cachedir"], "file_lists/hgfs") + errors = [] + for rdir in (fsb_cachedir, list_cachedir): + if os.path.exists(rdir): + try: + shutil.rmtree(rdir) + except OSError as exc: + errors.append(f"Unable to delete {rdir}: {exc}") + return errors + + +def clear_lock(remote=None): + """ + Clear update.lk + + ``remote`` can either be a dictionary containing repo configuration + information, or a pattern. If the latter, then remotes for which the URL + matches the pattern will be locked. + """ + + def _do_clear_lock(repo): + def _add_error(errlist, repo, exc): + msg = "Unable to remove update lock for {} ({}): {} ".format( + repo["url"], repo["lockfile"], exc + ) + log.debug(msg) + errlist.append(msg) + + success = [] + failed = [] + if os.path.exists(repo["lockfile"]): + try: + os.remove(repo["lockfile"]) + except OSError as exc: + if exc.errno == errno.EISDIR: + # Somehow this path is a directory. Should never happen + # unless some wiseguy manually creates a directory at this + # path, but just in case, handle it. + try: + shutil.rmtree(repo["lockfile"]) + except OSError as exc: + _add_error(failed, repo, exc) + else: + _add_error(failed, repo, exc) + else: + msg = "Removed lock for {}".format(repo["url"]) + log.debug(msg) + success.append(msg) + return success, failed + + if isinstance(remote, dict): + return _do_clear_lock(remote) + + cleared = [] + errors = [] + for repo in init(): + try: + if remote: + try: + if not fnmatch.fnmatch(repo["url"], remote): + continue + except TypeError: + # remote was non-string, try again + if not fnmatch.fnmatch(repo["url"], str(remote)): + continue + success, failed = _do_clear_lock(repo) + cleared.extend(success) + errors.extend(failed) + finally: + repo["repo"].close() + return cleared, errors + + +def lock(remote=None): + """ + Place an update.lk + + ``remote`` can either be a dictionary containing repo configuration + information, or a pattern. If the latter, then remotes for which the URL + matches the pattern will be locked. + """ + + def _do_lock(repo): + success = [] + failed = [] + if not os.path.exists(repo["lockfile"]): + try: + with salt.utils.files.fopen(repo["lockfile"], "w"): + pass + except OSError as exc: + msg = "Unable to set update lock for {} ({}): {} ".format( + repo["url"], repo["lockfile"], exc + ) + log.debug(msg) + failed.append(msg) + else: + msg = "Set lock for {}".format(repo["url"]) + log.debug(msg) + success.append(msg) + return success, failed + + if isinstance(remote, dict): + return _do_lock(remote) + + locked = [] + errors = [] + for repo in init(): + try: + if remote: + try: + if not fnmatch.fnmatch(repo["url"], remote): + continue + except TypeError: + # remote was non-string, try again + if not fnmatch.fnmatch(repo["url"], str(remote)): + continue + success, failed = _do_lock(repo) + locked.extend(success) + errors.extend(failed) + finally: + repo["repo"].close() + + return locked, errors + + +def update(): + """ + Execute an hg pull on all of the repos + """ + # data for the fileserver event + data = {"changed": False, "backend": "hgfs"} + # _clear_old_remotes runs init(), so use the value from there to avoid a + # second init() + data["changed"], repos = _clear_old_remotes() + for repo in repos: + try: + if os.path.exists(repo["lockfile"]): + log.warning( + "Update lockfile is present for hgfs remote %s, skipping. " + "If this warning persists, it is possible that the update " + "process was interrupted. Removing %s or running " + "'salt-run fileserver.clear_lock hgfs' will allow updates " + "to continue for this remote.", + repo["url"], + repo["lockfile"], + ) + continue + _, errors = lock(repo) + if errors: + log.error( + "Unable to set update lock for hgfs remote %s, skipping.", + repo["url"], + ) + continue + log.debug("hgfs is fetching from %s", repo["url"]) + repo["repo"].open() + curtip = repo["repo"].tip() + try: + repo["repo"].pull() + except Exception as exc: # pylint: disable=broad-except + log.error( + "Exception %s caught while updating hgfs remote %s", + exc, + repo["url"], + exc_info_on_loglevel=logging.DEBUG, + ) + else: + newtip = repo["repo"].tip() + if curtip[1] != newtip[1]: + data["changed"] = True + finally: + repo["repo"].close() + clear_lock(repo) + + env_cache = os.path.join(__opts__["cachedir"], "hgfs/envs.p") + if data.get("changed", False) is True or not os.path.isfile(env_cache): + env_cachedir = os.path.dirname(env_cache) + if not os.path.exists(env_cachedir): + os.makedirs(env_cachedir) + new_envs = envs(ignore_cache=True) + with salt.utils.files.fopen(env_cache, "wb+") as fp_: + fp_.write(salt.payload.dumps(new_envs)) + log.trace("Wrote env cache data to %s", env_cache) + + # if there is a change, fire an event + if __opts__.get("fileserver_events", False): + with salt.utils.event.get_event( + "master", + __opts__["sock_dir"], + opts=__opts__, + listen=False, + ) as event: + event.fire_event(data, tagify(["hgfs", "update"], prefix="fileserver")) + try: + salt.fileserver.reap_fileserver_cache_dir( + os.path.join(__opts__["cachedir"], "hgfs/hash"), find_file + ) + except OSError: + # Hash file won't exist if no files have yet been served up + pass + + +def _env_is_exposed(env): + """ + Check if an environment is exposed by comparing it against a whitelist and + blacklist. + """ + return salt.utils.stringutils.check_whitelist_blacklist( + env, + whitelist=__opts__["hgfs_saltenv_whitelist"], + blacklist=__opts__["hgfs_saltenv_blacklist"], + ) + + +def envs(ignore_cache=False): + """ + Return a list of refs that can be used as environments + """ + if not ignore_cache: + env_cache = os.path.join(__opts__["cachedir"], "hgfs/envs.p") + cache_match = salt.fileserver.check_env_cache(__opts__, env_cache) + if cache_match is not None: + return cache_match + ret = set() + + for repo in init(): + try: + repo["repo"].open() + if repo["branch_method"] in ("branches", "mixed"): + for branch in _all_branches(repo["repo"]): + branch_name = branch[0] + if branch_name == repo["base"]: + branch_name = "base" + ret.add(branch_name) + if repo["branch_method"] in ("bookmarks", "mixed"): + for bookmark in _all_bookmarks(repo["repo"]): + bookmark_name = bookmark[0] + if bookmark_name == repo["base"]: + bookmark_name = "base" + ret.add(bookmark_name) + ret.update([x[0] for x in _all_tags(repo["repo"])]) + finally: + repo["repo"].close() + return [x for x in sorted(ret) if _env_is_exposed(x)] + + +def find_file(path, tgt_env="base", **kwargs): # pylint: disable=W0613 + """ + Find the first file to match the path and ref, read the file out of hg + and send the path to the newly cached file + """ + fnd = {"path": "", "rel": ""} + if os.path.isabs(path) or tgt_env not in envs(): + return fnd + + dest = os.path.join(__opts__["cachedir"], "hgfs/refs", tgt_env, path) + hashes_glob = os.path.join( + __opts__["cachedir"], "hgfs/hash", tgt_env, f"{path}.hash.*" + ) + blobshadest = os.path.join( + __opts__["cachedir"], "hgfs/hash", tgt_env, f"{path}.hash.blob_sha1" + ) + lk_fn = os.path.join(__opts__["cachedir"], "hgfs/hash", tgt_env, f"{path}.lk") + destdir = os.path.dirname(dest) + hashdir = os.path.dirname(blobshadest) + if not os.path.isdir(destdir): + try: + os.makedirs(destdir) + except OSError: + # Path exists and is a file, remove it and retry + os.remove(destdir) + os.makedirs(destdir) + if not os.path.isdir(hashdir): + try: + os.makedirs(hashdir) + except OSError: + # Path exists and is a file, remove it and retry + os.remove(hashdir) + os.makedirs(hashdir) + + for repo in init(): + try: + if repo["mountpoint"] and not path.startswith( + repo["mountpoint"] + os.path.sep + ): + continue + repo_path = path[len(repo["mountpoint"]) :].lstrip(os.path.sep) + if repo["root"]: + repo_path = os.path.join(repo["root"], repo_path) + + repo["repo"].open() + ref = _get_ref(repo, tgt_env) + if not ref: + # Branch or tag not found in repo, try the next + repo["repo"].close() + continue + salt.fileserver.wait_lock(lk_fn, dest) + if os.path.isfile(blobshadest) and os.path.isfile(dest): + with salt.utils.files.fopen(blobshadest, "r") as fp_: + sha = fp_.read() + if sha == ref[2]: + fnd["rel"] = path + fnd["path"] = dest + repo["repo"].close() + return fnd + try: + repo["repo"].cat( + [salt.utils.stringutils.to_bytes(f"path:{repo_path}")], + rev=ref[2], + output=dest, + ) + except hglib.error.CommandError: + repo["repo"].close() + continue + with salt.utils.files.fopen(lk_fn, "w"): + pass + for filename in glob.glob(hashes_glob): + try: + os.remove(filename) + except Exception: # pylint: disable=broad-except + pass + with salt.utils.files.fopen(blobshadest, "w+") as fp_: + fp_.write(salt.utils.stringutils.to_str(ref[2])) + try: + os.remove(lk_fn) + except OSError: + pass + fnd["rel"] = path + fnd["path"] = dest + try: + # Converting the stat result to a list, the elements of the + # list correspond to the following stat_result params: + # 0 => st_mode=33188 + # 1 => st_ino=10227377 + # 2 => st_dev=65026 + # 3 => st_nlink=1 + # 4 => st_uid=1000 + # 5 => st_gid=1000 + # 6 => st_size=1056233 + # 7 => st_atime=1468284229 + # 8 => st_mtime=1456338235 + # 9 => st_ctime=1456338235 + fnd["stat"] = list(os.stat(dest)) + except Exception: # pylint: disable=broad-except + pass + finally: + repo["repo"].close() + return fnd + return fnd + + +def serve_file(load, fnd): + """ + Return a chunk from a file based on the data received + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + ret = {"data": "", "dest": ""} + if not all(x in load for x in ("path", "loc", "saltenv")): + return ret + if not fnd["path"]: + return ret + ret["dest"] = fnd["rel"] + gzip = load.get("gzip", None) + fpath = os.path.normpath(fnd["path"]) + with salt.utils.files.fopen(fpath, "rb") as fp_: + fp_.seek(load["loc"]) + data = fp_.read(__opts__["file_buffer_size"]) + if data and not salt.utils.files.is_binary(fpath): + data = data.decode(__salt_system_encoding__) + if gzip and data: + data = salt.utils.gzip_util.compress(data, gzip) + ret["gzip"] = gzip + ret["data"] = data + return ret + + +def file_hash(load, fnd): + """ + Return a file hash, the hash type is set in the master config file + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + if not all(x in load for x in ("path", "saltenv")): + return "" + ret = {"hash_type": __opts__["hash_type"]} + relpath = fnd["rel"] + path = fnd["path"] + hashdest = os.path.join( + __opts__["cachedir"], + "hgfs/hash", + load["saltenv"], + "{}.hash.{}".format(relpath, __opts__["hash_type"]), + ) + if not os.path.isfile(hashdest): + ret["hsum"] = salt.utils.hashutils.get_hash(path, __opts__["hash_type"]) + with salt.utils.files.fopen(hashdest, "w+") as fp_: + fp_.write(ret["hsum"]) + return ret + else: + with salt.utils.files.fopen(hashdest, "rb") as fp_: + ret["hsum"] = salt.utils.stringutils.to_unicode(fp_.read()) + return ret + + +def _file_lists(load, form): + """ + Return a dict containing the file lists for files and dirs + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + list_cachedir = os.path.join(__opts__["cachedir"], "file_lists/hgfs") + if not os.path.isdir(list_cachedir): + try: + os.makedirs(list_cachedir) + except OSError: + log.critical("Unable to make cachedir %s", list_cachedir) + return [] + list_cache = os.path.join(list_cachedir, "{}.p".format(load["saltenv"])) + w_lock = os.path.join(list_cachedir, ".{}.w".format(load["saltenv"])) + cache_match, refresh_cache, save_cache = salt.fileserver.check_file_list_cache( + __opts__, form, list_cache, w_lock + ) + if cache_match is not None: + return cache_match + if refresh_cache: + ret = {} + ret["files"] = _get_file_list(load) + ret["dirs"] = _get_dir_list(load) + if save_cache: + salt.fileserver.write_file_list_cache(__opts__, ret, list_cache, w_lock) + return ret.get(form, []) + # Shouldn't get here, but if we do, this prevents a TypeError + return [] + + +def file_list(load): + """ + Return a list of all files on the file server in a specified environment + """ + return _file_lists(load, "files") + + +def _get_file_list(load): + """ + Get a list of all files on the file server in a specified environment + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + if "saltenv" not in load or load["saltenv"] not in envs(): + return [] + ret = set() + for repo in init(): + try: + repo["repo"].open() + ref = _get_ref(repo, load["saltenv"]) + if ref: + manifest = _get_manifest(repo["repo"], ref=ref) + for tup in manifest: + relpath = os.path.relpath(tup[4], repo["root"]) + # Don't add files outside the hgfs_root + if not relpath.startswith("../"): + ret.add(os.path.join(repo["mountpoint"], relpath)) + finally: + repo["repo"].close() + return sorted(ret) + + +def file_list_emptydirs(load): # pylint: disable=W0613 + """ + Return a list of all empty directories on the master + """ + # Cannot have empty dirs in hg + return [] + + +def dir_list(load): + """ + Return a list of all directories on the master + """ + return _file_lists(load, "dirs") + + +def _get_dir_list(load): + """ + Get a list of all directories on the master + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + if "saltenv" not in load or load["saltenv"] not in envs(): + return [] + ret = set() + for repo in init(): + try: + repo["repo"].open() + ref = _get_ref(repo, load["saltenv"]) + if ref: + manifest = _get_manifest(repo["repo"], ref=ref) + for tup in manifest: + filepath = tup[4] + split = filepath.rsplit("/", 1) + while len(split) > 1: + relpath = os.path.relpath(split[0], repo["root"]) + # Don't add '.' + if relpath != ".": + # Don't add files outside the hgfs_root + if not relpath.startswith("../"): + ret.add(os.path.join(repo["mountpoint"], relpath)) + split = split[0].rsplit("/", 1) + finally: + repo["repo"].close() + if repo["mountpoint"]: + ret.add(repo["mountpoint"]) + return sorted(ret) diff --git a/salt/fileserver/roots.py b/salt/fileserver/roots.py index 2ac103049628..cb27396b9790 100644 --- a/salt/fileserver/roots.py +++ b/salt/fileserver/roots.py @@ -413,12 +413,6 @@ def _translate_sep(path): abs_path, ) link_dest = abs_path - # Not sure what the purpose of this is since symlinks that point outside - # the file roots are allowed (when following symlinks). Either way, this does not do what - # it's intended to do since a symlink that starts with ../ is not resolved - # relative to its full path, but to the containing directory as well. - # This allows symlinks to point to the parent and sibling directories of the file root - # and still be listed here. if link_dest.startswith(".."): joined = os.path.join(abs_path, link_dest) else: @@ -434,10 +428,10 @@ def _translate_sep(path): # Only count the link if it does not point # outside of the root dir of the fileserver # (i.e. the "path" variable) - ret["links"][rel_path] = _translate_sep(link_dest) + ret["links"][rel_path] = link_dest else: if not __opts__["fileserver_followsymlinks"]: - ret["links"][rel_path] = _translate_sep(link_dest) + ret["links"][rel_path] = link_dest for path in __opts__["file_roots"][saltenv]: if saltenv == "__env__": diff --git a/salt/fileserver/s3fs.py b/salt/fileserver/s3fs.py new file mode 100644 index 000000000000..d3c3d9cd78f0 --- /dev/null +++ b/salt/fileserver/s3fs.py @@ -0,0 +1,890 @@ +""" +Amazon S3 Fileserver Backend + +.. versionadded:: 0.16.0 + +This backend exposes directories in S3 buckets as Salt environments. To enable +this backend, add ``s3fs`` to the :conf_master:`fileserver_backend` option in the +Master config file. + +.. code-block:: yaml + + fileserver_backend: + - s3fs + +S3 credentials must also be set in the master config file: + +.. code-block:: yaml + + s3.keyid: GKTADJGHEIQSXMKKRBJ08H + s3.key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs + +Alternatively, if on EC2 these credentials can be automatically loaded from +instance metadata. + +This fileserver supports two modes of operation for the buckets: + +1. :strong:`A single bucket per environment` + + .. code-block:: yaml + + s3.buckets: + production: + - bucket1 + - bucket2 + staging: + - bucket3 + - bucket4 + +2. :strong:`Multiple environments per bucket` + + .. code-block:: yaml + + s3.buckets: + - bucket1 + - bucket2 + - bucket3 + - bucket4 + +Note that bucket names must be all lowercase both in the AWS console and in +Salt, otherwise you may encounter ``SignatureDoesNotMatch`` errors. + +A multiple-environment bucket must adhere to the following root directory +structure:: + + s3://// + +.. note:: This fileserver back-end requires the use of the MD5 hashing algorithm. + MD5 may not be compliant with all security policies. + +.. note:: This fileserver back-end is only compatible with MD5 ETag hashes in + the S3 metadata. This means that you must use SSE-S3 or plaintext for + bucket encryption, and that you must not use multipart upload when + uploading to your bucket. More information here: + https://docs.aws.amazon.com/AmazonS3/latest/API/RESTCommonResponseHeaders.html + + Objects without an MD5 ETag will be fetched on every fileserver update. + + If you deal with objects greater than 8MB, then you should use the + following AWS CLI config to avoid mutipart upload: + + .. code-block:: text + + s3 = + multipart_threshold = 1024MB + + More info here: + https://docs.aws.amazon.com/cli/latest/topic/s3-config.html + +.. note:: This fileserver back-end will by default sync all buckets on every + fileserver update. + + If you want files to be only populated in the cache when requested, you can + disable this in the master config: + + .. code-block:: yaml + + s3.s3_sync_on_update: False +""" + +import datetime +import logging +import os +import pickle +import time +import urllib.parse + +import salt.fileserver as fs +import salt.modules +import salt.utils.files +import salt.utils.gzip_util +import salt.utils.hashutils +import salt.utils.versions + +log = logging.getLogger(__name__) + +S3_HASH_TYPE = "md5" + + +def envs(): + """ + Return a list of directories within the bucket that can be + used as environments. + """ + + # update and grab the envs from the metadata keys + metadata = _init() + return list(metadata.keys()) + + +def update(): + """ + Update the cache file for the bucket. + """ + + metadata = _init() + + # sync cache on update rather than jit + if __opts__.get("s3.s3_sync_on_update", True): + # sync the buckets to the local cache + log.info("Syncing local cache from S3...") + for saltenv, env_meta in metadata.items(): + for bucket_files in _find_files(env_meta): + for bucket, files in bucket_files.items(): + for file_path in files: + cached_file_path = _get_cached_file_name( + bucket, saltenv, file_path + ) + + log.debug("%s - %s : %s", bucket, saltenv, file_path) + + # load the file from S3 if it's not in the cache or it's old + _get_file_from_s3( + metadata, saltenv, bucket, file_path, cached_file_path + ) + + log.info("Sync local cache from S3 completed.") + + +def find_file(path, saltenv="base", **kwargs): + """ + Look through the buckets cache file for a match. + If the field is found, it is retrieved from S3 only if its cached version + is missing, or if the MD5 does not match. + """ + if "env" in kwargs: + # "env" is not supported; Use "saltenv". + kwargs.pop("env") + + fnd = {"bucket": None, "path": None} + + metadata = _init() + if not metadata or saltenv not in metadata: + return fnd + + env_files = _find_files(metadata[saltenv]) + + if not _is_env_per_bucket(): + path = os.path.join(saltenv, path) + + # look for the files and check if they're ignored globally + for bucket in env_files: + for bucket_name, files in bucket.items(): + if path in files and not fs.is_file_ignored(__opts__, path): + fnd["bucket"] = bucket_name + fnd["path"] = path + break + else: + continue # only executes if we didn't break + break + + if not fnd["path"] or not fnd["bucket"]: + return fnd + + cached_file_path = _get_cached_file_name(fnd["bucket"], saltenv, path) + + # jit load the file from S3 if it's not in the cache or it's old + _get_file_from_s3(metadata, saltenv, fnd["bucket"], path, cached_file_path) + + return fnd + + +def file_hash(load, fnd): + """ + Return the hash of an object's cached copy + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + ret = {} + + if "saltenv" not in load: + return ret + + if "path" not in fnd or "bucket" not in fnd or not fnd["path"]: + return ret + + cached_file_path = _get_cached_file_name( + fnd["bucket"], load["saltenv"], fnd["path"] + ) + + if os.path.isfile(cached_file_path): + ret["hash_type"] = S3_HASH_TYPE + ret["hsum"] = salt.utils.hashutils.get_hash(cached_file_path, S3_HASH_TYPE) + + return ret + + +def serve_file(load, fnd): + """ + Return a chunk from a file based on the data received + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + ret = {"data": "", "dest": ""} + + if "path" not in load or "loc" not in load or "saltenv" not in load: + return ret + + if "path" not in fnd or "bucket" not in fnd: + return ret + + gzip = load.get("gzip", None) + + # get the saltenv/path file from the cache + cached_file_path = _get_cached_file_name( + fnd["bucket"], load["saltenv"], fnd["path"] + ) + + ret["dest"] = _trim_env_off_path([fnd["path"]], load["saltenv"])[0] + + with salt.utils.files.fopen(cached_file_path, "rb") as fp_: + fp_.seek(load["loc"]) + data = fp_.read(__opts__["file_buffer_size"]) + if data and not salt.utils.files.is_binary(cached_file_path): + data = data.decode(__salt_system_encoding__) + if gzip and data: + data = salt.utils.gzip_util.compress(data, gzip) + ret["gzip"] = gzip + ret["data"] = data + return ret + + +def file_list(load): + """ + Return a list of all files on the file server in a specified environment + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + ret = [] + + if "saltenv" not in load: + return ret + + saltenv = load["saltenv"] + metadata = _init() + + if not metadata or saltenv not in metadata: + return ret + for bucket in _find_files(metadata[saltenv]): + for buckets in bucket.values(): + files = [f for f in buckets if not fs.is_file_ignored(__opts__, f)] + ret += _trim_env_off_path(files, saltenv) + + return ret + + +def file_list_emptydirs(load): + """ + Return a list of all empty directories on the master + """ + # TODO - implement this + _init() + + return [] + + +def dir_list(load): + """ + Return a list of all directories on the master + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + ret = [] + + if "saltenv" not in load: + return ret + + saltenv = load["saltenv"] + metadata = _init() + + if not metadata or saltenv not in metadata: + return ret + + # grab all the dirs from the buckets cache file + for bucket in _find_dirs(metadata[saltenv]): + for dirs in bucket.values(): + # trim env and trailing slash + dirs = _trim_env_off_path(dirs, saltenv, trim_slash=True) + # remove empty string left by the base env dir in single bucket mode + ret += [_f for _f in dirs if _f] + + return ret + + +def _get_s3_key(): + """ + Get AWS keys from pillar or config + """ + + key = __opts__["s3.key"] if "s3.key" in __opts__ else None + keyid = __opts__["s3.keyid"] if "s3.keyid" in __opts__ else None + service_url = __opts__["s3.service_url"] if "s3.service_url" in __opts__ else None + verify_ssl = __opts__["s3.verify_ssl"] if "s3.verify_ssl" in __opts__ else None + kms_keyid = __opts__["aws.kmw.keyid"] if "aws.kms.keyid" in __opts__ else None + location = __opts__["s3.location"] if "s3.location" in __opts__ else None + path_style = __opts__["s3.path_style"] if "s3.path_style" in __opts__ else None + https_enable = ( + __opts__["s3.https_enable"] if "s3.https_enable" in __opts__ else None + ) + + return ( + key, + keyid, + service_url, + verify_ssl, + kms_keyid, + location, + path_style, + https_enable, + ) + + +def _init(): + """ + Connect to S3 and download the metadata for each file in all buckets + specified and cache the data to disk. + """ + cache_file = _get_buckets_cache_filename() + exp = time.time() - __opts__.get("s3.s3_cache_expire", 30) + + # check mtime of the buckets files cache + metadata = None + + try: + if os.path.getmtime(cache_file) > exp: + metadata = _read_buckets_cache_file(cache_file) + except OSError: + pass + + if metadata is None: + # bucket files cache expired or does not exist + metadata = _refresh_buckets_cache_file(cache_file) + + _prune_deleted_files(metadata) + + return metadata + + +def _get_cache_dir(): + """ + Return the path to the s3cache dir + """ + + return os.path.join(__opts__["cachedir"], "s3cache") + + +def _get_cached_file_name(bucket_name, saltenv, path): + """ + Return the cached file name for a bucket path file + """ + + return os.path.join(_get_cache_dir(), saltenv, bucket_name, path) + + +def _get_buckets_cache_filename(): + """ + Return the filename of the cache for bucket contents. + """ + + return os.path.join(_get_cache_dir(), "buckets_files.cache") + + +def _refresh_buckets_cache_file(cache_file): + """ + Retrieve the content of all buckets and cache the metadata to the buckets + cache file + """ + + log.debug("Refreshing buckets cache file") + + ( + key, + keyid, + service_url, + verify_ssl, + kms_keyid, + location, + path_style, + https_enable, + ) = _get_s3_key() + + metadata = {} + + # helper s3 query function + def __get_s3_meta(bucket, key=key, keyid=keyid): + ret, marker = [], "" + while True: + tmp = __utils__["s3.query"]( + key=key, + keyid=keyid, + kms_keyid=keyid, + bucket=bucket, + service_url=service_url, + verify_ssl=verify_ssl, + location=location, + return_bin=False, + path_style=path_style, + https_enable=https_enable, + params={"marker": marker}, + ) + headers = [] + for header in tmp: + if "Key" in header: + break + headers.append(header) + ret.extend(tmp) + if all( + [header.get("IsTruncated", "false") == "false" for header in headers] + ): + break + marker = tmp[-1]["Key"] + return ret + + if _is_env_per_bucket(): + # Single environment per bucket + for saltenv, buckets in _get_buckets().items(): + bucket_files_list = [] + for bucket_name in buckets: + bucket_files = {} + s3_meta = __get_s3_meta(bucket_name) + + # s3 query returned nothing + if not s3_meta: + continue + + # grab only the files/dirs + bucket_files[bucket_name] = [k for k in s3_meta if "Key" in k] + bucket_files_list.append(bucket_files) + + # check to see if we added any keys, otherwise investigate possible error conditions + if not bucket_files[bucket_name]: + meta_response = {} + for k in s3_meta: + if "Code" in k or "Message" in k: + # assumes no duplicate keys, consisdent with current erro response. + meta_response.update(k) + # attempt use of human readable output first. + try: + log.warning( + "'%s' response for bucket '%s'", + meta_response["Message"], + bucket_name, + ) + continue + except KeyError: + # no human readable error message provided + if "Code" in meta_response: + log.warning( + "'%s' response for bucket '%s'", + meta_response["Code"], + bucket_name, + ) + continue + else: + log.warning( + "S3 Error! Do you have any files in your S3 bucket?" + ) + return {} + + metadata[saltenv] = bucket_files_list + + else: + # Multiple environments per buckets + for bucket_name in _get_buckets(): + s3_meta = __get_s3_meta(bucket_name) + + # s3 query returned nothing + if not s3_meta: + continue + + # pull out the environment dirs (e.g. the root dirs) + files = [k for k in s3_meta if "Key" in k] + + # check to see if we added any keys, otherwise investigate possible error conditions + if not files: + meta_response = {} + for k in s3_meta: + if "Code" in k or "Message" in k: + # assumes no duplicate keys, consisdent with current erro response. + meta_response.update(k) + # attempt use of human readable output first. + try: + log.warning( + "'%s' response for bucket '%s'", + meta_response["Message"], + bucket_name, + ) + continue + except KeyError: + # no human readable error message provided + if "Code" in meta_response: + log.warning( + "'%s' response for bucket '%s'", + meta_response["Code"], + bucket_name, + ) + continue + else: + log.warning( + "S3 Error! Do you have any files in your S3 bucket?" + ) + return {} + + environments = [(os.path.dirname(k["Key"]).split("/", 1))[0] for k in files] + environments = set(environments) + + # pull out the files for the environment + for saltenv in environments: + # grab only files/dirs that match this saltenv + env_files = [k for k in files if k["Key"].startswith(saltenv)] + + if saltenv not in metadata: + metadata[saltenv] = [] + + found = False + for bucket_files in metadata[saltenv]: + if bucket_name in bucket_files: + bucket_files[bucket_name] += env_files + found = True + break + if not found: + metadata[saltenv].append({bucket_name: env_files}) + + # write the metadata to disk + _write_buckets_cache_file(metadata, cache_file) + + return metadata + + +def _prune_deleted_files(metadata): + cache_dir = _get_cache_dir() + cached_files = set() + roots = set() + + if _is_env_per_bucket(): + for env, env_data in metadata.items(): + for bucket_meta in env_data: + for bucket, bucket_data in bucket_meta.items(): + root = os.path.join(cache_dir, env, bucket) + + if os.path.exists(root): + roots.add(root) + + for meta in bucket_data: + path = meta["Key"] + cached_files.add(path) + + else: + for env, env_data in metadata.items(): + for bucket in _get_buckets(): + root = os.path.join(cache_dir, bucket) + + if os.path.exists(root): + roots.add(root) + + for meta in env_data: + cached_files.add(meta["Key"]) + + if log.isEnabledFor(logging.DEBUG): + import pprint + + log.debug("cached file list:\n%s", pprint.pformat(cached_files)) + + for root in roots: + for base, dirs, files in os.walk(root): + for file_name in files: + path = os.path.join(base, file_name) + relpath = os.path.relpath(path, root) + + if relpath not in cached_files: + log.debug("File '%s' not found in cached file list", path) + log.info( + "File '%s' was deleted from bucket, deleting local copy", + relpath, + ) + + os.unlink(path) + dirname = os.path.dirname(path) + + # delete empty dirs all the way up to the cache dir + while dirname != cache_dir and len(os.listdir(dirname)) == 0: + log.debug("Directory '%s' is now empty, removing", dirname) + os.rmdir(dirname) + dirname = os.path.dirname(dirname) + + +def _write_buckets_cache_file(metadata, cache_file): + """ + Write the contents of the buckets cache file + """ + cache_dir = _get_cache_dir() + + if not os.path.exists(cache_dir): + os.makedirs(cache_dir) + + if os.path.isfile(cache_file): + os.remove(cache_file) + + log.debug("Writing buckets cache file") + + with salt.utils.files.fopen(cache_file, "wb") as fp_: + pickle.dump(metadata, fp_) + + +def _read_buckets_cache_file(cache_file): + """ + Return the contents of the buckets cache file + """ + + log.debug("Reading buckets cache file") + + if not os.path.exists(cache_file): + log.debug("Cache file does not exist") + return None + + with salt.utils.files.fopen(cache_file, "rb") as fp_: + try: + data = pickle.load(fp_) + except ( + pickle.UnpicklingError, + AttributeError, + EOFError, + ImportError, + IndexError, + KeyError, + ValueError, + ) as exc: + log.debug("Exception reading buckets cache file: '%s'", exc) + data = None + + return data + + +def _find_files(metadata): + """ + Looks for all the files in the S3 bucket cache metadata + """ + + ret = [] + found = {} + + for bucket_dict in metadata: + for bucket_name, data in bucket_dict.items(): + filepaths = [k["Key"] for k in data] + filepaths = [k for k in filepaths if not k.endswith("/")] + if bucket_name not in found: + found[bucket_name] = True + ret.append({bucket_name: filepaths}) + else: + for bucket in ret: + if bucket_name in bucket: + bucket[bucket_name] += filepaths + break + return ret + + +def _find_dirs(metadata): + """ + Looks for all the directories in the S3 bucket cache metadata. + + Supports trailing '/' keys (as created by S3 console) as well as + directories discovered in the path of file keys. + """ + + ret = [] + found = {} + + for bucket_dict in metadata: + for bucket_name, data in bucket_dict.items(): + dirpaths = set() + for path in [k["Key"] for k in data]: + prefix = "" + for part in path.split("/")[:-1]: + directory = prefix + part + "/" + dirpaths.add(directory) + prefix = directory + if bucket_name not in found: + found[bucket_name] = True + ret.append({bucket_name: list(dirpaths)}) + else: + for bucket in ret: + if bucket_name in bucket: + bucket[bucket_name] += list(dirpaths) + bucket[bucket_name] = list(set(bucket[bucket_name])) + break + return ret + + +def _find_file_meta(metadata, bucket_name, saltenv, path): + """ + Looks for a file's metadata in the S3 bucket cache file + """ + env_meta = metadata[saltenv] if saltenv in metadata else {} + bucket_meta = {} + for bucket in env_meta: + if bucket_name in bucket: + bucket_meta = bucket[bucket_name] + files_meta = list(list(filter((lambda k: "Key" in k), bucket_meta))) + + for item_meta in files_meta: + if "Key" in item_meta and item_meta["Key"] == path: + try: + # Get rid of quotes surrounding md5 + item_meta["ETag"] = item_meta["ETag"].strip('"') + except KeyError: + pass + return item_meta + + +def _get_buckets(): + """ + Return the configuration buckets + """ + + return __opts__["s3.buckets"] if "s3.buckets" in __opts__ else {} + + +def _get_file_from_s3(metadata, saltenv, bucket_name, path, cached_file_path): + """ + Checks the local cache for the file, if it's old or missing go grab the + file from S3 and update the cache + """ + + # make sure bucket and saltenv directories exist + target_dir = os.path.dirname(cached_file_path) + + if not os.path.exists(target_dir): + os.makedirs(target_dir) + + ( + key, + keyid, + service_url, + verify_ssl, + kms_keyid, + location, + path_style, + https_enable, + ) = _get_s3_key() + + # check the local cache... + if os.path.isfile(cached_file_path): + file_meta = _find_file_meta(metadata, bucket_name, saltenv, path) + if file_meta: + file_etag = file_meta["ETag"] + + if file_etag.find("-") == -1: + file_md5 = file_etag + cached_md5 = salt.utils.hashutils.get_hash( + cached_file_path, S3_HASH_TYPE + ) + + # hashes match we have a cache hit + if cached_md5 == file_md5: + return + else: + log.info("found different hash for file %s, updating...", path) + else: + cached_file_stat = os.stat(cached_file_path) + cached_file_size = cached_file_stat.st_size + cached_file_mtime = datetime.datetime.fromtimestamp( + cached_file_stat.st_mtime + ) + + cached_file_lastmod = datetime.datetime.strptime( + file_meta["LastModified"], "%Y-%m-%dT%H:%M:%S.%fZ" + ) + if ( + cached_file_size == int(file_meta["Size"]) + and cached_file_mtime > cached_file_lastmod + ): + log.debug( + "cached file size equal to metadata size and " + "cached file mtime later than metadata last " + "modification time." + ) + ret = __utils__["s3.query"]( + key=key, + keyid=keyid, + kms_keyid=keyid, + method="HEAD", + bucket=bucket_name, + service_url=service_url, + verify_ssl=verify_ssl, + location=location, + path=urllib.parse.quote(path), + local_file=cached_file_path, + full_headers=True, + path_style=path_style, + https_enable=https_enable, + ) + if ret is not None: + s3_file_mtime = s3_file_size = None + for header_name, header_value in ret["headers"].items(): + name = header_name.strip() + value = header_value.strip() + if str(name).lower() == "last-modified": + s3_file_mtime = datetime.datetime.strptime( + value, "%a, %d %b %Y %H:%M:%S %Z" + ) + elif str(name).lower() == "content-length": + s3_file_size = int(value) + if (s3_file_size and cached_file_size == s3_file_size) and ( + s3_file_mtime and cached_file_mtime > s3_file_mtime + ): + log.info( + "%s - %s : %s skipped download since cached file size " + "equal to and mtime after s3 values", + bucket_name, + saltenv, + path, + ) + return + + # ... or get the file from S3 + __utils__["s3.query"]( + key=key, + keyid=keyid, + kms_keyid=keyid, + bucket=bucket_name, + service_url=service_url, + verify_ssl=verify_ssl, + location=location, + path=urllib.parse.quote(path), + local_file=cached_file_path, + path_style=path_style, + https_enable=https_enable, + ) + + +def _trim_env_off_path(paths, saltenv, trim_slash=False): + """ + Return a list of file paths with the saltenv directory removed + """ + env_len = None if _is_env_per_bucket() else len(saltenv) + 1 + slash_len = -1 if trim_slash else None + + return [d[env_len:slash_len] for d in paths] + + +def _is_env_per_bucket(): + """ + Return the configuration mode, either buckets per environment or a list of + buckets that have environment dirs in their root + """ + + buckets = _get_buckets() + if isinstance(buckets, dict): + return True + elif isinstance(buckets, list): + return False + else: + raise ValueError("Incorrect s3.buckets type given in config") diff --git a/salt/fileserver/svnfs.py b/salt/fileserver/svnfs.py new file mode 100644 index 000000000000..8e13ec993530 --- /dev/null +++ b/salt/fileserver/svnfs.py @@ -0,0 +1,787 @@ +""" +Subversion Fileserver Backend + +After enabling this backend, branches and tags in a remote subversion +repository are exposed to salt as different environments. To enable this +backend, add ``svnfs`` to the :conf_master:`fileserver_backend` option in the +Master config file. + +.. code-block:: yaml + + fileserver_backend: + - svnfs + +.. note:: + ``svn`` also works here. Prior to the 2018.3.0 release, *only* ``svn`` + would work. + +This backend assumes a standard svn layout with directories for ``branches``, +``tags``, and ``trunk``, at the repository root. + +:depends: - subversion + - pysvn + +.. versionchanged:: 2014.7.0 + The paths to the trunk, branches, and tags have been made configurable, via + the config options :conf_master:`svnfs_trunk`, + :conf_master:`svnfs_branches`, and :conf_master:`svnfs_tags`. + :conf_master:`svnfs_mountpoint` was also added. Finally, support for + per-remote configuration parameters was added. See the + :conf_master:`documentation ` for more information. +""" + +import copy +import errno +import fnmatch +import hashlib +import logging +import os +import shutil +from datetime import datetime + +import salt.fileserver +import salt.utils.data +import salt.utils.files +import salt.utils.gzip_util +import salt.utils.hashutils +import salt.utils.path +import salt.utils.stringutils +import salt.utils.url +import salt.utils.versions +from salt.config import DEFAULT_HASH_TYPE +from salt.exceptions import FileserverConfigError +from salt.utils.event import tagify + +PER_REMOTE_OVERRIDES = ("mountpoint", "root", "trunk", "branches", "tags") + + +# pylint: disable=import-error +HAS_SVN = False +try: + import pysvn + + HAS_SVN = True + CLIENT = pysvn.Client() +except ImportError: + pass +# pylint: enable=import-error + + +log = logging.getLogger(__name__) + +# Define the module's virtual name +__virtualname__ = "svnfs" +__virtual_aliases__ = ("svn",) + + +def __virtual__(): + """ + Only load if subversion is available + """ + if __virtualname__ not in __opts__["fileserver_backend"]: + return False + if not HAS_SVN: + log.error( + "Subversion fileserver backend is enabled in configuration " + "but could not be loaded, is pysvn installed?" + ) + return False + errors = [] + for param in ("svnfs_trunk", "svnfs_branches", "svnfs_tags"): + if os.path.isabs(__opts__[param]): + errors.append( + "Master configuration parameter '{}' (value: {}) cannot " + "be an absolute path".format(param, __opts__[param]) + ) + if errors: + for error in errors: + log.error(error) + log.error("Subversion fileserver backed will be disabled") + return False + return __virtualname__ + + +def _rev(repo): + """ + Returns revision ID of repo + """ + try: + repo_info = dict(CLIENT.info(repo["repo"]).items()) + except (pysvn._pysvn.ClientError, TypeError, KeyError, AttributeError) as exc: + log.error( + "Error retrieving revision ID for svnfs remote %s (cachedir: %s): %s", + repo["url"], + repo["repo"], + exc, + ) + else: + return repo_info["revision"].number + return None + + +def _failhard(): + """ + Fatal fileserver configuration issue, raise an exception + """ + raise FileserverConfigError("Failed to load svn fileserver backend") + + +def init(): + """ + Return the list of svn remotes and their configuration information + """ + bp_ = os.path.join(__opts__["cachedir"], "svnfs") + new_remote = False + repos = [] + + per_remote_defaults = {} + for param in PER_REMOTE_OVERRIDES: + per_remote_defaults[param] = str(__opts__[f"svnfs_{param}"]) + + for remote in __opts__["svnfs_remotes"]: + repo_conf = copy.deepcopy(per_remote_defaults) + if isinstance(remote, dict): + repo_url = next(iter(remote)) + per_remote_conf = { + key: str(val) + for key, val in salt.utils.data.repack_dictlist( + remote[repo_url] + ).items() + } + if not per_remote_conf: + log.error( + "Invalid per-remote configuration for remote %s. If no " + "per-remote parameters are being specified, there may be " + "a trailing colon after the URL, which should be removed. " + "Check the master configuration file.", + repo_url, + ) + _failhard() + + per_remote_errors = False + for param in (x for x in per_remote_conf if x not in PER_REMOTE_OVERRIDES): + log.error( + "Invalid configuration parameter '%s' for remote %s. " + "Valid parameters are: %s. See the documentation for " + "further information.", + param, + repo_url, + ", ".join(PER_REMOTE_OVERRIDES), + ) + per_remote_errors = True + if per_remote_errors: + _failhard() + + repo_conf.update(per_remote_conf) + else: + repo_url = remote + + if not isinstance(repo_url, str): + log.error( + "Invalid svnfs remote %s. Remotes must be strings, you may " + "need to enclose the URL in quotes", + repo_url, + ) + _failhard() + + try: + repo_conf["mountpoint"] = salt.utils.url.strip_proto( + repo_conf["mountpoint"] + ) + except TypeError: + # mountpoint not specified + pass + + hash_type = getattr(hashlib, __opts__.get("hash_type", DEFAULT_HASH_TYPE)) + repo_hash = hash_type(repo_url).hexdigest() + rp_ = os.path.join(bp_, repo_hash) + if not os.path.isdir(rp_): + os.makedirs(rp_) + + if not os.listdir(rp_): + # Only attempt a new checkout if the directory is empty. + try: + CLIENT.checkout(repo_url, rp_) + repos.append(rp_) + new_remote = True + except pysvn._pysvn.ClientError as exc: + log.error("Failed to initialize svnfs remote '%s': %s", repo_url, exc) + _failhard() + else: + # Confirm that there is an svn checkout at the necessary path by + # running pysvn.Client().status() + try: + CLIENT.status(rp_) + except pysvn._pysvn.ClientError as exc: + log.error( + "Cache path %s (corresponding remote: %s) exists but is " + "not a valid subversion checkout. You will need to " + "manually delete this directory on the master to continue " + "to use this svnfs remote.", + rp_, + repo_url, + ) + _failhard() + + repo_conf.update( + { + "repo": rp_, + "url": repo_url, + "hash": repo_hash, + "cachedir": rp_, + "lockfile": os.path.join(rp_, "update.lk"), + } + ) + repos.append(repo_conf) + + if new_remote: + remote_map = os.path.join(__opts__["cachedir"], "svnfs/remote_map.txt") + try: + with salt.utils.files.fopen(remote_map, "w+") as fp_: + timestamp = datetime.now().strftime("%d %b %Y %H:%M:%S.%f") + fp_.write(f"# svnfs_remote map as of {timestamp}\n") + for repo_conf in repos: + fp_.write( + salt.utils.stringutils.to_str( + "{} = {}\n".format(repo_conf["hash"], repo_conf["url"]) + ) + ) + except OSError: + pass + else: + log.info("Wrote new svnfs_remote map to %s", remote_map) + + return repos + + +def _clear_old_remotes(): + """ + Remove cache directories for remotes no longer configured + """ + bp_ = os.path.join(__opts__["cachedir"], "svnfs") + try: + cachedir_ls = os.listdir(bp_) + except OSError: + cachedir_ls = [] + repos = init() + # Remove actively-used remotes from list + for repo in repos: + try: + cachedir_ls.remove(repo["hash"]) + except ValueError: + pass + to_remove = [] + for item in cachedir_ls: + if item in ("hash", "refs"): + continue + path = os.path.join(bp_, item) + if os.path.isdir(path): + to_remove.append(path) + failed = [] + if to_remove: + for rdir in to_remove: + try: + shutil.rmtree(rdir) + except OSError as exc: + log.error( + "Unable to remove old svnfs remote cachedir %s: %s", rdir, exc + ) + failed.append(rdir) + else: + log.debug("svnfs removed old cachedir %s", rdir) + for fdir in failed: + to_remove.remove(fdir) + return bool(to_remove), repos + + +def clear_cache(): + """ + Completely clear svnfs cache + """ + fsb_cachedir = os.path.join(__opts__["cachedir"], "svnfs") + list_cachedir = os.path.join(__opts__["cachedir"], "file_lists/svnfs") + errors = [] + for rdir in (fsb_cachedir, list_cachedir): + if os.path.exists(rdir): + try: + shutil.rmtree(rdir) + except OSError as exc: + errors.append(f"Unable to delete {rdir}: {exc}") + return errors + + +def clear_lock(remote=None): + """ + Clear update.lk + + ``remote`` can either be a dictionary containing repo configuration + information, or a pattern. If the latter, then remotes for which the URL + matches the pattern will be locked. + """ + + def _do_clear_lock(repo): + def _add_error(errlist, repo, exc): + msg = "Unable to remove update lock for {} ({}): {} ".format( + repo["url"], repo["lockfile"], exc + ) + log.debug(msg) + errlist.append(msg) + + success = [] + failed = [] + if os.path.exists(repo["lockfile"]): + try: + os.remove(repo["lockfile"]) + except OSError as exc: + if exc.errno == errno.EISDIR: + # Somehow this path is a directory. Should never happen + # unless some wiseguy manually creates a directory at this + # path, but just in case, handle it. + try: + shutil.rmtree(repo["lockfile"]) + except OSError as exc: + _add_error(failed, repo, exc) + else: + _add_error(failed, repo, exc) + else: + msg = "Removed lock for {}".format(repo["url"]) + log.debug(msg) + success.append(msg) + return success, failed + + if isinstance(remote, dict): + return _do_clear_lock(remote) + + cleared = [] + errors = [] + for repo in init(): + if remote: + try: + if remote not in repo["url"]: + continue + except TypeError: + # remote was non-string, try again + if str(remote) not in repo["url"]: + continue + success, failed = _do_clear_lock(repo) + cleared.extend(success) + errors.extend(failed) + return cleared, errors + + +def lock(remote=None): + """ + Place an update.lk + + ``remote`` can either be a dictionary containing repo configuration + information, or a pattern. If the latter, then remotes for which the URL + matches the pattern will be locked. + """ + + def _do_lock(repo): + success = [] + failed = [] + if not os.path.exists(repo["lockfile"]): + try: + with salt.utils.files.fopen(repo["lockfile"], "w+") as fp_: + fp_.write("") + except OSError as exc: + msg = "Unable to set update lock for {} ({}): {} ".format( + repo["url"], repo["lockfile"], exc + ) + log.debug(msg) + failed.append(msg) + else: + msg = "Set lock for {}".format(repo["url"]) + log.debug(msg) + success.append(msg) + return success, failed + + if isinstance(remote, dict): + return _do_lock(remote) + + locked = [] + errors = [] + for repo in init(): + if remote: + try: + if not fnmatch.fnmatch(repo["url"], remote): + continue + except TypeError: + # remote was non-string, try again + if not fnmatch.fnmatch(repo["url"], str(remote)): + continue + success, failed = _do_lock(repo) + locked.extend(success) + errors.extend(failed) + + return locked, errors + + +def update(): + """ + Execute an svn update on all of the repos + """ + # data for the fileserver event + data = {"changed": False, "backend": "svnfs"} + # _clear_old_remotes runs init(), so use the value from there to avoid a + # second init() + data["changed"], repos = _clear_old_remotes() + for repo in repos: + if os.path.exists(repo["lockfile"]): + log.warning( + "Update lockfile is present for svnfs remote %s, skipping. " + "If this warning persists, it is possible that the update " + "process was interrupted. Removing %s or running " + "'salt-run fileserver.clear_lock svnfs' will allow updates " + "to continue for this remote.", + repo["url"], + repo["lockfile"], + ) + continue + _, errors = lock(repo) + if errors: + log.error( + "Unable to set update lock for svnfs remote %s, skipping.", repo["url"] + ) + continue + log.debug("svnfs is fetching from %s", repo["url"]) + old_rev = _rev(repo) + try: + CLIENT.update(repo["repo"]) + except pysvn._pysvn.ClientError as exc: + log.error( + "Error updating svnfs remote %s (cachedir: %s): %s", + repo["url"], + repo["cachedir"], + exc, + ) + + new_rev = _rev(repo) + if any(x is None for x in (old_rev, new_rev)): + # There were problems getting the revision ID + continue + if new_rev != old_rev: + data["changed"] = True + + clear_lock(repo) + + env_cache = os.path.join(__opts__["cachedir"], "svnfs/envs.p") + if data.get("changed", False) is True or not os.path.isfile(env_cache): + env_cachedir = os.path.dirname(env_cache) + if not os.path.exists(env_cachedir): + os.makedirs(env_cachedir) + new_envs = envs(ignore_cache=True) + with salt.utils.files.fopen(env_cache, "wb+") as fp_: + fp_.write(salt.payload.dumps(new_envs)) + log.trace("Wrote env cache data to %s", env_cache) + + # if there is a change, fire an event + if __opts__.get("fileserver_events", False): + with salt.utils.event.get_event( + "master", + __opts__["sock_dir"], + opts=__opts__, + listen=False, + ) as event: + event.fire_event(data, tagify(["svnfs", "update"], prefix="fileserver")) + try: + salt.fileserver.reap_fileserver_cache_dir( + os.path.join(__opts__["cachedir"], "svnfs/hash"), find_file + ) + except OSError: + # Hash file won't exist if no files have yet been served up + pass + + +def _env_is_exposed(env): + """ + Check if an environment is exposed by comparing it against a whitelist and + blacklist. + """ + return salt.utils.stringutils.check_whitelist_blacklist( + env, + whitelist=__opts__["svnfs_saltenv_whitelist"], + blacklist=__opts__["svnfs_saltenv_blacklist"], + ) + + +def envs(ignore_cache=False): + """ + Return a list of refs that can be used as environments + """ + if not ignore_cache: + env_cache = os.path.join(__opts__["cachedir"], "svnfs/envs.p") + cache_match = salt.fileserver.check_env_cache(__opts__, env_cache) + if cache_match is not None: + return cache_match + ret = set() + for repo in init(): + trunk = os.path.join(repo["repo"], repo["trunk"]) + if os.path.isdir(trunk): + # Add base as the env for trunk + ret.add("base") + else: + log.error( + "svnfs trunk path '%s' does not exist in repo %s, no base " + "environment will be provided by this remote", + repo["trunk"], + repo["url"], + ) + + branches = os.path.join(repo["repo"], repo["branches"]) + if os.path.isdir(branches): + ret.update(os.listdir(branches)) + else: + log.error( + "svnfs branches path '%s' does not exist in repo %s", + repo["branches"], + repo["url"], + ) + + tags = os.path.join(repo["repo"], repo["tags"]) + if os.path.isdir(tags): + ret.update(os.listdir(tags)) + else: + log.error( + "svnfs tags path '%s' does not exist in repo %s", + repo["tags"], + repo["url"], + ) + return [x for x in sorted(ret) if _env_is_exposed(x)] + + +def _env_root(repo, saltenv): + """ + Return the root of the directory corresponding to the desired environment, + or None if the environment was not found. + """ + # If 'base' is desired, look for the trunk + if saltenv == "base": + trunk = os.path.join(repo["repo"], repo["trunk"]) + if os.path.isdir(trunk): + return trunk + else: + return None + + # Check branches + branches = os.path.join(repo["repo"], repo["branches"]) + if os.path.isdir(branches) and saltenv in os.listdir(branches): + return os.path.join(branches, saltenv) + + # Check tags + tags = os.path.join(repo["repo"], repo["tags"]) + if os.path.isdir(tags) and saltenv in os.listdir(tags): + return os.path.join(tags, saltenv) + + return None + + +def find_file(path, tgt_env="base", **kwargs): # pylint: disable=W0613 + """ + Find the first file to match the path and ref. This operates similarly to + the roots file sever but with assumptions of the directory structure + based on svn standard practices. + """ + fnd = {"path": "", "rel": ""} + if os.path.isabs(path) or tgt_env not in envs(): + return fnd + + for repo in init(): + env_root = _env_root(repo, tgt_env) + if env_root is None: + # Environment not found, try the next repo + continue + if repo["mountpoint"] and not path.startswith(repo["mountpoint"] + os.path.sep): + continue + repo_path = path[len(repo["mountpoint"]) :].lstrip(os.path.sep) + if repo["root"]: + repo_path = os.path.join(repo["root"], repo_path) + + full = os.path.join(env_root, repo_path) + if os.path.isfile(full): + fnd["rel"] = path + fnd["path"] = full + try: + # Converting the stat result to a list, the elements of the + # list correspond to the following stat_result params: + # 0 => st_mode=33188 + # 1 => st_ino=10227377 + # 2 => st_dev=65026 + # 3 => st_nlink=1 + # 4 => st_uid=1000 + # 5 => st_gid=1000 + # 6 => st_size=1056233 + # 7 => st_atime=1468284229 + # 8 => st_mtime=1456338235 + # 9 => st_ctime=1456338235 + fnd["stat"] = list(os.stat(full)) + except Exception: # pylint: disable=broad-except + pass + return fnd + return fnd + + +def serve_file(load, fnd): + """ + Return a chunk from a file based on the data received + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + ret = {"data": "", "dest": ""} + if not all(x in load for x in ("path", "loc", "saltenv")): + return ret + if not fnd["path"]: + return ret + ret["dest"] = fnd["rel"] + gzip = load.get("gzip", None) + fpath = os.path.normpath(fnd["path"]) + with salt.utils.files.fopen(fpath, "rb") as fp_: + fp_.seek(load["loc"]) + data = fp_.read(__opts__["file_buffer_size"]) + if data and not salt.utils.files.is_binary(fpath): + data = data.decode(__salt_system_encoding__) + if gzip and data: + data = salt.utils.gzip_util.compress(data, gzip) + ret["gzip"] = gzip + ret["data"] = data + return ret + + +def file_hash(load, fnd): + """ + Return a file hash, the hash type is set in the master config file + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + if not all(x in load for x in ("path", "saltenv")): + return "" + saltenv = load["saltenv"] + if saltenv == "base": + saltenv = "trunk" + ret = {} + relpath = fnd["rel"] + path = fnd["path"] + + # If the file doesn't exist, we can't get a hash + if not path or not os.path.isfile(path): + return ret + + # Set the hash_type as it is determined by config + ret["hash_type"] = __opts__["hash_type"] + + # Check if the hash is cached + # Cache file's contents should be "hash:mtime" + cache_path = os.path.join( + __opts__["cachedir"], + "svnfs", + "hash", + saltenv, + "{}.hash.{}".format(relpath, __opts__["hash_type"]), + ) + # If we have a cache, serve that if the mtime hasn't changed + if os.path.exists(cache_path): + with salt.utils.files.fopen(cache_path, "rb") as fp_: + hsum, mtime = fp_.read().split(":") + if os.path.getmtime(path) == mtime: + # check if mtime changed + ret["hsum"] = hsum + return ret + + # if we don't have a cache entry-- lets make one + ret["hsum"] = salt.utils.hashutils.get_hash(path, __opts__["hash_type"]) + cache_dir = os.path.dirname(cache_path) + # make cache directory if it doesn't exist + if not os.path.exists(cache_dir): + os.makedirs(cache_dir) + # save the cache object "hash:mtime" + with salt.utils.files.fopen(cache_path, "w") as fp_: + fp_.write("{}:{}".format(ret["hsum"], os.path.getmtime(path))) + + return ret + + +def _file_lists(load, form): + """ + Return a dict containing the file lists for files, dirs, emptydirs and symlinks + """ + if "env" in load: + # "env" is not supported; Use "saltenv". + load.pop("env") + + if "saltenv" not in load or load["saltenv"] not in envs(): + return [] + + list_cachedir = os.path.join(__opts__["cachedir"], "file_lists/svnfs") + if not os.path.isdir(list_cachedir): + try: + os.makedirs(list_cachedir) + except OSError: + log.critical("Unable to make cachedir %s", list_cachedir) + return [] + list_cache = os.path.join(list_cachedir, "{}.p".format(load["saltenv"])) + w_lock = os.path.join(list_cachedir, ".{}.w".format(load["saltenv"])) + cache_match, refresh_cache, save_cache = salt.fileserver.check_file_list_cache( + __opts__, form, list_cache, w_lock + ) + if cache_match is not None: + return cache_match + if refresh_cache: + ret = {"files": set(), "dirs": set(), "empty_dirs": set()} + for repo in init(): + env_root = _env_root(repo, load["saltenv"]) + if env_root is None: + # Environment not found, try the next repo + continue + if repo["root"]: + env_root = os.path.join(env_root, repo["root"]).rstrip(os.path.sep) + if not os.path.isdir(env_root): + # svnfs root (global or per-remote) does not exist in env + continue + + for root, dirs, files in salt.utils.path.os_walk(env_root): + relpath = os.path.relpath(root, env_root) + dir_rel_fn = os.path.join(repo["mountpoint"], relpath) + if relpath != ".": + ret["dirs"].add(dir_rel_fn) + if not dirs and not files: + ret["empty_dirs"].add(dir_rel_fn) + for fname in files: + rel_fn = os.path.relpath(os.path.join(root, fname), env_root) + ret["files"].add(os.path.join(repo["mountpoint"], rel_fn)) + if repo["mountpoint"]: + ret["dirs"].add(repo["mountpoint"]) + # Convert all compiled sets to lists + for key in ret: + ret[key] = sorted(ret[key]) + if save_cache: + salt.fileserver.write_file_list_cache(__opts__, ret, list_cache, w_lock) + return ret.get(form, []) + # Shouldn't get here, but if we do, this prevents a TypeError + return [] + + +def file_list(load): + """ + Return a list of all files on the file server in a specified + environment + """ + return _file_lists(load, "files") + + +def file_list_emptydirs(load): + """ + Return a list of all empty directories on the master + """ + return _file_lists(load, "empty_dirs") + + +def dir_list(load): + """ + Return a list of all directories on the master + """ + return _file_lists(load, "dirs") diff --git a/salt/grains/chronos.py b/salt/grains/chronos.py new file mode 100644 index 000000000000..91d527f8e773 --- /dev/null +++ b/salt/grains/chronos.py @@ -0,0 +1,35 @@ +""" +Generate chronos proxy minion grains. + +.. versionadded:: 2015.8.2 + +""" + +import salt.utils.http +import salt.utils.platform + +__proxyenabled__ = ["chronos"] +__virtualname__ = "chronos" + + +def __virtual__(): + if not salt.utils.platform.is_proxy() or "proxy" not in __opts__: + return False + else: + return __virtualname__ + + +def kernel(): + return {"kernel": "chronos"} + + +def os(): + return {"os": "chronos"} + + +def os_family(): + return {"os_family": "chronos"} + + +def os_data(): + return {"os_data": "chronos"} diff --git a/salt/grains/cimc.py b/salt/grains/cimc.py new file mode 100644 index 000000000000..300321592946 --- /dev/null +++ b/salt/grains/cimc.py @@ -0,0 +1,34 @@ +""" +Generate baseline proxy minion grains for cimc hosts. + +""" + +import logging + +import salt.proxy.cimc +import salt.utils.platform + +__proxyenabled__ = ["cimc"] +__virtualname__ = "cimc" + +log = logging.getLogger(__file__) + +GRAINS_CACHE = {"os_family": "Cisco UCS"} + + +def __virtual__(): + try: + if salt.utils.platform.is_proxy() and __opts__["proxy"]["proxytype"] == "cimc": + return __virtualname__ + except KeyError: + pass + + return False + + +def cimc(proxy=None): + if not proxy: + return {} + if proxy["cimc.initialized"]() is False: + return {} + return {"cimc": proxy["cimc.grains"]()} diff --git a/salt/grains/core.py b/salt/grains/core.py index bde3df464766..b684d980ac06 100644 --- a/salt/grains/core.py +++ b/salt/grains/core.py @@ -28,7 +28,6 @@ # Solve the Chicken and egg problem where grains need to run before any # of the modules are loaded and are generally available for any usage. import salt.modules.cmdmod -import salt.modules.file as file import salt.modules.network import salt.modules.smbios import salt.utils.args @@ -793,9 +792,6 @@ def _windows_virtual(osdata): # Manufacturer: Parallels Software International Inc. elif "Parallels" in manufacturer: grains["virtual"] = "Parallels" - elif "Nutanix" in manufacturer and "AHV" in product_name: - grains["virtual"] = "kvm" - grains["virtual_subtype"] = "Nutanix AHV" # Apache CloudStack elif "CloudStack KVM Hypervisor" in product_name: grains["virtual"] = "kvm" @@ -967,10 +963,6 @@ def _virtual(osdata): elif "parallels" in line: grains["virtual"] = "Parallels" break - elif "nutanix" in line: - grains["virtual"] = "kvm" - grains["virtual_subtype"] = "Nutanix AHV" - break elif "hyperv" in line: grains["virtual"] = "HyperV" break @@ -1022,9 +1014,6 @@ def _virtual(osdata): grains["virtual"] = "Parallels" elif "Manufacturer: Google" in output: grains["virtual"] = "kvm" - elif "Manufacturer: Nutanix" in output and "Product Name: AHV" in output: - grains["virtual"] = "kvm" - grains["virtual_subtype"] = "Nutanix AHV" # Proxmox KVM elif "Vendor: SeaBIOS" in output: grains["virtual"] = "kvm" @@ -1281,7 +1270,6 @@ def _virtual(osdata): grains["virtual"] = "virtual" # Try to detect if the instance is running on Amazon EC2 - # or Nutanix AHV if grains["virtual"] in ("qemu", "kvm", "xen", "amazon"): dmidecode = salt.utils.path.which("dmidecode") if dmidecode: @@ -1301,9 +1289,6 @@ def _virtual(osdata): elif re.match(r".*Version: [^\r\n]+\.amazon.*", output, flags=re.DOTALL): grains["virtual_subtype"] = "Amazon EC2" - elif "Manufacturer: Nutanix" in output and "Product Name: AHV" in output: - grains["virtual_subtype"] = "Nutanix AHV" - for command in failed_commands: log.info( "Although '%s' was found in path, the current user " @@ -1808,9 +1793,7 @@ def id_(): "oracleserv": "OEL", "cloudserve": "CloudLinux", "cloudlinux": "CloudLinux", - "virtuozzo": "Virtuozzo", "almalinux": "AlmaLinux", - "almalinuxk": "AlmaLinux", "pidora": "Fedora", "scientific": "ScientificLinux", "synology": "Synology", @@ -1881,9 +1864,7 @@ def _derive_os_grain(osfullname, os_id=None): "Scientific": "RedHat", "Amazon": "RedHat", "CloudLinux": "RedHat", - "Virtuozzo": "RedHat", "AlmaLinux": "RedHat", - "AlmaLinux Kitten": "RedHat", "OVS": "RedHat", "OEL": "RedHat", "XCP": "RedHat", @@ -1944,7 +1925,6 @@ def _derive_os_grain(osfullname, os_id=None): "Alinux": "RedHat", "Mendel": "Debian", "OSMC": "Debian", - "openEuler": "RedHat", } @@ -2487,18 +2467,6 @@ def _legacy_linux_distribution_data(grains, os_release, lsb_has_error): grains["oscodename"] = oscodename if "os" not in grains: grains["os"] = _derive_os_grain(grains["osfullname"]) - if "SUSE_SUPPORT_PRODUCT" in os_release and "SUSE_SUPPORT_PRODUCT_VERSION": - # It's a workaround for very specific case of SL Micro 6.2 - # SL Micro 6.2 is different than prevoius ones and identifies itself - # as SLES-16, but transactional. This workaround was made to make the grains - # of SL Micro 6.2 aligned with the previous versions. - grains["oscodename"] = os_release.get( - "SUSE_PRETTY_NAME", - f"{os_release['SUSE_SUPPORT_PRODUCT']} {os_release['SUSE_SUPPORT_PRODUCT_VERSION']}", - ) - grains["osrelease"] = os_release["SUSE_SUPPORT_PRODUCT_VERSION"] - if os_release["SUSE_SUPPORT_PRODUCT"] == "SUSE Linux Micro": - grains["osfullname"] = "SL-Micro" # this assigns family names based on the os name # family defaults to the os name if not found grains["os_family"] = _OS_FAMILY_MAP.get(grains["os"], grains["os"]) @@ -2553,21 +2521,13 @@ def _osrelease_data(os, osfullname, osrelease): grains["osrelease_info"], ) - if os in ( - "Debian", - "FreeBSD", - "OpenBSD", - "NetBSD", - "Mac", - "Raspbian", - "AlmaLinux", - ): + if os in ("Debian", "FreeBSD", "OpenBSD", "NetBSD", "Mac", "Raspbian"): os_name = os else: os_name = osfullname grains["osfinger"] = "{}-{}".format( os_name, - osrelease if os in ("Ubuntu", "Pop", "NixOS") else grains["osrelease_info"][0], + osrelease if os in ("Ubuntu", "Pop") else grains["osrelease_info"][0], ) return grains @@ -2999,12 +2959,12 @@ def ip_fqdn(): if not ret["ipv" + ipv_num]: ret[key] = [] else: - start_time = datetime.datetime.now(tz=datetime.timezone.utc) + start_time = datetime.datetime.utcnow() try: info = socket.getaddrinfo(_fqdn, None, socket_type) ret[key] = list({item[4][0] for item in info}) except (OSError, UnicodeError): - timediff = datetime.datetime.now(tz=datetime.timezone.utc) - start_time + timediff = datetime.datetime.utcnow() - start_time if timediff.seconds > 5 and __opts__["__role"] == "master": log.warning( 'Unable to find IPv%s record for "%s" causing a %s ' @@ -3647,13 +3607,3 @@ def kernelparams(): log.debug("Failed to read /proc/cmdline: %s", exc) return grains - - -def fibre_channel_host(): - """ - Determine whether the minion is a fibre channel host - """ - grains = {"fibre_channel_host": False} - if file.directory_exists("/sys/class/fc_host"): - grains["fibre_channel_host"] = True - return grains diff --git a/salt/grains/esxi.py b/salt/grains/esxi.py new file mode 100644 index 000000000000..e9b40e73c229 --- /dev/null +++ b/salt/grains/esxi.py @@ -0,0 +1,115 @@ +""" +Generate baseline proxy minion grains for ESXi hosts. + +.. Warning:: + This module will be deprecated in a future release of Salt. VMware strongly + recommends using the + `VMware Salt extensions `_ + instead of the ESXi module. Because the Salt extensions are newer and + actively supported by VMware, they are more compatible with current versions + of ESXi and they work well with the latest features in the VMware product + line. + + +""" + +import logging + +import salt.utils.proxy +from salt.exceptions import SaltSystemExit + +__proxyenabled__ = ["esxi"] +__virtualname__ = "esxi" + +log = logging.getLogger(__file__) + +GRAINS_CACHE = {} + + +def __virtual__(): + + # import salt.utils.proxy again + # so it is available for tests. + import salt.utils.proxy + + try: + if salt.utils.proxy.is_proxytype(__opts__, "esxi"): + import salt.modules.vsphere + + return __virtualname__ + except KeyError: + pass + + return False + + +def esxi(): + return _grains() + + +def kernel(): + return {"kernel": "proxy"} + + +def os(): + if not GRAINS_CACHE: + GRAINS_CACHE.update(_grains()) + + try: + return {"os": GRAINS_CACHE.get("fullName")} + except AttributeError: + return {"os": "Unknown"} + + +def os_family(): + return {"os_family": "proxy"} + + +def _find_credentials(host): + """ + Cycle through all the possible credentials and return the first one that + works. + """ + user_names = [__pillar__["proxy"].get("username", "root")] + passwords = __pillar__["proxy"]["passwords"] + for user in user_names: + for password in passwords: + try: + # Try to authenticate with the given user/password combination + ret = salt.modules.vsphere.system_info( + host=host, username=user, password=password + ) + except SaltSystemExit: + # If we can't authenticate, continue on to try the next password. + continue + # If we have data returned from above, we've successfully authenticated. + if ret: + return user, password + # We've reached the end of the list without successfully authenticating. + raise SaltSystemExit( + "Cannot complete login due to an incorrect user name or password." + ) + + +def _grains(): + """ + Get the grains from the proxied device. + """ + try: + host = __pillar__["proxy"]["host"] + if host: + username, password = _find_credentials(host) + protocol = __pillar__["proxy"].get("protocol") + port = __pillar__["proxy"].get("port") + ret = salt.modules.vsphere.system_info( + host=host, + username=username, + password=password, + protocol=protocol, + port=port, + ) + GRAINS_CACHE.update(ret) + except KeyError: + pass + + return GRAINS_CACHE diff --git a/salt/grains/extra.py b/salt/grains/extra.py index 0d2fa18aa6df..c89185caa0ae 100644 --- a/salt/grains/extra.py +++ b/salt/grains/extra.py @@ -2,13 +2,12 @@ import logging import os -import yaml - import salt.utils import salt.utils.data import salt.utils.files import salt.utils.path import salt.utils.platform +import salt.utils.yaml __proxyenabled__ = ["*"] log = logging.getLogger(__name__) @@ -57,7 +56,7 @@ def config(): log.debug("Loading static grains from %s", gfn) with salt.utils.files.fopen(gfn, "rb") as fp_: try: - return salt.utils.data.decode(yaml.safe_load(fp_)) + return salt.utils.data.decode(salt.utils.yaml.safe_load(fp_)) except Exception: # pylint: disable=broad-except log.warning("Bad syntax in grains file! Skipping.") return {} diff --git a/salt/grains/fibre_channel.py b/salt/grains/fibre_channel.py new file mode 100644 index 000000000000..412f154d6e8c --- /dev/null +++ b/salt/grains/fibre_channel.py @@ -0,0 +1,74 @@ +""" +Grains for Fibre Channel WWN's. On Windows this runs a PowerShell command that +queries WMI to get the Fibre Channel WWN's available. + +.. versionadded:: 2018.3.0 + +To enable these grains set ``fibre_channel_grains: True`` in the minion config. + +.. code-block:: yaml + + fibre_channel_grains: True +""" + +import glob +import logging + +import salt.modules.cmdmod +import salt.utils.files +import salt.utils.platform + +__virtualname__ = "fibre_channel" + +# Get logging started +log = logging.getLogger(__name__) + + +def __virtual__(): + if __opts__.get("fibre_channel_grains", False) is False: + return False + else: + return __virtualname__ + + +def _linux_wwns(): + """ + Return Fibre Channel port WWNs from a Linux host. + """ + ret = [] + for fc_file in glob.glob("/sys/class/fc_host/*/port_name"): + with salt.utils.files.fopen(fc_file, "r") as _wwn: + content = _wwn.read() + for line in content.splitlines(): + ret.append(line.rstrip()[2:]) + return ret + + +def _windows_wwns(): + """ + Return Fibre Channel port WWNs from a Windows host. + """ + ps_cmd = ( + r"Get-WmiObject -ErrorAction Stop " + r"-class MSFC_FibrePortHBAAttributes " + r'-namespace "root\WMI" | ' + r"Select -Expandproperty Attributes | " + r'%{($_.PortWWN | % {"{0:x2}" -f $_}) -join ""}' + ) + ret = [] + cmd_ret = salt.modules.cmdmod.powershell(ps_cmd) + for line in cmd_ret: + ret.append(line.rstrip()) + return ret + + +def fibre_channel_wwns(): + """ + Return list of fiber channel HBA WWNs + """ + grains = {"fc_wwn": False} + if salt.utils.platform.is_linux(): + grains["fc_wwn"] = _linux_wwns() + elif salt.utils.platform.is_windows(): + grains["fc_wwn"] = _windows_wwns() + return grains diff --git a/salt/grains/fx2.py b/salt/grains/fx2.py new file mode 100644 index 000000000000..e341fb721efa --- /dev/null +++ b/salt/grains/fx2.py @@ -0,0 +1,124 @@ +""" +Generate baseline proxy minion grains for Dell FX2 chassis. +The challenge is that most of Salt isn't bootstrapped yet, +so we need to repeat a bunch of things that would normally happen +in proxy/fx2.py--just enough to get data from the chassis to include +in grains. +""" + +import logging + +import salt.modules.cmdmod +import salt.modules.dracr +import salt.proxy.fx2 +import salt.utils.platform + +__proxyenabled__ = ["fx2"] + +__virtualname__ = "fx2" + +logger = logging.getLogger(__file__) + + +GRAINS_CACHE = {} + + +def __virtual__(): + if ( + salt.utils.platform.is_proxy() + and "proxy" in __opts__ + and __opts__["proxy"].get("proxytype") == "fx2" + ): + return __virtualname__ + return False + + +def _find_credentials(): + """ + Cycle through all the possible credentials and return the first one that + works + """ + usernames = [] + usernames.append(__pillar__["proxy"].get("admin_username", "root")) + if "fallback_admin_username" in __pillar__.get("proxy"): + usernames.append(__pillar__["proxy"].get("fallback_admin_username")) + + for user in usernames: + for pwd in __pillar__["proxy"]["passwords"]: + r = salt.modules.dracr.get_chassis_name( + host=__pillar__["proxy"]["host"], + admin_username=user, + admin_password=pwd, + ) + # Retcode will be present if the chassis_name call failed + try: + if r.get("retcode", None) is None: + __opts__["proxy"]["admin_username"] = user + __opts__["proxy"]["admin_password"] = pwd + return (user, pwd) + except AttributeError: + # Then the above was a string, and we can return the username + # and password + __opts__["proxy"]["admin_username"] = user + __opts__["proxy"]["admin_password"] = pwd + return (user, pwd) + + logger.debug( + "grains fx2.find_credentials found no valid credentials, using Dell default" + ) + return ("root", "calvin") + + +def _grains(): + """ + Get the grains from the proxied device + """ + (username, password) = _find_credentials() + r = salt.modules.dracr.system_info( + host=__pillar__["proxy"]["host"], + admin_username=username, + admin_password=password, + ) + + if r.get("retcode", 0) == 0: + GRAINS_CACHE = r + else: + GRAINS_CACHE = {} + + GRAINS_CACHE.update( + salt.modules.dracr.inventory( + host=__pillar__["proxy"]["host"], + admin_username=username, + admin_password=password, + ) + ) + + return GRAINS_CACHE + + +def fx2(): + return _grains() + + +def kernel(): + return {"kernel": "proxy"} + + +def location(): + if not GRAINS_CACHE: + GRAINS_CACHE.update(_grains()) + + try: + return { + "location": GRAINS_CACHE.get("Chassis Information").get("Chassis Location") + } + except AttributeError: + return {"location": "Unknown"} + + +def os_family(): + return {"os_family": "proxy"} + + +def os_data(): + return {"os_data": "Unknown"} diff --git a/salt/grains/iscsi.py b/salt/grains/iscsi.py new file mode 100644 index 000000000000..62c4eccc7199 --- /dev/null +++ b/salt/grains/iscsi.py @@ -0,0 +1,109 @@ +""" +Grains for iSCSI Qualified Names (IQN). + +.. versionadded:: 2018.3.0 + +To enable these grains set `iscsi_grains: True` in the minion config. + +.. code-block:: yaml + + iscsi_grains: True +""" + +import errno +import logging + +import salt.modules.cmdmod +import salt.utils.files +import salt.utils.path +import salt.utils.platform + +__virtualname__ = "iscsi" + +# Get logging started +log = logging.getLogger(__name__) + + +def __virtual__(): + if __opts__.get("iscsi_grains", False) is False: + return False + else: + return __virtualname__ + + +def iscsi_iqn(): + """ + Return iSCSI IQN + """ + grains = {} + grains["iscsi_iqn"] = False + if salt.utils.platform.is_linux(): + grains["iscsi_iqn"] = _linux_iqn() + elif salt.utils.platform.is_windows(): + grains["iscsi_iqn"] = _windows_iqn() + elif salt.utils.platform.is_aix(): + grains["iscsi_iqn"] = _aix_iqn() + return grains + + +def _linux_iqn(): + """ + Return iSCSI IQN from a Linux host. + """ + ret = [] + + initiator = "/etc/iscsi/initiatorname.iscsi" + try: + with salt.utils.files.fopen(initiator, "r") as _iscsi: + for line in _iscsi: + line = line.strip() + if line.startswith("InitiatorName="): + ret.append(line.split("=", 1)[1]) + except OSError as ex: + if ex.errno != errno.ENOENT: + log.debug("Error while accessing '%s': %s", initiator, ex) + + return ret + + +def _aix_iqn(): + """ + Return iSCSI IQN from an AIX host. + """ + ret = [] + + aix_cmd = "lsattr -E -l iscsi0 | grep initiator_name" + + aix_ret = salt.modules.cmdmod.run(aix_cmd) + if aix_ret[0].isalpha(): + try: + ret.append(aix_ret.split()[1].rstrip()) + except IndexError: + pass + return ret + + +def _windows_iqn(): + """ + Return iSCSI nodes from a Windows host. + """ + cmd = "Get-InitiatorPort | Select NodeAddress" + ret = [] + + nodes = salt.modules.cmdmod.powershell(cmd) + + if not nodes: + log.trace("No iSCSI nodes found") + return ret + + # A single node will return a dictionary with a single entry + # {"NodeAddress": "iqn.1991-05.com.microsoft:johnj99-pc2.contoso.com"} + # Multiple nodes will return a list of single entry dicts + # We need a list of dict + if isinstance(nodes, dict): + nodes = [nodes] + + for node in nodes: + ret.append(node["NodeAddress"]) + + return ret diff --git a/salt/grains/junos.py b/salt/grains/junos.py new file mode 100644 index 000000000000..0dfd3344ff28 --- /dev/null +++ b/salt/grains/junos.py @@ -0,0 +1,65 @@ +""" +Grains for junos. +NOTE this is a little complicated--junos can only be accessed +via salt-proxy-minion. Thus, some grains make sense to get them +from the minion (PYTHONPATH), but others don't (ip_interfaces) +""" + +import logging + +import salt.utils.platform + +__proxyenabled__ = ["junos"] +__virtualname__ = "junos" + +# Get looging started +log = logging.getLogger(__name__) + + +def __virtual__(): + if "proxy" not in __opts__: + return False + else: + return __virtualname__ + + +def _remove_complex_types(dictionary): + """ + junos-eznc is now returning some complex types that + are not serializable by msgpack. Kill those. + """ + for k, v in dictionary.items(): + if isinstance(v, dict): + dictionary[k] = _remove_complex_types(v) + elif hasattr(v, "to_eng_string"): + dictionary[k] = v.to_eng_string() + + return dictionary + + +def defaults(): + if salt.utils.platform.is_proxy(): + return {"os": "proxy", "kernel": "unknown", "osrelease": "proxy"} + else: + return { + "os": "junos", + "kernel": "junos", + "osrelease": "junos FIXME", + } + + +def facts(proxy=None): + if proxy is None or proxy["junos.initialized"]() is False: + return {} + + ret_value = proxy["junos.get_serialized_facts"]() + if salt.utils.platform.is_proxy(): + ret = {"junos_facts": ret_value} + else: + ret = {"junos_facts": ret_value, "osrelease": ret_value["version"]} + + return ret + + +def os_family(): + return {"os_family": "junos"} diff --git a/salt/grains/marathon.py b/salt/grains/marathon.py new file mode 100644 index 000000000000..c9eb58d2f9d8 --- /dev/null +++ b/salt/grains/marathon.py @@ -0,0 +1,49 @@ +""" +Generate marathon proxy minion grains. + +.. versionadded:: 2015.8.2 + +""" + +import salt.utils.http +import salt.utils.platform + +__proxyenabled__ = ["marathon"] +__virtualname__ = "marathon" + + +def __virtual__(): + if ( + salt.utils.platform.is_proxy() + and "proxy" in __opts__ + and __opts__["proxy"].get("proxytype") == "marathon" + ): + return __virtualname__ + return False + + +def kernel(): + return {"kernel": "marathon"} + + +def os(): + return {"os": "marathon"} + + +def os_family(): + return {"os_family": "marathon"} + + +def os_data(): + return {"os_data": "marathon"} + + +def marathon(): + response = salt.utils.http.query( + "{}/v2/info".format(__opts__["proxy"].get("base_url", "http://locahost:8080")), + decode_type="json", + decode=True, + ) + if not response or "dict" not in response: + return {"marathon": None} + return {"marathon": response["dict"]} diff --git a/salt/grains/mdata.py b/salt/grains/mdata.py new file mode 100644 index 000000000000..009853bb7a3e --- /dev/null +++ b/salt/grains/mdata.py @@ -0,0 +1,154 @@ +""" +SmartOS Metadata grain provider + +:maintainer: Jorge Schrauwen +:maturity: new +:depends: salt.utils, salt.module.cmdmod +:platform: SmartOS + +.. versionadded:: 2017.7.0 + +""" + +import logging +import os + +import salt.modules.cmdmod +import salt.utils.dictupdate +import salt.utils.json +import salt.utils.path +import salt.utils.platform + +__virtualname__ = "mdata" +__salt__ = { + "cmd.run": salt.modules.cmdmod.run, +} + +log = logging.getLogger(__name__) + + +def __virtual__(): + """ + Figure out if we need to be loaded + """ + ## collect mdata grains in a SmartOS zone + if salt.utils.platform.is_smartos_zone(): + return __virtualname__ + ## collect mdata grains in a LX zone + if salt.utils.platform.is_linux() and "BrandZ virtual linux" in os.uname(): + return __virtualname__ + return False + + +def _user_mdata(mdata_list=None, mdata_get=None): + """ + User Metadata + """ + grains = {} + + if not mdata_list: + mdata_list = salt.utils.path.which("mdata-list") + + if not mdata_get: + mdata_get = salt.utils.path.which("mdata-get") + + if not mdata_list or not mdata_get: + return grains + + for mdata_grain in __salt__["cmd.run"]( + mdata_list, ignore_retcode=True + ).splitlines(): + if mdata_grain.startswith("ERROR:"): + log.warning("mdata-list returned an error, skipping mdata grains.") + continue + mdata_value = __salt__["cmd.run"]( + f"{mdata_get} {mdata_grain}", ignore_retcode=True + ) + + if not mdata_grain.startswith("sdc:"): + if "mdata" not in grains: + grains["mdata"] = {} + + log.debug("found mdata entry %s with value %s", mdata_grain, mdata_value) + mdata_grain = mdata_grain.replace("-", "_") + mdata_grain = mdata_grain.replace(":", "_") + grains["mdata"][mdata_grain] = mdata_value + + return grains + + +def _sdc_mdata(mdata_list=None, mdata_get=None): + """ + SDC Metadata specified by there specs + https://eng.joyent.com/mdata/datadict.html + """ + grains = {} + sdc_text_keys = [ + "uuid", + "server_uuid", + "datacenter_name", + "hostname", + "dns_domain", + "alias", + ] + sdc_json_keys = [ + "resolvers", + "nics", + "routes", + ] + + if not mdata_list: + mdata_list = salt.utils.path.which("mdata-list") + + if not mdata_get: + mdata_get = salt.utils.path.which("mdata-get") + + if not mdata_list or not mdata_get: + return grains + + for mdata_grain in sdc_text_keys + sdc_json_keys: + mdata_value = __salt__["cmd.run"]( + f"{mdata_get} sdc:{mdata_grain}", ignore_retcode=True + ) + if mdata_value.startswith("ERROR:"): + log.warning( + "unable to read sdc:%s via mdata-get, mdata grain may be incomplete.", + mdata_grain, + ) + continue + + if not mdata_value.startswith("No metadata for "): + if "mdata" not in grains: + grains["mdata"] = {} + if "sdc" not in grains["mdata"]: + grains["mdata"]["sdc"] = {} + + log.debug( + "found mdata entry sdc:%s with value %s", mdata_grain, mdata_value + ) + mdata_grain = mdata_grain.replace("-", "_") + mdata_grain = mdata_grain.replace(":", "_") + if mdata_grain in sdc_json_keys: + grains["mdata"]["sdc"][mdata_grain] = salt.utils.json.loads(mdata_value) + else: + grains["mdata"]["sdc"][mdata_grain] = mdata_value + + return grains + + +def mdata(): + """ + Provide grains from the SmartOS metadata + """ + grains = {} + mdata_list = salt.utils.path.which("mdata-list") + mdata_get = salt.utils.path.which("mdata-get") + + grains = salt.utils.dictupdate.update( + grains, _user_mdata(mdata_list, mdata_get), merge_lists=True + ) + grains = salt.utils.dictupdate.update( + grains, _sdc_mdata(mdata_list, mdata_get), merge_lists=True + ) + + return grains diff --git a/salt/grains/metadata.py b/salt/grains/metadata.py new file mode 100644 index 000000000000..29c2d37e0c02 --- /dev/null +++ b/salt/grains/metadata.py @@ -0,0 +1,142 @@ +""" +Grains from cloud metadata servers at 169.254.169.254 + +.. versionadded:: 2017.7.0 + +:depends: requests + +To enable these grains that pull from the http://169.254.169.254/latest +metadata server set `metadata_server_grains: True` in the minion config. + +.. code-block:: yaml + + metadata_server_grains: True + +""" + +import os +import socket + +import salt.utils.data +import salt.utils.http as http +import salt.utils.json +import salt.utils.stringutils + +# metadata server information +IP = "169.254.169.254" +HOST = f"http://{IP}/" + + +def __virtual__(): + if __opts__.get("metadata_server_grains", False) is False: + return False + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(0.1) + result = sock.connect_ex((IP, 80)) + if result != 0: + return False + if http.query(os.path.join(HOST, "latest/"), status=True).get("status") != 200: + # Initial connection failed, might need a token + _refresh_token() + if ( + http.query( + os.path.join(HOST, "latest/"), + status=True, + header_dict={ + "X-aws-ec2-metadata-token": __context__["metadata_aws_token"] + }, + ).get("status") + != 200 + ): + return False + return True + + +def _refresh_token(): + __context__["metadata_aws_token"] = http.query( + os.path.join(HOST, "latest/api/token"), + method="PUT", + header_dict={"X-aws-ec2-metadata-token-ttl-seconds": "21600"}, + ).get("body") + + +def _search(prefix="latest/"): + """ + Recursively look up all grains in the metadata server + """ + ret = {} + if "metadata_aws_token" in __context__: + if ( + http.query( + os.path.join(HOST, "latest/"), + status=True, + header_dict={ + "X-aws-ec2-metadata-token": __context__["metadata_aws_token"] + }, + ).get("status") + != 200 + ): + _refresh_token() + + linedata = http.query( + os.path.join(HOST, prefix), + header_dict={"X-aws-ec2-metadata-token": __context__["metadata_aws_token"]}, + headers=True, + ) + else: + linedata = http.query(os.path.join(HOST, prefix), headers=True) + if "body" not in linedata: + return ret + body = salt.utils.stringutils.to_unicode(linedata["body"]) + # Since 3006.3, salt.utils.http.query (tornado backend) returns ``body`` + # on HTTPError but does not populate ``headers``. Treat a missing + # ``headers`` key as "no Content-Type information" rather than letting + # KeyError propagate and break the whole grain load (#65184). + response_headers = linedata.get("headers") or {} + if response_headers.get("Content-Type", "text/plain") == "application/octet-stream": + return body + for line in body.split("\n"): + if line.endswith("/"): + ret[line[:-1]] = _search(prefix=os.path.join(prefix, line)) + elif line == "user-data": + # user-data is returned verbatim; do not fall through to the + # "=" splitter, which would corrupt user-data containing "=" + # characters (e.g. cloud-init #cloud-config payloads). + retdata = http.query(os.path.join(HOST, prefix, line)).get("body", None) + ret[line] = retdata + elif prefix == "latest/": + # (gtmanfred) The first level should have a forward slash since + # they have stuff underneath. This will not be doubled up though, + # because lines ending with a slash are checked first. + ret[line] = _search(prefix=os.path.join(prefix, line + "/")) + elif line.endswith(("dynamic", "meta-data")): + ret[line] = _search(prefix=os.path.join(prefix, line)) + elif "=" in line: + key, value = line.split("=") + ret[value] = _search(prefix=os.path.join(prefix, key)) + else: + if "metadata_aws_token" in __context__: + retdata = http.query( + os.path.join(HOST, prefix, line), + header_dict={ + "X-aws-ec2-metadata-token": __context__["metadata_aws_token"] + }, + ).get("body", None) + else: + retdata = http.query(os.path.join(HOST, prefix, line)).get("body", None) + # (gtmanfred) This try except block is slightly faster than + # checking if the string starts with a curly brace + if isinstance(retdata, bytes): + try: + ret[line] = salt.utils.json.loads( + salt.utils.stringutils.to_unicode(retdata) + ) + except ValueError: + ret[line] = salt.utils.stringutils.to_unicode(retdata) + else: + ret[line] = retdata + return salt.utils.data.decode(ret) + + +def metadata(): + return _search() diff --git a/salt/grains/metadata_gce.py b/salt/grains/metadata_gce.py new file mode 100644 index 000000000000..0c98a03b6ae2 --- /dev/null +++ b/salt/grains/metadata_gce.py @@ -0,0 +1,47 @@ +""" +Grains from cloud metadata servers at 169.254.169.254 in +google compute engine + +.. versionadded:: 3005 + +:depends: requests + +To enable these grains that pull from the http://169.254.169.254/computeMetadata/v1/ +metadata server set `metadata_server_grains: True` in the minion config. + +.. code-block:: yaml + + metadata_server_grains: True + +""" + +import logging + +import salt.utils.http as http +import salt.utils.json + +HOST = "http://169.254.169.254" +URL = f"{HOST}/computeMetadata/v1/?alt=json&recursive=true" +log = logging.getLogger(__name__) + + +def __virtual__(): + # Check if metadata_server_grains minion option is enabled + if __opts__.get("metadata_server_grains", False) is False: + return False + googletest = http.query(HOST, status=True, headers=True) + if ( + googletest.get("status", 404) != 200 + or googletest.get("headers", {}).get("Metadata-Flavor", False) != "Google" + ): + return False + return True + + +def metadata(): + """Takes no arguments, returns a dictionary of metadata values from Google.""" + log.debug("All checks true - loading gce metadata") + result = http.query(URL, headers=True, header_list=["Metadata-Flavor: Google"]) + metadata = salt.utils.json.loads(result.get("body", {})) + + return metadata diff --git a/salt/grains/napalm.py b/salt/grains/napalm.py new file mode 100644 index 000000000000..5fca987e132b --- /dev/null +++ b/salt/grains/napalm.py @@ -0,0 +1,445 @@ +""" +NAPALM Grains +============= + +:codeauthor: Mircea Ulinic +:maturity: new +:depends: napalm +:platform: unix + +Dependencies +------------ + +- :mod:`NAPALM proxy module ` + +.. versionadded:: 2016.11.0 +""" + +import logging + +import salt.utils.dns +import salt.utils.napalm + +log = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------------------------------------------------------- +# grains properties +# ---------------------------------------------------------------------------------------------------------------------- + +__virtualname__ = "napalm" +__proxyenabled__ = ["napalm"] + +# ---------------------------------------------------------------------------------------------------------------------- +# global variables +# ---------------------------------------------------------------------------------------------------------------------- + +GRAINS_CACHE = {} +DEVICE_CACHE = {} + +_FORBIDDEN_OPT_ARGS = [ + "secret", # used by IOS to enter in enable mode + "enable_password", # used by EOS +] + +# ---------------------------------------------------------------------------------------------------------------------- +# property functions +# ---------------------------------------------------------------------------------------------------------------------- + + +def __virtual__(): + """ + NAPALM library must be installed for this module to work and run in a (proxy) minion. + """ + return salt.utils.napalm.virtual(__opts__, __virtualname__, __file__) + + +# ---------------------------------------------------------------------------------------------------------------------- +# helpers +# ---------------------------------------------------------------------------------------------------------------------- + + +def _retrieve_grains_cache(proxy=None): + """ + Retrieves the grains from the network device if not cached already. + """ + global GRAINS_CACHE + if not GRAINS_CACHE: + if proxy and salt.utils.napalm.is_proxy(__opts__): + # if proxy var passed and is NAPALM-type proxy minion + GRAINS_CACHE = proxy["napalm.get_grains"]() + elif not proxy and salt.utils.napalm.is_minion(__opts__): + # if proxy var not passed and is running in a straight minion + GRAINS_CACHE = salt.utils.napalm.call(DEVICE_CACHE, "get_facts", **{}) + return GRAINS_CACHE + + +def _retrieve_device_cache(proxy=None): + """ + Loads the network device details if not cached already. + """ + global DEVICE_CACHE + if not DEVICE_CACHE: + if proxy and salt.utils.napalm.is_proxy(__opts__): + # if proxy var passed and is NAPALM-type proxy minion + if "napalm.get_device" in proxy: + DEVICE_CACHE = proxy["napalm.get_device"]() + elif not proxy and salt.utils.napalm.is_minion(__opts__): + # if proxy var not passed and is running in a straight minion + DEVICE_CACHE = salt.utils.napalm.get_device(__opts__) + return DEVICE_CACHE + + +def _get_grain(name, proxy=None): + """ + Retrieves the grain value from the cached dictionary. + """ + grains = _retrieve_grains_cache(proxy=proxy) + if grains.get("result", False) and grains.get("out", {}): + return grains.get("out").get(name) + + +def _get_device_grain(name, proxy=None): + """ + Retrieves device-specific grains. + """ + device = _retrieve_device_cache(proxy=proxy) + return device.get(name.upper()) + + +# ---------------------------------------------------------------------------------------------------------------------- +# actual grains +# ---------------------------------------------------------------------------------------------------------------------- + + +def getos(proxy=None): + """ + Returns the Operating System name running on the network device. + + Example: junos, iosxr, eos, ios etc. + + CLI Example - select all network devices running JunOS: + + .. code-block:: bash + + salt -G 'os:junos' test.ping + """ + return {"os": _get_device_grain("driver_name", proxy=proxy)} + + +def version(proxy=None): + """ + Returns the OS version. + + Example: 13.3R6.5, 6.0.2 etc. + + CLI Example - select all network devices running JunOS 13.3R6.5 and return the model: + + .. code-block:: bash + + salt -G 'os:junos and version:13.3R6.5' grains.get model + + Output: + + .. code-block:: yaml + + edge01.bjm01: + MX2000 + edge01.sjc01: + MX960 + edge01.mrs01: + MX480 + edge01.muc01: + MX240 + """ + return {"version": _get_grain("os_version", proxy=proxy)} + + +def model(proxy=None): + """ + Returns the network device chassis model. + + Example: MX480, ASR-9904-AC etc. + + CLI Example - select all Juniper MX480 routers and execute traceroute to 8.8.8.8: + + .. code-block:: bash + + salt -G 'model:MX480' net.traceroute 8.8.8.8 + """ + return {"model": _get_grain("model", proxy=proxy)} + + +def serial(proxy=None): + """ + Returns the chassis serial number. + + Example: FOX1234W00F + + CLI Example - select all devices whose serial number begins with `FOX` and display the serial number value: + + .. code-block:: bash + + salt -G 'serial:FOX*' grains.get serial + + Output: + + .. code-block:: yaml + + edge01.icn01: + FOXW00F001 + edge01.del01: + FOXW00F002 + edge01.yyz01: + FOXW00F003 + edge01.mrs01: + FOXW00F004 + """ + return {"serial": _get_grain("serial_number", proxy=proxy)} + + +def vendor(proxy=None): + """ + Returns the network device vendor. + + Example: juniper, cisco, arista etc. + + CLI Example - select all devices produced by Cisco and shutdown: + + .. code-block:: bash + + salt -G 'vendor:cisco' net.cli "shut" + """ + return {"vendor": _get_grain("vendor", proxy=proxy)} + + +def uptime(proxy=None): + """ + Returns the uptime in seconds. + + CLI Example - select all devices started/restarted within the last hour: + + .. code-block:: bash + + salt -G 'uptime<3600' test.ping + """ + return {"uptime": _get_grain("uptime", proxy=proxy)} + + +def interfaces(proxy=None): + """ + Returns the complete interfaces list of the network device. + + Example: ['lc-0/0/0', 'pfe-0/0/0', 'xe-1/3/0', 'lo0', 'irb', 'demux0', 'fxp0'] + + CLI Example - select all devices that have a certain interface, e.g.: xe-1/1/1: + + .. code-block:: bash + + salt -G 'interfaces:xe-1/1/1' test.ping + + Output: + + .. code-block:: yaml + + edge01.yyz01: + True + edge01.maa01: + True + edge01.syd01: + True + edge01.del01: + True + edge01.dus01: + True + edge01.kix01: + True + """ + return {"interfaces": _get_grain("interface_list", proxy=proxy)} + + +def username(proxy=None): + """ + Return the username. + + .. versionadded:: 2017.7.0 + + CLI Example - select all devices using `foobar` as username for connection: + + .. code-block:: bash + + salt -G 'username:foobar' test.ping + + Output: + + .. code-block:: yaml + + device1: + True + device2: + True + """ + if proxy and salt.utils.napalm.is_proxy(__opts__): + # only if proxy will override the username + # otherwise will use the default Salt grains + return {"username": _get_device_grain("username", proxy=proxy)} + + +def hostname(proxy=None): + """ + Return the hostname as configured on the network device. + + CLI Example: + + .. code-block:: bash + + salt 'device*' grains.get hostname + + Output: + + .. code-block:: yaml + + device1: + edge01.yyz01 + device2: + edge01.bjm01 + device3: + edge01.flw01 + """ + return {"hostname": _get_grain("hostname", proxy=proxy)} + + +def host(proxy=None): + """ + This grain is set by the NAPALM grain module + only when running in a proxy minion. + When Salt is installed directly on the network device, + thus running a regular minion, the ``host`` grain + provides the physical hostname of the network device, + as it would be on an ordinary minion server. + When running in a proxy minion, ``host`` points to the + value configured in the pillar: :mod:`NAPALM proxy module `. + + .. note:: + + The diference between ``host`` and ``hostname`` is that + ``host`` provides the physical location - either domain name or IP address, + while ``hostname`` provides the hostname as configured on the device. + They are not necessarily the same. + + .. versionadded:: 2017.7.0 + + CLI Example: + + .. code-block:: bash + + salt 'device*' grains.get host + + Output: + + .. code-block:: yaml + + device1: + ip-172-31-13-136.us-east-2.compute.internal + device2: + ip-172-31-11-193.us-east-2.compute.internal + device3: + ip-172-31-2-181.us-east-2.compute.internal + """ + if proxy and salt.utils.napalm.is_proxy(__opts__): + # this grain is set only when running in a proxy minion + # otherwise will use the default Salt grains + return {"host": _get_device_grain("hostname", proxy=proxy)} + + +def host_dns(proxy=None): + """ + Return the DNS information of the host. + This grain is a dictionary having two keys: + + - ``A`` + - ``AAAA`` + + .. note:: + This grain is disabled by default, as the proxy startup may be slower + when the lookup fails. + The user can enable it using the ``napalm_host_dns_grain`` option (in + the pillar or proxy configuration file): + + .. code-block:: yaml + + napalm_host_dns_grain: true + + .. versionadded:: 2017.7.0 + + CLI Example: + + .. code-block:: bash + + salt 'device*' grains.get host_dns + + Output: + + .. code-block:: yaml + + device1: + A: + - 172.31.9.153 + AAAA: + - fd52:188c:c068::1 + device2: + A: + - 172.31.46.249 + AAAA: + - fdca:3b17:31ab::17 + device3: + A: + - 172.31.8.167 + AAAA: + - fd0f:9fd6:5fab::1 + """ + if not __opts__.get("napalm_host_dns_grain", False): + return + device_host = host(proxy=proxy) + if device_host: + device_host_value = device_host["host"] + host_dns_ret = {"host_dns": {"A": [], "AAAA": []}} + dns_a = salt.utils.dns.lookup(device_host_value, "A") + if dns_a: + host_dns_ret["host_dns"]["A"] = dns_a + dns_aaaa = salt.utils.dns.lookup(device_host_value, "AAAA") + if dns_aaaa: + host_dns_ret["host_dns"]["AAAA"] = dns_aaaa + return host_dns_ret + + +def optional_args(proxy=None): + """ + Return the connection optional args. + + .. note:: + + Sensible data will not be returned. + + .. versionadded:: 2017.7.0 + + CLI Example - select all devices connecting via port 1234: + + .. code-block:: bash + + salt -G 'optional_args:port:1234' test.ping + + Output: + + .. code-block:: yaml + + device1: + True + device2: + True + """ + opt_args = _get_device_grain("optional_args", proxy=proxy) or {} + if opt_args and _FORBIDDEN_OPT_ARGS: + for arg in _FORBIDDEN_OPT_ARGS: + opt_args.pop(arg, None) + return {"optional_args": opt_args} diff --git a/salt/grains/nvme.py b/salt/grains/nvme.py new file mode 100644 index 000000000000..60cef03e32ff --- /dev/null +++ b/salt/grains/nvme.py @@ -0,0 +1,60 @@ +""" +Grains for NVMe Qualified Names (NQN). + +.. versionadded:: 3000 + +To enable these grains set `nvme_grains: True` in the minion config. + +.. code-block:: yaml + + nvme_grains: True +""" + +import errno +import logging + +import salt.utils.files +import salt.utils.path +import salt.utils.platform + +__virtualname__ = "nvme" + +# Get logging started +log = logging.getLogger(__name__) + + +def __virtual__(): + if __opts__.get("nvme_grains", False) is False: + return False + return __virtualname__ + + +def nvme_nqn(): + """ + Return NVMe NQN + """ + grains = {} + grains["nvme_nqn"] = False + if salt.utils.platform.is_linux(): + grains["nvme_nqn"] = _linux_nqn() + return grains + + +def _linux_nqn(): + """ + Return NVMe NQN from a Linux host. + """ + ret = [] + + initiator = "/etc/nvme/hostnqn" + try: + with salt.utils.files.fopen(initiator, "r") as _nvme: + for line in _nvme: + line = line.strip() + if line.startswith("nqn."): + ret.append(line) + except OSError as ex: + if ex.errno != errno.ENOENT: + log.debug("Error while accessing '%s': %s", initiator, ex) + + return ret diff --git a/salt/grains/nxos.py b/salt/grains/nxos.py new file mode 100644 index 000000000000..07c821ec1109 --- /dev/null +++ b/salt/grains/nxos.py @@ -0,0 +1,40 @@ +""" +Grains for Cisco NX-OS minions + +.. versionadded:: 2016.11.0 + +For documentation on setting up the nxos proxy minion look in the documentation +for :mod:`salt.proxy.nxos`. +""" + +import logging + +import salt.utils.nxos +import salt.utils.platform +from salt.exceptions import NxosClientError + +log = logging.getLogger(__name__) + +__proxyenabled__ = ["nxos"] +__virtualname__ = "nxos" + + +def __virtual__(): + try: + salt.utils.nxos.version_info() + except NxosClientError as err: + return False, err + + return __virtualname__ + + +def system_information(proxy=None): + if salt.utils.platform.is_proxy(): + if proxy is None: + return {} + if proxy["nxos.initialized"]() is False: + return {} + return {"nxos": proxy["nxos.grains"]()} + else: + data = salt.utils.nxos.version_info() + return salt.utils.nxos.system_info(data) diff --git a/salt/grains/panos.py b/salt/grains/panos.py new file mode 100644 index 000000000000..f5ba8d731dee --- /dev/null +++ b/salt/grains/panos.py @@ -0,0 +1,34 @@ +""" +Generate baseline proxy minion grains for panos hosts. + +""" + +import logging + +import salt.proxy.panos +import salt.utils.platform + +__proxyenabled__ = ["panos"] +__virtualname__ = "panos" + +log = logging.getLogger(__file__) + +GRAINS_CACHE = {"os_family": "panos"} + + +def __virtual__(): + try: + if salt.utils.platform.is_proxy() and __opts__["proxy"]["proxytype"] == "panos": + return __virtualname__ + except KeyError: + pass + + return False + + +def panos(proxy=None): + if not proxy: + return {} + if proxy["panos.initialized"]() is False: + return {} + return {"panos": proxy["panos.grains"]()} diff --git a/salt/grains/philips_hue.py b/salt/grains/philips_hue.py new file mode 100644 index 000000000000..12a340ba2daf --- /dev/null +++ b/salt/grains/philips_hue.py @@ -0,0 +1,51 @@ +# +# Copyright 2015 SUSE LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Static grains for the Philips HUE lamps + +.. versionadded:: 2015.8.3 +""" + +__proxyenabled__ = ["philips_hue"] + +__virtualname__ = "hue" + + +def __virtual__(): + if "proxy" not in __opts__: + return False + else: + return __virtualname__ + + +def kernel(): + return {"kernel": "RTOS"} + + +def os(): + return {"os": "FreeRTOS"} + + +def os_family(): + return {"os_family": "RTOS"} + + +def vendor(): + return {"vendor": "Philips"} + + +def product(): + return {"product": "HUE"} diff --git a/salt/grains/resources.py b/salt/grains/resources.py deleted file mode 100644 index 468498bd11a0..000000000000 --- a/salt/grains/resources.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -Expose the resource IDs managed by this minion as a grain. - -The grain ``salt_resources`` mirrors the ``resources:`` section of the minion -configuration so that the master's grains cache records which resources each -minion manages. This enables grain-based targeting (``G@salt_resources``) and -gives operators a human-readable view of resource topology via ``grains.items``. - -Example output:: - - salt_resources: - dummy: - - dummy-01 - - dummy-02 - - dummy-03 -""" - -import logging - -log = logging.getLogger(__name__) - - -def resources(): - """Return the resource IDs managed by this minion, keyed by resource type.""" - managed = __opts__.get("resources", {}) - if not managed: - return {} - return {"salt_resources": managed} diff --git a/salt/grains/smartos.py b/salt/grains/smartos.py new file mode 100644 index 000000000000..62e24b3798b0 --- /dev/null +++ b/salt/grains/smartos.py @@ -0,0 +1,215 @@ +""" +SmartOS grain provider + +:maintainer: Jorge Schrauwen +:maturity: new +:depends: salt.utils, salt.module.cmdmod +:platform: SmartOS + +.. versionadded:: 2017.7.0 + +""" + +import logging +import os +import re + +import salt.modules.cmdmod +import salt.utils.dictupdate +import salt.utils.json +import salt.utils.path +import salt.utils.platform +import salt.utils.stringutils + +__virtualname__ = "smartos" +__salt__ = { + "cmd.run": salt.modules.cmdmod.run, +} + +log = logging.getLogger(__name__) + + +def __virtual__(): + """ + Only load when we are on SmartOS + """ + if salt.utils.platform.is_smartos(): + return __virtualname__ + return False + + +def _smartos_computenode_data(): + """ + Return useful information from a SmartOS compute node + """ + # Provides: + # vms_total + # vms_running + # vms_stopped + # vms_type + # sdc_version + # vm_capable + # vm_hw_virt + + grains = {} + + # collect vm data + vms = {} + for vm in __salt__["cmd.run"]("vmadm list -p -o uuid,alias,state,type").split("\n"): + vm = dict(list(zip(["uuid", "alias", "state", "type"], vm.split(":")))) + vms[vm["uuid"]] = vm + del vms[vm["uuid"]]["uuid"] + + # set vm grains + grains["computenode_vms_total"] = len(vms) + grains["computenode_vms_running"] = 0 + grains["computenode_vms_stopped"] = 0 + grains["computenode_vms_type"] = {"KVM": 0, "LX": 0, "OS": 0} + for vm in vms: + if vms[vm]["state"].lower() == "running": + grains["computenode_vms_running"] += 1 + elif vms[vm]["state"].lower() == "stopped": + grains["computenode_vms_stopped"] += 1 + + if vms[vm]["type"] not in grains["computenode_vms_type"]: + # NOTE: be prepared for when bhyve gets its own type + grains["computenode_vms_type"][vms[vm]["type"]] = 0 + grains["computenode_vms_type"][vms[vm]["type"]] += 1 + + # sysinfo derived grains + sysinfo = salt.utils.json.loads(__salt__["cmd.run"]("sysinfo")) + grains["computenode_sdc_version"] = sysinfo["SDC Version"] + grains["computenode_vm_capable"] = sysinfo["VM Capable"] + if sysinfo["VM Capable"]: + grains["computenode_vm_hw_virt"] = sysinfo["CPU Virtualization"] + + # sysinfo derived smbios grains + grains["manufacturer"] = sysinfo["Manufacturer"] + grains["productname"] = sysinfo["Product"] + grains["uuid"] = sysinfo["UUID"] + + return grains + + +def _smartos_zone_data(): + """ + Return useful information from a SmartOS zone + """ + # Provides: + # zoneid + # zonename + # imageversion + grains = {} + + zoneinfo = __salt__["cmd.run"]("zoneadm list -p").strip().split(":") + grains["zoneid"] = zoneinfo[0] + grains["zonename"] = zoneinfo[1] + + imageversion = re.compile("Image:\\s(.+)") + grains["imageversion"] = "Unknown" + if os.path.isfile("/etc/product"): + with salt.utils.files.fopen("/etc/product", "r") as fp_: + for line in fp_: + line = salt.utils.stringutils.to_unicode(line) + match = imageversion.match(line) + if match: + grains["imageversion"] = match.group(1) + + return grains + + +def _smartos_zone_pkgsrc_data(): + """ + SmartOS zone pkgsrc information + """ + # Provides: + # pkgsrcversion + # pkgsrcpath + + grains = { + "pkgsrcversion": "Unknown", + "pkgsrcpath": "Unknown", + } + + # NOTE: we are specifically interested in the SmartOS pkgsrc version and path + # - PKG_PATH MAY be different on non-SmartOS systems, but they will not + # use this grains module. + # - A sysadmin with advanced needs COULD create a 'spin' with a totally + # different URL. But at that point the value would be meaning less in + # the context of the pkgsrcversion grain as it will not followed the + # SmartOS pkgsrc versioning. So 'Unknown' would be appropriate. + pkgsrcpath = re.compile("PKG_PATH=(.+)") + pkgsrcversion = re.compile( + "^https?://pkgsrc.joyent.com/packages/SmartOS/(.+)/(.+)/All$" + ) + pkg_install_paths = [ + "/opt/local/etc/pkg_install.conf", + "/opt/tools/etc/pkg_install.conf", + ] + for pkg_install in pkg_install_paths: + if os.path.isfile(pkg_install): + with salt.utils.files.fopen(pkg_install, "r") as fp_: + for line in fp_: + line = salt.utils.stringutils.to_unicode(line) + match_pkgsrcpath = pkgsrcpath.match(line) + if match_pkgsrcpath: + grains["pkgsrcpath"] = match_pkgsrcpath.group(1) + match_pkgsrcversion = pkgsrcversion.match( + match_pkgsrcpath.group(1) + ) + if match_pkgsrcversion: + grains["pkgsrcversion"] = match_pkgsrcversion.group(1) + break + + return grains + + +def _smartos_zone_pkgin_data(): + """ + SmartOS zone pkgin information + """ + # Provides: + # pkgin_repositories + + grains = { + "pkgin_repositories": [], + } + + pkginrepo = re.compile("^(?:https|http|ftp|file)://.*$") + repositories_path = [ + "/opt/local/etc/pkgin/repositories.conf", + "/opt/tools/etc/pkgin/repositories.conf", + ] + for repositories in repositories_path: + if os.path.isfile(repositories): + with salt.utils.files.fopen(repositories, "r") as fp_: + for line in fp_: + line = salt.utils.stringutils.to_unicode(line).strip() + if pkginrepo.match(line): + grains["pkgin_repositories"].append(line) + + return grains + + +def smartos(): + """ + Provide grains for SmartOS + """ + grains = {} + + if salt.utils.platform.is_smartos_zone(): + grains = salt.utils.dictupdate.update( + grains, _smartos_zone_data(), merge_lists=True + ) + elif salt.utils.platform.is_smartos_globalzone(): + grains = salt.utils.dictupdate.update( + grains, _smartos_computenode_data(), merge_lists=True + ) + grains = salt.utils.dictupdate.update( + grains, _smartos_zone_pkgin_data(), merge_lists=True + ) + grains = salt.utils.dictupdate.update( + grains, _smartos_zone_pkgsrc_data(), merge_lists=True + ) + + return grains diff --git a/salt/grains/ssh_sample.py b/salt/grains/ssh_sample.py new file mode 100644 index 000000000000..a51b79171781 --- /dev/null +++ b/salt/grains/ssh_sample.py @@ -0,0 +1,44 @@ +""" +Generate baseline proxy minion grains +""" + +import salt.utils.platform + +__proxyenabled__ = ["ssh_sample"] + +__virtualname__ = "ssh_sample" + + +def __virtual__(): + try: + if ( + salt.utils.platform.is_proxy() + and __opts__["proxy"]["proxytype"] == "ssh_sample" + ): + return __virtualname__ + except KeyError: + pass + + return False + + +def kernel(): + return {"kernel": "proxy"} + + +def proxy_functions(proxy): + """ + The loader will execute functions with one argument and pass + a reference to the proxymodules LazyLoader object. However, + grains sometimes get called before the LazyLoader object is setup + so `proxy` might be None. + """ + return {"proxy_functions": proxy["ssh_sample.fns"]()} + + +def location(): + return {"location": "At the other end of an SSH Tunnel!!"} + + +def os_data(): + return {"os_data": "DumbShell Endpoint release 4.09.g"} diff --git a/salt/grains/truststore.py b/salt/grains/truststore.py deleted file mode 100644 index 510d6f3ae900..000000000000 --- a/salt/grains/truststore.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Grain that reports which CA certificate store Salt is using for outbound -HTTPS/TLS connections. - -.. versionadded:: 3008.0 - -Possible values for the ``ca_truststore`` grain: - -``certifi`` - Default. Salt uses the ``certifi`` CA bundle (or a system bundle on - Linux when one is found at a well-known path). - -``os`` - Salt has successfully injected the native OS certificate store via - ``pip-system-certs`` (requires ``use_os_truststore: True`` in the minion - configuration and the ``pip-system-certs`` package installed). -""" - -import logging - -import salt.utils.ostruststore - -log = logging.getLogger(__name__) - -__virtualname__ = "truststore" - - -def __virtual__(): - return __virtualname__ - - -def ca_truststore(): - """ - Return the active CA trust store name as the ``ca_truststore`` grain. - - Example grain value:: - - ca_truststore: certifi - - or, when OS trust store is active:: - - ca_truststore: os - """ - return {"ca_truststore": salt.utils.ostruststore.active_store_name(__opts__)} diff --git a/salt/grains/zfs.py b/salt/grains/zfs.py new file mode 100644 index 000000000000..62f8f3def79a --- /dev/null +++ b/salt/grains/zfs.py @@ -0,0 +1,83 @@ +""" +ZFS grain provider + +:maintainer: Jorge Schrauwen +:maturity: new +:depends: salt.module.cmdmod +:platform: illumos,freebsd,linux + +.. versionadded:: 2018.3.0 + +""" + +import logging + +# Solve the Chicken and egg problem where grains need to run before any +# of the modules are loaded and are generally available for any usage. +import salt.modules.cmdmod +import salt.utils.dictupdate +import salt.utils.path +import salt.utils.platform +import salt.utils.zfs + +__virtualname__ = "zfs" +__salt__ = { + "cmd.run": salt.modules.cmdmod.run, +} +__utils__ = { + "zfs.is_supported": salt.utils.zfs.is_supported, + "zfs.has_feature_flags": salt.utils.zfs.has_feature_flags, + "zfs.zpool_command": salt.utils.zfs.zpool_command, + "zfs.to_size": salt.utils.zfs.to_size, +} + +log = logging.getLogger(__name__) + + +def __virtual__(): + """ + Load zfs grains + """ + # NOTE: we always load this grain so we can properly export + # at least the zfs_support grain + # except for Windows... don't try to load this on Windows (#51703) + if salt.utils.platform.is_windows(): + return False, "ZFS: Not available on Windows" + return __virtualname__ + + +def _zfs_pool_data(): + """ + Provide grains about zpools + """ + grains = {} + + # collect zpool data + zpool_list_cmd = __utils__["zfs.zpool_command"]( + "list", + flags=["-H"], + opts={"-o": "name,size"}, + ) + for zpool in __salt__["cmd.run"](zpool_list_cmd, ignore_retcode=True).splitlines(): + if "zpool" not in grains: + grains["zpool"] = {} + zpool = zpool.split() + grains["zpool"][zpool[0]] = __utils__["zfs.to_size"](zpool[1], False) + + # return grain data + return grains + + +def zfs(): + """ + Provide grains for zfs/zpool + """ + grains = {} + grains["zfs_support"] = __utils__["zfs.is_supported"]() + grains["zfs_feature_flags"] = __utils__["zfs.has_feature_flags"]() + if grains["zfs_support"]: + grains = salt.utils.dictupdate.update( + grains, _zfs_pool_data(), merge_lists=True + ) + + return grains diff --git a/salt/key.py b/salt/key.py index a776f99b90a6..66fd42ce073a 100644 --- a/salt/key.py +++ b/salt/key.py @@ -7,14 +7,15 @@ import itertools import logging import os +import shutil import sys import salt.cache import salt.client import salt.crypt +import salt.daemons.masterapi import salt.exceptions -import salt.payload -import salt.transport +import salt.minion import salt.utils.args import salt.utils.crypt import salt.utils.data @@ -22,11 +23,10 @@ import salt.utils.files import salt.utils.json import salt.utils.kinds -import salt.utils.minions +import salt.utils.master import salt.utils.sdb import salt.utils.stringutils import salt.utils.user -from salt.utils.decorators import cached_property log = logging.getLogger(__name__) @@ -49,16 +49,12 @@ class KeyCLI: def __init__(self, opts): self.opts = opts - import salt.wheel - self.client = salt.wheel.WheelClient(opts) + self.key = Key # instantiate the key object for masterless mode if not opts.get("eauth"): - self.key = get_key(opts) - else: - self.key = Key - - self.auth = {} + self.key = self.key(opts) + self.auth = None def _update_opts(self): # get the key command @@ -121,13 +117,11 @@ def _init_auth(self): low["key"] = salt.utils.stringutils.to_unicode(fp_.readline()) except OSError: low["token"] = self.opts["token"] - + # # If using eauth and a token hasn't already been loaded into # low, prompt the user to enter auth credentials if "token" not in low and "key" not in low and self.opts["eauth"]: # This is expensive. Don't do it unless we need to. - import salt.auth - resolver = salt.auth.Resolver(self.opts) res = resolver.cli(self.opts["eauth"]) if self.opts["mktoken"] and res: @@ -140,9 +134,6 @@ def _init_auth(self): low.update(res) low["eauth"] = self.opts["eauth"] else: - # late import to avoid circular import - import salt.utils.master - low["user"] = salt.utils.user.get_specific_user() low["key"] = salt.utils.master.get_master_key( low["user"], self.opts, skip_perm_errors @@ -253,7 +244,7 @@ def run(self): ret = None try: if cmd in ("accept", "reject", "delete"): - ret = self._run_cmd("glob_match") + ret = self._run_cmd("name_match") if not isinstance(ret, dict): salt.output.display_output(ret, "key", opts=self.opts) return ret @@ -303,10 +294,6 @@ def run(self): ret = f"{exc}" if not self.opts.get("quiet", False): salt.output.display_output(ret, "nested", self.opts) - except Exception as exc: # pylint: disable=broad-except - # dont swallow unexpected exceptions in salt-key - log.exception(exc) - return ret @@ -320,47 +307,27 @@ class Key: REJ = "minions_rejected" DEN = "minions_denied" - # handle transitions from legacy naming to simpler new format - STATE_MAP = {"accepted": ACC, "rejected": REJ, "pending": PEND, "denied": DEN} - DIR_MAP = {v: k for k, v in STATE_MAP.items()} - - ACT_MAP = { - ACC: "accept", - REJ: "reject", - PEND: "pend", - DEN: "denied", - } - def __init__(self, opts, io_loop=None): self.opts = opts - self.cache = salt.cache.Cache(opts, driver=self.opts["keys.cache_driver"]) - if self.opts.get("cluster_id", None) is not None: - self.pki_dir = self.opts.get("cluster_pki_dir", "") - else: - self.pki_dir = self.opts.get("pki_dir", "") - self._kind = self.opts.get("__role", "") # application kind - if self._kind not in salt.utils.kinds.APPL_KINDS: - emsg = f"Invalid application kind = '{self._kind}'." + self.pki_dir = self.opts["pki_dir"] + if self.opts["cluster_id"]: + self.pki_dir = self.opts["cluster_pki_dir"] + kind = self.opts.get("__role", "") # application kind + if kind not in salt.utils.kinds.APPL_KINDS: + emsg = f"Invalid application kind = '{kind}'." log.error(emsg) raise ValueError(emsg) + self.event = salt.utils.event.get_event( + kind, + opts["sock_dir"], + opts=opts, + listen=False, + io_loop=io_loop, + ) + self.passphrase = salt.utils.sdb.sdb_get( self.opts.get("signing_key_pass"), self.opts ) - self.io_loop = io_loop - - @cached_property - def master_keys(self): - return salt.crypt.MasterKeys(self.opts) - - @cached_property - def event(self): - return salt.utils.event.get_event( - self._kind, - self.opts["sock_dir"], - opts=self.opts, - listen=False, - io_loop=self.io_loop, - ) def _check_minions_directories(self): """ @@ -374,15 +341,11 @@ def _check_minions_directories(self): return minions_accepted, minions_pre, minions_rejected, minions_denied def _get_key_attrs(self, keydir, keyname, keysize, user): - cache = None if not keydir: if "gen_keys_dir" in self.opts: keydir = self.opts["gen_keys_dir"] else: keydir = self.pki_dir - cache = salt.cache.Cache( - self.opts, driver=self.opts["keys.cache_driver"], cachedir=keydir, user=user - ) if not keyname: if "gen_keys" in self.opts: keyname = self.pki_dir @@ -390,19 +353,23 @@ def _get_key_attrs(self, keydir, keyname, keysize, user): keyname = "minion" if not keysize: keysize = self.opts["keysize"] - return keydir, keyname, keysize, user, cache + return keydir, keyname, keysize, user def gen_keys(self, keydir=None, keyname=None, keysize=None, user=None): """ Generate minion RSA public keypair """ - keydir, keyname, keysize, user, cache = self._get_key_attrs( + keydir, keyname, keysize, user = self._get_key_attrs( keydir, keyname, keysize, user ) - priv = self.master_keys.find_or_create_keys( - keyname, keysize=keysize, cache=cache - ) - return salt.utils.crypt.pem_finger(key=priv.public_key()) + salt.crypt.gen_keys(keydir, keyname, keysize, user, self.passphrase) + return salt.utils.crypt.pem_finger(os.path.join(keydir, keyname + ".pub")) + + def gen_signature(self, privkey, pubkey, sig_path): + """ + Generate master public-key-signature + """ + return salt.crypt.gen_signature(privkey, pubkey, sig_path, self.passphrase) def gen_keys_signature( self, priv, pub, signature_path, auto_create=False, keysize=None @@ -430,29 +397,26 @@ def gen_keys_signature( if os.path.isfile(mpriv): priv = mpriv - if priv: - priv = salt.crypt.PrivateKey.from_file(priv) - else: + if not priv: if auto_create: log.debug( "Generating new signing key-pair .%s.* in %s", self.opts["master_sign_key_name"], self.pki_dir, ) - # we force re-create as master_keys init also does the same - # creation without these kwarg overrides - priv = self.master_keys.sign_key = self.master_keys.find_or_create_keys( - name=self.opts["master_sign_key_name"], - keysize=keysize or self.opts["keysize"], - passphrase=self.passphrase, - force=True, + salt.crypt.gen_keys( + self.pki_dir, + self.opts["master_sign_key_name"], + keysize or self.opts["keysize"], + self.opts.get("user"), + self.passphrase, ) + + priv = self.pki_dir + "/" + self.opts["master_sign_key_name"] + ".pem" else: return "No usable private-key found" - if pub: - pub = salt.crypt.PublicKey.from_file(pub).key - else: + if not pub: return "No usable public-key found" log.debug("Using public-key %s", pub) @@ -461,11 +425,13 @@ def gen_keys_signature( if signature_path: if not os.path.isdir(signature_path): log.debug("target directory %s does not exist", signature_path) - sign_path = signature_path + "/" + self.master_keys.master_pubkey_signature else: - sign_path = None + signature_path = self.pki_dir - return self.master_keys.gen_signature(priv, pub, sign_path) + sign_path = signature_path + "/" + self.opts["master_pubkey_signature"] + + skey = get_key(self.opts) + return skey.gen_signature(priv, pub, sign_path) def check_minion_cache(self, preserve_minions=None): """ @@ -474,28 +440,32 @@ def check_minion_cache(self, preserve_minions=None): Optionally, pass in a list of minions which should have their caches preserved. To preserve all caches, set __opts__['preserve_minion_cache'] """ - if self.opts.get("preserve_minion_cache", False): - return - if preserve_minions is None: preserve_minions = [] - preserve_minions = set(preserve_minions) - keys = self.list_keys() - - for val in keys.values(): - preserve_minions.update(val) - - # we use a new cache instance here as we dont want the key cache - cache = salt.cache.factory(self.opts) - - for bank in ["grains", "pillar"]: - clist = set(cache.list(bank)) - for minion in clist - preserve_minions: - # pillar optionally encodes pillarenv in the key as minion:$pillarenv - if ":" in minion and minion.split(":")[0] in preserve_minions: - continue - cache.flush(bank, minion) + minions = [] + for key, val in keys.items(): + minions.extend(val) + if not self.opts.get("preserve_minion_cache", False): + m_cache = os.path.join(self.opts["cachedir"], self.ACC) + if os.path.isdir(m_cache): + for minion in os.listdir(m_cache): + if minion not in minions and minion not in preserve_minions: + try: + shutil.rmtree(os.path.join(m_cache, minion)) + except OSError as ex: + log.warning( + "Key: Delete cache for %s got OSError/IOError: %s \n", + minion, + ex, + ) + continue + cache = salt.cache.factory(self.opts) + clist = cache.list(self.ACC) + if clist: + for minion in clist: + if minion not in minions and minion not in preserve_minions: + cache.flush(f"{self.ACC}/{minion}") def check_master(self): """ @@ -508,7 +478,7 @@ def check_master(self): return False return True - def glob_match(self, match, full=False): + def name_match(self, match, full=False): """ Accept a glob which to match the of a key and return the key's location """ @@ -519,43 +489,21 @@ def glob_match(self, match, full=False): ret = {} if "," in match and isinstance(match, str): match = match.split(",") - if not isinstance(match, list): - match = [match] for status, keys in matches.items(): - if match == ["*"] and keys: - ret[status] = keys - continue for key in salt.utils.data.sorted_ignorecase(keys): - for match_item in match: - if fnmatch.fnmatch(key, match_item): + if isinstance(match, list): + for match_item in match: + if fnmatch.fnmatch(key, match_item): + if status not in ret: + ret[status] = [] + ret[status].append(key) + else: + if fnmatch.fnmatch(key, match): if status not in ret: ret[status] = [] ret[status].append(key) return ret - def list_match(self, match): - """ - Accept a glob which to match the of a key and return the key's location - """ - ret = {} - if isinstance(match, str): - match = match.split(",") - - for name in match: - key = self.cache.fetch("keys", name) - if key: - try: - ret.setdefault(self.STATE_MAP[key["state"]], []) - ret[self.STATE_MAP[key["state"]]].append(name) - except KeyError: - log.error("unexpected key state returned for %s: %s", name, key) - - denied_keys = self.cache.fetch("denied_keys", name) - if denied_keys: - ret.setdefault(self.DEN, []) - ret[self.DEN].append(name) - return ret - def dict_match(self, match_dict): """ Accept a dictionary of keys and return the current state of the @@ -570,70 +518,38 @@ def dict_match(self, match_dict): ret.setdefault(keydir, []).append(key) return ret - def list_keys(self): + def local_keys(self): """ - Return a dict of managed keys and what the key status are. - - Uses ``cache.list_all("keys")`` when the configured cache driver - supports it (``mmap_key`` and ``localfs_key`` both do), which - walks the keys store in a single O(occupied) pass and avoids - N per-key cache probes. Falls back to ``list`` + per-key - ``fetch`` for any driver that does not implement ``list_all``. + Return a dict of local keys """ - if self.opts.get("key_cache") == "sched": - acc = "accepted" - - cache_file = os.path.join(self.opts["pki_dir"], acc, ".key_cache") - if self.opts["key_cache"] and os.path.exists(cache_file): - log.debug("Returning cached minion list") - with salt.utils.files.fopen(cache_file, mode="rb") as fn_: - return salt.payload.load(fn_) - - ret = { - "minions_pre": [], - "minions_rejected": [], - "minions": [], - "minions_denied": [], - } - - try: - entries = self.cache.list_all("keys") - except salt.exceptions.SaltCacheError: - entries = None - - if entries is not None: - for id_, entry in entries.items(): - state = (entry or {}).get("state") - if state == "accepted": - ret["minions"].append(id_) - elif state == "pending": - ret["minions_pre"].append(id_) - elif state == "rejected": - ret["minions_rejected"].append(id_) - for key in ret: - ret[key] = salt.utils.data.sorted_ignorecase(ret[key]) - else: - for id_ in salt.utils.data.sorted_ignorecase(self.cache.list("keys")): - key = self.cache.fetch("keys", id_) - if key["state"] == "accepted": - ret["minions"].append(id_) - elif key["state"] == "pending": - ret["minions_pre"].append(id_) - elif key["state"] == "rejected": - ret["minions_rejected"].append(id_) - - for id_ in salt.utils.data.sorted_ignorecase(self.cache.list("denied_keys")): - ret["minions_denied"].append(id_) + ret = {"local": []} + for fn_ in salt.utils.data.sorted_ignorecase(os.listdir(self.pki_dir)): + if fn_.endswith(".pub") or fn_.endswith(".pem"): + path = os.path.join(self.pki_dir, fn_) + ret["local"].append(fn_) return ret - def local_keys(self): + def list_keys(self): """ - Return a dict of local keys + Return a dict of managed keys and what the key status are """ - ret = {"local": []} - for key in salt.utils.data.sorted_ignorecase(self.cache.list("master_keys")): - if key.endswith(".pub") or key.endswith(".pem"): - ret["local"].append(key) + key_dirs = self._check_minions_directories() + + ret = {} + + for dir_ in key_dirs: + if dir_ is None: + continue + ret[os.path.basename(dir_)] = [] + try: + for fn_ in salt.utils.data.sorted_ignorecase(os.listdir(dir_)): + if not fn_.startswith("."): + ret[os.path.basename(dir_)].append( + salt.utils.stringutils.to_unicode(fn_) + ) + except OSError: + # key dir kind is not created yet, just skip + continue return ret def all_keys(self): @@ -648,152 +564,116 @@ def list_status(self, match): """ Return a dict of managed keys under a named status """ - ret = self.all_keys() + acc, pre, rej, den = self._check_minions_directories() + ret = {} if match.startswith("acc"): - return { - "minions": salt.utils.data.sorted_ignorecase(ret.get("minions", [])) - } + ret[os.path.basename(acc)] = [] + for fn_ in salt.utils.data.sorted_ignorecase(os.listdir(acc)): + if not fn_.startswith("."): + ret[os.path.basename(acc)].append(fn_) elif match.startswith("pre") or match.startswith("un"): - return { - "minions_pre": salt.utils.data.sorted_ignorecase( - ret.get("minions_pre", []) - ) - } + ret[os.path.basename(pre)] = [] + for fn_ in salt.utils.data.sorted_ignorecase(os.listdir(pre)): + if not fn_.startswith("."): + ret[os.path.basename(pre)].append(fn_) elif match.startswith("rej"): - return { - "minions_rejected": salt.utils.data.sorted_ignorecase( - ret.get("minions_rejected", []) - ) - } - elif match.startswith("den"): - return { - "minions_denied": salt.utils.data.sorted_ignorecase( - ret.get("minions_denied", []) - ) - } + ret[os.path.basename(rej)] = [] + for fn_ in salt.utils.data.sorted_ignorecase(os.listdir(rej)): + if not fn_.startswith("."): + ret[os.path.basename(rej)].append(fn_) + elif match.startswith("den") and den is not None: + ret[os.path.basename(den)] = [] + for fn_ in salt.utils.data.sorted_ignorecase(os.listdir(den)): + if not fn_.startswith("."): + ret[os.path.basename(den)].append(fn_) elif match.startswith("all"): - return ret - # this should never be reached - return {} + return self.all_keys() + return ret def key_str(self, match): """ Return the specified public key or keys based on a glob """ ret = {} - for status, keys in self.glob_match(match).items(): + for status, keys in self.name_match(match).items(): ret[status] = {} for key in salt.utils.data.sorted_ignorecase(keys): - if status == self.DEN: - denied = self.cache.fetch("denied_keys", key) - if len(denied) == 1: - ret[status][key] = denied[0] - else: - ret[status][key] = denied - else: - ret[status][key] = self.cache.fetch("keys", key).get("pub") + path = os.path.join(self.pki_dir, status, key) + with salt.utils.files.fopen(path, "r") as fp_: + ret[status][key] = salt.utils.stringutils.to_unicode(fp_.read()) return ret def key_str_all(self): """ Return all managed key strings """ - return self.key_str("*") - - def change_state( - self, - from_state, - to_state, - match=None, - match_dict=None, - include_rejected=False, - include_denied=False, - include_accepted=False, + ret = {} + for status, keys in self.list_keys().items(): + ret[status] = {} + for key in salt.utils.data.sorted_ignorecase(keys): + path = os.path.join(self.pki_dir, status, key) + with salt.utils.files.fopen(path, "r") as fp_: + ret[status][key] = salt.utils.stringutils.to_unicode(fp_.read()) + return ret + + def accept( + self, match=None, match_dict=None, include_rejected=False, include_denied=False ): """ - change key state from one state to another + Accept public keys. If "match" is passed, it is evaluated as a glob. + Pre-gathered matches can also be passed via "match_dict". """ if match is not None: - matches = self.glob_match(match) + matches = self.name_match(match) elif match_dict is not None and isinstance(match_dict, dict): matches = match_dict else: matches = {} - keydirs = [from_state] + keydirs = [self.PEND] if include_rejected: keydirs.append(self.REJ) if include_denied: keydirs.append(self.DEN) - if include_accepted: - keydirs.append(self.ACC) - invalid_keys = [] for keydir in keydirs: - for keyname in matches.get(keydir, []): - if to_state == self.DEN: - key = self.cache.fetch("keys", keyname) - self.cache.flush("keys", keyname) - self.cache.store("denied_keys", keyname, [key["pub"]]) - else: - if keydir == self.DEN: - # denied keys can be many per id, but we assume first for legacy - pub = self.cache.fetch("denied_keys", keyname)[0] - self.cache.flush("denied_keys", keyname) - key = {"pub": pub} - else: - key = self.cache.fetch("keys", keyname) - - try: - salt.crypt.PublicKey.from_str(key["pub"]) - except salt.exceptions.InvalidKeyError: - log.error("Invalid RSA public key: %s", keyname) - invalid_keys.append(keyname) - continue - - key["state"] = self.DIR_MAP[to_state] - self.cache.store("keys", keyname, key) - - eload = {"result": True, "act": self.DIR_MAP[to_state], "id": keyname} - # Cluster masters: include the public key body so peer - # masters can populate their local ``pki_dir//`` - # without a shared filesystem. The bytes are only useful - # to other cluster members; they're harmless on standalone - # masters that ignore the field. - pub_bytes = None - if to_state == self.DEN: - pub_bytes = key - elif "pub" in (key or {}): - pub_bytes = key["pub"] - if pub_bytes: - eload["pub"] = pub_bytes - self.event.fire_event(eload, salt.utils.event.tagify(prefix="key")) - - for key in invalid_keys: + for key in matches.get(keydir, []): + key_path = os.path.join(self.pki_dir, keydir, key) + try: + salt.crypt.get_rsa_pub_key(key_path) + except salt.exceptions.InvalidKeyError: + log.error("Invalid RSA public key: %s", key) + invalid_keys.append((keydir, key)) + continue + try: + shutil.move( + key_path, + os.path.join(self.pki_dir, self.ACC, key), + ) + eload = {"result": True, "act": "accept", "id": key} + self.event.fire_event(eload, salt.utils.event.tagify(prefix="key")) + except OSError: + pass + for keydir, key in invalid_keys: + matches[keydir].remove(key) sys.stderr.write(f"Unable to accept invalid key for {key}.\n") - - return self.glob_match(match) if match is not None else self.dict_match(matches) - - def accept( - self, match=None, match_dict=None, include_rejected=False, include_denied=False - ): - """ - Accept public keys. If "match" is passed, it is evaluated as a glob. - Pre-gathered matches can also be passed via "match_dict". - """ - return self.change_state( - self.PEND, - self.ACC, - match, - match_dict, - include_rejected=include_rejected, - include_denied=include_denied, - ) + return self.name_match(match) if match is not None else self.dict_match(matches) def accept_all(self): """ Accept all keys in pre """ - return self.accept(match="*") + keys = self.list_keys() + for key in keys[self.PEND]: + try: + shutil.move( + os.path.join(self.pki_dir, self.PEND, key), + os.path.join(self.pki_dir, self.ACC, key), + ) + eload = {"result": True, "act": "accept", "id": key} + self.event.fire_event(eload, salt.utils.event.tagify(prefix="key")) + except OSError: + pass + return self.list_keys() def delete_key( self, match=None, match_dict=None, preserve_minions=None, revoke_auth=False @@ -805,7 +685,7 @@ def delete_key( To preserve the master caches of minions who are matched, set preserve_minions """ if match is not None: - matches = self.glob_match(match) + matches = self.name_match(match) elif match_dict is not None and isinstance(match_dict, dict): matches = match_dict else: @@ -831,10 +711,7 @@ def delete_key( "master AES key is rotated or auth is revoked " "with 'saltutil.revoke_auth'.".format(key) ) - if status == "minions_denied": - self.cache.flush("denied_keys", key) - else: - self.cache.flush("keys", key) + os.remove(os.path.join(self.pki_dir, status, key)) eload = {"result": True, "act": "delete", "id": key} self.event.fire_event( eload, salt.utils.event.tagify(prefix="key") @@ -849,14 +726,21 @@ def delete_key( salt.crypt.dropfile( self.opts["cachedir"], self.opts["user"], self.opts["id"] ) - - return self.glob_match(match) if match is not None else self.dict_match(matches) + return self.name_match(match) if match is not None else self.dict_match(matches) def delete_den(self): """ Delete all denied keys """ - self.cache.flush("denied_keys") + keys = self.list_keys() + for status, keys in self.list_keys().items(): + for key in keys[self.DEN]: + try: + os.remove(os.path.join(self.pki_dir, status, key)) + eload = {"result": True, "act": "delete", "id": key} + self.event.fire_event(eload, salt.utils.event.tagify(prefix="key")) + except OSError: + pass self.check_minion_cache() return self.list_keys() @@ -867,7 +751,7 @@ def delete_all(self): for status, keys in self.list_keys().items(): for key in keys: try: - self.cache.flush("keys", key) + os.remove(os.path.join(self.pki_dir, status, key)) eload = {"result": True, "act": "delete", "id": key} self.event.fire_event(eload, salt.utils.event.tagify(prefix="key")) except OSError: @@ -886,26 +770,50 @@ def reject( Reject public keys. If "match" is passed, it is evaluated as a glob. Pre-gathered matches can also be passed via "match_dict". """ - ret = self.change_state( - self.PEND, - self.REJ, - match, - match_dict, - include_accepted=include_accepted, - include_denied=include_denied, - ) + if match is not None: + matches = self.name_match(match) + elif match_dict is not None and isinstance(match_dict, dict): + matches = match_dict + else: + matches = {} + keydirs = [self.PEND] + if include_accepted: + keydirs.append(self.ACC) + if include_denied: + keydirs.append(self.DEN) + for keydir in keydirs: + for key in matches.get(keydir, []): + try: + shutil.move( + os.path.join(self.pki_dir, keydir, key), + os.path.join(self.pki_dir, self.REJ, key), + ) + eload = {"result": True, "act": "reject", "id": key} + self.event.fire_event(eload, salt.utils.event.tagify(prefix="key")) + except OSError: + pass self.check_minion_cache() if self.opts.get("rotate_aes_key"): salt.crypt.dropfile( self.opts["cachedir"], self.opts["user"], self.opts["id"] ) - return ret + return self.name_match(match) if match is not None else self.dict_match(matches) def reject_all(self): """ Reject all keys in pre """ - self.reject(match="*") + keys = self.list_keys() + for key in keys[self.PEND]: + try: + shutil.move( + os.path.join(self.pki_dir, self.PEND, key), + os.path.join(self.pki_dir, self.REJ, key), + ) + eload = {"result": True, "act": "reject", "id": key} + self.event.fire_event(eload, salt.utils.event.tagify(prefix="key")) + except OSError: + pass self.check_minion_cache() if self.opts.get("rotate_aes_key"): salt.crypt.dropfile( @@ -918,32 +826,18 @@ def finger(self, match, hash_type=None): Return the fingerprint for a specified key """ if hash_type is None: - hash_type = self.opts["hash_type"] + hash_type = __opts__["hash_type"] - matches = self.glob_match(match, full=True) + matches = self.name_match(match, True) ret = {} for status, keys in matches.items(): ret[status] = {} for key in keys: - if status == "minions_denied": - denied = self.cache.fetch("denied_keys", key) - for den in denied: - finger = salt.utils.crypt.pem_finger( - key=den.encode("utf-8"), sum_type=hash_type - ) - ret[status].setdefault(key, []).append(finger) - # brush over some dumb backcompat with how denied keys work - # with the legacy system - if len(denied) == 1: - ret[status][key] = ret[status][key][0] + if status == "local": + path = os.path.join(self.pki_dir, key) else: - if status == "local": - pub = self.cache.fetch("master_keys", key).encode("utf-8") - else: - pub = self.cache.fetch("keys", key)["pub"].encode("utf-8") - ret[status][key] = salt.utils.crypt.pem_finger( - key=pub, sum_type=hash_type - ) + path = os.path.join(self.pki_dir, status, key) + ret[status][key] = salt.utils.crypt.pem_finger(path, sum_type=hash_type) return ret def finger_all(self, hash_type=None): @@ -951,9 +845,18 @@ def finger_all(self, hash_type=None): Return fingerprints for all keys """ if hash_type is None: - hash_type = self.opts["hash_type"] + hash_type = __opts__["hash_type"] - return self.finger("*", hash_type=hash_type) + ret = {} + for status, keys in self.all_keys().items(): + ret[status] = {} + for key in keys: + if status == "local": + path = os.path.join(self.pki_dir, key) + else: + path = os.path.join(self.pki_dir, status, key) + ret[status][key] = salt.utils.crypt.pem_finger(path, sum_type=hash_type) + return ret def __enter__(self): return self diff --git a/salt/loader/__init__.py b/salt/loader/__init__.py index ec1033ff599b..c7cf48c56977 100644 --- a/salt/loader/__init__.py +++ b/salt/loader/__init__.py @@ -63,7 +63,6 @@ str(SALT_BASE_PATH / "output"), str(SALT_BASE_PATH / "pillar"), str(SALT_BASE_PATH / "proxy"), - str(SALT_BASE_PATH / "resources"), str(SALT_BASE_PATH / "queues"), str(SALT_BASE_PATH / "renderers"), str(SALT_BASE_PATH / "returners"), @@ -251,57 +250,17 @@ def _module_dirs( if os.path.isdir(maybe_dir): cli_module_dirs.insert(0, maybe_dir) - # Per-resource-type override directories. When the loader is being - # built for a specific resource type (``opts["resource_type"]`` is - # set), every directory layer that already contributes modules also - # gets a ``resources///`` subdirectory check. The - # per-type overrides for a layer are inserted JUST BEFORE that - # layer's standard dir so the type-specific files win for that - # layer (e.g. an in-tree override beats the in-tree standard, an - # extension's override beats the extension's standard, etc.). - rtype = opts.get("resource_type") - rtype_subpath = ( - os.path.join("resources", rtype, int_type or ext_type) if rtype else None - ) - - def _per_type(base): - """Return [base/resources//] if it exists, else [].""" - if not rtype_subpath: - return [] - candidate = os.path.join(base, "resources", rtype, int_type or ext_type) - return [candidate] if os.path.isdir(candidate) else [] - - cli_per_type = [] - for _dir in opts.get("module_dirs", []): - cli_per_type.extend(_per_type(_dir)) - - ext_per_type = [] - if opts.get("extension_modules"): - ext_per_type.extend(_per_type(opts["extension_modules"])) - - # Entry-point packages: for each entry point that contributed a path - # via ``ext_type_types``, also try its ``resources//`` - # sibling. We approximate by walking the parent of each contributed - # path: if the entry point gave us ``/``, we also - # consider ``/resources//``. - entry_point_per_type = [] - if rtype_subpath: - for ep_dir in ext_type_types: - ep_pkg = os.path.dirname(ep_dir) - entry_point_per_type.extend(_per_type(ep_pkg)) - - sys_per_type = _per_type(base_path or str(SALT_BASE_PATH)) - - return ( - cli_per_type - + cli_module_dirs - + ext_per_type - + ext_types - + entry_point_per_type - + ext_type_types - + sys_per_type - + sys_types - ) + if opts.get("features", {}).get( + "enable_deprecated_module_search_path_priority", False + ): + salt.utils.versions.warn_until( + 3008, + "The old module search path priority will be removed in Salt 3008. " + "For more information see https://github.com/saltstack/salt/pull/65938.", + ) + return cli_module_dirs + ext_type_types + ext_types + sys_types + else: + return cli_module_dirs + ext_types + ext_type_types + sys_types def minion_mods( @@ -314,7 +273,6 @@ def minion_mods( notify=False, static_modules=None, proxy=None, - pillar=None, file_client=None, ): """ @@ -358,20 +316,17 @@ def minion_mods( # TODO Publish documentation for module whitelisting if not whitelist: whitelist = opts.get("whitelist_modules", None) - pack = { - "__context__": context, - "__utils__": utils, - "__proxy__": proxy, - "__opts__": opts, - "__file_client__": file_client, - } - if pillar is not None: - pack["__pillar__"] = pillar ret = LazyLoader( _module_dirs(opts, "modules", "module"), opts, tag="module", - pack=pack, + pack={ + "__context__": context, + "__utils__": utils, + "__proxy__": proxy, + "__opts__": opts, + "__file_client__": file_client, + }, whitelist=whitelist, loaded_base_name=loaded_base_name, static_modules=static_modules, @@ -468,35 +423,19 @@ def metaproxy(opts, loaded_base_name=None): ) -def matchers(opts, loaded_base_name=None, context=None, pillar=None): +def matchers(opts, loaded_base_name=None): """ Return the matcher services plugins :param dict opts: The Salt options dictionary :param str loaded_base_name: The imported modules namespace when imported by the salt loader. - :param dict context: The Salt context dictionary - :param dict pillar: The Salt pillar dictionary """ - if context is None: - context = {} - - pack = { - "__salt__": {}, - "__runners__": {}, - "__grains__": opts.get("grains", {}), - "__context__": context, - "__file_client__": None, - } - if pillar is not None: - pack["__pillar__"] = pillar - return LazyLoader( _module_dirs(opts, "matchers"), opts, tag="matchers", loaded_base_name=loaded_base_name, - pack=pack, ) @@ -566,115 +505,6 @@ def proxy( ) -def resource( - opts, - functions=None, - utils=None, - context=None, - loaded_base_name=None, -): - """ - Load the resource connection modules (``salt/resources//__init__.py``). - - Each resource type lives in its own subpackage under ``salt/resources/``; - the package's ``__init__.py`` is the connection module (the equivalent - of a proxy module's main file). LazyLoader discovers each subpackage - as a single module. - - Returns a LazyLoader whose functions are accessible via the - ``__resource_funcs__`` dunder injected into resource execution modules. - Analogous to :func:`proxy` for proxy minions. - - :param dict opts: The Salt options dictionary. - :param LazyLoader functions: A LazyLoader returned from :func:`minion_mods`. - :param LazyLoader utils: A LazyLoader returned from :func:`utils`. - :param dict context: Shared loader context dictionary. - :param str loaded_base_name: Module namespace prefix for this loader. - """ - return LazyLoader( - _module_dirs(opts, "resources"), - opts, - tag="resources", - pack={ - "__salt__": functions, - "__utils__": utils, - "__context__": context, - "__resource__": {}, - }, - extra_module_dirs=utils.module_dirs if utils else None, - pack_self="__resource_funcs__", - loaded_base_name=loaded_base_name, - ) - - -def resource_modules( - opts, - resource_type, - resource_funcs=None, - utils=None, - context=None, - loaded_base_name=None, - minion_mods=None, -): - """ - Load execution modules for a specific resource type. - - Creates an isolated :class:`LazyLoader` whose opts contain - ``resource_type``, allowing execution modules to gate their - ``__virtual__`` on that value — the same mechanism proxy modules use - with ``proxytype``. A minion managing N resource types holds N of - these loaders simultaneously (one per type, not one per device). - - Modules loaded here see ``__salt__`` (this loader, via - ``pack_self``) and ``__minion__`` (the managing minion's loader, - when supplied) as separate namespaces. Resource-specific override - modules call ``__minion__["x.y"]`` to explicitly run something on - the underlying managing minion (e.g. ``ssh-keygen`` before pushing - a key), and call ``__salt__["x.y"]`` to dispatch through the - resource itself. - - :param dict opts: The Salt options dictionary. A copy is made and - ``resource_type`` is injected before passing to the loader. - :param str resource_type: The resource type string (e.g. ``"dummy"``). - :param LazyLoader resource_funcs: The resource connection loader returned - by :func:`resource`, injected as ``__resource_funcs__``. - :param LazyLoader utils: A LazyLoader returned from :func:`utils`. - :param dict context: Shared loader context dictionary. - :param str loaded_base_name: Module namespace prefix for this loader. - :param LazyLoader minion_mods: The managing minion's execution-module - loader (``salt.loader.minion_mods`` result). Packed as - ``__minion__`` so resource-specific modules can call into the - managing minion explicitly. Optional; when None, ``__minion__`` - is not exposed. - """ - resource_opts = dict(opts) - resource_opts["resource_type"] = resource_type - - pack = { - "__context__": context, - "__utils__": utils, - "__resource_funcs__": resource_funcs, - "__opts__": resource_opts, - # Empty sentinel so LazyLoader creates a NamedLoaderContext for - # __resource__ on every loaded module. The NamedLoaderContext - # reads from resource_ctxvar, which _thread_return sets per-call - # before dispatching — giving each resource job its own identity. - "__resource__": {}, - } - if minion_mods is not None: - pack["__minion__"] = minion_mods - - return LazyLoader( - _module_dirs(resource_opts, "modules", "module"), - resource_opts, - tag="module", - pack=pack, - extra_module_dirs=utils.module_dirs if utils else None, - loaded_base_name=loaded_base_name, - pack_self="__salt__", - ) - - def returners( opts, functions, whitelist=None, context=None, proxy=None, loaded_base_name=None ): @@ -706,7 +536,6 @@ def utils( context=None, proxy=None, file_client=None, - pillar=None, pack_self=None, loaded_base_name=None, ): @@ -721,9 +550,6 @@ def utils( :param str loaded_base_name: The imported modules namespace when imported by the salt loader. """ - pack = {"__context__": context, "__proxy__": proxy or {}} - if pillar is not None: - pack["__pillar__"] = pillar return LazyLoader( _module_dirs(opts, "utils", ext_type_dirs="utils_dirs", load_extensions=False), opts, @@ -740,7 +566,7 @@ def utils( ) -def pillars(opts, functions, context=None, pillar=None, loaded_base_name=None): +def pillars(opts, functions, context=None, loaded_base_name=None): """ Returns the pillars modules @@ -752,14 +578,11 @@ def pillars(opts, functions, context=None, pillar=None, loaded_base_name=None): by the salt loader. """ _utils = utils(opts) - pack = {"__salt__": functions, "__context__": context, "__utils__": _utils} - if pillar is not None: - pack["__pillar__"] = pillar ret = LazyLoader( _module_dirs(opts, "pillar"), opts, tag="pillar", - pack=pack, + pack={"__salt__": functions, "__context__": context, "__utils__": _utils}, extra_module_dirs=_utils.module_dirs, pack_self="__ext_pillar__", loaded_base_name=loaded_base_name, @@ -979,15 +802,12 @@ def states( context=None, loaded_base_name=None, file_client=None, - minion_mods=None, ): """ Returns the state modules :param dict opts: The Salt options dictionary - :param LazyLoader functions: A LazyLoader instance returned from ``minion_mods`` - (or, in a resource context, from ``resource_modules``). This becomes - ``__salt__`` for state modules. + :param LazyLoader functions: A LazyLoader instance returned from ``minion_mods``. :param LazyLoader runners: A LazyLoader instance returned from ``runner``. :param LazyLoader utils: A LazyLoader instance returned from ``utils``. :param LazyLoader serializers: An optional LazyLoader instance returned from ``serializers``. @@ -997,11 +817,6 @@ def states( generated modules in __context__ :param str loaded_base_name: The imported modules namespace when imported by the salt loader. - :param LazyLoader minion_mods: Optional escape-hatch loader for the - managing minion's modules. Packed as ``__minion__`` so state - modules running in a resource context can call back into the - managing minion explicitly. Typically the result of - ``salt.loader.minion_mods(opts)``. .. code-block:: python @@ -1014,22 +829,18 @@ def states( if context is None: context = {} - pack = { - "__salt__": functions, - "__proxy__": proxy or {}, - "__utils__": utils, - "__serializers__": serializers, - "__context__": context, - "__file_client__": file_client, - } - if minion_mods is not None: - pack["__minion__"] = minion_mods - return LazyLoader( _module_dirs(opts, "states"), opts, tag="states", - pack=pack, + pack={ + "__salt__": functions, + "__proxy__": proxy or {}, + "__utils__": utils, + "__serializers__": serializers, + "__context__": context, + "__file_client__": file_client, + }, whitelist=whitelist, extra_module_dirs=utils.module_dirs if utils else None, pack_self="__states__", @@ -1115,7 +926,6 @@ def render( proxy=None, context=None, file_client=None, - pillar=None, loaded_base_name=None, ): """ @@ -1139,8 +949,6 @@ def render( "__context__": context, "__file_client__": file_client, } - if pillar is not None: - pack["__pillar__"] = pillar if states: pack["__states__"] = states @@ -1208,6 +1016,7 @@ def grain_funcs(opts, proxy=None, context=None, loaded_base_name=None): grainfuncs = salt.loader.grain_funcs(__opts__) """ _utils = utils(opts, proxy=proxy) + pack = {"__utils__": utils(opts, proxy=proxy), "__context__": context} ret = LazyLoader( _module_dirs( opts, @@ -1218,9 +1027,10 @@ def grain_funcs(opts, proxy=None, context=None, loaded_base_name=None): opts, tag="grains", extra_module_dirs=_utils.module_dirs, - pack={"__utils__": _utils, "__context__": context}, + pack=pack, loaded_base_name=loaded_base_name, ) + ret.pack["__utils__"] = _utils return ret @@ -1271,10 +1081,6 @@ def _load_cached_grains(opts, cfn): return _format_cached_grains(cached_grains) except (OSError, SaltDeserializationError): - log.debug( - "Grains cache was not readable or did not deserialize and might be corrupted. Refreshing.", - exc_info=True, - ) return None diff --git a/salt/loader/context.py b/salt/loader/context.py index 0859df9132be..38d0093a8baf 100644 --- a/salt/loader/context.py +++ b/salt/loader/context.py @@ -19,14 +19,6 @@ loader_ctxvar = contextvars.ContextVar(DEFAULT_CTX_VAR) -# Per-call resource context. Set via resource_ctxvar.set() in -# _thread_return before executing the job. contextvars are per-thread: each -# new thread inherits a copy of the parent's context, and set() only mutates -# the current thread's copy. LazyLoader.run() calls copy_context() fresh on -# every invocation, so the snapshot it passes to _last_context.run() already -# contains the value we set here — completely isolated from other threads. -resource_ctxvar = contextvars.ContextVar("__resource__", default={}) - @contextlib.contextmanager def loader_context(loader): @@ -76,13 +68,6 @@ def value(self): """ The value of the current for this context """ - # __resource__ is served from resource_ctxvar, which is set - # per-thread in _thread_return before the job function executes. - # LazyLoader.run() snapshots the thread context via copy_context() - # on every call, so each _run_as invocation sees the value that was - # current when the function was invoked — no pack mutation needed. - if self.name == "__resource__": - return resource_ctxvar.get() loader = self.loader() if loader is None: return self.default @@ -124,12 +109,9 @@ def __delitem__(self, item): return self.value().__delitem__(item) def __eq__(self, other): - if isinstance(other, self.__class__): - return ( - self.loader_context == other.loader_context and self.name == other.name - ) - # Delegate to underlying value for comparisons with other types - return self.value() == other + if not isinstance(other, self.__class__): + return False + return self.loader_context == other.loader_context and self.name == other.name def __getstate__(self): return { diff --git a/salt/loader/lazy.py b/salt/loader/lazy.py index cec370d787e7..49d7def2ff49 100644 --- a/salt/loader/lazy.py +++ b/salt/loader/lazy.py @@ -29,8 +29,6 @@ import salt.utils.event import salt.utils.files import salt.utils.lazy - -# Lazy import: salt.utils.optsdict imported only when creating loaders import salt.utils.platform import salt.utils.stringutils import salt.utils.versions @@ -305,7 +303,6 @@ def __init__( In pack, if any of the values are None they will be replaced with an empty context-specific dict """ - import salt.utils.optsdict self.parent_loader = None self.inject_globals = {} @@ -315,15 +312,12 @@ def __init__( self.pack[i] = self.pack[i].value() if opts is None: opts = {} - # Use OptsDict for copy-on-write instead of deep copy - opts = salt.utils.optsdict.safe_opts_copy(opts, name=f"loader:{tag}") + opts = copy.deepcopy(opts) for i in ["pillar", "grains"]: if i in opts and isinstance( opts[i], salt.loader.context.NamedLoaderContext ): opts[i] = opts[i].value() - if "optimization_order" not in opts: - opts["optimization_order"] = [0, 1, 2] threadsafety = not opts.get("multiprocessing") self.opts = self.__prep_mod_opts(opts) self.pack_self = pack_self @@ -764,26 +758,11 @@ def __prep_mod_opts(self, opts): pillar = pillar.value() self.pack["__pillar__"] = pillar - # Preserve OptsDict type if present, otherwise create new dict - if isinstance(opts, salt.utils.optsdict.OptsDict): - # For OptsDict, we can remove logger key directly if needed - if "logger" in opts: - # Create child without logger - mod_opts = salt.utils.optsdict.OptsDict.from_parent( - opts, name=f"prep:{self.tag}" - ) - # We can't delete from parent, so we'll just keep it - # The logger key won't hurt anything - mod_opts = opts # Keep the OptsDict as-is - else: - mod_opts = opts - else: - # Original behavior for regular dict - mod_opts = {} - for key, val in list(opts.items()): - if key == "logger": - continue - mod_opts[key] = val + mod_opts = {} + for key, val in list(opts.items()): + if key == "logger": + continue + mod_opts[key] = val if "__opts__" not in self.pack: self.pack["__opts__"] = mod_opts @@ -881,12 +860,9 @@ def _load_module(self, name): self.loaded_files.add(name) fpath_dirname = os.path.dirname(fpath) - fpath_appended = False try: self.__populate_sys_path() - if fpath_dirname not in sys.path: - sys.path.append(fpath_dirname) - fpath_appended = True + sys.path.append(fpath_dirname) if suffix == ".pyx": mod = pyximport.load_module(name, fpath, tempfile.gettempdir()) elif suffix == ".o": @@ -1024,8 +1000,7 @@ def _load_module(self, name): self.missing_modules[name] = error return False finally: - if fpath_appended: - sys.path.remove(fpath_dirname) + sys.path.remove(fpath_dirname) self.__clean_sys_path() loader_context = salt.loader.context.LoaderContext() @@ -1040,24 +1015,13 @@ def _load_module(self, name): if not isinstance(mod.__opts__, salt.loader.context.NamedLoaderContext): if not hasattr(mod, "__orig_opts__"): mod.__orig_opts__ = copy.deepcopy(mod.__opts__) - # Use OptsDict for copy-on-write instead of deep copy - # Create child OptsDict with loader's opts as parent - mod.__opts__ = salt.utils.optsdict.safe_opts_copy( - self.opts, name=f"module:{name}" - ) - # Apply module-specific opts on top - if mod.__orig_opts__: - mod.__opts__.update(mod.__orig_opts__) + mod.__opts__ = copy.deepcopy(mod.__orig_opts__) + mod.__opts__.update(self.opts) else: if not hasattr(mod, "__orig_opts__"): mod.__orig_opts__ = {} - # Use OptsDict for copy-on-write instead of deep copy - mod.__opts__ = salt.utils.optsdict.safe_opts_copy( - self.opts, name=f"module:{name}" - ) - # Apply module-specific opts on top - if mod.__orig_opts__: - mod.__opts__.update(mod.__orig_opts__) + mod.__opts__ = copy.deepcopy(mod.__orig_opts__) + mod.__opts__.update(self.opts) # pack whatever other globals we were asked to for p_name, p_value in self.pack.items(): @@ -1317,11 +1281,7 @@ def _load_all(self): for name in self.file_mapping: if name in self.loaded_files or name in self.missing_modules: continue - try: - self._load_module(name) - except FileNotFoundError: - log.warning("Module file not found %s", name) - self.missing_modules[name] = f"Module file not found {name}" + self._load_module(name) self.loaded = True diff --git a/salt/log_handlers/fluent_mod.py b/salt/log_handlers/fluent_mod.py new file mode 100644 index 000000000000..a23544ff8712 --- /dev/null +++ b/salt/log_handlers/fluent_mod.py @@ -0,0 +1,547 @@ +""" + Fluent Logging Handler + ====================== + + .. versionadded:: 2015.8.0 + + This module provides some fluentd_ logging handlers. + + + Fluent Logging Handler + ---------------------- + + In the `fluent` configuration file: + + .. code-block:: text + + + type forward + bind localhost + port 24224 + + + Then, to send logs via fluent in Logstash format, add the + following to the salt (master and/or minion) configuration file: + + .. code-block:: yaml + + fluent_handler: + host: localhost + port: 24224 + + To send logs via fluent in the Graylog raw json format, add the + following to the salt (master and/or minion) configuration file: + + .. code-block:: yaml + + fluent_handler: + host: localhost + port: 24224 + payload_type: graylog + tags: + - salt_master.SALT + + The above also illustrates the `tags` option, which allows + one to set descriptive (or useful) tags on records being + sent. If not provided, this defaults to the single tag: + 'salt'. Also note that, via Graylog "magic", the 'facility' + of the logged message is set to 'SALT' (the portion of the + tag after the first period), while the tag itself will be + set to simply 'salt_master'. This is a feature, not a bug :) + + Note: + There is a third emitter, for the GELF format, but it is + largely untested, and I don't currently have a setup supporting + this config, so while it runs cleanly and outputs what LOOKS to + be valid GELF, any real-world feedback on its usefulness, and + correctness, will be appreciated. + + Log Level + ......... + + The ``fluent_handler`` configuration section accepts an additional setting + ``log_level``. If not set, the logging level used will be the one defined + for ``log_level`` in the global configuration file section. + + .. admonition:: Inspiration + + This work was inspired in `fluent-logger-python`_ + + .. _fluentd: http://www.fluentd.org + .. _`fluent-logger-python`: https://github.com/fluent/fluent-logger-python + +""" + +import datetime +import logging +import logging.handlers +import socket +import threading +import time + +import salt.utils.msgpack +import salt.utils.network +from salt._logging import LOG_LEVELS + +log = logging.getLogger(__name__) + + +# Define the module's virtual name +__virtualname__ = "fluent" + +_global_sender = None + +# Python logger's idea of "level" is wildly at variance with +# Graylog's (and, incidentally, the rest of the civilized world). +syslog_levels = { + "EMERG": 0, + "ALERT": 2, + "CRIT": 2, + "ERR": 3, + "WARNING": 4, + "NOTICE": 5, + "INFO": 6, + "DEBUG": 7, +} + + +def setup(tag, **kwargs): + host = kwargs.get("host", "localhost") + port = kwargs.get("port", 24224) + + global _global_sender + _global_sender = FluentSender(tag, host=host, port=port) + + +def get_global_sender(): + return _global_sender + + +def __virtual__(): + if not any(["fluent_handler" in __opts__]): + log.trace( + "The required configuration section, 'fluent_handler', " + "was not found the in the configuration. Not loading the fluent " + "logging handlers module." + ) + return False + return __virtualname__ + + +def setup_handlers(): + host = port = None + + if "fluent_handler" in __opts__: + host = __opts__["fluent_handler"].get("host", None) + port = __opts__["fluent_handler"].get("port", None) + payload_type = __opts__["fluent_handler"].get("payload_type", None) + # in general, you want the value of tag to ALSO be a member of tags + tags = __opts__["fluent_handler"].get("tags", ["salt"]) + tag = tags[0] if tags else "salt" + if payload_type == "graylog": + version = 0 + elif payload_type == "gelf": + # We only support version 1.1 (the latest) of GELF... + version = 1.1 + else: + # Default to logstash for backwards compat + payload_type = "logstash" + version = __opts__["fluent_handler"].get("version", 1) + + if host is None and port is None: + log.debug( + "The required 'fluent_handler' configuration keys, " + "'host' and/or 'port', are not properly configured. Not " + "enabling the fluent logging handler." + ) + else: + formatter = MessageFormatter( + payload_type=payload_type, version=version, tags=tags + ) + fluent_handler = FluentHandler(tag, host=host, port=port) + fluent_handler.setFormatter(formatter) + fluent_handler.setLevel( + LOG_LEVELS[ + __opts__["fluent_handler"].get( + "log_level", __opts__.get("log_level", "error") + ) + ] + ) + yield fluent_handler + + if host is None and port is None: + yield False + + +class MessageFormatter(logging.Formatter): + def __init__(self, payload_type, version, tags, msg_type=None, msg_path=None): + self.payload_type = payload_type + self.version = version + self.tag = tags[0] if tags else "salt" # 'salt' for backwards compat + self.tags = tags + self.msg_path = msg_path if msg_path else payload_type + self.msg_type = msg_type if msg_type else payload_type + format_func = f"format_{payload_type}_v{version}".replace(".", "_") + self.format = getattr(self, format_func) + super().__init__(fmt=None, datefmt=None) + + def formatTime(self, record, datefmt=None): + if self.payload_type == "gelf": # GELF uses epoch times + return record.created + return datetime.datetime.utcfromtimestamp(record.created).isoformat()[:-3] + "Z" + + def format_graylog_v0(self, record): + """ + Graylog 'raw' format is essentially the raw record, minimally munged to provide + the bare minimum that td-agent requires to accept and route the event. This is + well suited to a config where the client td-agents log directly to Graylog. + """ + message_dict = { + "message": record.getMessage(), + "timestamp": self.formatTime(record), + # Graylog uses syslog levels, not whatever it is Python does... + "level": syslog_levels.get(record.levelname, "ALERT"), + "tag": self.tag, + } + + if record.exc_info: + exc_info = self.formatException(record.exc_info) + message_dict.update({"full_message": exc_info}) + + # Add any extra attributes to the message field + for key, value in record.__dict__.items(): + if key in ( + "args", + "asctime", + "bracketlevel", + "bracketname", + "bracketprocess", + "created", + "exc_info", + "exc_text", + "id", + "levelname", + "levelno", + "msecs", + "msecs", + "message", + "msg", + "relativeCreated", + "version", + ): + # These are already handled above or explicitly pruned. + continue + + if value is None or isinstance(value, (str, bool, dict, float, int, list)): + val = value + else: + val = repr(value) + message_dict.update({f"{key}": val}) + return message_dict + + def format_gelf_v1_1(self, record): + """ + If your agent is (or can be) configured to forward pre-formed GELF to Graylog + with ZERO fluent processing, this function is for YOU, pal... + """ + message_dict = { + "version": self.version, + "host": salt.utils.network.get_fqhostname(), + "short_message": record.getMessage(), + "timestamp": self.formatTime(record), + "level": syslog_levels.get(record.levelname, "ALERT"), + "_tag": self.tag, + } + + if record.exc_info: + exc_info = self.formatException(record.exc_info) + message_dict.update({"full_message": exc_info}) + + # Add any extra attributes to the message field + for key, value in record.__dict__.items(): + if key in ( + "args", + "asctime", + "bracketlevel", + "bracketname", + "bracketprocess", + "created", + "exc_info", + "exc_text", + "id", + "levelname", + "levelno", + "msecs", + "msecs", + "message", + "msg", + "relativeCreated", + "version", + ): + # These are already handled above or explicitly avoided. + continue + + if value is None or isinstance(value, (str, bool, dict, float, int, list)): + val = value + else: + val = repr(value) + # GELF spec require "non-standard" fields to be prefixed with '_' (underscore). + message_dict.update({f"_{key}": val}) + + return message_dict + + def format_logstash_v0(self, record): + """ + Messages are formatted in logstash's expected format. + """ + host = salt.utils.network.get_fqhostname() + message_dict = { + "@timestamp": self.formatTime(record), + "@fields": { + "levelname": record.levelname, + "logger": record.name, + "lineno": record.lineno, + "pathname": record.pathname, + "process": record.process, + "threadName": record.threadName, + "funcName": record.funcName, + "processName": record.processName, + }, + "@message": record.getMessage(), + "@source": f"{self.msg_type}://{host}/{self.msg_path}", + "@source_host": host, + "@source_path": self.msg_path, + "@tags": self.tags, + "@type": self.msg_type, + } + + if record.exc_info: + message_dict["@fields"]["exc_info"] = self.formatException(record.exc_info) + + # Add any extra attributes to the message field + for key, value in record.__dict__.items(): + if key in ( + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "id", + "levelname", + "levelno", + "lineno", + "module", + "msecs", + "msecs", + "message", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "thread", + "threadName", + ): + # These are already handled above or not handled at all + continue + + if value is None: + message_dict["@fields"][key] = value + continue + + if isinstance(value, (str, bool, dict, float, int, list)): + message_dict["@fields"][key] = value + continue + + message_dict["@fields"][key] = repr(value) + return message_dict + + def format_logstash_v1(self, record): + """ + Messages are formatted in logstash's expected format. + """ + message_dict = { + "@version": 1, + "@timestamp": self.formatTime(record), + "host": salt.utils.network.get_fqhostname(), + "levelname": record.levelname, + "logger": record.name, + "lineno": record.lineno, + "pathname": record.pathname, + "process": record.process, + "threadName": record.threadName, + "funcName": record.funcName, + "processName": record.processName, + "message": record.getMessage(), + "tags": self.tags, + "type": self.msg_type, + } + + if record.exc_info: + message_dict["exc_info"] = self.formatException(record.exc_info) + + # Add any extra attributes to the message field + for key, value in record.__dict__.items(): + if key in ( + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "id", + "levelname", + "levelno", + "lineno", + "module", + "msecs", + "msecs", + "message", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "thread", + "threadName", + ): + # These are already handled above or not handled at all + continue + + if value is None: + message_dict[key] = value + continue + + if isinstance(value, (str, bool, dict, float, int, list)): + message_dict[key] = value + continue + + message_dict[key] = repr(value) + return message_dict + + +class FluentHandler(logging.Handler): + """ + Logging Handler for fluent. + """ + + def __init__(self, tag, host="localhost", port=24224, timeout=3.0, verbose=False): + + self.tag = tag + self.sender = FluentSender( + tag, host=host, port=port, timeout=timeout, verbose=verbose + ) + logging.Handler.__init__(self) + + def emit(self, record): + data = self.format(record) + self.sender.emit(None, data) + + def close(self): + self.acquire() + try: + self.sender._close() + logging.Handler.close(self) + finally: + self.release() + + +class FluentSender: + def __init__( + self, + tag, + host="localhost", + port=24224, + bufmax=1 * 1024 * 1024, + timeout=3.0, + verbose=False, + ): + + self.tag = tag + self.host = host + self.port = port + self.bufmax = bufmax + self.timeout = timeout + self.verbose = verbose + + self.socket = None + self.pendings = None + self.lock = threading.Lock() + + try: + self._reconnect() + except Exception: # pylint: disable=broad-except + # will be retried in emit() + self._close() + + def emit(self, label, data): + cur_time = int(time.time()) + self.emit_with_time(label, cur_time, data) + + def emit_with_time(self, label, timestamp, data): + bytes_ = self._make_packet(label, timestamp, data) + self._send(bytes_) + + def _make_packet(self, label, timestamp, data): + if label: + tag = ".".join((self.tag, label)) + else: + tag = self.tag + packet = (tag, timestamp, data) + if self.verbose: + print(packet) + return salt.utils.msgpack.packb(packet) + + def _send(self, bytes_): + self.lock.acquire() + try: + self._send_internal(bytes_) + finally: + self.lock.release() + + def _send_internal(self, bytes_): + # buffering + if self.pendings: + self.pendings += bytes_ + bytes_ = self.pendings + + try: + # reconnect if possible + self._reconnect() + + # send message + self.socket.sendall(bytes_) + + # send finished + self.pendings = None + except Exception: # pylint: disable=broad-except + # close socket + self._close() + # clear buffer if it exceeds max bufer size + if self.pendings and (len(self.pendings) > self.bufmax): + # TODO: add callback handler here + self.pendings = None + else: + self.pendings = bytes_ + + def _reconnect(self): + if not self.socket: + if self.host.startswith("unix://"): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + sock.connect(self.host[len("unix://") :]) + else: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + sock.connect((self.host, self.port)) + self.socket = sock + + def _close(self): + if self.socket: + self.socket.close() + self.socket = None diff --git a/salt/log_handlers/log4mongo_mod.py b/salt/log_handlers/log4mongo_mod.py new file mode 100644 index 000000000000..3f99a0ca995a --- /dev/null +++ b/salt/log_handlers/log4mongo_mod.py @@ -0,0 +1,90 @@ +""" + Log4Mongo Logging Handler + ========================= + + This module provides a logging handler for sending salt logs to MongoDB + + Configuration + ------------- + + In the salt configuration file (e.g. /etc/salt/{master,minion}): + + .. code-block:: yaml + + log4mongo_handler: + host: mongodb_host + port: 27017 + database_name: logs + collection: salt_logs + username: logging + password: reindeerflotilla + write_concern: 0 + log_level: warning + + + Log Level + ......... + + If not set, the log_level will be set to the level defined in the global + configuration file setting. + + .. admonition:: Inspiration + + This work was inspired by the Salt logging handlers for LogStash and + Sentry and by the log4mongo Python implementation. +""" + +import logging +import socket + +from salt._logging import LOG_LEVELS + +try: + from log4mongo.handlers import MongoFormatter, MongoHandler + + HAS_MONGO = True +except ImportError: + HAS_MONGO = False + +__virtualname__ = "mongo" + + +def __virtual__(): + if not HAS_MONGO: + return False + return __virtualname__ + + +class FormatterWithHost(logging.Formatter): + def format(self, record): + mongoformatter = MongoFormatter() + document = mongoformatter.format(record) + document["hostname"] = socket.gethostname() + return document + + +def setup_handlers(): + handler_id = "log4mongo_handler" + if handler_id in __opts__: + config_fields = { + "host": "host", + "port": "port", + "database_name": "database_name", + "collection": "collection", + "username": "username", + "password": "password", + "write_concern": "w", + } + + config_opts = {} + for config_opt, arg_name in config_fields.items(): + config_opts[arg_name] = __opts__[handler_id].get(config_opt) + + config_opts["level"] = LOG_LEVELS[ + __opts__[handler_id].get("log_level", __opts__.get("log_level", "error")) + ] + + handler = MongoHandler(formatter=FormatterWithHost(), **config_opts) + yield handler + else: + yield False diff --git a/salt/log_handlers/logstash_mod.py b/salt/log_handlers/logstash_mod.py new file mode 100644 index 000000000000..b3e30f83a990 --- /dev/null +++ b/salt/log_handlers/logstash_mod.py @@ -0,0 +1,461 @@ +""" + Logstash Logging Handler + ======================== + + .. versionadded:: 0.17.0 + + This module provides some `Logstash`_ logging handlers. + + + UDP Logging Handler + ------------------- + + For versions of `Logstash`_ before 1.2.0: + + In the salt configuration file: + + .. code-block:: yaml + + logstash_udp_handler: + host: 127.0.0.1 + port: 9999 + version: 0 + msg_type: logstash + + In the `Logstash`_ configuration file: + + .. code-block:: text + + input { + udp { + type => "udp-type" + format => "json_event" + } + } + + For version 1.2.0 of `Logstash`_ and newer: + + In the salt configuration file: + + .. code-block:: yaml + + logstash_udp_handler: + host: 127.0.0.1 + port: 9999 + version: 1 + msg_type: logstash + + In the `Logstash`_ configuration file: + + .. code-block:: text + + input { + udp { + port => 9999 + codec => json + } + } + + Please read the `UDP input`_ configuration page for additional information. + + + ZeroMQ Logging Handler + ---------------------- + + For versions of `Logstash`_ before 1.2.0: + + In the salt configuration file: + + .. code-block:: yaml + + logstash_zmq_handler: + address: tcp://127.0.0.1:2021 + version: 0 + + In the `Logstash`_ configuration file: + + .. code-block:: text + + input { + zeromq { + type => "zeromq-type" + mode => "server" + topology => "pubsub" + address => "tcp://0.0.0.0:2021" + charset => "UTF-8" + format => "json_event" + } + } + + For version 1.2.0 of `Logstash`_ and newer: + + In the salt configuration file: + + .. code-block:: yaml + + logstash_zmq_handler: + address: tcp://127.0.0.1:2021 + version: 1 + + In the `Logstash`_ configuration file: + + .. code-block:: text + + input { + zeromq { + topology => "pubsub" + address => "tcp://0.0.0.0:2021" + codec => json + } + } + + Please read the `ZeroMQ input`_ configuration page for additional + information. + + .. admonition:: Important Logstash Setting + + One of the most important settings that you should not forget on your + `Logstash`_ configuration file regarding these logging handlers is + ``format``. + Both the `UDP` and `ZeroMQ` inputs need to have ``format`` as + ``json_event`` which is what we send over the wire. + + + Log Level + ......... + + Both the ``logstash_udp_handler`` and the ``logstash_zmq_handler`` + configuration sections accept an additional setting ``log_level``. If not + set, the logging level used will be the one defined for ``log_level`` in + the global configuration file section. + + HWM + ... + + The `high water mark`_ for the ZMQ socket setting. Only applicable for the + ``logstash_zmq_handler``. + + + + .. admonition:: Inspiration + + This work was inspired in `pylogstash`_, `python-logstash`_, `canary`_ + and the `PyZMQ logging handler`_. + + + .. _`Logstash`: http://logstash.net + .. _`canary`: https://github.com/ryanpetrello/canary + .. _`pylogstash`: https://github.com/turtlebender/pylogstash + .. _`python-logstash`: https://github.com/vklochan/python-logstash + .. _`PyZMQ logging handler`: https://github.com/zeromq/pyzmq/blob/master/zmq/log/handlers.py + .. _`UDP input`: http://logstash.net/docs/latest/inputs/udp + .. _`ZeroMQ input`: http://logstash.net/docs/latest/inputs/zeromq + .. _`high water mark`: http://api.zeromq.org/3-2:zmq-setsockopt + +""" + +import datetime +import logging +import logging.handlers +import os + +import salt.utils.json +import salt.utils.network +import salt.utils.stringutils +from salt._logging import LOG_LEVELS + +try: + import zmq + import zmq.error +except ImportError: + pass + +log = logging.getLogger(__name__) + +# Define the module's virtual name +__virtualname__ = "logstash" + + +def __virtual__(): + if not any( + ["logstash_udp_handler" in __opts__, "logstash_zmq_handler" in __opts__] + ): + log.trace( + "None of the required configuration sections, " + "'logstash_udp_handler' and 'logstash_zmq_handler', " + "were found in the configuration. Not loading the Logstash " + "logging handlers module." + ) + return False + return __virtualname__ + + +def setup_handlers(): + host = port = address = None + + if "logstash_udp_handler" in __opts__: + host = __opts__["logstash_udp_handler"].get("host", None) + port = __opts__["logstash_udp_handler"].get("port", None) + version = __opts__["logstash_udp_handler"].get("version", 0) + msg_type = __opts__["logstash_udp_handler"].get("msg_type", "logstash") + + if host is None and port is None: + log.debug( + "The required 'logstash_udp_handler' configuration keys, " + "'host' and/or 'port', are not properly configured. Not " + "configuring the logstash UDP logging handler." + ) + else: + logstash_formatter = LogstashFormatter(msg_type=msg_type, version=version) + udp_handler = DatagramLogstashHandler(host, port) + udp_handler.setFormatter(logstash_formatter) + udp_handler.setLevel( + LOG_LEVELS[ + __opts__["logstash_udp_handler"].get( + "log_level", + # Not set? Get the main salt log_level setting on the + # configuration file + __opts__.get( + "log_level", + # Also not set?! Default to 'error' + "error", + ), + ) + ] + ) + yield udp_handler + + if "logstash_zmq_handler" in __opts__: + address = __opts__["logstash_zmq_handler"].get("address", None) + zmq_hwm = __opts__["logstash_zmq_handler"].get("hwm", 1000) + version = __opts__["logstash_zmq_handler"].get("version", 0) + + if address is None: + log.debug( + "The required 'logstash_zmq_handler' configuration key, " + "'address', is not properly configured. Not " + "configuring the logstash ZMQ logging handler." + ) + else: + logstash_formatter = LogstashFormatter(version=version) + zmq_handler = ZMQLogstashHander(address, zmq_hwm=zmq_hwm) + zmq_handler.setFormatter(logstash_formatter) + zmq_handler.setLevel( + LOG_LEVELS[ + __opts__["logstash_zmq_handler"].get( + "log_level", + # Not set? Get the main salt log_level setting on the + # configuration file + __opts__.get( + "log_level", + # Also not set?! Default to 'error' + "error", + ), + ) + ] + ) + yield zmq_handler + + if host is None and port is None and address is None: + yield False + + +class LogstashFormatter(logging.Formatter): + def __init__(self, msg_type="logstash", msg_path="logstash", version=0): + self.msg_path = msg_path + self.msg_type = msg_type + self.version = version + self.format = getattr(self, f"format_v{version}") + super().__init__(fmt=None, datefmt=None) + + def formatTime(self, record, datefmt=None): + return datetime.datetime.utcfromtimestamp(record.created).isoformat()[:-3] + "Z" + + def format_v0(self, record): + host = salt.utils.network.get_fqhostname() + message_dict = { + "@timestamp": self.formatTime(record), + "@fields": { + "levelname": record.levelname, + "logger": record.name, + "lineno": record.lineno, + "pathname": record.pathname, + "process": record.process, + "threadName": record.threadName, + "funcName": record.funcName, + "processName": record.processName, + }, + "@message": record.getMessage(), + "@source": f"{self.msg_type}://{host}/{self.msg_path}", + "@source_host": host, + "@source_path": self.msg_path, + "@tags": ["salt"], + "@type": self.msg_type, + } + + if record.exc_info: + message_dict["@fields"]["exc_info"] = self.formatException(record.exc_info) + + # Add any extra attributes to the message field + for key, value in record.__dict__.items(): + if key in ( + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "id", + "levelname", + "levelno", + "lineno", + "module", + "msecs", + "msecs", + "message", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "thread", + "threadName", + ): + # These are already handled above or not handled at all + continue + + if value is None: + message_dict["@fields"][key] = value + continue + + if isinstance(value, (str, bool, dict, float, int, list)): + message_dict["@fields"][key] = value + continue + + message_dict["@fields"][key] = repr(value) + return salt.utils.json.dumps(message_dict) + + def format_v1(self, record): + message_dict = { + "@version": 1, + "@timestamp": self.formatTime(record), + "host": salt.utils.network.get_fqhostname(), + "levelname": record.levelname, + "logger": record.name, + "lineno": record.lineno, + "pathname": record.pathname, + "process": record.process, + "threadName": record.threadName, + "funcName": record.funcName, + "processName": record.processName, + "message": record.getMessage(), + "tags": ["salt"], + "type": self.msg_type, + } + + if record.exc_info: + message_dict["exc_info"] = self.formatException(record.exc_info) + + # Add any extra attributes to the message field + for key, value in record.__dict__.items(): + if key in ( + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "id", + "levelname", + "levelno", + "lineno", + "module", + "msecs", + "msecs", + "message", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "thread", + "threadName", + ): + # These are already handled above or not handled at all + continue + + if value is None: + message_dict[key] = value + continue + + if isinstance(value, (str, bool, dict, float, int, list)): + message_dict[key] = value + continue + + message_dict[key] = repr(value) + return salt.utils.json.dumps(message_dict) + + +class DatagramLogstashHandler(logging.handlers.DatagramHandler): + """ + Logstash UDP logging handler. + """ + + def makePickle(self, record): + return salt.utils.stringutils.to_bytes(self.format(record)) + + +class ZMQLogstashHander(logging.Handler): + """ + Logstash ZMQ logging handler. + """ + + def __init__(self, address, level=logging.NOTSET, zmq_hwm=1000): + super().__init__(level=level) + self._context = self._publisher = None + self._address = address + self._zmq_hwm = zmq_hwm + self._pid = os.getpid() + + @property + def publisher(self): + current_pid = os.getpid() + if not getattr(self, "_publisher") or self._pid != current_pid: + # We forked? Multiprocessing? Recreate!!! + self._pid = current_pid + self._context = zmq.Context() + self._publisher = self._context.socket(zmq.PUB) + # Above 1000 unsent events in the socket queue, stop dropping them + try: + # Above the defined high water mark(unsent messages), start + # dropping them + self._publisher.setsockopt(zmq.HWM, self._zmq_hwm) + except (AttributeError, zmq.error.ZMQError): + # In ZMQ >= 3.0, there are separate send and receive HWM + # settings + self._publisher.setsockopt(zmq.SNDHWM, self._zmq_hwm) + self._publisher.setsockopt(zmq.RCVHWM, self._zmq_hwm) + + self._publisher.connect(self._address) + return self._publisher + + def emit(self, record): + formatted_object = salt.utils.stringutils.to_bytes(self.format(record)) + self.publisher.send(formatted_object) + + def close(self): + if self._context is not None: + # One second to send any queued messages + if hasattr(self._context, "destroy"): + self._context.destroy(1 * 1000) + else: + if getattr(self, "_publisher", None) is not None: + self._publisher.setsockopt(zmq.LINGER, 1 * 1000) + self._publisher.close() + + if self._context.closed is False: + self._context.term() diff --git a/salt/log_handlers/sentry_mod.py b/salt/log_handlers/sentry_mod.py new file mode 100644 index 000000000000..a12366f9fcbc --- /dev/null +++ b/salt/log_handlers/sentry_mod.py @@ -0,0 +1,238 @@ +""" + Sentry Logging Handler + ====================== + + .. versionadded:: 0.17.0 + + This module provides a `Sentry`_ logging handler. Sentry is an open source + error tracking platform that provides deep context about exceptions that + happen in production. Details about stack traces along with the context + variables available at the time of the exception are easily browsable and + filterable from the online interface. For more details please see + `Sentry`_. + + .. admonition:: Note + + The `Raven`_ library needs to be installed on the system for this + logging handler to be available. + + Configuring the python `Sentry`_ client, `Raven`_, should be done under the + ``sentry_handler`` configuration key. Additional `context` may be provided + for corresponding grain item(s). + At the bare minimum, you need to define the `DSN`_. As an example: + + .. code-block:: yaml + + sentry_handler: + dsn: https://pub-key:secret-key@app.getsentry.com/app-id + + + More complex configurations can be achieved, for example: + + .. code-block:: yaml + + sentry_handler: + servers: + - https://sentry.example.com + - http://192.168.1.1 + project: app-id + public_key: deadbeefdeadbeefdeadbeefdeadbeef + secret_key: beefdeadbeefdeadbeefdeadbeefdead + context: + - os + - master + - saltversion + - cpuarch + - ec2.tags.environment + + .. admonition:: Note + + The ``public_key`` and ``secret_key`` variables are not supported with + Sentry > 3.0. The `DSN`_ key should be used instead. + + All the client configuration keys are supported, please see the + `Raven client documentation`_. + + The default logging level for the sentry handler is ``ERROR``. If you wish + to define a different one, define ``log_level`` under the + ``sentry_handler`` configuration key: + + .. code-block:: yaml + + sentry_handler: + dsn: https://pub-key:secret-key@app.getsentry.com/app-id + log_level: warning + + + The available log levels are those also available for the salt ``cli`` + tools and configuration; ``salt --help`` should give you the required + information. + + + Threaded Transports + ------------------- + + Raven's documents rightly suggest using its threaded transport for + critical applications. However, don't forget that if you start having + troubles with Salt after enabling the threaded transport, please try + switching to a non-threaded transport to see if that fixes your problem. + + + + .. _`DSN`: https://raven.readthedocs.io/en/latest/config/index.html#the-sentry-dsn + .. _`Sentry`: https://getsentry.com + .. _`Raven`: https://raven.readthedocs.io + .. _`Raven client documentation`: https://raven.readthedocs.io/en/latest/config/index.html#client-arguments +""" + +import logging +import re + +import salt.loader +from salt._logging import LOG_LEVELS + +try: + import raven + from raven.handlers.logging import SentryHandler + + HAS_RAVEN = True +except ImportError: + HAS_RAVEN = False + +log = logging.getLogger(__name__) + +# Define the module's virtual name +__virtualname__ = "sentry" + + +def __virtual__(): + load_err_msg = [] + if not HAS_RAVEN: + load_err_msg.append("Cannot find 'raven' python library") + if not __opts__.get("sentry_handler"): + load_err_msg.append("'sentry_handler' config is empty or not defined") + if load_err_msg: + return False, ", ".join(load_err_msg) + return __virtualname__ + + +def setup_handlers(): + """ + sets up the sentry handler + """ + if not __opts__.get("sentry_handler"): + log.debug("'sentry_handler' config is empty or not defined") + return False + + # Regenerating dunders can be expensive, so only do it if the user enables + # `sentry_handler` as checked above + __grains__ = salt.loader.grains(__opts__) + __salt__ = salt.loader.minion_mods(__opts__) + + options = {} + dsn = get_config_value("dsn") + if dsn is not None: + try: + # support raven ver 5.5.0 + from raven.transport import TransportRegistry, default_transports + from raven.utils.urlparse import urlparse + + transport_registry = TransportRegistry(default_transports) + url = urlparse(dsn) + if not transport_registry.supported_scheme(url.scheme): + raise ValueError(f"Unsupported Sentry DSN scheme: {url.scheme}") + except ValueError as exc: + log.info("Raven failed to parse the configuration provided DSN: %s", exc) + + if not dsn: + for key in ("project", "servers", "public_key", "secret_key"): + config_value = get_config_value(key) + if config_value is None and key not in options: + log.debug( + "The required 'sentry_handler' configuration key, " + "'%s', is not properly configured. Not configuring " + "the sentry logging handler.", + key, + ) + return + elif config_value is None: + continue + options[key] = config_value + + # site: An optional, arbitrary string to identify this client installation. + options.update( + { + # site: An optional, arbitrary string to identify this client + # installation + "site": get_config_value("site"), + # name: This will override the server_name value for this installation. + # Defaults to socket.gethostname() + "name": get_config_value("name"), + # exclude_paths: Extending this allow you to ignore module prefixes + # when sentry attempts to discover which function an error comes from + "exclude_paths": get_config_value("exclude_paths", ()), + # include_paths: For example, in Django this defaults to your list of + # INSTALLED_APPS, and is used for drilling down where an exception is + # located + "include_paths": get_config_value("include_paths", ()), + # list_max_length: The maximum number of items a list-like container + # should store. + "list_max_length": get_config_value("list_max_length"), + # string_max_length: The maximum characters of a string that should be + # stored. + "string_max_length": get_config_value("string_max_length"), + # auto_log_stacks: Should Raven automatically log frame stacks + # (including locals) all calls as it would for exceptions. + "auto_log_stacks": get_config_value("auto_log_stacks"), + # timeout: If supported, the timeout value for sending messages to + # remote. + "timeout": get_config_value("timeout", 1), + # processors: A list of processors to apply to events before sending + # them to the Sentry server. Useful for sending additional global state + # data or sanitizing data that you want to keep off of the server. + "processors": get_config_value("processors"), + # dsn: Ensure the DSN is passed into the client + "dsn": dsn, + } + ) + + client = raven.Client(**options) + context = get_config_value("context") + context_dict = {} + if context is not None: + for tag in context: + try: + tag_value = __grains__[tag] + except KeyError: + log.debug("Sentry tag '%s' not found in grains.", tag) + continue + if tag_value: + context_dict[tag] = tag_value + if context_dict: + client.context.merge({"tags": context_dict}) + try: + handler = SentryHandler(client) + + exclude_patterns = get_config_value("exclude_patterns", None) + if exclude_patterns: + filter_regexes = [re.compile(pattern) for pattern in exclude_patterns] + + class FilterExcludedMessages: + @staticmethod + def filter(record): + m = record.getMessage() + return not any(regex.search(m) for regex in filter_regexes) + + handler.addFilter(FilterExcludedMessages()) + + handler.setLevel(LOG_LEVELS[get_config_value("log_level", "error")]) + return handler + except ValueError as exc: + log.debug("Failed to setup the sentry logging handler", exc_info=True) + + +def get_config_value(name, default=None): + """ + returns a configuration option for the sentry_handler + """ + return __opts__["sentry_handler"].get(name, default) diff --git a/salt/master.py b/salt/master.py index ba71d1df6e44..ec3219a7c0ba 100644 --- a/salt/master.py +++ b/salt/master.py @@ -4,15 +4,12 @@ """ import asyncio -import binascii import collections import copy import ctypes -import hashlib import logging import multiprocessing import os -import pathlib import re import signal import stat @@ -21,14 +18,13 @@ import time from collections import OrderedDict +import tornado.gen + import salt.acl import salt.auth -import salt.cache import salt.channel.server import salt.client import salt.client.ssh.client -import salt.cluster.healthchecks -import salt.cluster.ring_membership import salt.crypt import salt.daemons.masterapi import salt.defaults.exitcodes @@ -43,9 +39,6 @@ import salt.state import salt.utils.args import salt.utils.atomicfile -import salt.utils.batch_manager -import salt.utils.batch_output -import salt.utils.batch_state import salt.utils.cache import salt.utils.ctx import salt.utils.event @@ -55,24 +48,19 @@ import salt.utils.jid import salt.utils.job import salt.utils.master -import salt.utils.metrics import salt.utils.minions import salt.utils.platform import salt.utils.process -import salt.utils.resource_registry import salt.utils.schedule import salt.utils.ssdp import salt.utils.stringutils -import salt.utils.tracing import salt.utils.user import salt.utils.verify import salt.utils.zeromq import salt.wheel from salt.config import DEFAULT_INTERVAL from salt.defaults import DEFAULT_TARGET_DELIM -from salt.exceptions import UnsupportedAlgorithm from salt.transport import TRANSPORTS -from salt.utils.cache import CacheCli from salt.utils.channel import iter_transport_opts from salt.utils.debug import enable_sigusr1_handler, enable_sigusr2_handler from salt.utils.event import tagify @@ -89,81 +77,6 @@ log = logging.getLogger(__name__) -# Shared ``multiprocessing.Value`` for the "MWorker payloads in flight" -# observable gauge. Created by ``Master.start`` before any worker is -# spawned so all children inherit the same shared memory via fork. Read -# by the parent's observable callback and incremented/decremented by -# every ``MWorker._handle_payload`` invocation. -_WORKERS_INFLIGHT = None - - -def _register_master_observables(opts, workers_inflight): - """ - Register the master-side observable gauges with the metrics module. - - Called once from the master parent process during :meth:`Master.start`. - Workers must not call this — they'd register duplicate callbacks and - over-report. - """ - if not salt.utils.metrics.is_enabled(): - return - # opentelemetry.metrics.Observation is only available when otel is - # importable; we already gated on is_enabled() above. - from opentelemetry.metrics import Observation - - try: - import psutil - except ImportError: # pragma: no cover - psutil = None # type: ignore[assignment] - - def _connected_minions_cb(_options): - try: - ck = salt.utils.minions.CkMinions(opts) - return (Observation(len(ck.connected_ids())),) - except Exception as exc: # pylint: disable=broad-except - log.debug("connected_minions observable failed: %s", exc) - return () - - def _queue_depth_cb(_options): - try: - with workers_inflight.get_lock(): - value = int(workers_inflight.value) - return (Observation(value, {"pool": "default"}),) - except Exception as exc: # pylint: disable=broad-except - log.debug("workers.queue.depth observable failed: %s", exc) - return () - - def _open_fds_cb(_options): - if psutil is None: - return () - try: - return (Observation(psutil.Process().num_fds()),) - except (NotImplementedError, AttributeError): - # ``num_fds`` is Linux/BSD only. Windows raises - # NotImplementedError; older psutil lacks the method. - return () - except Exception as exc: # pylint: disable=broad-except - log.debug("open_fds observable failed: %s", exc) - return () - - salt.utils.metrics.observable_gauge( - "salt.master.connected_minions.count", - _connected_minions_cb, - description="Number of minions the master currently considers connected.", - ) - salt.utils.metrics.observable_gauge( - "salt.master.workers.queue.depth", - _queue_depth_cb, - description="MWorker payloads in flight (incremented on _handle_payload entry).", - ) - salt.utils.metrics.observable_gauge( - "salt.process.open_fds", - _open_fds_cb, - description="Open file descriptor count for the current process.", - unit="{fd}", - ) - - class SMaster: """ Create a simple salt-master, this will generate the top-level master @@ -292,11 +205,6 @@ def rotate_cluster_secret( def populate_secrets(self): if self.opts["cluster_id"]: - # Gate that request workers check before serving minion/CLI traffic. - # Cleared on start; set by RaftService once this node is a committed voter. - SMaster.secrets["cluster_ready"] = { - "event": multiprocessing.Event(), - } # Setup the secrets here because the PubServerChannel may need # them as well. SMaster.secrets["cluster_aes"] = { @@ -457,7 +365,6 @@ def run(self): self.opts, loadauth=self._cached_loadauth ) salt.daemons.masterapi.clean_pub_auth(self.opts) - salt.utils.master.clean_proc_dir(self.opts) if not last or (now - last_git_pillar_update) >= git_pillar_update_interval: last_git_pillar_update = now self.handle_git_pillar() @@ -465,10 +372,6 @@ def run(self): self.handle_key_cache() self.handle_presence(old_present) self.handle_key_rotate(now) - # Safety net for stalled/orphaned async batch jobs. Fires - # salt/batch//recover events so the BatchManager can - # re-adopt and advance them. - self.handle_batch_jobs() salt.utils.verify.check_max_open_files(self.opts) last = now now = int(time.time()) @@ -631,62 +534,6 @@ def handle_presence(self, old_present): ) presence_cache["present"] = list(present) - def handle_batch_jobs(self): - """ - Safety net for stalled or orphaned async batch jobs. - - Reads the active batch index (``batch_active.p``) and checks - each active batch for staleness. A batch is stale if - ``last_progress`` exceeds - ``timeout + gather_job_timeout + stale_buffer``. - - For stale batches, fires a ``salt/batch//recover`` event - so the BatchManager can re-adopt and advance them. Batches - that are already ``halted`` are cleaned up from the index. - - The heavy lifting lives in :mod:`salt.utils.batch_state` and - :mod:`salt.utils.batch_output`; this method is the scheduler. - """ - jids = salt.utils.batch_state.read_active_index(self.opts) - if not jids: - return - now = time.time() - stale_buffer = self.opts.get("batch_manager_loop_interval", 5) * 6 - for jid in jids: - state = salt.utils.batch_state.read_batch_state(jid, self.opts) - if state is None: - # .batch.p gone or corrupt — drop from the index. - log.info( - "Maintenance: pruning stale active-batch entry %s " - "(no readable .batch.p)", - jid, - ) - salt.utils.batch_state.remove_from_active_index(jid, self.opts) - continue - if state.get("halted"): - salt.utils.batch_state.remove_from_active_index(jid, self.opts) - continue - threshold = ( - state.get("timeout", 60) - + state.get("gather_job_timeout", 10) - + stale_buffer - ) - age = now - state.get("last_progress", now) - if age <= threshold: - continue - log.warning( - "Maintenance: batch %s is stale (age=%.1fs, threshold=%.1fs); " - "firing salt/batch/%s/recover", - jid, - age, - threshold, - jid, - ) - self.event.fire_event( - salt.utils.batch_output.recover_payload(state, age), - salt.utils.batch_output.tag_recover(jid), - ) - class FileserverUpdate(salt.utils.process.SignalHandlingProcess): """ @@ -942,6 +789,16 @@ def _pre_flight(self): if not self.opts["fileserver_backend"]: errors.append("No fileserver backends are configured") + # Check to see if we need to create a pillar cache dir + if self.opts["pillar_cache"] and not os.path.isdir( + os.path.join(self.opts["cachedir"], "pillar_cache") + ): + try: + with salt.utils.files.set_umask(0o077): + os.mkdir(os.path.join(self.opts["cachedir"], "pillar_cache")) + except OSError: + pass + if self.opts.get("git_pillar_verify_config", True): try: git_pillars = [ @@ -976,13 +833,6 @@ def _pre_flight(self): finally: del new_opts - if self.opts.get("cluster_id") and not self.opts.get("cluster_peers"): - critical_errors.append( - "cluster_id is set but cluster_peers is empty. " - "Every cluster member must have at least one peer configured. " - "Dynamic peer discovery is not supported." - ) - if errors or critical_errors: for error in errors: log.error(error) @@ -1012,31 +862,11 @@ def start(self): self._pre_flight() log.info("salt-master is starting as user '%s'", salt.utils.user.get_user()) - # Wipe stale health-probe sentinels from any previous run before - # subprocesses come up, so a probe cannot pass on data from the - # last incarnation. Failures are logged but non-fatal. - salt.cluster.healthchecks.reset_health_dir(self.opts) - enable_sigusr1_handler() enable_sigusr2_handler() self.__set_max_open_files() - # Configure OpenTelemetry metrics for the master parent process and - # register the observable gauges (connected_minions, workers - # queue depth, process open_fds). Observable gauges *must* be - # registered exactly once, in the parent — registering them in - # MWorker children would over-count. Workers call configure - # again in ``MWorker.run`` but skip the observables. - salt.utils.metrics.configure({**self.opts, "__role": "master"}) - # Cross-process counter for "MWorker payloads in flight". Created - # here so all forked workers inherit the same shared memory. Stashed - # at module level so ``MWorker._handle_payload`` can read it without - # a constructor change. - global _WORKERS_INFLIGHT # pylint: disable=global-statement - _WORKERS_INFLIGHT = multiprocessing.Value("i", 0) - _register_master_observables(self.opts, _WORKERS_INFLIGHT) - # Reset signals to default ones before adding processes to the process # manager. We don't want the processes being started to inherit those # signal handlers @@ -1048,43 +878,6 @@ def start(self): log.info("Creating master process manager") # Since there are children having their own ProcessManager we should wait for kill more time. self.process_manager = salt.utils.process.ProcessManager(wait_for_kill=5) - - log.info("Creating master event publisher process") - ipc_publisher = salt.channel.server.MasterPubServerChannel.factory( - self.opts, - ) - ipc_publisher.pre_fork(self.process_manager) - if not ipc_publisher.transport.started.wait(30): - raise salt.exceptions.SaltMasterError( - "IPC publish server did not start within 30 seconds. Something went wrong." - ) - - ipc_publisher.send_aes_key_event() - - # If this master has no cluster private key yet, it has not - # completed a join handshake and needs to run the discover->join - # protocol so existing peers add it as a Raft learner. - # - # The designated founder (lowest interface address among - # ``{self} ∪ cluster_peers``) skips discover entirely — it - # bootstraps the cluster as the founding voter and waits for - # everyone else to join. Letting it discover would race the - # founding-voter timer in ``_publish_daemon`` and risk turning - # the founder itself into a learner via an inbound join-reply, - # leaving the cluster with zero voters. - if self.opts.get("cluster_id") and self.opts.get("cluster_peers"): - bootstrap_pool = sorted( - {self.opts["interface"], *self.opts.get("cluster_peers", [])} - ) - is_founder = ( - bool(bootstrap_pool) and bootstrap_pool[0] == self.opts["interface"] - ) - if not ipc_publisher._has_joined_cluster() and not is_founder: - log.info("No cluster join sentinel — running cluster discover/join") - join_event = multiprocessing.Event() - ipc_publisher._discover_event = join_event - ipc_publisher.discover_peers() - pub_channels = [] log.info("Creating master publisher process") for _, opts in iter_transport_opts(self.opts): @@ -1096,6 +889,15 @@ def start(self): ) pub_channels.append(chan) + log.info("Creating master event publisher process") + ipc_publisher = salt.channel.server.MasterPubServerChannel.factory( + self.opts + ) + ipc_publisher.pre_fork(self.process_manager) + if not ipc_publisher.transport.started.wait(30): + raise salt.exceptions.SaltMasterError( + "IPC publish server did not start within 30 seconds. Something went wrong." + ) self.process_manager.add_process( EventMonitor, args=[self.opts, ipc_publisher], @@ -1130,13 +932,6 @@ def start(self): name="Maintenance", ) - log.info("Creating master batch manager process") - self.process_manager.add_process( - salt.utils.batch_manager.BatchManager, - args=(self.opts,), - name="BatchManager", - ) - if self.opts.get("event_return"): log.info("Creating master event return process") self.process_manager.add_process( @@ -1176,10 +971,10 @@ def start(self): kwargs["secrets"] = SMaster.secrets self.process_manager.add_process( - RequestServer, + ReqServer, args=(self.opts, self.key, self.master_key), kwargs=kwargs, - name="RequestServer", + name="ReqServer", ) self.process_manager.add_process( @@ -1216,54 +1011,10 @@ def start(self): # No custom signal handling was added, install our own signal.signal(signal.SIGTERM, self._handle_signals) - # Mark startup complete now that every subprocess has been - # registered with the process manager. For a non-cluster master - # mark readiness here as well — there is no Raft gate to wait - # for, so the master is immediately willing to serve traffic. - # For cluster masters the readiness sentinel is written from - # ``MasterPubServerChannel._signal_cluster_ready`` once the - # founding/promotion CONFIG entry commits. - salt.cluster.healthchecks.mark_startup_complete(self.opts) - if not salt.cluster.healthchecks.is_clustered(self.opts): - salt.cluster.healthchecks.mark_cluster_ready(self.opts) - - asyncio.run(self._run_with_heartbeat()) - - async def _run_with_heartbeat(self): - """ - Run the process manager alongside a periodic liveness heartbeat. - - The heartbeat task runs in this same asyncio loop, so if the - loop wedges (the prototypical liveness-failure case) the - ``alive`` sentinel's mtime stops advancing and Kubernetes - restarts the pod. Spawning the heartbeat as a subprocess would - miss this — a deadlocked parent could keep an unrelated child - ticking. - - ``asynchronous=True`` is required: the default branch of - ``ProcessManager.run`` uses blocking ``time.sleep(10)`` which - would starve the heartbeat task. - """ - heartbeat_task = asyncio.create_task(self._heartbeat_loop()) - try: - await self.process_manager.run(asynchronous=True) - finally: - heartbeat_task.cancel() - try: - await heartbeat_task - except (asyncio.CancelledError, Exception): # pylint: disable=broad-except - pass - - async def _heartbeat_loop(self): - """Touch the liveness sentinel every ``DEFAULT_ALIVE_INTERVAL`` seconds.""" - interval = salt.cluster.healthchecks.DEFAULT_ALIVE_INTERVAL - while True: - try: - salt.cluster.healthchecks.touch_alive(self.opts) - except Exception: # pylint: disable=broad-except - # Never let a probe-write mishap kill the master. - log.exception("healthchecks: heartbeat write failed") - await asyncio.sleep(interval) + if self.opts.get("cluster_id", None): + # Notify the rest of the cluster we're starting. + ipc_publisher.send_aes_key_event() + self.process_manager.run() def _handle_signals(self, signum, sigframe): # escalate the signals to the process manager @@ -1309,90 +1060,6 @@ async def handle_event(self, package): for chan in self.channels: tasks.append(asyncio.create_task(chan.publish(data))) await asyncio.gather(*tasks) - elif tag.startswith("salt/job") and "/new" in tag: - # Cluster replication of job submissions: when a peer master - # publishes a new job, mirror its `minions` list into our - # local job cache so any CLI on this master can later look - # up the jid without sharing ``cachedir``. - # - # Multi-ring gating: ``owns_for(opts, "jobs", jid)`` - # consults the cluster-log routing snapshot. No routing - # entry == broadcast (today's behaviour, every master - # mirrors). A route to a ring this master hosts defers - # to that ring's consistent hash; routed-to-a-ring-this- - # master-is-not-in returns False so non-members no-op - # the write. Delegate-on-miss: when the drop happens - # AND we know the ring owner's address, forward the - # write to that owner so a misconfigured topology - # doesn't silently lose data. - peer_id = data.pop("__peer_id", None) - if peer_id and self.opts.get("cluster_id"): - jid = data.get("jid") - minions = data.get("minions") or [] - if jid and salt.cluster.ring_membership.owns_for( - self.opts, "jobs", jid - ): - try: - salt.utils.job.store_minions(self.opts, jid, minions) - except Exception: # pylint: disable=broad-except - log.exception("Failed to mirror peer job submission %s", jid) - elif jid: - self._delegate_on_miss( - "jobs", jid, "store_minions", {"jid": jid, "minions": minions} - ) - elif tag.startswith("salt/job") and "/ret/" in tag: - # Cluster replication of job returns: minion responded to a - # peer master; persist the return into our local cache so a - # CLI on this master can deliver it to the user. - # - # Same multi-ring gating as the /new branch above. Once - # the operator routes ``"jobs"`` to a ring, only ring - # owners persist the return locally; today (no routing - # entry) every master writes every return. Delegate-on- - # miss forwards the write to the owner when this master - # isn't a ring member. - peer_id = data.pop("__peer_id", None) - if peer_id and self.opts.get("cluster_id"): - jid = data.get("jid") - if salt.cluster.ring_membership.owns_for(self.opts, "jobs", jid): - try: - salt.utils.job.store_job(self.opts, data) - except Exception: # pylint: disable=broad-except - log.exception( - "Failed to mirror peer job return for jid %s", - jid, - ) - elif jid: - self._delegate_on_miss("jobs", jid, "store_job", dict(data)) - elif tag.startswith("salt/key"): - # Replicate accepted/rejected/denied/deleted minion key state - # across the cluster. When a minion's key state changes on one - # master, ``salt.key.Key.change_state`` fires ``salt/key`` with - # the new state and the public key body. ``MasterPubServerChannel`` - # forwards the event to peers; here we install the bytes into the - # local pki tree so every master agrees on which minions are - # accepted without sharing pki_dir over a filesystem. - # - # Ring gating note: even when ring mode flips to voter - # sharding in stage 1, every master still needs the public - # key bytes to *verify* a minion's signature (latency- - # sensitive on every auth), so we keep replicating the bytes - # everywhere. The "is this key accepted/denied" *metadata* - # is what stage 2+ will shard; that's a separate gate. - peer_id = data.pop("__peer_id", None) - if peer_id and self.opts.get("cluster_id"): - act = data.get("act") - minion_id = data.get("id") - pub = data.get("pub") - if minion_id and act: - try: - self._apply_peer_key_change(act, minion_id, pub) - except Exception: # pylint: disable=broad-except - log.exception( - "Failed to apply peer key change %s for %s", - act, - minion_id, - ) elif tag == "rotate_cluster_aes_key": peer_id = data.pop("__peer_id", None) if peer_id: @@ -1403,307 +1070,17 @@ async def handle_event(self, package): else: log.trace("Ignore tag %s", tag) - _PEER_KEY_STATE = { - "accept": "accepted", - "reject": "rejected", - "pend": "pending", - } - - def _delegate_on_miss(self, data_type, key, write_kind, payload): - """ - Delegate a routed write this master doesn't own to the ring - owner. - - When :func:`salt.cluster.ring_membership.owns_for` answers - ``False`` because this master isn't a ring member (or the - key hashes to a sibling), the cluster bus has already - replicated the same event to every cluster peer including - the owner. Under symmetric cluster topology the owner - already wrote — this delegate is a safety net for asymmetric - topologies where the bus event might not reach the owner. - - Fires a local salt event ``cluster/runner/delegate_write`` - that the publish daemon picks up and forwards as a cluster- - AES-encrypted ``cluster/peer/delegate-write`` event targeted - at the named ring's current owner. - - :param data_type: Logical cache identifier - (e.g. ``"jobs"``). - :param key: The key whose ownership determines the - owner (typically the JID). - :param write_kind: ``"store_minions"`` or ``"store_job"`` — - the receiver dispatches based on this. - :param payload: Opaque dict the receiver applies via the - appropriate returner function. - """ - import salt.utils.event # pylint: disable=import-outside-toplevel - - ring_id = salt.cluster.ring_membership.get_routes().get(data_type) - if not ring_id: - # No routing for this data_type — owns_for would have - # returned True (broadcast) and we wouldn't have ended - # up here. Defensive skip. - return - ring = salt.cluster.ring_membership.get_ring(ring_id) - owner = None - if ring and ring.nodes(): - try: - owner = ring.get_owner(key) - except Exception: # pylint: disable=broad-except - owner = None - if not owner or owner == self.opts.get("interface"): - # No reachable owner, or we ARE the owner (shouldn't - # happen — owns_for would have returned True). Drop - # silently; ``drop_stats`` already counted this. - return - try: - with salt.utils.event.get_event( - "master", sock_dir=self.opts["sock_dir"], opts=self.opts, listen=False - ) as event: - event.fire_event( - { - "data_type": data_type, - "ring_id": ring_id, - "owner": owner, - "write_kind": write_kind, - "payload": payload, - }, - "cluster/runner/delegate_write", - ) - except Exception: # pylint: disable=broad-except - log.exception( - "delegate-on-miss: failed to fire delegate event for %s/%s", - data_type, - key, - ) - - def _apply_peer_key_change(self, act, minion_id, pub): - """ - Mirror a peer master's minion-key state change into our local - keys cache so this master can authenticate the minion without - sharing storage with the other cluster members. - - ``act`` is one of accept/reject/pend/deny/delete; ``pub`` is the - public key PEM (None for ``delete``). - """ - cache = salt.cache.Cache(self.opts, driver=self.opts["keys.cache_driver"]) - if act == "delete": - try: - cache.flush("keys", minion_id) - except Exception: # pylint: disable=broad-except - log.exception("_apply_peer_key_change: flush keys/%s failed", minion_id) - try: - cache.flush("denied_keys", minion_id) - except Exception: # pylint: disable=broad-except - log.exception( - "_apply_peer_key_change: flush denied_keys/%s failed", - minion_id, - ) - return - if not pub: - return - if act == "deny": - cache.store("denied_keys", minion_id, [pub]) - log.info("Applied peer key change: deny minion %s", minion_id) - return - state = self._PEER_KEY_STATE.get(act) - if state is None: - return - cache.store("keys", minion_id, {"state": state, "pub": pub}) - log.info( - "Applied peer key change: %s minion %s (state=%s)", - act, - minion_id, - state, - ) - def run(self): - io_loop = asyncio.new_event_loop() - asyncio.set_event_loop(io_loop) + io_loop = tornado.ioloop.IOLoop() with salt.utils.event.get_master_event( self.opts, self.opts["sock_dir"], io_loop=io_loop, listen=True ) as event_bus: event_bus.subscribe("") event_bus.set_event_handler(self.handle_event) - try: - io_loop.run_forever() - except (KeyboardInterrupt, SystemExit): - pass - finally: - io_loop.close() - - -class RequestRouter: - """ - Classify incoming master requests and map them to their worker pool. - - :class:`RequestRouter` is the in-process routing table used by the - pooled request path (see :py:class:`salt.channel.server.PoolRoutingChannel`). - Given a payload, :meth:`route_request` extracts the ``cmd`` field - (transparently decrypting the load when necessary) and returns the name - of the pool that should service it. It does not own sockets or spawn - processes — the transport layer uses the decision to forward the - payload to the pool's IPC RequestServer. - - The mapping is built once at construction time from the - ``worker_pools`` section of the master configuration. Exactly one - pool must claim the ``"*"`` catchall, which handles any command that - is not listed explicitly. See - :func:`salt.config.worker_pools.validate_worker_pools_config` for the - structural invariants enforced before this class ever sees the - configuration. - - Instances also keep a per-pool routing counter in :attr:`stats`, which - the master can surface for observability. - - :param dict opts: Master configuration dictionary. Must contain a - resolved ``worker_pools`` layout; the layout is read directly from - ``opts`` without re-running validation. - :param dict secrets: Optional master secrets dictionary. When present, - :meth:`_extract_command` can decrypt AES- or RSA-encrypted payloads - in order to inspect their ``cmd`` field for routing. This is - required for netapi and minion traffic where the transport delivers - encrypted blobs to the routing process. - """ - - def __init__(self, opts, secrets=None): - self.opts = opts - self.secrets = secrets - self.cmd_to_pool = {} - self.default_pool = None - self.pools = {} - self.stats = {} - - self._build_routing_table() - - def _build_routing_table(self): - """Build command-to-pool routing table from user configuration.""" - from salt.config.worker_pools import DEFAULT_WORKER_POOLS - - worker_pools = self.opts.get("worker_pools", DEFAULT_WORKER_POOLS) - catchall_pool = None - - # Build reverse mapping: cmd -> pool_name - for pool_name, pool_config in worker_pools.items(): - commands = pool_config.get("commands", []) - for cmd in commands: - if cmd == "*": - # Found catchall pool - if catchall_pool is not None: - raise ValueError( - f"Multiple pools have catchall ('*'): " - f"'{catchall_pool}' and '{pool_name}'. " - "Only one pool can use catchall." - ) - catchall_pool = pool_name - continue - - if cmd in self.cmd_to_pool: - # Validation: detect duplicate command mappings - raise ValueError( - f"Command '{cmd}' mapped to multiple pools: " - f"'{self.cmd_to_pool[cmd]}' and '{pool_name}'" - ) - self.cmd_to_pool[cmd] = pool_name - - # Exactly one pool must own the catchall so every command has a - # routing destination. - if not catchall_pool: - raise ValueError( - "Worker pool configuration must have exactly one pool with " - "catchall ('*') in its commands." - ) - self.default_pool = catchall_pool - - # Initialize stats for each pool - for pool_name in worker_pools.keys(): - self.stats[pool_name] = 0 - - def route_request(self, payload): - """ - Determine which pool should handle this request. - - Args: - payload: Request payload dictionary - - Returns: - str: Name of the pool that should handle this request - """ - cmd = self._extract_command(payload) - pool = self._classify_request(cmd) - self.stats[pool] = self.stats.get(pool, 0) + 1 - return pool - - def _classify_request(self, cmd): - """ - Classify request based on user-defined pool routing. - - Args: - cmd: Command name string - - Returns: - str: Pool name for this command - """ - # O(1) lookup in pre-built routing table - return self.cmd_to_pool.get(cmd, self.default_pool) - - def _extract_command(self, payload): - """ - Extract command from request payload. - - Args: - payload: Request payload dictionary - - Returns: - str: Command name or empty string if not found - """ - try: - load = payload.get("load", {}) - if isinstance(load, bytes) and self.secrets: - # Payload is encrypted. Try to decrypt it to extract the command. - # This is common for netapi and minion-to-master communication. - try: - # Determine which key to use based on the 'enc' field - enc = payload.get("enc", "aes") - if enc == "aes": - key = self.secrets.get("aes", {}).get("secret", {}).value - if key: - import salt.crypt - - crypticle = salt.crypt.Crypticle(self.opts, key) - load = crypticle.decrypt(load) - elif enc == "pub": - # RSA encryption - import salt.crypt - - mkey = salt.crypt.MasterKeys(self.opts) - load = mkey.priv_decrypt(load) - - if isinstance(load, bytes): - import salt.payload - - load = salt.payload.loads(load) - except Exception: # pylint: disable=broad-except - # If decryption fails, we can't extract the command - pass - - if isinstance(load, dict): - # Standard payload: {'cmd': '...', ...} - if "cmd" in load: - return load["cmd"] - # Peer publish: {'publish': {'cmd': '...', ...}} - if "publish" in load and isinstance(load["publish"], dict): - return load["publish"].get("cmd", "") - return "" - if isinstance(load, str): - # String command (uncommon but possible in some tests) - return load - return "" - except (AttributeError, KeyError): - return "" + io_loop.start() -class RequestServer(salt.utils.process.SignalHandlingProcess): +class ReqServer(salt.utils.process.SignalHandlingProcess): """ Starts up the master request server, minions send results to this interface. @@ -1717,7 +1094,7 @@ def __init__(self, opts, key, mkey, secrets=None, **kwargs): :key dict: The user starting the server and the AES key :mkey dict: The user starting the server and the RSA key - :rtype: RequestServer + :rtype: ReqServer :returns: Request server """ super().__init__(**kwargs) @@ -1753,19 +1130,10 @@ def __bind(self): name="ReqServer_ProcessManager", wait_for_kill=1 ) - # Create request server channels req_channels = [] - worker_pools = None - if self.opts.get("worker_pools_enabled", True): - from salt.config.worker_pools import get_worker_pools_config - - worker_pools = get_worker_pools_config(self.opts) - for transport, opts in iter_transport_opts(self.opts): chan = salt.channel.server.ReqServerChannel.factory(opts) - # Pass worker_pools to pre_fork. Transports that support it (ZeroMQ) - # will start the router/device. Others will just bind/initialize. - chan.pre_fork(self.process_manager, worker_pools=worker_pools) + chan.pre_fork(self.process_manager) req_channels.append(chan) if self.opts["req_server_niceness"] and not salt.utils.platform.is_windows(): @@ -1779,38 +1147,18 @@ def __bind(self): # manager. We don't want the processes being started to inherit those # signal handlers with salt.utils.process.default_signals(signal.SIGINT, signal.SIGTERM): - if worker_pools: - # Multi-pool mode: Create workers for each pool - for pool_name, pool_config in worker_pools.items(): - worker_count = pool_config.get("worker_count", 1) - for pool_index in range(worker_count): - name = f"MWorker-{pool_name}-{pool_index}" - self.process_manager.add_process( - MWorker, - args=( - self.opts, - self.master_key, - self.key, - req_channels, - ), - kwargs={"pool_name": pool_name, "pool_index": pool_index}, - name=name, - ) - else: - # Legacy single-pool mode - for ind in range(int(self.opts["worker_threads"])): - name = f"MWorker-{ind}" - self.process_manager.add_process( - MWorker, - args=(self.opts, self.master_key, self.key, req_channels), - name=name, - ) - - asyncio.run(self.process_manager.run()) + for ind in range(int(self.opts["worker_threads"])): + name = f"MWorker-{ind}" + self.process_manager.add_process( + MWorker, + args=(self.opts, self.master_key, self.key, req_channels), + name=name, + ) + self.process_manager.run() def run(self): """ - Start up the RequestServer + Start up the ReqServer """ self.__bind() @@ -1833,23 +1181,19 @@ class MWorker(salt.utils.process.SignalHandlingProcess): salt master. """ - def __init__( - self, opts, mkey, key, req_channels, pool_name=None, pool_index=None, **kwargs - ): + def __init__(self, opts, mkey, key, req_channels, **kwargs): """ Create a salt master worker process :param dict opts: The salt options :param dict mkey: The user running the salt master and the RSA key :param dict key: The user running the salt master and the AES key - :param str pool_name: Name of the worker pool this worker belongs to - :param int pool_index: Index of this worker within its pool :rtype: MWorker :return: Master worker """ super().__init__(**kwargs) - self.opts = opts.copy() # Copy opts to avoid modifying the shared instance + self.opts = opts self.req_channels = req_channels self.mkey = mkey @@ -1858,10 +1202,6 @@ def __init__( self.stats = collections.defaultdict(lambda: {"mean": 0, "runs": 0}) self.stat_clock = time.time() - # Pool-specific attributes - self.pool_name = pool_name or "default" - self.pool_index = pool_index if pool_index is not None else 0 - # We need __setstate__ and __getstate__ to also pickle 'SMaster.secrets'. # Otherwise, 'SMaster.secrets' won't be copied over to the spawned process # on Windows since spawning processes on Windows requires pickling. @@ -1902,102 +1242,31 @@ def _handle_signals(self, signum, sigframe): def __bind(self): """ - Bind to the local port. - - The event loop and socket binding happen first so that auth requests - can be processed immediately while the heavier module loading - (ClearFuncs, AESFuncs) proceeds concurrently in a background thread. - This allows minions to authenticate without waiting for full - initialization to complete. + Bind to the local port """ - self.io_loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.io_loop) - - # Create a threading event to signal when modules are ready. - # We use threading.Event here because it's set from a background thread - # and then converted to an asyncio.Event for use in coroutines. - self._modules_loaded = threading.Event() - + self.io_loop = tornado.ioloop.IOLoop() for req_channel in self.req_channels: req_channel.post_fork( - self._handle_payload, io_loop=self.io_loop, pool_name=self.pool_name - ) - - def _load_modules(): - try: - self.clear_funcs = ClearFuncs( - self.opts, - self.key, - ) - self.clear_funcs.connect() - self.aes_funcs = AESFuncs(self.opts) - except Exception: # pylint: disable=broad-except - log.exception( - "%s failed to load modules, worker will be non-functional", - self.name, - ) - finally: - self._modules_loaded.set() - self.io_loop.call_soon_threadsafe(self._async_modules_ready.set) - - loader_thread = threading.Thread( - target=_load_modules, name=f"{self.name}-loader", daemon=True - ) - - async def _start(): - self._async_modules_ready = asyncio.Event() - loader_thread.start() - - self.io_loop.run_until_complete(_start()) - + self._handle_payload, io_loop=self.io_loop + ) # TODO: cleaner? Maybe lazily? try: - self.io_loop.run_forever() + self.io_loop.start() except (KeyboardInterrupt, SystemExit): + # Tornado knows what to do pass - finally: - self.io_loop.close() async def _handle_payload(self, payload): """ The _handle_payload method is the key method used to figure out what needs to be done with communication to the server """ - # Bracket the entire handler with the shared "workers in flight" - # counter so the master's observable gauge can report queue depth. - # The flag survives across forks via the parent-created - # multiprocessing.Value. - _inflight = _WORKERS_INFLIGHT - if _inflight is not None: - with _inflight.get_lock(): - _inflight.value += 1 - try: - if payload.get("cmd") == "_auth": - if self.opts["master_stats"]: - self.stats["_auth"]["runs"] += 1 - self._post_stats(payload["_start"], "_auth") - return - # Wait for module initialization to complete before handling non-auth - # requests. Auth requests are handled at the channel level before - # reaching this handler, so they don't need modules to be loaded. - if not self._modules_loaded.is_set(): - await self._async_modules_ready.wait() - if not hasattr(self, "clear_funcs") or not hasattr(self, "aes_funcs"): - log.error( - "%s received request but module initialization failed", - self.name, - ) - return {}, {"fun": "send_clear"} - key = payload["enc"] - load = payload["load"] - if key == "clear": - ret = await self._handle_clear(load) - else: - ret = self._handle_aes(load) - return ret - finally: - if _inflight is not None: - with _inflight.get_lock(): - _inflight.value -= 1 + key = payload["enc"] + load = payload["load"] + if key == "clear": + ret = await self._handle_clear(load) + else: + ret = self._handle_aes(load) + return ret def _post_stats(self, start, cmd): """ @@ -2014,8 +1283,6 @@ def _post_stats(self, start, cmd): { "time": end - self.stat_clock, "worker": self.name, - "pool": self.pool_name, - "pool_index": self.pool_index, "stats": self.stats, }, tagify(self.name, "stats"), @@ -2045,29 +1312,11 @@ async def _handle_clear(self, load): if self.opts["master_stats"]: start = time.time() self.stats[cmd]["runs"] += 1 - # OTel parity with master_stats: count + time every dispatched - # command, regardless of whether master_stats is enabled. ``cmd`` - # is a bounded set (the methods exposed by ``ClearFuncs``). - _metric_start = time.perf_counter() - salt.utils.metrics.counter( - "salt.master.requests.handled", - description="Requests handled by the master worker dispatcher.", - ).add(1, attributes={"cmd": cmd}) - try: - if cmd in self.clear_funcs.async_methods: - reply = await method(load) - ret = reply, {"fun": "send_clear"} - else: - ret = method(load), {"fun": "send_clear"} - finally: - salt.utils.metrics.histogram( - "salt.master.requests.duration", - description="Per-command dispatcher latency on the master worker.", - unit="ms", - ).record( - (time.perf_counter() - _metric_start) * 1000.0, - attributes={"cmd": cmd}, - ) + if cmd in self.clear_funcs.async_methods: + reply = await method(load) + ret = reply, {"fun": "send_clear"} + else: + ret = method(load), {"fun": "send_clear"} if self.opts["master_stats"]: self._post_stats(start, cmd) return ret @@ -2091,24 +1340,13 @@ def _handle_aes(self, data): if self.opts["master_stats"]: start = time.time() self.stats[cmd]["runs"] += 1 - # OTel parity with master_stats — see ``_handle_clear`` above. - _metric_start = time.perf_counter() - salt.utils.metrics.counter( - "salt.master.requests.handled", - description="Requests handled by the master worker dispatcher.", - ).add(1, attributes={"cmd": cmd}) - try: - with salt.utils.ctx.request_context({"data": data, "opts": self.opts}): - ret = self.aes_funcs.run_func(data["cmd"], data) - finally: - salt.utils.metrics.histogram( - "salt.master.requests.duration", - description="Per-command dispatcher latency on the master worker.", - unit="ms", - ).record( - (time.perf_counter() - _metric_start) * 1000.0, - attributes={"cmd": cmd}, - ) + + def run_func(data): + return self.aes_funcs.run_func(data["cmd"], data) + + with salt.utils.ctx.request_context({"data": data, "opts": self.opts}): + ret = run_func(data) + if self.opts["master_stats"]: self._post_stats(start, cmd) return ret @@ -2117,16 +1355,13 @@ def run(self): """ Start a Master Worker """ - salt.utils.tracing.configure(self.opts) - salt.utils.metrics.configure({**self.opts, "__role": "master"}) # if we inherit req_server level without our own, reset it if not salt.utils.platform.is_windows(): enforce_mworker_niceness = True if self.opts["req_server_niceness"]: if salt.utils.user.get_user() == "root": log.info( - "%s decrementing inherited RequestServer niceness to 0", - self.name, + "%s decrementing inherited ReqServer niceness to 0", self.name ) os.nice(-1 * self.opts["req_server_niceness"]) else: @@ -2145,6 +1380,12 @@ def run(self): self.opts["mworker_niceness"], ) os.nice(self.opts["mworker_niceness"]) + self.clear_funcs = ClearFuncs( + self.opts, + self.key, + ) + self.clear_funcs.connect() + self.aes_funcs = AESFuncs(self.opts) self.__bind() @@ -2184,7 +1425,6 @@ class AESFuncs(TransportMethods): "_mine", "_mine_delete", "_mine_flush", - "_register_resources", "_file_recv", "_pillar", "_minion_event", @@ -2239,9 +1479,6 @@ def __init__(self, opts): self.pki_dir = self.opts["cluster_pki_dir"] else: self.pki_dir = self.opts.get("pki_dir", "") - self.key_cache = salt.cache.Cache( - self.opts, driver=self.opts["keys.cache_driver"] - ) def __setup_fileserver(self): """ @@ -2274,25 +1511,18 @@ def __verify_minion(self, id_, token): """ if not salt.utils.verify.valid_id(self.opts, id_): return False - - key = self.key_cache.fetch("keys", id_) - - if not key: - log.error("Unexpectedly got no pub key for %s", id_) - return False - + pub_path = salt.utils.verify.clean_join(self.pki_dir, "minions", id_) try: - pub = salt.crypt.PublicKey.from_str(key["pub"]) - except (OSError, KeyError): + pub = salt.crypt.PublicKey(pub_path) + except OSError: log.warning( "Salt minion claiming to be %s attempted to communicate with " "master, but key could not be read and verification was denied.", id_, - exc_info=True, ) return False except (ValueError, IndexError, TypeError) as err: - log.error('Unable to load public key "%s": %s', id_, err) + log.error('Unable to load public key "%s": %s', pub_path, err) try: if pub.decrypt(token) == b"salt": return True @@ -2441,7 +1671,7 @@ def _mine_get(self, load): if load is False: return {} else: - return self.masterapi._mine_get(load, skip_verify=False) + return self.masterapi._mine_get(load, skip_verify=True) def _mine(self, load): """ @@ -2455,7 +1685,7 @@ def _mine(self, load): load = self.__verify_load(load, ("id", "data")) if load is False: return {} - return self.masterapi._mine(load, skip_verify=False) + return self.masterapi._mine(load, skip_verify=True) def _mine_delete(self, load): """ @@ -2484,96 +1714,6 @@ def _mine_flush(self, load): else: return self.masterapi._mine_flush(load, skip_verify=True) - def _register_resources(self, load): - """ - Update the resource registry for a minion. Called by the minion on - startup via ``cmd: "_register_resources"`` so that the master knows - which resource IDs each minion manages. - - Delegates to :func:`salt.utils.minions.update_resource_index`, which - is a thin shim over - :meth:`salt.utils.resource_registry.ResourceRegistry.register_minion`. - The registry is an mmap-backed primary with in-process derived - ``by_type`` / ``by_minion`` views; this master worker sees the new - entries on its next read (its version cache is invalidated - on-write) and other worker processes pick up the writes on their - next throttled staleness check against the primary file — the - ``st_mtime_ns`` bump on every put/delete (see - :meth:`MmapCache._touch_mtime`) makes cross-process mutations - visible without a compaction. - """ - load = self.__verify_load(load, ("id", "resources")) - if load is False: - return {} - # The mmap resource registry is independent of minion pillar/grains disk - # cache (:conf_master:`minion_data_cache`). Registration must always run - # when minions report inventory; otherwise bare-id / T@ targeting breaks - # silently while still returning success to the minion. - n_put, n_del = salt.utils.minions.update_resource_index( - self.opts, load["id"], load["resources"] - ) - log.debug( - "Registered resources for minion '%s': %s (put=%d, deleted=%d)", - load["id"], - list(load["resources"].keys()), - n_put, - n_del, - ) - # Persist per-resource grains in the ``resource_grains`` cache bank - # so ``salt -G ':' …`` can match resources alongside - # minions. Stale entries (resource removed from this minion since - # last registration) are flushed first so a shrinking inventory - # doesn't leave ghost entries. - if self.opts.get("minion_data_cache", False): - resource_grains = load.get("resource_grains") or {} - try: - cache = self.masterapi.cache - current_srns = set(resource_grains.keys()) - # Walk existing entries and drop ones tied to this minion - # that aren't in the new payload. Owner identification piggy - # backs on the registry: each SRN is owned by exactly one - # minion at a time. - for srn in list( - cache.list(salt.utils.resource_registry.RESOURCE_GRAINS_BANK) or [] - ): - rtype, _, rid = srn.partition(":") - if not rid: - continue - if srn in current_srns: - continue - owners = self.ckminions.registry.get_managing_minions_for_srn( - rtype, rid - ) - if load["id"] in owners or not owners: - try: - cache.flush( - salt.utils.resource_registry.RESOURCE_GRAINS_BANK, srn - ) - except Exception as exc: # pylint: disable=broad-except - log.debug("resource_grains flush %s failed: %s", srn, exc) - for srn, gdict in resource_grains.items(): - if isinstance(gdict, dict): - cache.store( - salt.utils.resource_registry.RESOURCE_GRAINS_BANK, - srn, - gdict, - ) - except Exception as exc: # pylint: disable=broad-except - log.warning( - "Failed to persist resource_grains for minion '%s': %s", - load["id"], - exc, - ) - # Mirror the notification ``_pillar`` fires when ordinary minion - # grains are refreshed in the cache, so consumers subscribed to - # ``salt/minion/*/refresh/*`` see resource-grain refreshes too. - if self.opts.get("minion_data_cache_events") is True: - self.event.fire_event( - {"Resource cache refresh": load["id"]}, - tagify(load["id"], "refresh", "resource"), - ) - return True - def _file_recv(self, load): """ Allows minions to send files to the master, files are sent to the @@ -2678,8 +1818,11 @@ def _pillar(self, load): data = pillar.compile_pillar() self.fs_.update_opts() if self.opts.get("minion_data_cache", False): - self.masterapi.cache.store("grains", load["id"], load["grains"]) - + self.masterapi.cache.store( + "minions/{}".format(load["id"]), + "data", + {"grains": load["grains"], "pillar": data}, + ) if self.opts.get("minion_data_cache_events") is True: self.event.fire_event( {"Minion data cache refresh": load["id"]}, @@ -2738,20 +1881,6 @@ def _return(self, load): :param dict load: The minion payload """ - salt.utils.metrics.counter( - "salt.jobs.completed", - description="Returns received from minions.", - ).add( - 1, - attributes={ - "fun": load.get("fun", "") if isinstance(load, dict) else "", - "success": ( - str(bool(load.get("success", True))).lower() - if isinstance(load, dict) - else "true" - ), - }, - ) if self.opts["require_minion_sign_messages"] and "sig" not in load: log.critical( "_return: Master is requiring minions to sign their " @@ -2764,17 +1893,17 @@ def _return(self, load): if "sig" in load: log.trace("Verifying signed event publish from minion") sig = load.pop("sig") - this_minion_pubkey = self.key_cache.fetch("keys", load["id"]) + this_minion_pubkey = salt.utils.verify.clean_join( + self.pki_dir, "minions", load["id"] + ) serialized_load = salt.serializers.msgpack.serialize(load) - if not this_minion_pubkey or not salt.crypt.PublicKey.from_str( - this_minion_pubkey["pub"] - ).verify(serialized_load, sig, algorithm=self.opts["signing_algorithm"]): - if not this_minion_pubkey: - log.error("Failed to fetch pub key for minion %s.", load["id"]) - else: - log.info( - "Failed to verify event signature from minion %s.", load["id"] - ) + if not salt.crypt.verify_signature( + this_minion_pubkey, + serialized_load, + sig, + algorithm=self.opts["signing_algorithm"], + ): + log.info("Failed to verify event signature from minion %s.", load["id"]) if self.opts["drop_messages_signature_fail"]: log.critical( "drop_messages_signature_fail is enabled, dropping " @@ -2789,13 +1918,6 @@ def _return(self, load): ) load["sig"] = sig - # Transport security uses load["id"] (the minion's authenticated ID) for - # the channel check above. For resource returns the minion embeds the - # resource ID separately so we can remap here, after authentication, so - # the event and job cache are keyed by the resource ID instead. - if "resource_id" in load: - load["id"] = load.pop("resource_id") - try: salt.utils.job.store_job( self.opts, load, event=self.event, mminion=self.mminion @@ -3069,660 +2191,6 @@ def destroy(self): self._file_envs = None -class AuthFuncs(TransportMethods): - """ - Set up the function used to authenticate minions. - - This class owns the minion authentication handshake (the ``_auth`` - cleartext command). It is instantiated by the request server channel - and runs inside the worker process that handles the auth pool, so that - auth requests do not contend with regular minion command processing. - """ - - expose_methods = ("_auth",) - - def __init__(self, opts): - self.opts = opts - self.cache = salt.cache.Cache(opts, driver=self.opts["keys.cache_driver"]) - self.event = salt.utils.event.get_master_event( - self.opts, self.opts["sock_dir"], listen=False - ) - self.master_key = salt.crypt.MasterKeys(self.opts) - (pathlib.Path(self.opts["cachedir"]) / "sessions").mkdir(exist_ok=True) - self.sessions = {} - self.auto_key = salt.daemons.masterapi.AutoKey(self.opts) - if self.opts["con_cache"]: - self.cache_cli = CacheCli(self.opts) - self.ckminions = None - else: - self.cache_cli = False - self.ckminions = salt.utils.minions.CkMinions(self.opts) - - @property - def aes_key(self): - if self.opts.get("cluster_id", None): - return SMaster.secrets["cluster_aes"]["secret"].value - return SMaster.secrets["aes"]["secret"].value - - def session_key(self, minion): - """ - Returns a session key for the given minion id. - """ - now = time.time() - path = pathlib.Path(self.opts["cachedir"]) / "sessions" / minion - if minion in self.sessions: - if now - self.sessions[minion][0] < self.opts["publish_session"]: - # Master cluster deployments share ``sessions/`` - # on a shared filesystem so a peer master's rotation must - # invalidate our in-memory cache. Comparing the file - # mtime against the mtime we cached catches that case - # without penalising the single-master fast path -- the - # ``stat`` is cheap and only runs on cache hits. - try: - disk_mtime = path.stat().st_mtime - except FileNotFoundError: - disk_mtime = None - if disk_mtime is not None and disk_mtime <= self.sessions[minion][0]: - return self.sessions[minion][1] - - try: - if now - path.stat().st_mtime > self.opts["publish_session"]: - salt.crypt.Crypticle.write_key(path) - except FileNotFoundError: - salt.crypt.Crypticle.write_key(path) - - self.sessions[minion] = ( - path.stat().st_mtime, - salt.crypt.Crypticle.read_key(path), - ) - return self.sessions[minion][1] - - @classmethod - def compare_keys(cls, key1, key2): - """ - Normalize and compare two keys - - Returns: - bool: ``True`` if the keys match, otherwise ``False`` - """ - return salt.crypt.clean_key(key1) == salt.crypt.clean_key(key2) - - def _clear_signed(self, load, algorithm): - try: - tosign = salt.payload.dumps(load) - return { - "enc": "clear", - "load": tosign, - "sig": self.master_key.sign(tosign, algorithm=algorithm), - } - except UnsupportedAlgorithm: - log.info( - "Minion tried to authenticate with unsupported signing algorithm: %s", - algorithm, - ) - return {"enc": "clear", "load": {"ret": "bad sig algo"}} - - def _auth(self, load, sign_messages=False, version=0): - """ - Authenticate the client. Wraps :meth:`_auth_impl` to record one - ``salt.auth.attempts`` increment per call, labelling the result - from the wrapped return value. - """ - result = "error" - try: - ret = self._auth_impl(load, sign_messages=sign_messages, version=version) - # ``ret`` may be ``{"enc": "clear", "load": {"ret": ...}}`` or a - # ``_clear_signed``-wrapped variant of the same shape. Salt - # encodes outcomes in the inner ``ret`` value: True / a dict = - # success, False = key rejected, "full" = max_minions hit, - # "denied" / "rejected" = explicit reject. - try: - inner = ret.get("load", {}) if isinstance(ret, dict) else {} - if isinstance(inner, dict): - r = inner.get("ret") - if r is True or isinstance(r, dict): - result = "success" - elif r == "full": - result = "max_minions" - elif r in (False, "denied", "rejected"): - result = "rejected" - elif isinstance(r, str): - result = r - except Exception: # pylint: disable=broad-except - pass - return ret - finally: - salt.utils.metrics.counter( - "salt.auth.attempts", - description="Minion authentication attempts.", - ).add(1, attributes={"result": result}) - - def _auth_impl(self, load, sign_messages=False, version=0): - """ - Authenticate the client, use the sent public key to encrypt the AES key - which was generated at start up. - - This method fires an event over the master event manager. The event is - tagged "auth" and returns a dict with information about the auth - event - - - Verify that the key we are receiving matches the stored key - - Store the key if it is not there - - Make an RSA key with the pub key - - Encrypt the AES key as an encrypted salt.payload - - Package the return and return it - """ - enc_algo = load.get("enc_algo", salt.crypt.OAEP_SHA1) - sig_algo = load.get("sig_algo", salt.crypt.PKCS1v15_SHA1) - - if not salt.utils.verify.valid_id(self.opts, load["id"]): - log.info("Authentication request from invalid id %s", load["id"]) - if sign_messages: - return self._clear_signed( - {"ret": False, "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": False}} - log.info("Authentication request from %s", load["id"]) - # remove any trailing whitespace - load["pub"] = load["pub"].strip() - - # 0 is default which should be 'unlimited' - if self.opts["max_minions"] > 0: - # use the ConCache if enabled, else use the minion utils - if self.cache_cli: - minions = self.cache_cli.get_cached() - else: - minions = self.ckminions.connected_ids() - if len(minions) > 1000: - log.info( - "With large numbers of minions it is advised " - "to enable the ConCache with 'con_cache: True' " - "in the masters configuration file." - ) - - if not len(minions) <= self.opts["max_minions"]: - # we reject new minions, minions that are already - # connected must be allowed for the mine, highstate, etc. - if load["id"] not in minions: - log.info( - "Too many minions connected (max_minions=%s). " - "Rejecting connection from id %s", - self.opts["max_minions"], - load["id"], - ) - - if self.opts.get("auth_events") is True: - eload = { - "result": False, - "act": "full", - "id": load["id"], - "pub": load["pub"], - } - autosign_grains = load.get("autosign_grains", None) - if ( - "full" in self.opts.get("auth_events_autosign_grains", []) - and autosign_grains - ): - eload["autosign_grains"] = autosign_grains - self.event.fire_event( - eload, salt.utils.event.tagify(prefix="auth") - ) - if sign_messages: - return self._clear_signed( - {"ret": "full", "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": "full"}} - - # Check if key is configured to be auto-rejected/signed - auto_reject = self.auto_key.check_autoreject(load["id"]) - auto_sign = self.auto_key.check_autosign( - load["id"], load.get("autosign_grains", None) - ) - - # key will be a dict of str and state - # state can be one of pending, rejected, accepted - key = self.cache.fetch("keys", load["id"]) - - # although keys should be always newline stripped in current state of auth.py - # older salt versions may have written pub-keys with trailing whitespace - if key and "pub" in key: - key["pub"] = key["pub"].strip() - - # any number of keys can be denied for a given minion_id regardless of above - denied = self.cache.fetch("denied_keys", load["id"]) or [] - - if self.opts["open_mode"]: - # open mode is turned on, nuts to checks and overwrite whatever - # is there - pass - elif key and key["state"] == "rejected": - # The key has been rejected, don't place it in pending - log.info( - "Public key rejected for %s. Key is present in rejection key dir.", - load["id"], - ) - if self.opts.get("auth_events") is True: - eload = { - "result": False, - "act": "reject", - "id": load["id"], - "pub": load["pub"], - } - autosign_grains = load.get("autosign_grains", None) - if ( - "reject" in self.opts.get("auth_events_autosign_grains", []) - and autosign_grains - ): - eload["autosign_grains"] = autosign_grains - self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) - if sign_messages: - return self._clear_signed( - {"ret": False, "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": False}} - elif key and key["state"] == "accepted": - # The key has been accepted, check it - if not self.compare_keys(key["pub"], load["pub"]): - log.error( - "Authentication attempt from %s failed, the public " - "keys did not match. This may be an attempt to compromise " - "the Salt cluster.", - load["id"], - ) - # put denied minion key into minions_denied - if load["pub"] not in denied: - denied.append(load["pub"]) - self.cache.store("denied_keys", load["id"], denied) - - if self.opts.get("auth_events") is True: - eload = { - "result": False, - "id": load["id"], - "act": "denied", - "pub": load["pub"], - } - autosign_grains = load.get("autosign_grains", None) - if ( - "denied" in self.opts.get("auth_events_autosign_grains", []) - and autosign_grains - ): - eload["autosign_grains"] = autosign_grains - self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) - if sign_messages: - return self._clear_signed( - {"ret": False, "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": False}} - - elif not key: - # The key has not been accepted, this is a new minion - key_act = None - if auto_reject: - log.info( - "New public key for %s rejected via autoreject_file", load["id"] - ) - key = {"pub": load["pub"], "state": "rejected"} - self.cache.store("keys", load["id"], key) - key_act = "reject" - key_result = False - elif not auto_sign: - log.info("New public key for %s placed in pending", load["id"]) - key = {"pub": load["pub"], "state": "pending"} - self.cache.store("keys", load["id"], key) - key_act = "pend" - key_result = True - else: - # The key is being automatically accepted, don't do anything - # here and let the auto accept logic below handle it. - key_result = None - - if key_result is not None: - if self.opts.get("auth_events") is True: - eload = { - "result": key_result, - "act": key_act, - "id": load["id"], - "pub": load["pub"], - } - autosign_grains = load.get("autosign_grains", None) - if ( - key_act in self.opts.get("auth_events_autosign_grains", []) - and autosign_grains - ): - eload["autosign_grains"] = autosign_grains - self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) - if sign_messages: - return self._clear_signed( - {"ret": key_result, "nonce": load["nonce"]}, - sig_algo, - ) - else: - return {"enc": "clear", "load": {"ret": key_result}} - - elif key and key["state"] == "pending": - # This key is in the pending dir and is awaiting acceptance - if auto_reject: - # We don't care if the keys match, this minion is being - # auto-rejected. Move the key file from the pending dir to the - # rejected dir. - key["state"] = "rejected" - self.cache.store("keys", load["id"], key) - log.info( - "Pending public key for %s rejected via autoreject_file", - load["id"], - ) - if self.opts.get("auth_events") is True: - eload = { - "result": False, - "act": "reject", - "id": load["id"], - "pub": load["pub"], - } - autosign_grains = load.get("autosign_grains", None) - if ( - "reject" in self.opts.get("auth_events_autosign_grains", []) - and autosign_grains - ): - eload["autosign_grains"] = autosign_grains - self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) - if sign_messages: - return self._clear_signed( - {"ret": False, "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": False}} - - elif not auto_sign: - # This key is in the pending dir and is not being auto-signed. - # Check if the keys are the same and error out if this is the - # case. Otherwise log the fact that the minion is still - # pending. - if not self.compare_keys(key["pub"], load["pub"]): - log.error( - "Authentication attempt from %s failed, the public " - "key in pending did not match. This may be an " - "attempt to compromise the Salt cluster.", - load["id"], - ) - # put denied minion key into minions_denied - if load["pub"] not in denied: - denied.append(load["pub"]) - self.cache.store("denied_keys", load["id"], denied) - if self.opts.get("auth_events") is True: - eload = { - "result": False, - "id": load["id"], - "act": "denied", - "pub": load["pub"], - } - autosign_grains = load.get("autosign_grains", None) - if ( - "denied" in self.opts.get("auth_events_autosign_grains", []) - and autosign_grains - ): - eload["autosign_grains"] = autosign_grains - self.event.fire_event( - eload, salt.utils.event.tagify(prefix="auth") - ) - if sign_messages: - return self._clear_signed( - {"ret": False, "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": False}} - else: - log.info( - "Authentication failed from host %s, the key is in " - "pending and needs to be accepted with salt-key " - "-a %s", - load["id"], - load["id"], - ) - if self.opts.get("auth_events") is True: - eload = { - "result": True, - "act": "pend", - "id": load["id"], - "pub": load["pub"], - } - autosign_grains = load.get("autosign_grains", None) - if ( - "pend" in self.opts.get("auth_events_autosign_grains", []) - and autosign_grains - ): - eload["autosign_grains"] = autosign_grains - self.event.fire_event( - eload, salt.utils.event.tagify(prefix="auth") - ) - if sign_messages: - return self._clear_signed( - {"ret": True, "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": True}} - else: - # This key is in pending and has been configured to be - # auto-signed. Check to see if it is the same key, and if - # so, pass on doing anything here, and let it get automatically - # accepted below. - if not self.compare_keys(key["pub"], load["pub"]): - log.error( - "Authentication attempt from %s failed, the public " - "keys in pending did not match. This may be an " - "attempt to compromise the Salt cluster.", - load["id"], - ) - # put denied minion key into minions_denied - if load["pub"] not in denied: - denied.append(load["pub"]) - self.cache.store("denied_keys", load["id"], denied) - if self.opts.get("auth_events") is True: - eload = { - "result": False, - "act": "denied", - "id": load["id"], - "pub": load["pub"], - } - autosign_grains = load.get("autosign_grains", None) - if ( - "denied" in self.opts.get("auth_events_autosign_grains", []) - and autosign_grains - ): - eload["autosign_grains"] = autosign_grains - self.event.fire_event( - eload, salt.utils.event.tagify(prefix="auth") - ) - if sign_messages: - return self._clear_signed( - {"ret": False, "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": False}} - else: - # Something happened that I have not accounted for, FAIL! - log.warning("Unaccounted for authentication failure") - if self.opts.get("auth_events") is True: - eload = { - "result": False, - "act": "error", - "id": load["id"], - "pub": load["pub"], - } - autosign_grains = load.get("autosign_grains", None) - if ( - "error" in self.opts.get("auth_events_autosign_grains", []) - and autosign_grains - ): - eload["autosign_grains"] = autosign_grains - self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) - if sign_messages: - return self._clear_signed( - {"ret": False, "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": False}} - - log.info("Authentication accepted from %s", load["id"]) - - # only write to disk if you are adding the file, and in open mode, - # which implies we accept any key from a minion. - key_persisted = False - if (not key or key["state"] != "accepted") and not self.opts["open_mode"]: - key = {"pub": load["pub"], "state": "accepted"} - self.cache.store("keys", load["id"], key) - key_persisted = True - elif self.opts["open_mode"]: - if load["pub"] and (not key or load["pub"] != key["pub"]): - key = {"pub": load["pub"], "state": "accepted"} - self.cache.store("keys", load["id"], key) - key_persisted = True - elif not load["pub"]: - log.error("Public key is empty: %s", load["id"]) - if sign_messages: - return self._clear_signed( - {"ret": False, "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": False}} - # Cluster-wide replication: fire a ``salt/key/accept`` event with - # the public key body so peer masters mirror this acceptance into - # their own pki_dir without sharing a filesystem. Standalone - # masters ignore the cross-master path; the event is harmless. - if key_persisted and self.opts.get("cluster_id"): - self.event.fire_event( - { - "result": True, - "act": "accept", - "id": load["id"], - "pub": load["pub"], - }, - salt.utils.event.tagify(prefix="key"), - ) - - pub = None - - # the con_cache is enabled, send the minion id to the cache - if self.cache_cli: - self.cache_cli.put_cache([load["id"]]) - - # The key payload may sometimes be corrupt when using auto-accept - # and an empty request comes in - try: - pub = salt.crypt.PublicKey.from_str(key["pub"]) - except Exception as err: # pylint: disable=broad-except - log.error( - 'Corrupt or missing public key "%s": %s', - load["id"], - err, - exc_info_on_loglevel=logging.DEBUG, - ) - if sign_messages: - return self._clear_signed( - {"ret": False, "nonce": load["nonce"]}, sig_algo - ) - else: - return {"enc": "clear", "load": {"ret": False}} - - ret = { - "enc": "pub", - "pub_key": self.master_key.get_pub_str(), - "publish_port": self.opts["publish_port"], - } - - # sign the master's pubkey (if enabled) before it is - # sent to the minion that was just authenticated - if self.opts["master_sign_pubkey"]: - # append the pre-computed signature to the auth-reply - if self.master_key.pubkey_signature: - log.debug("Adding pubkey signature to auth-reply") - log.debug(self.master_key.pubkey_signature) - ret.update({"pub_sig": self.master_key.pubkey_signature}) - else: - # the master has its own signing-keypair, compute the master.pub's - # signature and append that to the auth-reply - log.debug("Signing master public key before sending") - pub_sign = self.master_key.sign_key.sign( - ret["pub_key"], algorithm=sig_algo - ) - ret.update({"pub_sig": binascii.b2a_base64(pub_sign)}) - - if self.opts["auth_mode"] >= 2: - if "token" in load: - try: - mtoken = self.master_key.decrypt(load["token"], enc_algo) - aes = "{}_|-{}".format( - SMaster.secrets["aes"]["secret"].value, mtoken - ) - except UnsupportedAlgorithm as exc: - log.info( - "Minion %s tried to authenticate with unsupported encryption algorithm: %s", - load["id"], - enc_algo, - ) - return {"enc": "clear", "load": {"ret": "bad enc algo"}} - except Exception as exc: # pylint: disable=broad-except - log.warning("Token failed to decrypt %s", exc) - # Token failed to decrypt, send back the salty bacon to - # support older minions - else: - aes = self.aes_key - - ret["aes"] = pub.encrypt(aes, enc_algo) - ret["session"] = pub.encrypt(self.session_key(load["id"]), enc_algo) - else: - if "token" in load: - try: - mtoken = self.master_key.decrypt(load["token"], enc_algo) - ret["token"] = pub.encrypt(mtoken, enc_algo) - except UnsupportedAlgorithm as exc: - log.info( - "Minion %s tried to authenticate with unsupported encryption algorithm: %s", - load["id"], - enc_algo, - ) - return {"enc": "clear", "load": {"ret": "bad enc algo"}} - except Exception as exc: # pylint: disable=broad-except - # Token failed to decrypt, send back the salty bacon to - # support older minions - log.warning("Token failed to decrypt: %r", exc) - - aes = self.aes_key - ret["aes"] = pub.encrypt(aes, enc_algo) - ret["session"] = pub.encrypt(self.session_key(load["id"]), enc_algo) - - if version < 3: - log.warning( - "Minion using legacy request server protocol, please upgrade %s", - load["id"], - ) - - # Be aggressive about the signature - digest = salt.utils.stringutils.to_bytes(hashlib.sha256(aes).hexdigest()) - ret["sig"] = self.master_key.encrypt(digest) - if self.opts.get("auth_events") is True: - eload = { - "result": True, - "act": "accept", - "id": load["id"], - "pub": load["pub"], - } - autosign_grains = load.get("autosign_grains", None) - if ( - "accept" in self.opts.get("auth_events_autosign_grains", []) - and autosign_grains - ): - eload["autosign_grains"] = autosign_grains - self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth")) - if sign_messages: - ret["nonce"] = load["nonce"] - return self._clear_signed(ret, sig_algo) - return ret - - class ClearFuncs(TransportMethods): """ Set up functions that are safe to execute when commands sent to the master @@ -3898,17 +2366,12 @@ def wheel(self, clear_load): "tag": tag, "user": username, } - clear_load.update( - { - "__jid__": jid, - "__tag__": tag, - "__user__": username, - "print_event": clear_load.get("print_event", False), - } - ) + + self.event.fire_event(data, tagify([jid, "new"], "wheel")) ret = self.wheel_.call_func(fun, full_return=True, **clear_load) data["return"] = ret["return"] data["success"] = ret["success"] + self.event.fire_event(data, tagify([jid, "ret"], "wheel")) return {"tag": tag, "data": data} except Exception as exc: # pylint: disable=broad-except log.error("Exception occurred while introspecting %s: %s", fun, exc) @@ -3970,10 +2433,7 @@ async def publish(self, clear_load): delimiter = extra.get("delimiter", DEFAULT_TARGET_DELIM) _res = self.ckminions.check_minions( - clear_load["tgt"], - clear_load.get("tgt_type", "glob"), - delimiter, - fun=clear_load.get("fun"), + clear_load["tgt"], clear_load.get("tgt_type", "glob"), delimiter ) minions = _res.get("minions", list()) missing = _res.get("missing", list()) @@ -4088,12 +2548,8 @@ async def publish(self, clear_load): }, } jid = self._prep_jid(clear_load, extra) - if jid is None or isinstance(jid, dict): - if jid and "error" in jid: - load = jid - else: - load = {"error": "Master failed to assign jid"} - return load + if jid is None: + return {"enc": "clear", "load": {"error": "Master failed to assign jid"}} payload = self._prep_pub(minions, jid, clear_load, extra, missing) if self.opts.get("order_masters"): @@ -4193,6 +2649,8 @@ def _prep_pub(self, minions, jid, clear_load, extra, missing): clear_load["jid"] = jid delimiter = clear_load.get("kwargs", {}).get("delimiter", DEFAULT_TARGET_DELIM) + # TODO Error reporting over the master event bus + self.event.fire_event({"minions": minions}, clear_load["jid"]) new_job_load = { "jid": clear_load["jid"], "tgt_type": clear_load["tgt_type"], @@ -4306,9 +2764,6 @@ def _prep_pub(self, minions, jid, clear_load, extra, missing): if "ret_kwargs" in clear_load["kwargs"]: load["ret_kwargs"] = clear_load["kwargs"].get("ret_kwargs") - if clear_load["kwargs"].get("start_event"): - load["start_event"] = True - if "user" in clear_load: log.info( "User %s Published command %s with jid %s", diff --git a/salt/matchers/compound_match.py b/salt/matchers/compound_match.py index 5438a4470f3c..04da7281e3ee 100644 --- a/salt/matchers/compound_match.py +++ b/salt/matchers/compound_match.py @@ -50,8 +50,6 @@ def match(tgt, opts=None, minion_id=None): "N": None, # Nodegroups should already be expanded "S": "ipcidr", "E": "pcre", - "T": "resource", - "M": "managing_minion", } if HAS_RANGE: ref["R"] = "range" diff --git a/salt/matchers/confirm_top.py b/salt/matchers/confirm_top.py index f582294c9a52..09a11ff428da 100644 --- a/salt/matchers/confirm_top.py +++ b/salt/matchers/confirm_top.py @@ -22,15 +22,7 @@ def confirm_top(match, data, nodegroups=None): if "match" in item: matcher = item["match"] - if "matchers" in __context__: - matchers = __context__["matchers"] - else: - # Matchers need pillar data if available - pillar = __pillar__ if "__pillar__" in globals() else None - if hasattr(pillar, "value"): - pillar = pillar.value() - matchers = salt.loader.matchers(__opts__, context=__context__, pillar=pillar) - __context__["matchers"] = matchers + matchers = salt.loader.matchers(__opts__) funcname = matcher + "_match.match" if matcher == "nodegroup": return matchers[funcname](match, nodegroups) diff --git a/salt/matchers/managing_minion_match.py b/salt/matchers/managing_minion_match.py deleted file mode 100644 index f18f2c03b906..000000000000 --- a/salt/matchers/managing_minion_match.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -Minion-side matcher for the ``M@`` managing-minion targeting engine. - -A ``M@`` expression targets a minion directly by its ID, as the entity -*responsible for* a set of resources — rather than targeting the resources -themselves. It is most useful in compound expressions where you want to -constrain a resource target to those owned by a specific minion: - -.. code-block:: text - - salt -C 'M@vcenter-1 and T@vcf_host' - -That expression matches all ``vcf_host`` resources managed by the minion -whose ID is ``vcenter-1``. On its own ``M@vcenter-1`` is equivalent to -``L@vcenter-1``, but pairing it with ``T@`` is its primary use-case. -""" - -import logging - -log = logging.getLogger(__name__) - - -def match(tgt, opts=None, minion_id=None): - """ - Return ``True`` if this minion's ID equals ``tgt``. - - ``tgt`` is the minion ID given after the ``M@`` prefix. The match is - always an exact equality check — no globbing or regex. - - :param str tgt: The minion ID to match against. - :param dict opts: Salt opts dict; defaults to ``__opts__``. - :param str minion_id: The minion ID to evaluate; defaults to ``opts["id"]``. - :rtype: bool - """ - if opts is None: - opts = __opts__ # pylint: disable=undefined-variable - if minion_id is None: - minion_id = opts.get("id", "") - result = minion_id == tgt - log.debug("managing_minion_match: M@%s => %s (id=%s)", tgt, result, minion_id) - return result diff --git a/salt/matchers/pillar_exact_match.py b/salt/matchers/pillar_exact_match.py index ea233ad94155..ac62c49f9ded 100644 --- a/salt/matchers/pillar_exact_match.py +++ b/salt/matchers/pillar_exact_match.py @@ -20,16 +20,11 @@ def match(tgt, delimiter=":", opts=None, minion_id=None): log.error("Got insufficient arguments for pillar match statement from master") return False - if opts.get("pillar"): + if "pillar" in opts: pillar = opts["pillar"] - elif "__pillar__" in globals(): - pillar = __pillar__ - if hasattr(pillar, "value"): - pillar = pillar.value() - elif opts.get("ext_pillar"): + elif "ext_pillar" in opts: + log.info("No pillar found, fallback to ext_pillar") pillar = opts["ext_pillar"] - else: - pillar = {} return salt.utils.data.subdict_match( pillar, tgt, delimiter=delimiter, exact_match=True diff --git a/salt/matchers/pillar_match.py b/salt/matchers/pillar_match.py index 929bf7cb489d..87b0df606baf 100644 --- a/salt/matchers/pillar_match.py +++ b/salt/matchers/pillar_match.py @@ -21,15 +21,10 @@ def match(tgt, delimiter=DEFAULT_TARGET_DELIM, opts=None, minion_id=None): log.error("Got insufficient arguments for pillar match statement from master") return False - if opts.get("pillar"): + if "pillar" in opts: pillar = opts["pillar"] - elif "__pillar__" in globals(): - pillar = __pillar__ - if hasattr(pillar, "value"): - pillar = pillar.value() - elif opts.get("ext_pillar"): + elif "ext_pillar" in opts: + log.info("No pillar found, fallback to ext_pillar") pillar = opts["ext_pillar"] - else: - pillar = {} return salt.utils.data.subdict_match(pillar, tgt, delimiter=delimiter) diff --git a/salt/matchers/pillar_pcre_match.py b/salt/matchers/pillar_pcre_match.py index 8c627fc80acb..ba76a26fa4ba 100644 --- a/salt/matchers/pillar_pcre_match.py +++ b/salt/matchers/pillar_pcre_match.py @@ -23,16 +23,11 @@ def match(tgt, delimiter=DEFAULT_TARGET_DELIM, opts=None, minion_id=None): ) return False - if opts.get("pillar"): + if "pillar" in opts: pillar = opts["pillar"] - elif "__pillar__" in globals(): - pillar = __pillar__ - if hasattr(pillar, "value"): - pillar = pillar.value() - elif opts.get("ext_pillar"): + elif "ext_pillar" in opts: + log.info("No pillar found, fallback to ext_pillar") pillar = opts["ext_pillar"] - else: - pillar = {} return salt.utils.data.subdict_match( pillar, tgt, delimiter=delimiter, regex_match=True diff --git a/salt/matchers/resource_match.py b/salt/matchers/resource_match.py deleted file mode 100644 index cb6ddc7bb378..000000000000 --- a/salt/matchers/resource_match.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -Minion-side matcher for the ``T@`` resource targeting engine. - -A ``T@`` expression targets Salt Resources managed by this minion. The -pattern is either a bare resource type or a full Salt Resource Name (SRN): - -.. code-block:: text - - T@vcf_host # any resource of this type - T@vcf_host:esxi-01 # one specific resource by SRN - -This matcher is evaluated on the minion. It reads from ``opts["resources"]``, -which is populated when the minion loads its resource modules — analogous to -how ``grain_match`` reads from ``opts["grains"]``. No cache or registry -lookup is performed. -""" - -import logging - -log = logging.getLogger(__name__) - - -def match(tgt, opts=None, minion_id=None): - """ - Return ``True`` if this minion manages at least one resource that matches - the ``T@`` pattern ``tgt``. - - ``tgt`` is the portion of the ``T@`` expression after the ``@``. It is - either a bare resource type (``vcf_host``) or a full SRN - (``vcf_host:esxi-01``). When a bare type is given, every resource of that - type in ``opts["resources"]`` satisfies the match. When a full SRN is - given, only an exact match against a resource ID in ``opts["resources"]`` - satisfies it. - - The structure of ``opts["resources"]`` is populated by the resource module - loader at minion startup, analogous to ``opts["grains"]``. - - :param str tgt: The T@ pattern — a resource type or a full SRN. - :param dict opts: Salt opts dict; defaults to ``__opts__``. - :param str minion_id: The minion ID to evaluate; defaults to ``opts["id"]``. - :rtype: bool - """ - if opts is None: - opts = __opts__ # pylint: disable=undefined-variable - resources = opts.get("resources", {}) - if not resources: - return False - - if ":" in tgt: - resource_type, resource_id = tgt.split(":", 1) - result = resource_id in resources.get(resource_type, []) - else: - result = bool(resources.get(tgt)) - - log.debug("resource_match: T@%s => %s (resources=%s)", tgt, result, list(resources)) - return result diff --git a/salt/metaproxy/deltaproxy.py b/salt/metaproxy/deltaproxy.py index cb2b8e2c9a09..b5c01b45f9da 100644 --- a/salt/metaproxy/deltaproxy.py +++ b/salt/metaproxy/deltaproxy.py @@ -5,7 +5,6 @@ import asyncio import concurrent.futures import copy -import functools import logging import os import signal @@ -13,6 +12,9 @@ import traceback import types +import tornado.gen +import tornado.ioloop + import salt import salt._logging import salt.beacons @@ -59,7 +61,8 @@ log = logging.getLogger(__name__) -async def post_master_init(self, master): +@tornado.gen.coroutine +def post_master_init(self, master): """ Function to finish init after a deltaproxy proxy minion has finished connecting to a master. @@ -69,7 +72,7 @@ async def post_master_init(self, master): """ if self.connected: - self.opts["pillar"] = await salt.pillar.get_async_pillar( + self.opts["pillar"] = yield salt.pillar.get_async_pillar( self.opts, self.opts["grains"], self.opts["id"], @@ -83,7 +86,7 @@ async def post_master_init(self, master): self.opts["master"] = master tag = "salt/deltaproxy/start" - await self._fire_master_main(tag=tag) + self._fire_master(tag=tag) if "proxy" not in self.opts["pillar"] and "proxy" not in self.opts: errmsg = ( @@ -165,13 +168,8 @@ async def post_master_init(self, master): # Start engines here instead of in the Minion superclass __init__ # This is because we need to inject the __proxy__ variable but # it is not setup until now. - self.io_loop.call_soon( - functools.partial( - salt.engines.start_engines, - self.opts, - self.process_manager, - proxy=self.proxy, - ) + self.io_loop.spawn_callback( + salt.engines.start_engines, self.opts, self.process_manager, proxy=self.proxy ) proxy_init_func_name = f"{fq_proxyname}.init" @@ -355,10 +353,9 @@ async def post_master_init(self, master): ) try: - results = await asyncio.gather(*waitfor) + results = yield tornado.gen.multi(waitfor) except Exception as exc: # pylint: disable=broad-except log.error("Errors loading sub proxies: %s", exc) - raise _failed = self.opts["proxy"].get("ids", [])[:] for sub_proxy_data in results: @@ -380,7 +377,7 @@ async def post_master_init(self, master): log.debug("Initiating non-parallel startup for proxies") for _id in self.opts["proxy"].get("ids", []): try: - sub_proxy_data = await subproxy_post_master_init( + sub_proxy_data = yield subproxy_post_master_init( _id, uid, self.opts, self.proxy, self.utils ) except Exception as exc: # pylint: disable=broad-except @@ -409,7 +406,8 @@ async def post_master_init(self, master): self.ready = True -async def subproxy_post_master_init(minion_id, uid, opts, main_proxy, main_utils): +@tornado.gen.coroutine +def subproxy_post_master_init(minion_id, uid, opts, main_proxy, main_utils): """ Function to finish init after a deltaproxy proxy minion has finished connecting to a master. @@ -436,7 +434,7 @@ async def subproxy_post_master_init(minion_id, uid, opts, main_proxy, main_utils proxy_grains = salt.loader.grains( proxyopts, proxy=main_proxy, context=proxy_context ) - proxy_pillar = await salt.pillar.get_async_pillar( + proxy_pillar = yield salt.pillar.get_async_pillar( proxyopts, proxy_grains, minion_id, @@ -591,7 +589,7 @@ async def subproxy_post_master_init(minion_id, uid, opts, main_proxy, main_utils "__proxy_keepalive", persist=True, fire_event=False ) - return {"proxy_minion": _proxy_minion, "proxy_opts": proxyopts} + raise tornado.gen.Return({"proxy_minion": _proxy_minion, "proxy_opts": proxyopts}) def target(cls, minion_instance, opts, data, connected, creds_map): diff --git a/salt/metaproxy/proxy.py b/salt/metaproxy/proxy.py index 1ab4502abbe0..83b5e2627754 100644 --- a/salt/metaproxy/proxy.py +++ b/salt/metaproxy/proxy.py @@ -4,7 +4,6 @@ import asyncio import copy -import functools import logging import os import signal @@ -12,6 +11,9 @@ import traceback import types +import tornado.gen +import tornado.ioloop + import salt import salt.beacons import salt.cli.daemons @@ -56,7 +58,8 @@ log = logging.getLogger(__name__) -async def post_master_init(self, master): +@tornado.gen.coroutine +def post_master_init(self, master): """ Function to finish init after a proxy minion has finished connecting to a master. @@ -69,7 +72,7 @@ async def post_master_init(self, master): if self.connected: self.opts["master"] = master - self.opts["pillar"] = await salt.pillar.get_async_pillar( + self.opts["pillar"] = yield salt.pillar.get_async_pillar( self.opts, self.opts["grains"], self.opts["id"], @@ -161,13 +164,8 @@ async def post_master_init(self, master): # Start engines here instead of in the Minion superclass __init__ # This is because we need to inject the __proxy__ variable but # it is not setup until now. - self.io_loop.call_soon( - functools.partial( - salt.engines.start_engines, - self.opts, - self.process_manager, - proxy=self.proxy, - ) + self.io_loop.spawn_callback( + salt.engines.start_engines, self.opts, self.process_manager, proxy=self.proxy ) if ( diff --git a/salt/minion.py b/salt/minion.py index aa4f27954088..a7a9c9f93df6 100644 --- a/salt/minion.py +++ b/salt/minion.py @@ -24,6 +24,7 @@ from collections import OrderedDict import tornado +import tornado.gen import tornado.ioloop import salt @@ -43,7 +44,6 @@ import salt.syspaths import salt.transport import salt.utils.args -import salt.utils.asynchronous import salt.utils.atomicfile import salt.utils.context import salt.utils.ctx @@ -55,17 +55,14 @@ import salt.utils.extmods import salt.utils.files import salt.utils.jid -import salt.utils.metrics import salt.utils.minion import salt.utils.minions import salt.utils.network import salt.utils.platform import salt.utils.process -import salt.utils.resources import salt.utils.schedule import salt.utils.ssdp import salt.utils.state -import salt.utils.tracing import salt.utils.user import salt.utils.zeromq from salt._compat import ipaddress @@ -114,46 +111,6 @@ log = logging.getLogger(__name__) -# Flag so we register the observable gauges exactly once per minion -# process even though ``tune_in`` may be called from multiple entry -# points (proxy minions, sub-minions, etc.). -_MINION_OBSERVABLES_REGISTERED = False - - -def _register_minion_observables(): - """Register per-minion observable gauges (FD count today).""" - global _MINION_OBSERVABLES_REGISTERED # pylint: disable=global-statement - if _MINION_OBSERVABLES_REGISTERED: - return - if not salt.utils.metrics.is_enabled(): - return - from opentelemetry.metrics import Observation - - try: - import psutil - except ImportError: # pragma: no cover - psutil = None # type: ignore[assignment] - - def _open_fds_cb(_options): - if psutil is None: - return () - try: - return (Observation(psutil.Process().num_fds()),) - except (NotImplementedError, AttributeError): - return () - except Exception as exc: # pylint: disable=broad-except - log.debug("open_fds observable failed: %s", exc) - return () - - salt.utils.metrics.observable_gauge( - "salt.process.open_fds", - _open_fds_cb, - description="Open file descriptor count for the minion process.", - unit="{fd}", - ) - _MINION_OBSERVABLES_REGISTERED = True - - # Event used to abort an in-progress resolve_dns() retry loop. The minion # signal handler sets this so that a SIGTERM arriving while the minion is # stuck retrying master DNS resolution can shut the io_loop down promptly @@ -530,11 +487,6 @@ def service_name(): class MinionBase: def __init__(self, opts): - # Ensure opts is OptsDict for mutate_key() and other OptsDict methods - from salt.utils.optsdict import OptsDict - - if not isinstance(opts, OptsDict): - opts = OptsDict.from_dict(opts, name="minionbase_opts") self.opts = opts self.beacons_leader = opts.get("beacons_leader", True) @@ -559,11 +511,6 @@ def gen_modules(self, initial_load=False, context=None): pillarenv=self.opts.get("pillarenv"), ).compile_pillar() - # Populate opts["resources"] from pillar now that pillar is available. - # Must happen before the resource loader loop below so that per-type - # execution module loaders are created for the correct set of types. - self.opts["resources"] = self._discover_resources() - self.utils = salt.loader.utils(self.opts, context=context) self.functions = salt.loader.minion_mods( self.opts, utils=self.utils, context=context @@ -575,59 +522,6 @@ def gen_modules(self, initial_load=False, context=None): self.proxy = salt.loader.proxy( self.opts, functions=self.functions, returners=self.returners ) - # Load resource connection modules (salt/resource/*.py) and build - # one execution-module loader per managed resource type. - self.resource_funcs = salt.loader.resource( - self.opts, - functions=self.functions, - utils=self.utils, - context=context, - ) - self.resource_funcs.pack["__salt__"] = self.functions - # Build resource_loaders into a local dict before assigning to - # self.resource_loaders. Without this, the previous pattern: - # - # self.resource_loaders = {} ← exposes empty dict - # for ...: self.resource_loaders[t] = … - # - # creates a window where a concurrent thread (multiprocessing: False) - # calling gen_modules() can read resource_loaders.get(type) == None - # and fail with "No resource loader available". A single dict - # assignment is atomic in CPython, so the old loaders remain visible - # until the new complete set is ready. - _new_resource_loaders = {} - for resource_type in self.opts.get("resources", {}): - rtype_base = ( - f"{self.opts.get('loaded_base_name', 'salt.loaded.int')}" - f".resource.{resource_type}" - ) - _new_resource_loaders[resource_type] = salt.loader.resource_modules( - self.opts, - resource_type, - resource_funcs=self.resource_funcs, - utils=self.utils, - context=context, - loaded_base_name=rtype_base, - ) - self.resource_loaders = _new_resource_loaders - - # Call init() on each resource type so that __context__ is populated - # before any per-resource operations (grains, ping, etc.) are dispatched. - # Mirrors how proxy.init() is called during proxy-minion startup. - for resource_type in self.opts.get("resources", {}): - init_fn = f"{resource_type}.init" - if init_fn in self.resource_funcs: - try: - self.resource_funcs[init_fn](self.opts) - log.debug("Initialized resource type '%s'", resource_type) - except Exception as exc: # pylint: disable=broad-except - log.error( - "Failed to initialize resource type '%s': %s", - resource_type, - exc, - exc_info=True, - ) - # TODO: remove self.function_errors = {} # Keep the funcs clean self.states = salt.loader.states( @@ -647,63 +541,6 @@ def gen_modules(self, initial_load=False, context=None): self.opts, functions=self.functions, proxy=self.proxy, context=context ) - def _discover_resources(self): - """ - Build ``opts["resources"]`` by calling each resource type's - ``discover(opts)`` function. - - Resource types are read from the pillar subtree at - ``opts["pillar"][opts["resource_pillar_key"]]`` (default key - ``"resources"``, configurable via minion option ``resource_pillar_key``). - A temporary resource loader is used to call each type's - ``discover(opts)``; the return value is a dict of - ``{resource_type: [resource_id, ...]}``. - - If the merged pillar contains no key by that name, that is treated the - same as an empty mapping: no pillar-declared resource types, so - discovery returns an empty dict (no stale IDs left in - ``opts["resources"]``). - - If the pillar *does* contain that key (even if its value is empty / - all entries removed), that is an authoritative declaration and the - result reflects only what the pillar says (via ``discover()`` per - type). - - Called from :meth:`gen_modules` after pillar is compiled and before - the per-type execution-module loaders are created. - """ - pillar_resources = salt.utils.resources.pillar_resources_tree(self.opts) - - # A minimal resource loader is sufficient here — discover() only reads - # from the opts dict passed to it and does not need other dunders. - discovery_loader = salt.loader.resource(self.opts) - discovered = {} - for resource_type in pillar_resources: - discover_fn = f"{resource_type}.discover" - if discover_fn not in discovery_loader: - log.warning( - "No resource module found for type '%s'; skipping discovery.", - resource_type, - ) - continue - try: - ids = discovery_loader[discover_fn](self.opts) - if ids: - discovered[resource_type] = list(ids) - log.debug( - "Discovered %d resource(s) of type '%s': %s", - len(ids), - resource_type, - ids, - ) - except Exception as exc: # pylint: disable=broad-except - log.warning( - "Resource discovery failed for type '%s': %s", - resource_type, - exc, - ) - return discovered - @staticmethod def process_schedule(minion, loop_interval): try: @@ -738,9 +575,8 @@ def process_beacons(self, functions): ) # pylint: disable=no-member return [] - async def eval_master( - self, opts, timeout=60, safe=True, failed=False, failback=False - ): + @tornado.gen.coroutine + def eval_master(self, opts, timeout=60, safe=True, failed=False, failback=False): """ Evaluates and returns a tuple of the current master address and the pub_channel. @@ -760,7 +596,7 @@ async def eval_master( if opts["master_type"] == "disable": log.warning("Master is set to disable, skipping connection") self.connected = False - return (None, None) + raise tornado.gen.Return((None, None)) # Run masters discovery over SSDP. This may modify the whole configuration, # depending of the networking and sets of masters. @@ -930,7 +766,7 @@ async def eval_master( if attempts != 0: # Give up a little time between connection attempts # to allow the IOLoop to run any other scheduled tasks. - await asyncio.sleep(opts["acceptance_wait_time"]) + yield tornado.gen.sleep(opts["acceptance_wait_time"]) attempts += 1 if tries > 0: log.debug("Connecting to master. Attempt %s of %s", attempts, tries) @@ -960,7 +796,7 @@ async def eval_master( opts, **factory_kwargs ) try: - await pub_channel.connect() + yield pub_channel.connect() conn = True # If we reached here, we are connected. We set pub_channel to None # so that the finally block doesn't close it, but we keep a reference @@ -1006,18 +842,11 @@ async def eval_master( else: self.tok = pub_channel.auth.gen_token(b"salt") self.connected = True - return (opts["master"], pub_channel) + raise tornado.gen.Return((opts["master"], pub_channel)) # single master sign in else: - # In multi-master mode the MinionManager spawns one Minion per - # master, each bound to a single master but inheriting the - # operator's ``random_master`` setting -- a documented way to - # spread salt-call load across an all-hot master list (see the - # ``random_master`` minion config docs). Those children are - # single-master by design, so only warn about a pointless - # ``random_master`` for a genuinely single-master minion. - if opts["random_master"] and not opts.get("multimaster"): + if opts["random_master"]: log.warning( "random_master is True but there is only one master specified." " Ignoring." @@ -1027,7 +856,7 @@ async def eval_master( if attempts != 0: # Give up a little time between connection attempts # to allow the IOLoop to run any other scheduled tasks. - await asyncio.sleep(opts["acceptance_wait_time"]) + yield tornado.gen.sleep(opts["acceptance_wait_time"]) attempts += 1 if tries > 0: log.debug("Connecting to master. Attempt %s of %s", attempts, tries) @@ -1049,7 +878,7 @@ async def eval_master( pub_channel = salt.channel.client.AsyncPubChannel.factory( self.opts, **factory_kwargs ) - await pub_channel.connect() + yield pub_channel.connect() if not pub_channel.auth.authenticated: # Close the unauthenticated channel before # the next iteration overwrites the @@ -1063,14 +892,14 @@ async def eval_master( pub_channel = salt.channel.client.AsyncPubChannel.factory( self.opts, **factory_kwargs ) - await pub_channel.connect() + yield pub_channel.connect() self.tok = pub_channel.auth.gen_token(b"salt") self.connected = True # Hand the channel off to the caller; clear the local so # the finally block does not close it. ret_pub_channel = pub_channel pub_channel = None - return (opts["master"], ret_pub_channel) + raise tornado.gen.Return((opts["master"], ret_pub_channel)) except SaltClientError: if attempts == tries: # Exhausted all attempts. Return exception. @@ -1174,24 +1003,21 @@ def __init__(self, opts, context=None): # Late setup of the opts grains, so we can log from the grains module import salt.loader - # MinionBase.__init__ will ensure opts is OptsDict - # We need to call super().__init__ first to get the OptsDict conversion + opts["grains"] = salt.loader.grains(opts) super().__init__(opts) - new_grains = salt.loader.grains(self.opts) - self.opts.mutate_key("grains", new_grains) - # Clean out the proc directory (default /var/cache/salt/minion/proc) if self.opts.get("file_client", "remote") == "remote" or self.opts.get( "use_master_when_local", False ): io_loop = tornado.ioloop.IOLoop.current() - async def eval_master(): + @tornado.gen.coroutine + def eval_master(): """ Wrap eval master in order to close the returned publish channel. """ - master, pub_channel = await self.eval_master(self.opts, failed=True) + master, pub_channel = yield self.eval_master(self.opts, failed=True) pub_channel.close() io_loop.run_sync( @@ -1360,13 +1186,11 @@ def __init__(self, opts): self.max_auth_wait = self.opts["acceptance_wait_time_max"] self.minions = [] self.jid_queue = [] - try: - self.io_loop = asyncio.get_running_loop() - except RuntimeError: - self.io_loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.io_loop) + self.io_loop = tornado.ioloop.IOLoop.current() self.process_manager = ProcessManager(name="MultiMinionProcessManager") - self.io_loop.create_task(self.process_manager.run(asynchronous=True)) + self.io_loop.spawn_callback( + self.process_manager.run, **{"asynchronous": True} + ) # Tornado backward compat self.event_publisher = None self.event = None @@ -1379,11 +1203,10 @@ def __del__(self): def _bind(self): # start up the event publisher, so we can see events during startup self.event_publisher = salt.transport.ipc_publish_server("minion", self.opts) - self.io_loop.create_task( - self.event_publisher.publisher( - self.event_publisher.publish_payload, - io_loop=self.io_loop, - ) + self.io_loop.spawn_callback( + self.event_publisher.publisher, + self.event_publisher.publish_payload, + self.io_loop, ) self.event = salt.utils.event.get_event( "minion", opts=self.opts, io_loop=self.io_loop @@ -1452,13 +1275,7 @@ def _spawn_minions(self, timeout=60): beacons_leader = True for master in masters: - # Use OptsDict copy-on-write instead of deepcopy - # Only master, multimaster, and beacons_leader are mutated - # Grains, pillar, and other large dicts are shared via copy-on-write - # self.opts is guaranteed to be OptsDict (set by MinionBase.__init__) - from salt.utils.optsdict import OptsDict - - s_opts = OptsDict.from_parent(self.opts, name=f"minion_manager:{master}") + s_opts = copy.deepcopy(self.opts) s_opts["master"] = master s_opts["multimaster"] = True s_opts["beacons_leader"] = beacons_leader @@ -1472,7 +1289,7 @@ def _spawn_minions(self, timeout=60): loaded_base_name="salt.loader.{}".format(s_opts["master"]), jid_queue=self.jid_queue, ) - self.io_loop.create_task(self._connect_minion(minion)) + self.io_loop.spawn_callback(self._connect_minion, minion) self.io_loop.call_later(timeout, self._check_minions) async def _connect_minion(self, minion): @@ -1540,12 +1357,7 @@ def tune_in(self): self._spawn_minions() # serve forever! - try: - self.io_loop.run_forever() - except (KeyboardInterrupt, SystemExit): - pass - finally: - self.io_loop.close() + self.io_loop.start() @property def restart(self): @@ -1568,9 +1380,12 @@ def stop(self, signum, parent_sig_handler): # hostname is unresolvable is silently swallowed until systemd # escalates to SIGKILL. See #69466. request_resolve_dns_abort() - self.io_loop.create_task(self.stop_async(signum, parent_sig_handler)) + self.io_loop.add_callback( # pylint: disable=not-callable + self.stop_async, signum, parent_sig_handler + ) - async def stop_async(self, signum, parent_sig_handler): + @tornado.gen.coroutine + def stop_async(self, signum, parent_sig_handler): """ Stop minions managed by the MinionManager allowing the io_loop to run and any remaining events to be processed before stopping the minions. @@ -1581,7 +1396,7 @@ async def stop_async(self, signum, parent_sig_handler): # Ideally, we would dynamically wait for all pending messages to be flushed # from the I/O loop instead of using a static sleep amount, but for now # this 5-second window handles most cases. - await asyncio.sleep(5) + yield tornado.gen.sleep(5) # Continue to stop the minions for minion in self.minions: @@ -1657,18 +1472,9 @@ def __init__( self._system_resource_limit_hit_timestamp = 0 if io_loop is None: - try: - self.io_loop = asyncio.get_running_loop() - except RuntimeError: - self.io_loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.io_loop) + self.io_loop = tornado.ioloop.IOLoop.current() else: - # Accept either asyncio loop or Tornado IOLoop (extract asyncio loop) - if isinstance(io_loop, asyncio.AbstractEventLoop): - self.io_loop = io_loop - else: - # Assume it's a Tornado IOLoop, extract the asyncio loop - self.io_loop = salt.utils.asynchronous.aioloop(io_loop) + self.io_loop = io_loop # Warn if ZMQ < 3.2 if zmq: @@ -1685,8 +1491,7 @@ def __init__( # post_master_init if not salt.utils.platform.is_proxy(): if load_grains: - new_grains = salt.loader.grains(opts) - self.opts.mutate_key("grains", new_grains) + self.opts["grains"] = salt.loader.grains(opts) else: if self.opts.get("beacons_before_connect", False): log.warning( @@ -1714,11 +1519,11 @@ def __init__( time.sleep(sleep_time) self.process_manager = ProcessManager(name="MinionProcessManager") - self.io_loop.create_task(self.process_manager.run(asynchronous=True)) + self.io_loop.spawn_callback(self.process_manager.run, **{"asynchronous": True}) # We don't have the proxy setup yet, so we can't start engines # Engines need to be able to access __proxy__ if not salt.utils.platform.is_proxy(): - self.io_loop.call_soon( + self.io_loop.spawn_callback( salt.engines.start_engines, self.opts, self.process_manager ) @@ -1793,7 +1598,7 @@ def on_connect_master_future_done(future): if timeout: self.io_loop.call_later(timeout, self.io_loop.stop) try: - self.io_loop.run_forever() + self.io_loop.start() except KeyboardInterrupt: self.destroy() # I made the following 3 line oddity to preserve traceback. @@ -1808,7 +1613,8 @@ def on_connect_master_future_done(future): if timeout and self._sync_connect_master_success is False: raise SaltDaemonNotRunning("Failed to connect to the salt-master") - async def connect_master(self, failed=False): + @tornado.gen.coroutine + def connect_master(self, failed=False): """ Return a future which will complete when you are connected to a master """ @@ -1823,7 +1629,7 @@ async def connect_master(self, failed=False): self.req_channel = None # Consider refactoring so that eval_master does not have a subtle side-effect on the contents of the opts array - master, self.pub_channel = await self.eval_master( + master, self.pub_channel = yield self.eval_master( self.opts, self.timeout, self.safe, failed ) @@ -1832,16 +1638,18 @@ async def connect_master(self, failed=False): self.opts, io_loop=self.io_loop ) log.debug("Connecting minion's long-running req channel") - await self.req_channel.connect() - await self._post_master_init(master) + yield self.req_channel.connect() + yield self._post_master_init(master) - async def handle_payload(self, payload, reply_func): + @tornado.gen.coroutine + def handle_payload(self, payload, reply_func): self.payloads.append(payload) - await reply_func(payload) + yield reply_func(payload) self.payload_ack.notify() # TODO: better name... - async def _post_master_init(self, master): + @tornado.gen.coroutine + def _post_master_init(self, master): """ Function to finish init after connecting to a master @@ -1866,13 +1674,8 @@ async def _post_master_init(self, master): self.opts["saltenv"], pillarenv=self.opts.get("pillarenv"), ) - self.opts["pillar"] = await async_pillar.compile_pillar() + self.opts["pillar"] = yield async_pillar.compile_pillar() async_pillar.destroy() - # _setup_core uses _load_modules only — unlike gen_modules it does not - # run _discover_resources(). tune_in schedules _register_resources_with_master - # right after connect; without this, the master registry gets {} until an - # async pillar refresh completes (easy to miss with saltutil.sync_all). - self.opts["resources"] = self._discover_resources() if not self.ready: self._setup_core() @@ -1885,7 +1688,6 @@ async def _post_master_init(self, master): self.function_errors, self.executors, ) = self._load_modules() - self.opts["resources"] = self._discover_resources() if hasattr(self, "schedule"): self.schedule.functions = self.functions self.schedule.returners = self.returners @@ -2019,19 +1821,13 @@ def _load_modules( context = {} if grains is None: - new_grains = salt.loader.grains( + opts["grains"] = salt.loader.grains( opts, force_refresh, proxy=proxy, context=context ) - opts.mutate_key("grains", new_grains) self.utils = salt.loader.utils(opts, proxy=proxy, context=context) if opts.get("multimaster", False): - # Use OptsDict copy-on-write instead of deepcopy - # Loader already handles OptsDict, so this is safe - # opts is guaranteed to be OptsDict (set by MinionBase.__init__ via super().__init__) - from salt.utils.optsdict import OptsDict - - s_opts = OptsDict.from_parent(opts, name="minion_multimaster_loader") + s_opts = copy.deepcopy(opts) functions = salt.loader.minion_mods( s_opts, utils=self.utils, @@ -2085,15 +1881,15 @@ def _send_req_sync(self, load, timeout): ) log.trace("Reply from main %s", request_id) return ret["ret"] - raise SaltReqTimeoutError("Request timed out") + raise salt.exceptions.SaltReqTimeoutError("Request timed out") - async def _send_req_async(self, load, timeout): - # XXX: Signing should happen in RequestChannel to be fixed in 3008 + @tornado.gen.coroutine + def _send_req_async(self, load, timeout): # XXX: This is only used by syndic with salt.utils.event.get_event("minion", opts=self.opts, listen=True) as event: request_id = str(uuid.uuid4()) log.trace("Send request to main id=%s", request_id) - await event.fire_event_async( + yield event.fire_event_async( load, f"__master_req_channel_payload/{request_id}/{self.opts['master']}", timeout=timeout, @@ -2105,21 +1901,23 @@ async def _send_req_async(self, load, timeout): ) if ret: break - await asyncio.sleep(0.3) + yield tornado.gen.sleep(0.3) else: - raise SaltReqTimeoutError("Did not recieve return event") + raise TimeoutError("Did not recieve return event") log.trace("Reply from main %s", request_id) - return ret["ret"] + raise tornado.gen.Return(ret["ret"]) - async def _send_req_async_main(self, load, timeout): + @tornado.gen.coroutine + def _send_req_async_main(self, load, timeout): """ Send a request to the master's request server. To be called from the top level process in the main thread only. Worker threads and processess should call _send_req_sync or _send_req_async as nessecery. """ - return await self.req_channel.send( + ret = yield self.req_channel.send( load, timeout=timeout, tries=self.opts["return_retry_tries"] ) + raise tornado.gen.Return(ret) def _fire_master_prepare( self, data, tag, events, pretag, include_startup_grains=False @@ -2150,7 +1948,8 @@ def _fire_master_prepare( load["grains"] = grains_to_add return load - async def _fire_master_main( + @tornado.gen.coroutine + def _fire_master_main( self, data=None, tag=None, @@ -2174,7 +1973,7 @@ def handle_timeout(*_): timeout_handler = handle_timeout - await self._send_req_async_main(load, timeout) + yield self._send_req_async_main(load, timeout) def _fire_master( self, @@ -2210,16 +2009,7 @@ async def _handle_decoded_payload(self, data): Override this method if you wish to handle the decoded data differently. """ - trace_ctx = salt.utils.tracing.extract(data) if isinstance(data, dict) else None - fun = data.get("fun", "") if isinstance(data, dict) else "" - jid = data.get("jid", "") if isinstance(data, dict) else "" - with salt.utils.tracing.start_span( - f"salt.minion.recv.{fun}" if fun else "salt.minion.recv", - kind=salt.utils.tracing.SpanKind.SERVER, - attributes={"salt.fun": fun, "salt.jid": str(jid)}, - context=trace_ctx, - ): - await self._handle_decoded_payload_impl(data) + await self._handle_decoded_payload_impl(data) async def _handle_decoded_payload_impl(self, data): """ @@ -2242,13 +2032,7 @@ async def _handle_decoded_payload_impl(self, data): # Check bypass flag early to prevent deduplication of queued jobs bypass_check = data.get("__ignore_process_count_max", False) if self.jid_queue is not None: - if data.get("resource_job"): - # Resource jobs intentionally share the parent job's JID so - # that returns are filed under the same job ID. Skip the - # deduplication gate entirely — each resource is a distinct - # execution even though the JID is the same. - pass - elif data["jid"] in self.jid_queue: + if data["jid"] in self.jid_queue: if not bypass_check: return else: @@ -2272,8 +2056,9 @@ async def _handle_decoded_payload_impl(self, data): proxy = self.proxy else: proxy = None - new_grains = salt.loader.grains(self.opts, force_refresh=True, proxy=proxy) - self.opts.mutate_key("grains", new_grains) + self.opts["grains"] = salt.loader.grains( + self.opts, force_refresh=True, proxy=proxy + ) # Check if we should bypass the process_count_max check # This is used for jobs that have been queued and are now being released @@ -2334,7 +2119,42 @@ async def _handle_decoded_payload_impl(self, data): return # Execute the job and get the process handle - self._invoke_execution(data) + proc = self._invoke_execution(data) + + # Write placeholder proc file with the ACTUAL PID to prevent "Invisible Gap" + # This ensures that when the child starts and checks 'running()', it sees itself. + if proc: + proc_dir = os.path.join(self.opts["cachedir"], "proc") + if not os.path.isdir(proc_dir): + try: + os.makedirs(proc_dir) + except OSError: + pass + + proc_fn = os.path.join(proc_dir, str(data["jid"])) + + # Use the real PID from the handle (multiprocessing) or current PID (threading) + real_pid = getattr(proc, "pid", os.getpid()) + if real_pid is None: + real_pid = os.getpid() + + placeholder_data = data.copy() + placeholder_data["pid"] = real_pid + + try: + with salt.utils.files.fopen(proc_fn, "w+b") as fp_: + salt.payload.dump(placeholder_data, fp_) + except OSError: + log.error("Failed to write placeholder proc file %s", proc_fn) + + # Now that the placeholder proc file is written, we can safely delete + # the running_ queue file to close the "invisible gap". + for qf in ("_job_queue_file", "_state_queue_file"): + if qf in data: + try: + os.remove(data[qf]) + except OSError: + pass def _queue_job(self, data): """ @@ -2347,8 +2167,8 @@ def _queue_job(self, data): except OSError: pass - # Use timestamp first to ensure strict FIFO ordering - # and suffix with JID to ensure uniqueness + # Use timestamp to ensure FIFO ordering + # We use microseconds to avoid collisions jid = data.get("jid") fn = f"queued_{int(time.time() * 1000000)}_{jid}.p" path = os.path.join(queue_dir, fn) @@ -2549,7 +2369,7 @@ def setup_process_queue_processing(self): """ if "process_queue" not in self.periodic_callbacks: self.add_periodic_callback( - "process_queue", self.process_process_queue, interval=0.2 + "process_queue", self.process_process_queue, interval=0.3 ) def process_process_queue(self): @@ -2561,13 +2381,14 @@ def process_process_queue(self): return self._process_queue_processing_active = True - self.io_loop.create_task(self._process_process_queue_async()) + self.io_loop.spawn_callback(self._process_process_queue_async) - async def _process_process_queue_async(self): + @tornado.gen.coroutine + def _process_process_queue_async(self): """ Async body of process_process_queue. """ - await self._process_process_queue_async_impl() + yield self._process_process_queue_async_impl() async def _process_process_queue_async_impl(self): """ @@ -2692,28 +2513,27 @@ async def _process_process_queue_async_impl(self): pass continue - # Extract JID from filename to ensure the execution JID matches the - # queuing JID used for ordering. - try: - parts = fn.split("_") - if len(parts) >= 3: - jid_str = parts[2] - if jid_str.endswith(".p"): - jid_str = jid_str[:-2] - data["jid"] = jid_str - except (ValueError, IndexError): - pass - # Mark to bypass checks (we already checked count) data["__ignore_process_count_max"] = True log.info("Re-submitting queued job %s", data.get("jid")) - self.io_loop.create_task(self._handle_decoded_payload(data)) + if hasattr(self, "io_loop"): + self.io_loop.spawn_callback( + self._handle_decoded_payload, data + ) + else: + self.io_loop.spawn_callback( + self._handle_decoded_payload, data + ) - # Remove from queue + # Rename file to running_ to avoid duplicate execution + # and to close the invisible gap for check_prior_running_states + running_fn = fn.replace("queued_", "running_", 1) + running_path = os.path.join(queue_dir, running_fn) try: - os.remove(path) + os.rename(path, running_path) + data["_job_queue_file"] = running_path except OSError: pass @@ -2765,25 +2585,22 @@ def _target(cls, minion_instance, opts, data, connected, creds_map): uid = salt.utils.user.get_uid(user=opts.get("user", None)) minion_instance.proc_dir = get_proc_dir(opts["cachedir"], uid=uid) - with salt.utils.ctx.request_context({"data": data, "opts": opts}): + def run_func(minion_instance, opts, data): if isinstance(data["fun"], tuple) or isinstance(data["fun"], list): return Minion._thread_multi_return(minion_instance, opts, data) else: return Minion._thread_return(minion_instance, opts, data) + with salt.utils.ctx.request_context({"data": data, "opts": opts}): + run_func(minion_instance, opts, data) + def _execute_job_function( - self, function_name, function_args, executors, opts, data, functions=None + self, function_name, function_args, executors, opts, data ): """ Executes a function within a job given it's name, the args and the executors. It also checks if the function is allowed to run if 'blackout mode' is enabled. - - ``functions`` defaults to ``self.functions`` but callers may pass a - different loader (e.g. a per-resource-type loader) to route execution - to the correct module set. """ - if functions is None: - functions = self.functions minion_blackout_violation = False if self.connected and self.opts["pillar"].get("minion_blackout", False): whitelist = self.opts["pillar"].get("minion_blackout_whitelist", []) @@ -2808,14 +2625,14 @@ def _execute_job_function( "saltutil.refresh_pillar allowed in blackout mode." ) - if function_name in functions: - func = functions[function_name] + if function_name in self.functions: + func = self.functions[function_name] args, kwargs = load_args_and_kwargs(func, function_args, data) else: - # only run if function_name is not in functions and allow_missing_funcs is True + # only run if function_name is not in minion_instance.functions and allow_missing_funcs is True func = function_name args, kwargs = function_args, data - functions.pack["__context__"]["retcode"] = 0 + self.functions.pack["__context__"]["retcode"] = 0 if isinstance(executors, str): executors = [executors] @@ -2848,124 +2665,42 @@ def _thread_return(cls, minion_instance, opts, data): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - salt.utils.tracing.configure(opts) - salt.utils.metrics.configure({**opts, "__role": "minion"}) - _exec_trace_ctx = ( - salt.utils.tracing.extract(data) if isinstance(data, dict) else None - ) - _exec_fun = data.get("fun", "") if isinstance(data, dict) else "" - _exec_span_cm = salt.utils.tracing.start_span( - f"salt.minion.exec.{_exec_fun}" if _exec_fun else "salt.minion.exec", - attributes={ - "salt.fun": _exec_fun, - "salt.jid": str(data.get("jid", "")) if isinstance(data, dict) else "", - }, - context=_exec_trace_ctx, - ) - # The span needs to outlive the existing try/finally below, which - # makes a plain ``with`` block awkward; enter / exit manually. - _exec_span_cm.__enter__() # pylint: disable=unnecessary-dunder-call - _exec_span_exit_called = False - # Companion histogram for the same wall-clock window the exec span - # covers; recorded in the finally below. - _exec_perf_start = time.perf_counter() - minion_instance.gen_modules() - fn_ = os.path.join(minion_instance.proc_dir, str(data["jid"])) - try: - if opts.get("multiprocessing", True): - salt.utils.process.appendproctitle(f"{cls.__name__}._thread_return") + if opts.get("multiprocessing", True): + salt.utils.process.appendproctitle(f"{cls.__name__}._thread_return") - sdata = {"pid": os.getpid()} - sdata.update(data) - log.info("Starting a new job %s with PID %s", data["jid"], sdata["pid"]) - with salt.utils.files.fopen(fn_, "w+b") as fp_: - fp_.write(salt.payload.dumps(sdata)) - if data.get("start_event"): - minion_instance._fire_start_event(data) - ret = {"success": False} - function_name = data["fun"] - function_args = data["arg"] - executors = ( - data.get("module_executors") - or getattr(minion_instance, "module_executors", []) - or opts.get("module_executors", ["direct_call"]) - ) - allow_missing_funcs = any( - [ - minion_instance.executors[f"{executor}.allow_missing_func"]( - function_name - ) - for executor in executors - if f"{executor}.allow_missing_func" in minion_instance.executors - ] - ) - # Resolve which execution-module loader to use. For resource - # jobs we use the per-type loader so that resource-specific - # execution modules (e.g. dummyresource_test.py) take - # precedence over the managing minion's own modules. - # Unknown functions for a resource type fail loudly rather than - # silently falling through to execute on the managing minion. - resource_target = data.get("resource_target") - if resource_target: - resource_type = resource_target["type"] - functions_to_use = minion_instance.resource_loaders.get(resource_type) - if functions_to_use is None: - ret["return"] = ( - f"No resource loader available for type '{resource_type}'. " - "Ensure the resource module exists and the minion is " - "configured to manage resources of this type." - ) - ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC - else: - # Set the per-call resource context via resource_ctxvar. - # contextvars are per-thread, so this value is invisible - # to other threads. LazyLoader.run() calls copy_context() - # fresh on every invocation, capturing this value in the - # snapshot before _run_as executes — fully isolated from - # concurrent resource jobs sharing the same loader object. - import salt.loader.context as _loader_ctx - - _loader_ctx.resource_ctxvar.set(resource_target) - grains_fn = f"{resource_type}.grains" - if grains_fn in minion_instance.resource_funcs: - functions_to_use.pack["__grains__"] = ( - minion_instance.resource_funcs[grains_fn]() - ) - elif ( - function_name in cls._MERGE_RESOURCE_FUNS - and data.get("resource_targets") - and data.get("pure_resource_target") - ): - # Pure resource target with a merge-mode function: the - # operator addressed only resources, the managing minion - # is a passthrough that runs the merge. Skip the regular - # function execution so the managing minion's own state - # tree (which won't contain resource-only state modules) - # doesn't taint the result with a "not found" entry. The - # merge block below populates ret["return"] from each - # resource's own loader. - functions_to_use = None - ret["return"] = {} - ret["retcode"] = salt.defaults.exitcodes.EX_OK - ret["success"] = True - else: - functions_to_use = minion_instance.functions + sdata = {"pid": os.getpid()} + sdata.update(data) + log.info("Starting a new job %s with PID %s", data["jid"], sdata["pid"]) + with salt.utils.files.fopen(fn_, "w+b") as fp_: + fp_.write(salt.payload.dumps(sdata)) + ret = {"success": False} + function_name = data["fun"] + function_args = data["arg"] + executors = ( + data.get("module_executors") + or getattr(minion_instance, "module_executors", []) + or opts.get("module_executors", ["direct_call"]) + ) + allow_missing_funcs = any( + [ + minion_instance.executors[f"{executor}.allow_missing_func"]( + function_name + ) + for executor in executors + if f"{executor}.allow_missing_func" in minion_instance.executors + ] + ) + try: if ( - ret.get("retcode") is None - and functions_to_use is not None - and (function_name in functions_to_use or allow_missing_funcs is True) + function_name in minion_instance.functions + or allow_missing_funcs is True ): try: return_data = minion_instance._execute_job_function( - function_name, - function_args, - executors, - opts, - data, - functions=functions_to_use, + function_name, function_args, executors, opts, data ) log.info( "Job %s execution finished, return_data: %s", @@ -2993,7 +2728,7 @@ def _thread_return(cls, minion_instance, opts, data): else: ret["return"] = return_data - retcode = functions_to_use.pack["__context__"].get( + retcode = minion_instance.functions.pack["__context__"].get( "retcode", salt.defaults.exitcodes.EX_OK ) if retcode == salt.defaults.exitcodes.EX_OK: @@ -3051,10 +2786,14 @@ def _thread_return(cls, minion_instance, opts, data): ret["out"] = "nested" ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC except TypeError as exc: + # XXX: This can ba extreemly missleading when something outside of a + # execution module call raises a TypeError. Make this it's own + # type of exception when we start validating state and + # execution argument module inputs. msg = "Passed invalid arguments to {}: {}\n{}".format( function_name, exc, - functions_to_use[function_name].__doc__ or "", + minion_instance.functions[function_name].__doc__ or "", ) log.warning(msg, exc_info_on_loglevel=logging.DEBUG) ret["return"] = msg @@ -3069,30 +2808,6 @@ def _thread_return(cls, minion_instance, opts, data): ret["return"] = f"{msg}: {traceback.format_exc()}" ret["out"] = "nested" ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC - elif resource_target: - if functions_to_use is not None: - # Resource type has a loader but function is not implemented. - # Fail loudly rather than silently falling through to the - # managing minion — the caller explicitly targeted a resource. - ret["return"] = ( - f"Function '{function_name}' is not supported for " - f"resource type '{resource_type}'. Implement it in a " - f"'{resource_type}resource_*' execution module." - ) - ret["success"] = False - ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC - ret["out"] = "nested" - # else: no-loader case already populated ret above - elif ( - function_name in cls._MERGE_RESOURCE_FUNS - and data.get("resource_targets") - and data.get("pure_resource_target") - ): - # Pure-resource merge case set ret["return"]={} earlier; the - # merge block below will fold each resource's results in. - # Skip the missing-function fallback so we don't overwrite - # the seed dict with "'state.apply' is not available." - pass else: docs = minion_instance.functions["sys.doc"](f"{function_name}*") if docs: @@ -3113,152 +2828,6 @@ def _thread_return(cls, minion_instance, opts, data): ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC ret["out"] = "nested" - # ------------------------------------------------------------------- - # Merge-mode: for state functions targeting resources the managing - # minion runs each resource's function inline and emits ONE return - # per resource. Each return carries ``resource_id`` so the master's - # ``_return`` handler remaps the load id to the resource id — same - # shape as ``salt -C 'T@dummy:dummy-01' test.ping``. Consumers can - # write ``data[resource_id]`` for both state and non-state functions. - # ------------------------------------------------------------------- - if ( - not data.get("resource_target") - and data.get("fun") in cls._MERGE_RESOURCE_FUNS - and data.get("resource_targets") - and isinstance(ret.get("return"), dict) - ): - import salt.loader.context as _loader_ctx # noqa: PLC0415 - - for resource in data["resource_targets"]: - rid = resource["id"] - rtype = resource["type"] - per_resource_ret = { - "success": True, - "return": {}, - "retcode": salt.defaults.exitcodes.EX_OK, - "out": "highstate", - } - resource_loader = getattr( - minion_instance, "resource_loaders", {} - ).get(rtype) - - if resource_loader is None: - per_resource_ret["return"] = ( - f"No resource loader for type '{rtype}'. " - "Ensure the resource module exists." - ) - per_resource_ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC - per_resource_ret["success"] = False - per_resource_ret["out"] = "nested" - elif function_name not in resource_loader: - # Same shape as the separate-job path's error response. - per_resource_ret["return"] = ( - f"Function '{function_name}' is not supported for " - f"resource type '{rtype}'." - ) - per_resource_ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC - per_resource_ret["success"] = False - per_resource_ret["out"] = "nested" - else: - token = _loader_ctx.resource_ctxvar.set(resource) - try: - resource_return = minion_instance._execute_job_function( - function_name, - function_args, - executors, - opts, - data, - functions=resource_loader, - ) - except Exception as exc: # pylint: disable=broad-except - log.error( - "Inline resource execution for '%s' raised: %s", - rid, - exc, - exc_info=True, - ) - per_resource_ret["return"] = ( - f"ERROR running {function_name} for '{rid}': {exc}" - ) - per_resource_ret["retcode"] = ( - salt.defaults.exitcodes.EX_GENERIC - ) - per_resource_ret["success"] = False - per_resource_ret["out"] = "nested" - else: - per_resource_ret["return"] = resource_return - r_retcode = resource_loader.pack["__context__"].get( - "retcode", 0 - ) - per_resource_ret["retcode"] = r_retcode - per_resource_ret["success"] = ( - r_retcode == salt.defaults.exitcodes.EX_OK - ) - if not isinstance(resource_return, dict): - per_resource_ret["out"] = "nested" - finally: - _loader_ctx.resource_ctxvar.reset(token) - - per_resource_ret["jid"] = data["jid"] - per_resource_ret["fun"] = data["fun"] - per_resource_ret["fun_args"] = data["arg"] - # The master's ``_return`` handler reads ``resource_id`` - # and remaps ``load["id"]`` to it — so the response is - # keyed by the resource id end-to-end. - per_resource_ret["resource_id"] = rid - if "user" in data: - per_resource_ret["user"] = data["user"] - if "master_id" in data: - per_resource_ret["master_id"] = data["master_id"] - if "metadata" in data and isinstance(data["metadata"], dict): - per_resource_ret["metadata"] = data["metadata"] - - if minion_instance.connected: - minion_instance._return_pub( - per_resource_ret, - timeout=minion_instance.opts["return_retry_tries"] - * minion_instance._return_retry_timer(max=True), - ) - else: - log.warning( - "Minion not connected; dropping return for " - "resource %s job %s", - rid, - data["jid"], - ) - - # Explicit returners (data["ret"]) fire per resource too. - if isinstance(opts.get("return"), str): - if data.get("ret"): - data["ret"] = ",".join((data["ret"], opts["return"])) - else: - data["ret"] = opts["return"] - if data.get("ret") and isinstance(data["ret"], str): - if "ret_config" in data: - per_resource_ret["ret_config"] = data["ret_config"] - if "ret_kwargs" in data: - per_resource_ret["ret_kwargs"] = data["ret_kwargs"] - per_resource_ret["id"] = opts["id"] - for returner in set(data["ret"].split(",")): - try: - returner_str = f"{returner}.returner" - if returner_str in minion_instance.returners: - minion_instance.returners[returner_str]( - per_resource_ret - ) - except Exception as exc: # pylint: disable=broad-except - log.exception( - "Returner failed for resource %s on job %s: %s", - rid, - data["jid"], - exc, - ) - - # Per-resource returns have been emitted (one per resource); - # skip the outer single-return path so we don't also send a - # stub return under the managing minion's own id. - return - if isinstance(ret["return"], dict) and ret["return"].get("__no_return__"): # This is used to suppress the return for queued jobs # The job will be executed later and will return then @@ -3272,11 +2841,6 @@ def _thread_return(cls, minion_instance, opts, data): ret["jid"] = data["jid"] ret["fun"] = data["fun"] ret["fun_args"] = data["arg"] - if data.get("resource_target"): - log.info( - "resource_target in _thread_return: %s", data["resource_target"] - ) - ret["resource_id"] = data["resource_target"]["id"] if "user" in data: ret["user"] = data["user"] if "master_id" in data: @@ -3316,13 +2880,11 @@ def _thread_return(cls, minion_instance, opts, data): ret["ret_kwargs"] = data["ret_kwargs"] ret["id"] = opts["id"] for returner in set(data["ret"].split(",")): - _returner_status = "ok" try: returner_str = f"{returner}.returner" if returner_str in minion_instance.returners: minion_instance.returners[returner_str](ret) else: - _returner_status = "missing" returner_err = minion_instance.returners.missing_fun_string( returner_str ) @@ -3332,45 +2894,14 @@ def _thread_return(cls, minion_instance, opts, data): returner_err, ) except Exception as exc: # pylint: disable=broad-except - _returner_status = "error" log.exception( "The return failed for job %s: %s", data["jid"], exc ) - finally: - salt.utils.metrics.counter( - "salt.returners.calls", - description="Minion returner invocations.", - ).add( - 1, - attributes={ - "returner": returner, - "status": _returner_status, - }, - ) finally: try: os.remove(fn_) except OSError: pass - # Close the event loop created at the start of this function so its - # selector and any async resources release their file descriptors. - try: - loop.close() - except Exception as exc: # pylint: disable=broad-except - log.warning("Error closing event loop for job %s: %s", data["jid"], exc) - if not _exec_span_exit_called: - _exec_span_exit_called = True - _exec_span_cm.__exit__( # pylint: disable=unnecessary-dunder-call - None, None, None - ) - salt.utils.metrics.histogram( - "salt.minion.exec.duration", - description="Minion-side wall-clock for a single function execution.", - unit="ms", - ).record( - (time.perf_counter() - _exec_perf_start) * 1000.0, - attributes={"fun": _exec_fun}, - ) @classmethod def _thread_multi_return(cls, minion_instance, opts, data): @@ -3382,7 +2913,6 @@ def _thread_multi_return(cls, minion_instance, opts, data): asyncio.set_event_loop(loop) minion_instance.gen_modules() - fn_ = os.path.join(minion_instance.proc_dir, str(data["jid"])) if opts.get("multiprocessing", True): @@ -3411,9 +2941,6 @@ def _thread_multi_return(cls, minion_instance, opts, data): return raise - if data.get("start_event"): - minion_instance._fire_start_event(data) - multifunc_ordered = opts.get("multifunc_ordered", False) num_funcs = len(data["fun"]) if multifunc_ordered: @@ -3491,12 +3018,6 @@ def _thread_multi_return(cls, minion_instance, opts, data): os.remove(fn_) except OSError: pass - # Close the event loop created at the start of this function so its - # selector and any async resources release their file descriptors. - try: - loop.close() - except Exception as exc: # pylint: disable=broad-except - log.warning("Error closing event loop for job %s: %s", data["jid"], exc) if data["ret"]: if "ret_config" in data: ret["ret_config"] = data["ret_config"] @@ -3509,38 +3030,6 @@ def _thread_multi_return(cls, minion_instance, opts, data): except Exception as exc: # pylint: disable=broad-except log.error("The return failed for job %s: %s", data["jid"], exc) - def _fire_start_event(self, data): - """ - Fire a ``salt/job//start/`` event to the master to - signal that this minion has accepted the published job and is about - to begin executing it. - - Only called when the master propagated ``start_event=True`` from the - caller's kwargs into the published load. Failures here must never - abort job execution. - """ - try: - load = { - "id": self.opts["id"], - "jid": data["jid"], - "fun": data.get("fun"), - "tgt": data.get("tgt"), - "tgt_type": data.get("tgt_type"), - "user": data.get("user"), - } - if data.get("master_id"): - load["master_id"] = data["master_id"] - if data.get("metadata") is not None: - load["metadata"] = data["metadata"] - tag = tagify([data["jid"], "start", self.opts["id"]], "job") - self._fire_master(load, tag) - except Exception: # pylint: disable=broad-except - log.warning( - "Failed to fire start event for job %s", - data.get("jid"), - exc_info=True, - ) - def _prepare_return_pub(self, ret, ret_cmd="_return"): jid = ret.get("jid", ret.get("__jid__")) fun = ret.get("fun", ret.get("__fun__")) @@ -3609,7 +3098,8 @@ def _prepare_return_pub(self, ret, ret_cmd="_return"): raise return load - async def _return_pub_main(self, ret, ret_cmd="_return", timeout=60): + @tornado.gen.coroutine + def _return_pub_main(self, ret, ret_cmd="_return", timeout=60): jid = ret.get("jid", ret.get("__jid__")) load = self._prepare_return_pub(ret, ret_cmd) if not self.opts["pub_ret"]: @@ -3626,12 +3116,12 @@ def timeout_handler(*_): return True try: - ret_val = await self._send_req_async_main(load, timeout=timeout) + ret_val = yield self._send_req_async_main(load, timeout=timeout) except SaltReqTimeoutError: timeout_handler() ret_val = "" log.trace("ret_val = %s", ret_val) # pylint: disable=no-member - return ret_val + raise tornado.gen.Return(ret_val) def _return_pub(self, ret, ret_cmd="_return", timeout=60): """ @@ -3668,7 +3158,7 @@ def timeout_handler(*_): log.trace("ret_val = %s", ret_val) # pylint: disable=no-member return ret_val - def _return_pub_multi(self, rets, ret_cmd="_return", timeout=60, sync=False): + def _return_pub_multi(self, rets, ret_cmd="_return", timeout=60, sync=True): """ Return the data from the executed command to the master server """ @@ -3746,27 +3236,20 @@ def timeout_handler(*_): if sync: try: - return self._send_req_sync(load, timeout=timeout) + ret_val = self._send_req_sync(load, timeout=timeout) except SaltReqTimeoutError: timeout_handler() return "" else: # pylint: disable=unexpected-keyword-arg - future = asyncio.Future() - - async def callback(future, load, timeout): - try: - ret_val = await self._send_req_async( - load, - timeout=timeout, - ) - log.trace("ret_val = %s", ret_val) # pylint: disable=no-member - future.set_result(ret_val) - except Exception as exc: # pylint: disable=broad-except - future.set_exception(exc) - + ret_val = self._send_req_async( + load, + timeout=timeout, + ) # pylint: enable=unexpected-keyword-arg - return future + + log.trace("ret_val = %s", ret_val) # pylint: disable=no-member + return ret_val def _state_run(self): """ @@ -3793,7 +3276,7 @@ def _state_run(self): else: data["fun"] = "state.highstate" data["arg"] = [] - self.io_loop.create_task(self._handle_decoded_payload(data)) + self.io_loop.add_callback(self._handle_decoded_payload, data) def _refresh_grains_watcher(self, refresh_interval_in_minutes): """ @@ -3814,168 +3297,26 @@ def _refresh_grains_watcher(self, refresh_interval_in_minutes): } ) - def _spawn_background(self, coro): - """ - Schedule a fire-and-forget coroutine on ``self.io_loop`` while - keeping a strong reference to the resulting :class:`asyncio.Task` - until it completes. - - Without this, ``self.io_loop.create_task(coro)`` returns a Task - whose only reference is held by the event loop's scheduling weak - reference. CPython's garbage collector can then destroy the Task - before it runs, producing the asyncio "Task was destroyed but it - is pending!" error and silently dropping the coroutine. This was - the root cause of resource registration intermittently failing — - ``_register_resources_with_master`` would never reach the master, - leaving its registry empty and breaking every ``T@`` / ``-L`` / - bare-id resource targeting test that depended on registration. - - Tasks are stored on a per-instance set and removed via a - ``add_done_callback`` so the set doesn't grow unbounded. - """ - if not hasattr(self, "_background_tasks"): - self._background_tasks = set() - task = self.io_loop.create_task(coro) - self._background_tasks.add(task) - task.add_done_callback(self._background_tasks.discard) - return task - - async def _fire_master_minion_start(self): + @tornado.gen.coroutine + def _fire_master_minion_start(self): include_grains = False if self.opts["start_event_grains"]: include_grains = True # Send an event to the master that the minion is live if self.opts["enable_legacy_startup_events"]: # Old style event. Defaults to False in 3001 release. - await self._fire_master_main( + yield self._fire_master_main( "Minion {} started at {}".format(self.opts["id"], time.asctime()), "minion_start", include_startup_grains=include_grains, ) # send name spaced event - await self._fire_master_main( + yield self._fire_master_main( "Minion {} started at {}".format(self.opts["id"], time.asctime()), tagify([self.opts["id"], "start"], "minion"), include_startup_grains=include_grains, ) - def _collect_resource_grains(self): - """ - Render per-resource grain dicts for every resource this minion manages. - - For each ``(resource_type, resource_id)`` pair, sets the per-call - :data:`salt.loader.context.resource_ctxvar` and invokes - ``resource_funcs[f"{type}.grains"]()``. Returns a mapping keyed by - composite SRN ``":"`` -> grain dict, suitable for shipping - to the master in :meth:`_register_resources_with_master`. - - Resource types without a ``grains`` callable are skipped silently. - Per-resource grain failures are logged and skipped; a single broken - resource never blocks registration of the others. - - :rtype: dict[str, dict] - """ - import salt.loader.context as _loader_ctx # noqa: PLC0415 - - resource_grains = {} - resources = self.opts.get("resources", {}) - for rtype, rids in (resources or {}).items(): - grains_fn = f"{rtype}.grains" - if grains_fn not in getattr(self, "resource_funcs", {}): - continue - for rid in rids or (): - target = {"id": rid, "type": rtype} - tok = _loader_ctx.resource_ctxvar.set(target) - try: - gdict = self.resource_funcs[grains_fn]() - except Exception as exc: # pylint: disable=broad-except - log.warning( - "Failed to collect grains for resource %s:%s: %s", - rtype, - rid, - exc, - ) - gdict = None - finally: - _loader_ctx.resource_ctxvar.reset(tok) - if isinstance(gdict, dict): - resource_grains[f"{rtype}:{rid}"] = gdict - return resource_grains - - async def _register_resources_with_master(self): - """ - Send this minion's resource list to the master for registry population. - - Called on startup (and reconnect) so that the master's - ``minion_resources`` cache bank is up-to-date. This allows - :class:`salt.utils.minions.CkMinions` to include resource IDs when - expanding glob / non-compound targets (e.g. ``salt '*' test.ping``). - - Also ships a per-resource ``resource_grains`` mapping so the master - can populate its ``resource_grains`` cache bank for ``-G`` / - ``salt -G`` grain-based targeting of resources. - - **Freshness model.** The ``resource_grains`` snapshot is taken at - the moment of this call by :meth:`_collect_resource_grains`. A - per-resource ``.grains_refresh()`` invocation that mutates - the underlying state does **not** automatically propagate to the - master — the master's view is refreshed only when this method runs - again. The triggers that re-run it are: - - * minion start / reconnect (``tune_in``); - * the ``resource_refresh`` event on the minion event bus (see - ``manage_event_iter``); and - * a successful ``saltutil.refresh_pillar`` / ``module_refresh`` - (because :meth:`_post_master_init` re-discovers resources). - - Operators who need the master to see fresh resource grains after - an out-of-band state change should fire ``resource_refresh`` or - run ``salt-call saltutil.refresh_pillar``. - - An empty resource dict is sent deliberately when the minion has no - resources — this clears any stale entries left by a previous - registration (e.g. after a resource type is removed from the pillar). - """ - resources = self.opts.get("resources", {}) - if resources and not getattr(self, "resource_funcs", None): - # ``resource_funcs`` is the loader for ``salt/resource/.py`` - # connection modules. Unlike ``functions``/``returners`` (built - # by :meth:`_setup_core` during :meth:`_post_master_init`), the - # resource loaders are normally materialised lazily on the - # **first job dispatch** by :meth:`_thread_return.gen_modules`. - # Resource registration runs **before** any job has been - # dispatched, so without this eager call ``resource_funcs`` is - # an empty :class:`LazyLoader` and - # :meth:`_collect_resource_grains` finds no ``.grains`` - # callables to invoke — the master ends up with an empty - # ``resource_grains`` bank and ``salt -G ...`` silently fails - # to match resources. The cost is paid once per registration; - # subsequent calls hit the populated loader and skip this - # branch. - try: - self.gen_modules() - except Exception as exc: # pylint: disable=broad-except - log.warning( - "Failed to gen_modules before resource grain collection: %s", - exc, - ) - resource_grains = self._collect_resource_grains() if resources else {} - # Cache locally so :meth:`_resolve_resource_targets` can resolve - # ``tgt_type == "grain"`` without re-rendering. - self._resource_grains_cache = resource_grains - load = { - "cmd": "_register_resources", - "id": self.opts["id"], - "resources": resources, - "resource_grains": resource_grains, - "tok": self.tok, - } - try: - await self._send_req_async_main(load, timeout=self._return_retry_timer()) - log.debug("Registered resources with master: %s", list(resources.keys())) - except Exception as err: # pylint: disable=broad-except - log.warning("Unable to register resources with master: %s", err) - def module_refresh(self, force_refresh=False, notify=False): """ Refresh the functions and returners. @@ -4051,7 +3392,8 @@ def pillar_schedule_refresh(self, current, new): return pillar_schedule # TODO: only allow one future in flight at a time? - async def pillar_refresh(self, force_refresh=False, clean_cache=False): + @tornado.gen.coroutine + def pillar_refresh(self, force_refresh=False, clean_cache=False): """ Refresh the pillar """ @@ -4068,7 +3410,7 @@ async def pillar_refresh(self, force_refresh=False, clean_cache=False): clean_cache=clean_cache, ) try: - new_pillar = await async_pillar.compile_pillar() + new_pillar = yield async_pillar.compile_pillar() except SaltClientError: # Do not exit if a pillar refresh fails. log.error( @@ -4083,12 +3425,6 @@ async def pillar_refresh(self, force_refresh=False, clean_cache=False): ) self.opts["pillar"] = new_pillar self.functions.pack["__pillar__"] = self.opts["pillar"] - # Re-discover resources now that pillar has changed. Must - # happen *after* opts["pillar"] is updated so that - # _discover_resources sees the new resource declarations (or - # their absence when a type is removed from the pillar). - self.opts["resources"] = self._discover_resources() - await self._register_resources_with_master() finally: async_pillar.destroy() self.matchers_refresh() @@ -4253,19 +3589,19 @@ def _mine_send(self, tag, data): log.warning("Unable to send mine data to master.") return None - async def handle_event(self, package): + @tornado.gen.coroutine + def handle_event(self, package): """ Handle an event from the epull_sock (all local minion events) """ if not self.ready: - log.warning("Received event but not ready to process it") - return + raise tornado.gen.Return() tag, data = salt.utils.event.SaltEvent.unpack(package) if "proxy_target" in data and self.opts.get("metaproxy") == "deltaproxy": proxy_target = data["proxy_target"] if proxy_target not in self.deltaproxy_objs: - return + raise tornado.gen.Return() _minion = self.deltaproxy_objs[proxy_target] else: _minion = self @@ -4281,7 +3617,7 @@ async def handle_event(self, package): if job_master == self.opts["master"]: ret = None try: - ret = await _minion.req_channel.send( + ret = yield _minion.req_channel.send( data, timeout=_minion._return_retry_timer(), tries=_minion.opts["return_retry_tries"], @@ -4296,7 +3632,7 @@ async def handle_event(self, package): "minion", opts=self.opts, listen=False, io_loop=self.io_loop ) as event: try: - await event.fire_event_async( + yield event.fire_event_async( {"ret": None, "error": "timeout"}, f"__master_req_channel_return/{request_id}", ) @@ -4304,12 +3640,12 @@ async def handle_event(self, package): log.error( "Error firing master request timeout event: %s", exc ) - return + raise tornado.gen.Return() with salt.utils.event.get_event( "minion", opts=self.opts, listen=False, io_loop=self.io_loop ) as event: try: - await event.fire_event_async( + yield event.fire_event_async( {"ret": ret}, f"__master_req_channel_return/{request_id}", ) @@ -4323,7 +3659,7 @@ async def handle_event(self, package): request_id, ) elif tag.startswith("pillar_refresh"): - await _minion.pillar_refresh( + yield _minion.pillar_refresh( force_refresh=data.get("force_refresh", False), clean_cache=data.get("clean_cache", False), ) @@ -4331,9 +3667,6 @@ async def handle_event(self, package): _minion.beacons_refresh() elif tag.startswith("matchers_refresh"): _minion.matchers_refresh() - elif tag.startswith("resource_refresh"): - _minion.opts["resources"] = _minion._discover_resources() - _minion._spawn_background(_minion._register_resources_with_master()) elif tag.startswith("manage_schedule"): _minion.manage_schedule(tag, data) elif tag.startswith("manage_beacons"): @@ -4343,7 +3676,7 @@ async def handle_event(self, package): data.get("force_refresh", False) or _minion.grains_cache != _minion.opts["grains"] ): - await _minion.pillar_refresh(force_refresh=True) + yield _minion.pillar_refresh(force_refresh=True) _minion.grains_cache = _minion.opts["grains"] elif tag.startswith("environ_setenv"): self.environ_setenv(tag, data) @@ -4356,7 +3689,7 @@ async def handle_event(self, package): data["tag"], self.opts["master"], ) - await self._fire_master_main( + yield self._fire_master_main( data["data"], data["tag"], data["events"], @@ -4380,7 +3713,7 @@ async def handle_event(self, package): and data["master"] != self.opts["master"] ): # not mine master, ignore - return + raise tornado.gen.Return() if tag.startswith(master_event(type="failback")): # if the master failback event is not for the top master, raise an exception if data["master"] != self.opts["master_list"][0]: @@ -4417,7 +3750,7 @@ async def handle_event(self, package): # if eval_master finds a new master for us, self.connected # will be True again on successful master authentication try: - master, self.pub_channel = await self.eval_master( + master, self.pub_channel = yield self.eval_master( opts=self.opts, failed=True, failback=tag.startswith(master_event(type="failback")), @@ -4437,7 +3770,7 @@ async def handle_event(self, package): self.req_channel = salt.channel.client.AsyncReqChannel.factory( self.opts, io_loop=self.io_loop ) - await self.req_channel.connect() + yield self.req_channel.connect() # put the current schedule into the new loaders self.opts["schedule"] = self.schedule.option("schedule") @@ -4450,15 +3783,11 @@ async def handle_event(self, package): # make the schedule to use the new 'functions' loader self.schedule.functions = self.functions self.pub_channel.on_recv(self._handle_payload) - await self._fire_master_minion_start() - await self._register_resources_with_master() + yield self._fire_master_minion_start() log.info("Minion is ready to receive requests!") # update scheduled job to run with the new master addr - if ( - self.opts["transport"] != "tcp" - and self.opts["master_alive_interval"] > 0 - ): + if self.opts["transport"] != "tcp": schedule = { "function": "status.master", "seconds": self.opts["master_alive_interval"], @@ -4507,10 +3836,7 @@ async def handle_event(self, package): self.connected = True # modify the __master_alive job to only fire, # if the connection is lost again - if ( - self.opts["transport"] != "tcp" - and self.opts["master_alive_interval"] > 0 - ): + if self.opts["transport"] != "tcp": schedule = { "function": "status.master", "seconds": self.opts["master_alive_interval"], @@ -4534,11 +3860,11 @@ async def handle_event(self, package): 1 ], ) - await self._return_pub_main(data, ret_cmd="_return") + yield self._return_pub_main(data, ret_cmd="_return") elif tag.startswith("_salt_error"): if self.connected: log.debug("Forwarding salt error event tag=%s", tag) - await self._fire_master_main(data, tag) + yield self._fire_master_main(data, tag) elif tag.startswith("salt/auth/creds"): key = tuple(data["key"]) log.debug( @@ -4551,7 +3877,7 @@ async def handle_event(self, package): elif tag.startswith("__beacons_return"): if self.connected: log.debug("Firing beacons to master") - await self._fire_master_main(events=data["beacons"]) + yield self._fire_master_main(events=data["beacons"]) def cleanup_subprocesses(self): """ @@ -4687,16 +4013,16 @@ def process_state_queue(self): return self._state_queue_processing_active = True - self.io_loop.create_task(self._process_state_queue_async()) + self.io_loop.spawn_callback(self._process_state_queue_async) - async def _process_state_queue_async(self): + @tornado.gen.coroutine + def _process_state_queue_async(self): """ Async body of process_state_queue. """ - await self._process_state_queue_async_impl() + yield self._process_state_queue_async_impl() async def _process_state_queue_async_impl(self): - log.trace("State queue processing firing") try: queue_dir = salt.utils.state.state_queue_dir(self.opts) if not os.path.exists(queue_dir): @@ -4717,8 +4043,6 @@ async def _process_state_queue_async_impl(self): if not files: return - log.debug("State queue processing: found queued files: %s", files) - # Sort by JID to ensure we process in the order expected by the state system's # dependency check (_prior_running_states), which relies on JID comparison. # Filename: queued__.p @@ -4811,7 +4135,10 @@ def sort_key(fn): return # Extract JID from filename to ensure the execution JID matches the - # queuing JID used for ordering. + # queuing JID used for ordering. Ideally these would match in the + # payload, but state.py generates a new JID for the payload while + # using the original JID for the filename. This mismatch causes + # the loop (Running New > Queued Old). try: parts = fn.split("_") if len(parts) >= 3: @@ -4834,19 +4161,25 @@ def sort_key(fn): data.get("jid"), ) + # Rename file to running_ to close the invisible gap + running_fn = fn.replace("queued_", "running_", 1) + running_path = os.path.join(queue_dir, running_fn) + try: + os.rename(path, running_path) + data["_state_queue_file"] = running_path + except OSError as exc: + log.error("Failed to rename queued job file %s: %s", path, exc) + return + # Mark job to bypass process_count_max checks since it has already waited # its turn in the State queue and we don't want it to starve. data["__ignore_process_count_max"] = True - # Always await job completion to prevent dispatching all queued jobs - # simultaneously. This ensures proper sequential processing. - await self._handle_decoded_payload(data) - - # Remove from queue - try: - os.remove(path) - except OSError: - pass + if hasattr(self, "io_loop"): + self.io_loop.spawn_callback(self._handle_decoded_payload, data) + else: + # Fallback if io_loop is not explicit (should not happen in Minion) + self.io_loop.spawn_callback(self._handle_decoded_payload, data) except (OSError, salt.exceptions.FileLockError) as exc: if isinstance(exc, salt.exceptions.FileLockError) or ( @@ -4899,9 +4232,6 @@ def tune_in(self, start=True): """ self._pre_tune() - salt.utils.tracing.configure(self.opts) - salt.utils.metrics.configure({**self.opts, "__role": "minion"}) - _register_minion_observables() log.debug("Minion '%s' trying to tune in", self.opts["id"]) if start: @@ -4911,8 +4241,7 @@ def tune_in(self, start=True): self.setup_scheduler(before_connect=True) self.sync_connect_master() if self.connected: - self._spawn_background(self._fire_master_minion_start()) - self._spawn_background(self._register_resources_with_master()) + self.io_loop.add_callback(self._fire_master_minion_start) log.info("Minion is ready to receive requests!") # Make sure to gracefully handle SIGUSR1 @@ -4929,7 +4258,7 @@ def tune_in(self, start=True): self.setup_scheduler() self.setup_state_queue_processing() self.setup_process_queue_processing() - self.add_periodic_callback("cleanup", self.cleanup_subprocesses, interval=0.2) + self.add_periodic_callback("cleanup", self.cleanup_subprocesses) # schedule the stuff that runs every interval ping_interval = self.opts.get("ping_interval", 0) * 60 @@ -4957,12 +4286,11 @@ def ping_timeout_handler(*_): "minion is running under an init system." ) - self.io_loop.create_task( - self._fire_master_main( - "ping", - "minion_ping", - timeout_handler=ping_timeout_handler, - ) + self.io_loop.add_callback( + self._fire_master_main, + "ping", + "minion_ping", + timeout_handler=ping_timeout_handler, ) except Exception: # pylint: disable=broad-except log.warning( @@ -4989,43 +4317,19 @@ def ping_timeout_handler(*_): if start: try: - self.io_loop.run_forever() + self.io_loop.start() if self.restart: self.destroy() except ( KeyboardInterrupt, RuntimeError, - ): # A RuntimeError can be re-raised during shutdown + ): # A RuntimeError can be re-raised by Tornado on shutdown self.destroy() - finally: - if not self.io_loop.is_closed(): - self.io_loop.close() async def _handle_payload(self, payload): if payload is not None and payload["enc"] == "aes": if self._target_load(payload["load"]): - load = payload["load"] - - if load.get("minion_is_target", True): - await self._handle_decoded_payload(load) - - # For merge-mode functions (state.apply etc.) resources are - # executed inline inside _thread_return and folded into the - # managing minion's own response. Dispatching them as - # separate jobs would send duplicate responses the master is - # no longer waiting for. - fun = load.get("fun") - is_merge_fun = isinstance(fun, str) and fun in self._MERGE_RESOURCE_FUNS - if not is_merge_fun: - for resource in load.get("resource_targets", []): - resource_load = dict(load) - resource_load["resource_target"] = resource - # Flag so _handle_decoded_payload_impl can bypass JID - # deduplication — resource jobs share the parent JID by - # design but are independent executions. - resource_load["resource_job"] = True - await self._handle_decoded_payload(resource_load) - + await self._handle_decoded_payload(payload["load"]) elif self.opts["zmq_filtering"]: # In the filtering enabled case, we'd like to know when minion sees something it shouldn't log.trace( @@ -5061,338 +4365,15 @@ def _target_load(self, load): return False if load["tgt_type"] in ("grain", "grain_pcre", "pillar"): delimiter = load.get("delimiter", DEFAULT_TARGET_DELIM) - minion_matches = match_func(load["tgt"], delimiter=delimiter) - else: - minion_matches = match_func(load["tgt"]) - else: - minion_matches = self.matchers["glob_match.match"](load["tgt"]) - - resource_targets = self._resolve_resource_targets(load) - load["resource_targets"] = resource_targets - # For pure resource targets (e.g. ``T@dummy:dummy-01`` or a bare - # resource id glob like ``salt 'dummy-01' state.single``) the - # managing minion would normally NOT count as a target — the - # operator is addressing resources, not the minion. But - # merge-mode functions (state.apply, state.highstate, …) run - # resources INLINE inside the managing minion and emit one - # return per resource. The managing minion therefore has to - # execute the publish whenever ``is_merge_fun`` and - # ``resource_targets`` are set — even when its own id didn't - # match ``tgt`` — otherwise nothing runs and the job silently - # produces no return. - fun = load.get("fun") - is_merge_fun = isinstance(fun, str) and fun in self._MERGE_RESOURCE_FUNS - is_pure_resource = self._is_pure_resource_target(load) - load["pure_resource_target"] = is_pure_resource - load["minion_is_target"] = ( - bool(minion_matches) and (is_merge_fun or not is_pure_resource) - ) or (is_merge_fun and bool(resource_targets)) - - if not load["minion_is_target"] and not resource_targets: - return False - return True - - def _is_pure_resource_target(self, load): - """ - Return True when the target expression addresses only Salt - Resources — not the managing minion itself. - - Two shapes count as pure-resource: - - * Compound expressions whose every term is a ``T@`` or ``M@`` - engine (with the usual boolean operators). - * Bare-id glob targets (no wildcards) whose ``tgt`` matches a - managed resource id. ``salt 'dummy-01' state.single …`` - looks like an ordinary glob to the master but is logically a - pure-resource target from the managing minion's point of - view — the bare id is the resource, not the minion. - """ - tgt = load.get("tgt", "") - tgt_type = load.get("tgt_type", "glob") - if tgt_type == "compound": - words = tgt.split() if isinstance(tgt, str) else list(tgt) - opers = {"and", "or", "not", "(", ")"} - return all( - w in opers or w.startswith("T@") or w.startswith("M@") for w in words - ) - if ( - tgt_type == "glob" - and isinstance(tgt, str) - and not any(c in tgt for c in ("*", "?", "[")) - ): - resources = self.opts.get("resources", {}) - for rids in resources.values(): - if tgt in rids: - return True - return False - - # Functions that are internal Salt plumbing and should never be dispatched - # to managed resources. Resources don't participate in job-status queries, - # module refreshes, or other minion-only housekeeping calls. - _NO_RESOURCE_FUNS = frozenset( - { - "saltutil.find_job", - "saltutil.running", - "saltutil.is_running", - "saltutil.kill_job", - "saltutil.signal_job", - "saltutil.term_job", - "saltutil.refresh_grains", - "saltutil.sync_all", - "saltutil.sync_grains", - "saltutil.sync_modules", - "sys.reload_modules", - } - ) - - # Functions where resource results are merged into the managing minion's - # own response rather than dispatched as independent jobs. This produces - # ONE combined block + Summary section per managing minion instead of - # separate blocks per resource, matching how any other minion looks. - _MERGE_RESOURCE_FUNS = frozenset( - { - "state.apply", - "state.highstate", - "state.sls", - "state.sls_id", - "state.single", - } - ) - - @staticmethod - def _prefix_resource_state_key(sid, rid): - """Re-label the ID/name components of a state result key with rid. - - Key format: {module}_|-{id}_|-{name}_|-{function} - Only comps[1] and comps[2] (id and name) are prefixed so the - highstate formatter still reads {comps[0]}.{comps[3]} correctly, - preserving ``Function: pkg.installed`` while showing ``ID: node1 curl``. - """ - parts = sid.split("_|-", 3) - if len(parts) == 4: - parts[1] = f"{rid} {parts[1]}" - parts[2] = f"{rid} {parts[2]}" - return "_|-".join(parts) - return f"no_|-{rid}_|-{rid}_|-None" - - def _resource_term_matches(self, term, rtype, rid, gdict, resources): - """ - Evaluate a single compound-expression term against one resource. - - Used by :meth:`_resource_matches_compound` to render each term in - a compound expression to ``True``/``False`` before evaluating the - boolean combinators. - - Supported engines (everything else returns ``False`` because it - targets minions, not resources): - - * ``T@type[:id]`` — resource-type / resource-id match. - * ``G@key:value`` — per-resource grain ``subdict_match``. - * ``P@key:regex`` — per-resource grain regex match. - * ``L@a,b,c`` — bare-id list membership. - * ``E@regex`` — bare-id regex match. - """ - if term.startswith("T@"): - pattern = term[2:] - if ":" in pattern: - t, _, r = pattern.partition(":") - if not r: - return rtype == t and rid in resources.get(t, []) - return rtype == t and rid == r and rid in resources.get(t, []) - return rtype == pattern and rid in resources.get(pattern, []) - if term.startswith("G@"): - try: - return bool( - salt.utils.data.subdict_match( - gdict, - term[2:], - delimiter=DEFAULT_TARGET_DELIM, - regex_match=False, - ) - ) - except Exception: # pylint: disable=broad-except + if not match_func(load["tgt"], delimiter=delimiter): + return False + elif not match_func(load["tgt"]): return False - if term.startswith("P@"): - try: - return bool( - salt.utils.data.subdict_match( - gdict, - term[2:], - delimiter=DEFAULT_TARGET_DELIM, - regex_match=True, - ) - ) - except Exception: # pylint: disable=broad-except - return False - if term.startswith("L@"): - return rid in [t.strip() for t in term[2:].split(",") if t.strip()] - if term.startswith("E@"): - try: - import re as _re # noqa: PLC0415 - - return bool(_re.match(term[2:], rid)) - except _re.error: + else: + if not self.matchers["glob_match.match"](load["tgt"]): return False - # M@, I@, J@, S@, N@, R@, plain glob — these target minions or - # operate on data resources don't carry. Treated as non-matching - # so that compounds like ``G@a:1 and not S@10/8`` evaluate against - # a resource purely on its grains (the ``S@`` term contributes - # ``False`` and ``not False`` is ``True``, leaving the grain term - # to decide). - return False - - def _resource_matches_compound(self, tgt, srn, gdict, resources): - """ - Evaluate a full compound expression against one resource. - - Tokenises ``tgt``, replaces every non-operator token with the - ``True``/``False`` literal produced by :meth:`_resource_term_matches`, - and evaluates the resulting Python boolean expression. The eval - environment is restricted to no builtins and no locals, so only - operator semantics (``and``, ``or``, ``not``, ``(``, ``)``) are - available — there is no path for tokens to inject arbitrary Python. - - :returns: ``True`` if the compound expression matches this - resource, ``False`` otherwise (including malformed input). - """ - rtype, _, rid = srn.partition(":") - if not rid: - return False - words = tgt.split() if isinstance(tgt, str) else list(tgt) - if not words: - return False - parts = [] - for word in words: - if word in ("and", "or", "not", "(", ")"): - parts.append(word) - continue - matched = self._resource_term_matches(word, rtype, rid, gdict, resources) - parts.append("True" if matched else "False") - expression = " ".join(parts).strip() - if not expression: - return False - try: - return bool( - eval(expression, {"__builtins__": {}}, {}) # pylint: disable=eval-used - ) - except Exception: # pylint: disable=broad-except - return False - def _resolve_resource_targets(self, load): - """ - Return the list of per-resource dicts ``{"id": ..., "type": ...}`` that - the target expression matches against ``opts["resources"]``. - - For wildcard glob targets (e.g. ``salt '*'``), returns all managed - resources so that the command also runs against resources. - For compound T@ targets, returns only the matched resources. - For list targets, returns resources whose bare id appears in the list. - For an exact glob with no wildcards, returns a single resource if ``tgt`` - is a bare id managed by this minion (``salt ``). - For specific-name glob targets that are not resource ids (e.g. - ``salt 'minion'``), grain, pillar, or compound expressions with no T@ - terms, returns an empty list — the operator is targeting the minion - itself, not its resources. - Internal/plumbing functions (see ``_NO_RESOURCE_FUNS``) are never - dispatched to resources. - """ - resources = self.opts.get("resources", {}) - if not resources: - return [] - - fun = load.get("fun") - # Multi-fun jobs (``fun`` is a list) bypass resource dispatch — the - # multifun execution path doesn't fold per-resource returns. - if not isinstance(fun, str): - return [] - if fun in self._NO_RESOURCE_FUNS: - return [] - - tgt = load.get("tgt", "") - tgt_type = load.get("tgt_type", "glob") - - if tgt_type == "compound": - # Per-resource boolean evaluation of the compound expression. - # Each resource is treated as an independent target whose - # identity (``type``, ``id``) and grain dict drive the truth - # value of every term; ``and`` / ``or`` / ``not`` / parens are - # evaluated using Python's boolean operators after rendering - # each term to ``True``/``False``. - rg = getattr(self, "_resource_grains_cache", None) - if rg is None: - rg = self._collect_resource_grains() - self._resource_grains_cache = rg - # Include every managed resource even if it has no grain entry - # — so plain ``T@type`` / ``T@type:id`` compounds still match - # for resources whose connection module exposes no ``grains``. - all_srns = dict(rg) - for rtype, rids in (resources or {}).items(): - for rid in rids or (): - all_srns.setdefault(f"{rtype}:{rid}", {}) - targets = [] - for srn, gdict in all_srns.items(): - if self._resource_matches_compound(tgt, srn, gdict, resources): - rtype, _, rid = srn.partition(":") - if rid: - targets.append({"id": rid, "type": rtype}) - return targets - - if tgt_type == "list": - tokens = ( - [t.strip() for t in tgt.split(",") if t.strip()] - if isinstance(tgt, str) - else [str(t).strip() for t in tgt if str(t).strip()] - ) - targets = [] - for token in tokens: - for rtype, rids in resources.items(): - if token in rids: - targets.append({"id": token, "type": rtype}) - return targets - - if tgt_type in ("grain", "grain_pcre"): - # Match each managed resource's per-resource grains against - # ``tgt`` (a ``key:value`` expression). Uses the cache populated - # by :meth:`_register_resources_with_master`; refreshes on miss - # so a never-registered minion still resolves its own targets. - rg = getattr(self, "_resource_grains_cache", None) - if rg is None: - rg = self._collect_resource_grains() - self._resource_grains_cache = rg - targets = [] - for srn, gdict in rg.items(): - if salt.utils.data.subdict_match( - gdict, - tgt, - delimiter=DEFAULT_TARGET_DELIM, - regex_match=(tgt_type == "grain_pcre"), - ): - rtype, _, rid = srn.partition(":") - if rid: - targets.append({"id": rid, "type": rtype}) - return targets - - # For glob targets, only dispatch to resources when the pattern - # contains a wildcard. A bare name like ``salt 'minion' test.ping`` - # targets the minion itself; it should not implicitly run against its - # resources. ``salt '*' test.ping`` or ``salt 'web*' test.ping`` - # opts in to resource dispatch. - if ( - tgt_type == "glob" - and isinstance(tgt, str) - and not any(c in tgt for c in ("*", "?", "[")) - ): - for rtype, rids in resources.items(): - if tgt in rids: - return [{"id": tgt, "type": rtype}] - return [] - - # Wildcard glob — dispatch to all managed resources. - all_resources = [] - for rtype, rids in resources.items(): - for rid in rids: - all_resources.append({"id": rid, "type": rtype}) - return all_resources + return True def destroy(self): """ @@ -5407,10 +4388,8 @@ def destroy(self): if hasattr(self, "pub_channel") and self.pub_channel is not None: self.pub_channel.on_recv(None) self.pub_channel.close() - self.pub_channel = None if hasattr(self, "req_channel") and self.req_channel is not None: self.req_channel.close() - self.req_channel = None if hasattr(self, "periodic_callbacks"): for cb in self.periodic_callbacks.values(): cb.stop() @@ -5479,23 +4458,13 @@ async def _handle_decoded_payload(self, data): Override this method if you wish to handle the decoded data differently. """ - trace_ctx = salt.utils.tracing.extract(data) if isinstance(data, dict) else None # TODO: even do this?? data["to"] = int(data.get("to", self.opts["timeout"])) - 1 # Only forward the command if it didn't originate from ourselves if data.get("master_id", 0) != self.opts.get("master_id", 1): - with salt.utils.tracing.start_span( - "salt.syndic.forward", - kind=salt.utils.tracing.SpanKind.SERVER, - attributes={ - "salt.fun": data.get("fun", ""), - "salt.jid": str(data.get("jid", "")), - }, - context=trace_ctx, - ): - await self.syndic_cmd(data) + self.syndic_cmd(data) - async def syndic_cmd(self, data): + def syndic_cmd(self, data): """ Take the now clear load and forward it on to the client cmd """ @@ -5517,7 +4486,7 @@ def timeout_handler(*args): log.warning("Unable to forward pub data: %s", args[1]) return True - await self.local.pub_async( + self.local.pub_async( data["tgt"], data["fun"], data["arg"], @@ -5535,10 +4504,12 @@ def _send_req_sync(self, load, timeout): load, timeout=timeout, tries=self.opts["return_retry_tries"] ) - async def _send_req_async(self, load, timeout): - return await self.async_req_channel.send( + @tornado.gen.coroutine + def _send_req_async(self, load, timeout): + ret = yield self.async_req_channel.send( load, timeout=timeout, tries=self.opts["return_retry_tries"] ) + return ret def fire_master_syndic_start(self): # Send an event to the master that the minion is live @@ -5583,7 +4554,8 @@ async def _process_cmd_socket(self, payload): # In the future, we could add support for some clearfuncs, but # the syndic currently has no need. - async def reconnect(self): + @tornado.gen.coroutine + def reconnect(self): if hasattr(self, "pub_channel"): self.pub_channel.on_recv(None) if hasattr(self.pub_channel, "close"): @@ -5592,14 +4564,14 @@ async def reconnect(self): # if eval_master finds a new master for us, self.connected # will be True again on successful master authentication - master, self.pub_channel = await self.eval_master(opts=self.opts) + master, self.pub_channel = yield self.eval_master(opts=self.opts) if self.connected: self.opts["master"] = master self.pub_channel.on_recv(self._process_cmd_socket) log.info("Minion is ready to receive requests!") - return self + raise tornado.gen.Return(self) def destroy(self): """ @@ -5661,18 +4633,9 @@ def __init__(self, opts, io_loop=None): self.jid_forward_cache = set() if io_loop is None: - try: - self.io_loop = asyncio.get_running_loop() - except RuntimeError: - self.io_loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.io_loop) + self.io_loop = tornado.ioloop.IOLoop.current() else: - # Accept either asyncio loop or Tornado IOLoop (extract asyncio loop) - if isinstance(io_loop, asyncio.AbstractEventLoop): - self.io_loop = io_loop - else: - # Assume it's a Tornado IOLoop, extract the asyncio loop - self.io_loop = salt.utils.asynchronous.aioloop(io_loop) + self.io_loop = io_loop # List of events self.raw_events = [] @@ -5685,8 +4648,6 @@ def __init__(self, opts, io_loop=None): self.tries = collections.defaultdict(int) # Active pub futures: {master_id: (future, [job_ret, ...]), ...} self.pub_futures = {} - # Local client (set in tune_in()) - self.local = None def _spawn_syndics(self): """ @@ -5699,19 +4660,10 @@ def _spawn_syndics(self): for master in masters: s_opts = copy.copy(self.opts) s_opts["master"] = master + self._syndics[master] = self._connect_syndic(s_opts) - future = asyncio.Future() - self._syndics[master] = future - - async def connect(future, s_opts): - try: - future.set_result(await self._connect_syndic(s_opts)) - except Exception as exc: # pylint: disable=broad-except - future.set_exception(exc) - - self.io_loop.create_task(connect(future, s_opts)) - - async def _connect_syndic(self, opts): + @tornado.gen.coroutine + def _connect_syndic(self, opts): """ Create a syndic, and asynchronously connect it to a master """ @@ -5727,7 +4679,7 @@ async def _connect_syndic(self, opts): safe=False, io_loop=self.io_loop, ) - await syndic.connect_master(failed=failed) + yield syndic.connect_master(failed=failed) # set up the syndic to handle publishes (specifically not event forwarding) syndic.tune_in_no_block() @@ -5746,7 +4698,7 @@ async def _connect_syndic(self, opts): last = time.time() if auth_wait < self.max_auth_wait: auth_wait += self.auth_wait - await asyncio.sleep(auth_wait) # TODO: log? + yield tornado.gen.sleep(auth_wait) # TODO: log? except (KeyboardInterrupt, SystemExit): # pylint: disable=try-except-raise raise except Exception: # pylint: disable=broad-except @@ -5757,7 +4709,7 @@ async def _connect_syndic(self, opts): exc_info=True, ) - return syndic + raise tornado.gen.Return(syndic) def _mark_master_dead(self, master): """ @@ -5840,10 +4792,9 @@ def _return_pub_syndic(self, values, master_id=None): continue else: self.tries = collections.defaultdict(int) - # XXX This does not make sense - future = asyncio.Future() + future = getattr(syndic_future.result(), func)( - values, "_syndic_return", timeout=self._return_retry_timer(), sync=True + values, "_syndic_return", timeout=self._return_retry_timer(), sync=False ) self.pub_futures[master] = (future, values) return True @@ -5906,13 +4857,7 @@ def tune_in(self): # Make sure to gracefully handle SIGUSR1 enable_sigusr1_handler() - try: - self.io_loop.run_forever() - except (KeyboardInterrupt, SystemExit): - pass - finally: - if not self.io_loop.is_closed(): - self.io_loop.close() + self.io_loop.start() async def _process_event(self, raw): # TODO: cleanup: Move down into event class @@ -5929,14 +4874,8 @@ async def _process_event(self, raw): ): job_event = True - # Only skip forwarding job RETURN events with matching master_id - # We must still forward /new events with minion lists so the master - # of masters knows which minions to expect returns from - if ( - job_event - and tag_parts[3] == "ret" - and self.syndic_mode == "cluster" - and data.get("master_id", 0) == self.opts.get("master_id", 1) + if self.syndic_mode == "cluster" and data.get("master_id", 0) == self.opts.get( + "master_id", 1 ): return_event = False @@ -5985,7 +4924,9 @@ async def _process_event(self, raw): self.syndic_mode == "sync" # Even in cluster mode we need to forward the raw event with the minions # list to determine which minions we expect to return on the master of masters. - or (return_event and job_event and "minions" in data) + or ( + return_event and (salt.utils.jid.is_jid(mtag) and "minions" in data) + ) ): # Add generic event aggregation here if "retcode" not in data: @@ -6120,7 +5061,8 @@ class ProxyMinion(Minion): """ # TODO: better name... - async def _post_master_init(self, master): + @tornado.gen.coroutine + def _post_master_init(self, master): """ Function to finish init after connecting to a master @@ -6135,16 +5077,17 @@ async def _post_master_init(self, master): functions. """ mp_call = _metaproxy_call(self.opts, "post_master_init") - await mp_call(self, master) + yield mp_call(self, master) - async def subproxy_post_master_init(self, minion_id, uid): + @tornado.gen.coroutine + def subproxy_post_master_init(self, minion_id, uid): """ Function to finish init for the sub proxies :rtype : None """ mp_call = _metaproxy_call(self.opts, "subproxy_post_master_init") - await mp_call(self, minion_id, uid) + yield mp_call(self, minion_id, uid) def tune_in(self, start=True): """ @@ -6161,6 +5104,7 @@ def _target_load(self, load): mp_call = _metaproxy_call(self.opts, "target_load") return mp_call(self, load) + # @tornado.gen.coroutine async def _handle_payload(self, payload): mp_call = _metaproxy_call(self.opts, "handle_payload") return await mp_call(self, payload) @@ -6206,16 +5150,14 @@ def gen_modules(self, initial_load=False, context=None): """ # need sync of custom grains as may be used in pillar compilation salt.utils.extmods.sync(self.opts, "grains") - new_grains = salt.loader.grains(self.opts) - self.opts.mutate_key("grains", new_grains) - new_pillar = salt.pillar.get_pillar( + self.opts["grains"] = salt.loader.grains(self.opts) + self.opts["pillar"] = salt.pillar.get_pillar( self.opts, self.opts["grains"], self.opts["id"], saltenv=self.opts["saltenv"], pillarenv=self.opts.get("pillarenv"), ).compile_pillar() - self.opts.mutate_key("pillar", new_pillar) if "proxy" not in self.opts["pillar"] and "proxy" not in self.opts: errmsg = ( @@ -6286,31 +5228,9 @@ def gen_modules(self, initial_load=False, context=None): proxy_init_fn = self.proxy[fq_proxyname + ".init"] proxy_init_fn(self.opts) - new_grains = salt.loader.grains(self.opts, proxy=self.proxy) - self.opts.mutate_key("grains", new_grains) + self.opts["grains"] = salt.loader.grains(self.opts, proxy=self.proxy) # Sync the grains here so the proxy can communicate them to the master self.functions["saltutil.sync_grains"](saltenv="base") self.grains_cache = self.opts["grains"] self.ready = True - - -# Expose the resource targeting helpers on MinionBase so SMinion (used by -# salt-call) can dispatch to resources via the same logic the full Minion -# uses for master-driven jobs. The methods only depend on self.opts / -# self.resource_funcs / self._resource_grains_cache, all of which SMinion -# also provides via MinionBase.gen_modules(). This rebind keeps a single -# implementation on Minion while making it reachable through the SMinion -# MRO without a 250-line code move. -for _attr in ( - "_NO_RESOURCE_FUNS", - "_MERGE_RESOURCE_FUNS", - "_prefix_resource_state_key", - "_resource_term_matches", - "_resource_matches_compound", - "_resolve_resource_targets", - "_is_pure_resource_target", - "_collect_resource_grains", -): - setattr(MinionBase, _attr, getattr(Minion, _attr)) -del _attr diff --git a/salt/modules/acme.py b/salt/modules/acme.py new file mode 100644 index 000000000000..27af892ae8f6 --- /dev/null +++ b/salt/modules/acme.py @@ -0,0 +1,436 @@ +""" +ACME / Let's Encrypt module +=========================== + +.. versionadded:: 2016.3.0 + +This module currently looks for certbot script in the $PATH as +- certbot, +- lestsencrypt, +- certbot-auto, +- letsencrypt-auto +eventually falls back to /opt/letsencrypt/letsencrypt-auto + +.. note:: + + Installation & configuration of the Let's Encrypt client can for example be done using + https://github.com/saltstack-formulas/letsencrypt-formula + +.. warning:: + + Be sure to set at least accept-tos = True in cli.ini! + +Most parameters will fall back to cli.ini defaults if None is given. + +DNS plugins +----------- + +This module currently supports the CloudFlare certbot DNS plugin. The DNS +plugin credentials file needs to be passed in using the +``dns_plugin_credentials`` argument. + +Make sure the appropriate certbot plugin for the wanted DNS provider is +installed before using this module. + +""" + +import datetime +import logging +import os + +import salt.utils.path +from salt.exceptions import SaltInvocationError + +log = logging.getLogger(__name__) + +LEA = salt.utils.path.which_bin( + [ + "certbot", + "letsencrypt", + "certbot-auto", + "letsencrypt-auto", + "/opt/letsencrypt/letsencrypt-auto", + ] +) +LE_LIVE = "/etc/letsencrypt/live/" + +if salt.utils.platform.is_freebsd(): + LE_LIVE = "/usr/local" + LE_LIVE + + +def __virtual__(): + """ + Only work when letsencrypt-auto is installed + """ + return ( + LEA is not None, + "The ACME execution module cannot be loaded: letsencrypt-auto not installed.", + ) + + +def _cert_file(name, cert_type): + """ + Return expected path of a Let's Encrypt live cert + """ + return os.path.join(LE_LIVE, name, f"{cert_type}.pem") + + +def _expires(name): + """ + Return the expiry date of a cert + + :rtype: datetime + :return: Expiry date + """ + cert_file = _cert_file(name, "cert") + # Use the salt module if available + if "tls.cert_info" in __salt__: + expiry = __salt__["tls.cert_info"](cert_file).get("not_after", 0) + # Cobble it together using the openssl binary + else: + openssl_cmd = f"openssl x509 -in {cert_file} -noout -enddate" + # No %e format on my Linux'es here + strptime_sux_cmd = f'date --date="$({openssl_cmd} | cut -d= -f2)" +%s' + expiry = float(__salt__["cmd.shell"](strptime_sux_cmd, output_loglevel="quiet")) + # expiry = datetime.datetime.strptime(expiry.split('=', 1)[-1], '%b %e %H:%M:%S %Y %Z') + return datetime.datetime.fromtimestamp(expiry) + + +def _renew_by(name, window=None): + """ + Date before a certificate should be renewed + + :param str name: Name of the certificate + :param int window: days before expiry date to renew + :rtype: datetime + :return: First renewal date + """ + expiry = _expires(name) + if window is not None: + expiry = expiry - datetime.timedelta(days=window) + + return expiry + + +def cert( + name, + aliases=None, + email=None, + webroot=None, + test_cert=False, + renew=None, + keysize=None, + server=None, + owner="root", + group="root", + mode="0640", + certname=None, + preferred_challenges=None, + tls_sni_01_port=None, + tls_sni_01_address=None, + http_01_port=None, + http_01_address=None, + dns_plugin=None, + dns_plugin_credentials=None, + dns_plugin_propagate_seconds=10, + manual_auth_hook=None, + manual_cleanup_hook=None, +): + """ + Obtain/renew a certificate from an ACME CA, probably Let's Encrypt. + + :param name: Common Name of the certificate (DNS name of certificate) + :param aliases: subjectAltNames (Additional DNS names on certificate) + :param email: e-mail address for interaction with ACME provider + :param webroot: True or a full path to use to use webroot. Otherwise use standalone mode + :param test_cert: Request a certificate from the Happy Hacker Fake CA (mutually + exclusive with 'server') + :param renew: True/'force' to force a renewal, or a window of renewal before + expiry in days + :param keysize: RSA key bits + :param server: API endpoint to talk to + :param owner: owner of the private key file + :param group: group of the private key file + :param mode: mode of the private key file + :param certname: Name of the certificate to save + :param preferred_challenges: A sorted, comma delimited list of the preferred + challenge to use during authorization with the most preferred challenge + listed first. + :param tls_sni_01_port: Port used during tls-sni-01 challenge. This only affects + the port Certbot listens on. A conforming ACME server will still attempt + to connect on port 443. + :param tls_sni_01_address: The address the server listens to during tls-sni-01 + challenge. + :param http_01_port: Port used in the http-01 challenge. This only affects + the port Certbot listens on. A conforming ACME server will still attempt + to connect on port 80. + :param https_01_address: The address the server listens to during http-01 challenge. + :param dns_plugin: Name of a DNS plugin to use (currently only 'cloudflare' + or 'digitalocean') + :param dns_plugin_credentials: Path to the credentials file if required by + the specified DNS plugin + :param dns_plugin_propagate_seconds: Number of seconds to wait for DNS propogations + before asking ACME servers to verify the DNS record. (default 10) + :param manual_auth_hook: Path to the manual authentication hook script. + :param manual_cleanup_hook: Path to the manual cleanup or post-authentication hook script. + :rtype: dict + :return: Dictionary with 'result' True/False/None, 'comment' and certificate's + expiry date ('not_after') + + CLI Example: + + .. code-block:: bash + + salt 'gitlab.example.com' acme.cert dev.example.com "[gitlab.example.com]" test_cert=True \ + renew=14 webroot=/opt/gitlab/embedded/service/gitlab-rails/public + """ + + cmd = [LEA, "certonly", "--non-interactive", "--agree-tos"] + if certname is None: + certname = name + + supported_dns_plugins = ["cloudflare"] + + cert_file = _cert_file(certname, "cert") + if not __salt__["file.file_exists"](cert_file): + log.debug("Certificate %s does not exist (yet)", cert_file) + renew = False + elif needs_renewal(certname, renew): + log.debug("Certificate %s will be renewed", cert_file) + cmd.append("--renew-by-default") + renew = True + if server: + cmd.append(f"--server {server}") + + if certname: + cmd.append(f"--cert-name {certname}") + + if test_cert: + if server: + return { + "result": False, + "comment": "Use either server or test_cert, not both", + } + cmd.append("--test-cert") + + if webroot: + cmd.append("--authenticator webroot") + if webroot is not True: + cmd.append(f"--webroot-path {webroot}") + elif dns_plugin in supported_dns_plugins: + if dns_plugin == "cloudflare": + cmd.append("--dns-cloudflare") + cmd.append(f"--dns-cloudflare-credentials {dns_plugin_credentials}") + cmd.append( + f"--dns-cloudflare-propagation-seconds {dns_plugin_propagate_seconds}" + ) + else: + return { + "result": False, + "comment": f"DNS plugin '{dns_plugin}' is not supported", + } + elif manual_auth_hook: + cmd.append("--manual") + cmd.append(f"--manual-auth-hook '{manual_auth_hook}'") + if manual_cleanup_hook: + cmd.append(f"--manual-cleanup-hook '{manual_cleanup_hook}'") + else: + cmd.append("--authenticator standalone") + + if email: + cmd.append(f"--email {email}") + + if keysize: + cmd.append(f"--rsa-key-size {keysize}") + + cmd.append(f"--domains {name}") + if aliases is not None: + for dns in aliases: + cmd.append(f"--domains {dns}") + + if preferred_challenges: + cmd.append(f"--preferred-challenges {preferred_challenges}") + + if tls_sni_01_port: + cmd.append(f"--tls-sni-01-port {tls_sni_01_port}") + if tls_sni_01_address: + cmd.append(f"--tls-sni-01-address {tls_sni_01_address}") + if http_01_port: + cmd.append(f"--http-01-port {http_01_port}") + if http_01_address: + cmd.append(f"--http-01-address {http_01_address}") + + res = __salt__["cmd.run_all"](" ".join(cmd)) + + if res["retcode"] != 0: + if "expand" in res["stderr"]: + cmd.append("--expand") + res = __salt__["cmd.run_all"](" ".join(cmd)) + if res["retcode"] != 0: + return { + "result": False, + "comment": "Certificate {} renewal failed with:\n{}".format( + name, res["stderr"] + ), + } + else: + return { + "result": False, + "comment": "Certificate {} renewal failed with:\n{}".format( + name, res["stderr"] + ), + } + + if "no action taken" in res["stdout"]: + comment = f"Certificate {cert_file} unchanged" + result = None + elif renew: + comment = f"Certificate {certname} renewed" + result = True + else: + comment = f"Certificate {certname} obtained" + result = True + + ret = { + "comment": comment, + "not_after": expires(certname), + "changes": {}, + "result": result, + } + ret, _ = __salt__["file.check_perms"]( + _cert_file(certname, "privkey"), ret, owner, group, mode, follow_symlinks=True + ) + + return ret + + +def certs(): + """ + Return a list of active certificates + + CLI Example: + + .. code-block:: bash + + salt 'vhost.example.com' acme.certs + """ + return [ + item + for item in __salt__["file.readdir"](LE_LIVE)[2:] + if os.path.isdir(os.path.join(LE_LIVE, item)) + ] + + +def info(name): + """ + Return information about a certificate + + :param str name: Name of certificate + :rtype: dict + :return: Dictionary with information about the certificate. + If neither the ``tls`` nor the ``x509`` module can be used to determine + the certificate information, the information will be retrieved as one + big text block under the key ``text`` using the openssl cli. + + CLI Example: + + .. code-block:: bash + + salt 'gitlab.example.com' acme.info dev.example.com + """ + if not has(name): + return {} + cert_file = _cert_file(name, "cert") + # Use the tls salt module if available + if "tls.cert_info" in __salt__: + cert_info = __salt__["tls.cert_info"](cert_file) + # Strip out the extensions object contents; + # these trip over our poor state output + # and they serve no real purpose here anyway + cert_info["extensions"] = list(cert_info["extensions"]) + elif "x509.read_certificate" in __salt__: + cert_info = __salt__["x509.read_certificate"](cert_file) + else: + # Cobble it together using the openssl binary + openssl_cmd = f"openssl x509 -in {cert_file} -noout -text" + cert_info = {"text": __salt__["cmd.run"](openssl_cmd, output_loglevel="quiet")} + return cert_info + + +def expires(name): + """ + The expiry date of a certificate in ISO format + + :param str name: Name of certificate + :rtype: str + :return: Expiry date in ISO format. + + CLI Example: + + .. code-block:: bash + + salt 'gitlab.example.com' acme.expires dev.example.com + """ + return _expires(name).isoformat() + + +def has(name): + """ + Test if a certificate is in the Let's Encrypt Live directory + + :param str name: Name of certificate + :rtype: bool + + Code example: + + .. code-block:: python + + if __salt__['acme.has']('dev.example.com'): + log.info('That is one nice certificate you have there!') + """ + return __salt__["file.file_exists"](_cert_file(name, "cert")) + + +def renew_by(name, window=None): + """ + Date in ISO format when a certificate should first be renewed + + :param str name: Name of certificate + :param int window: number of days before expiry when renewal should take place + :rtype: str + :return: Date of certificate renewal in ISO format. + """ + return _renew_by(name, window).isoformat() + + +def needs_renewal(name, window=None): + """ + Check if a certificate needs renewal + + :param str name: Name of certificate + :param bool/str/int window: Window in days to renew earlier or True/force to just return True + :rtype: bool + :return: Whether or not the certificate needs to be renewed. + + Code example: + + .. code-block:: python + + if __salt__['acme.needs_renewal']('dev.example.com'): + __salt__['acme.cert']('dev.example.com', **kwargs) + else: + log.info('Your certificate is still good') + """ + if window: + if str(window).lower() in ("force", "true"): + return True + if not ( + isinstance(window, int) or (hasattr(window, "isdigit") and window.isdigit()) + ): + raise SaltInvocationError( + 'The argument "window", if provided, must be one of the following : ' + 'True (boolean), "force" or "Force" (str) or a numerical value in days.' + ) + window = int(window) + + return _renew_by(name, window) <= datetime.datetime.today() diff --git a/salt/modules/ansiblegate.py b/salt/modules/ansiblegate.py index 487ab5b5d271..ec87aa9969c6 100644 --- a/salt/modules/ansiblegate.py +++ b/salt/modules/ansiblegate.py @@ -18,7 +18,6 @@ import json import logging import os -import shlex import subprocess import sys from tempfile import NamedTemporaryFile @@ -365,9 +364,9 @@ def playbooks( if diff: command.append("--diff") if isinstance(extra_vars, dict): - command.append(f"--extra-vars={shlex.quote(json.dumps(extra_vars))}") + command.append(f"--extra-vars='{json.dumps(extra_vars)}'") elif isinstance(extra_vars, str) and extra_vars.startswith("@"): - command.append(f"--extra-vars={shlex.quote(extra_vars)}") + command.append(f"--extra-vars={extra_vars}") if flush_cache: command.append("--flush-cache") if inventory: @@ -421,7 +420,7 @@ def playbooks( return retdata -def targets(inventory=None, inventories=None, yaml=False, export=False): +def targets(inventory="/etc/ansible/hosts", yaml=False, export=False): """ .. versionadded:: 3005 @@ -430,10 +429,6 @@ def targets(inventory=None, inventories=None, yaml=False, export=False): :param inventory: The inventory file to read the inventory from. Default: "/etc/ansible/hosts" - :param inventories: - The list of inventory files to read the inventory from. - Uses `inventory` in case if `inventories` is not specified. - :param yaml: Return the inventory as yaml output. Default: False @@ -448,9 +443,7 @@ def targets(inventory=None, inventories=None, yaml=False, export=False): salt 'ansiblehost' ansible.targets inventory=my_custom_inventory """ - return salt.utils.ansible.targets( - inventory=inventory, inventories=inventories, yaml=yaml, export=export - ) + return salt.utils.ansible.targets(inventory=inventory, yaml=yaml, export=export) def discover_playbooks( @@ -506,7 +499,7 @@ def discover_playbooks( List of paths to discover playbooks from. :param playbook_extension: - File extension(s) of playbook files to search for, can be a string or tuple of strings. Default: (".yml", ".yaml") + File extension of playbooks file to search for. Default: "yml" :param hosts_filename: Filename of custom playbook inventory to search for. Default: "hosts" @@ -537,7 +530,7 @@ def discover_playbooks( ) if not playbook_extension: - playbook_extension = (".yml", ".yaml") + playbook_extension = "yml" if not hosts_filename: hosts_filename = "hosts" @@ -575,7 +568,7 @@ def _explore_path(path, playbook_extension, hosts_filename, syntax_check): # Check files in the given path for _f in os.listdir(path): _path = os.path.join(path, _f) - if os.path.isfile(_path) and _path.endswith(playbook_extension): + if os.path.isfile(_path) and _path.endswith("." + playbook_extension): ret[_f] = {"fullpath": _path} # Check for custom inventory file if os.path.isfile(os.path.join(path, hosts_filename)): @@ -586,7 +579,9 @@ def _explore_path(path, playbook_extension, hosts_filename, syntax_check): # Check files in the 1st level of subdirectories for _f2 in os.listdir(_path): _path2 = os.path.join(_path, _f2) - if os.path.isfile(_path2) and _path2.endswith(playbook_extension): + if os.path.isfile(_path2) and _path2.endswith( + "." + playbook_extension + ): ret[os.path.join(_f, _f2)] = {"fullpath": _path2} # Check for custom inventory file if os.path.isfile(os.path.join(_path, hosts_filename)): diff --git a/salt/modules/apcups.py b/salt/modules/apcups.py new file mode 100644 index 000000000000..2b653061db5e --- /dev/null +++ b/salt/modules/apcups.py @@ -0,0 +1,115 @@ +""" +Module for apcupsd +""" + +import logging + +import salt.utils.decorators as decorators +import salt.utils.path + +log = logging.getLogger(__name__) + +# Define the module's virtual name +__virtualname__ = "apcups" + + +@decorators.memoize +def _check_apcaccess(): + """ + Looks to see if apcaccess is present on the system + """ + return salt.utils.path.which("apcaccess") + + +def __virtual__(): + """ + Provides apcupsd only if apcaccess is present + """ + if _check_apcaccess(): + return __virtualname__ + return ( + False, + "{} module can only be loaded on when apcupsd is installed".format( + __virtualname__ + ), + ) + + +def status(): + """ + Return apcaccess output + + CLI Example: + + .. code-block:: bash + + salt '*' apcups.status + """ + ret = {} + apcaccess = _check_apcaccess() + res = __salt__["cmd.run_all"](apcaccess) + retcode = res["retcode"] + if retcode != 0: + ret["Error"] = "Something with wrong executing apcaccess, is apcupsd running?" + return ret + + for line in res["stdout"].splitlines(): + line = line.split(":") + ret[line[0].strip()] = line[1].strip() + + return ret + + +def status_load(): + """ + Return load + + CLI Example: + + .. code-block:: bash + + salt '*' apcups.status_load + """ + data = status() + if "LOADPCT" in data: + load = data["LOADPCT"].split() + if load[1].lower() == "percent": + return float(load[0]) + + return {"Error": "Load not available."} + + +def status_charge(): + """ + Return battery charge + + CLI Example: + + .. code-block:: bash + + salt '*' apcups.status_charge + """ + data = status() + if "BCHARGE" in data: + charge = data["BCHARGE"].split() + if charge[1].lower() == "percent": + return float(charge[0]) + + return {"Error": "Load not available."} + + +def status_battery(): + """ + Return true if running on battery power + + CLI Example: + + .. code-block:: bash + + salt '*' apcups.status_battery + """ + data = status() + if "TONBATT" in data: + return not data["TONBATT"] == "0 Seconds" + + return {"Error": "Battery status not available."} diff --git a/salt/modules/apkpkg.py b/salt/modules/apkpkg.py new file mode 100644 index 000000000000..e1240d28d156 --- /dev/null +++ b/salt/modules/apkpkg.py @@ -0,0 +1,602 @@ +""" +Support for apk + +.. important:: + If you feel that Salt should be using this module to manage packages on a + minion, and it is using a different module (or gives an error similar to + *'pkg.install' is not available*), see :ref:`here + `. + +.. versionadded:: 2017.7.0 + +""" + +import copy +import logging + +import salt.utils.data +import salt.utils.itertools +from salt.exceptions import CommandExecutionError + +log = logging.getLogger(__name__) + +# Define the module's virtual name +__virtualname__ = "pkg" + + +def __virtual__(): + """ + Confirm this module is running on an Alpine Linux distribution + """ + if __grains__.get("os_family", False) == "Alpine": + return __virtualname__ + return (False, "Module apk only works on Alpine Linux based systems") + + +# def autoremove(list_only=False, purge=False): +# return 'Not available' +# def hold(name=None, pkgs=None, sources=None, **kwargs): # pylint: disable=W0613 +# return 'Not available' +# def unhold(name=None, pkgs=None, sources=None, **kwargs): # pylint: disable=W0613 +# return 'Not available' +# def upgrade_available(name): +# return 'Not available' +# def version_cmp(pkg1, pkg2, ignore_epoch=False): +# return 'Not available' +# def list_repos(): +# return 'Not available' +# def get_repo(repo, **kwargs): +# return 'Not available' +# def del_repo(repo, **kwargs): +# return 'Not available' +# def del_repo_key(name=None, **kwargs): +# return 'Not available' +# def mod_repo(repo, saltenv='base', **kwargs): +# return 'Not available' +# def expand_repo_def(**kwargs): +# return 'Not available' +# def get_selections(pattern=None, state=None): +# return 'Not available' +# def set_selections(path=None, selection=None, clear=False, saltenv='base'): +# return 'Not available' +# def info_installed(*names): +# return 'Not available' + + +def version(*names, **kwargs): + """ + Returns a string representing the package version or an empty string if not + installed. If more than one package name is specified, a dict of + name/version pairs is returned. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.version + salt '*' pkg.version ... + """ + return __salt__["pkg_resource.version"](*names, **kwargs) + + +def refresh_db(**kwargs): + """ + Updates the package list + + - ``True``: Database updated successfully + - ``False``: Problem updating database + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.refresh_db + """ + ret = {} + cmd = ["apk", "update"] + call = __salt__["cmd.run_all"](cmd, output_loglevel="trace", python_shell=False) + if call["retcode"] == 0: + errors = [] + ret = True + else: + errors = [call["stdout"]] + ret = False + + if errors: + raise CommandExecutionError( + "Problem encountered installing package(s)", + info={"errors": errors, "changes": ret}, + ) + + return ret + + +def _list_pkgs_from_context(versions_as_list): + """ + Use pkg list from __context__ + """ + if versions_as_list: + return __context__["pkg.list_pkgs"] + else: + ret = copy.deepcopy(__context__["pkg.list_pkgs"]) + __salt__["pkg_resource.stringify"](ret) + return ret + + +def list_pkgs(versions_as_list=False, **kwargs): + """ + List the packages currently installed in a dict:: + + {'': ''} + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.list_pkgs + salt '*' pkg.list_pkgs versions_as_list=True + """ + versions_as_list = salt.utils.data.is_true(versions_as_list) + # not yet implemented or not applicable + if any( + [salt.utils.data.is_true(kwargs.get(x)) for x in ("removed", "purge_desired")] + ): + return {} + + if "pkg.list_pkgs" in __context__ and kwargs.get("use_context", True): + return _list_pkgs_from_context(versions_as_list) + + cmd = ["apk", "info", "-v"] + ret = {} + out = __salt__["cmd.run"](cmd, output_loglevel="trace", python_shell=False) + for line in salt.utils.itertools.split(out, "\n"): + pkg_version = "-".join(line.split("-")[-2:]) + pkg_name = "-".join(line.split("-")[:-2]) + __salt__["pkg_resource.add_pkg"](ret, pkg_name, pkg_version) + + __salt__["pkg_resource.sort_pkglist"](ret) + __context__["pkg.list_pkgs"] = copy.deepcopy(ret) + if not versions_as_list: + __salt__["pkg_resource.stringify"](ret) + return ret + + +def latest_version(*names, **kwargs): + """ + Return the latest version of the named package available for upgrade or + installation. If more than one package name is specified, a dict of + name/version pairs is returned. + + If the latest version of a given package is already installed, an empty + string will be returned for that package. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.latest_version + salt '*' pkg.latest_version + salt '*' pkg.latest_version ... + """ + refresh = salt.utils.data.is_true(kwargs.pop("refresh", True)) + + if not names: + return "" + + ret = {} + for name in names: + ret[name] = "" + pkgs = list_pkgs() + + # Refresh before looking for the latest version available + if refresh: + refresh_db() + + # Upgrade check + cmd = ["apk", "upgrade", "-s"] + out = __salt__["cmd.run_stdout"](cmd, output_loglevel="trace", python_shell=False) + for line in salt.utils.itertools.split(out, "\n"): + try: + name = line.split(" ")[2] + _oldversion = line.split(" ")[3].strip("(") + newversion = line.split(" ")[5].strip(")") + if name in names: + ret[name] = newversion + except (ValueError, IndexError): + pass + + # If version is empty, package may not be installed + for pkg in ret: + if not ret[pkg]: + installed = pkgs.get(pkg) + cmd = ["apk", "search", pkg] + out = __salt__["cmd.run_stdout"]( + cmd, output_loglevel="trace", python_shell=False + ) + for line in salt.utils.itertools.split(out, "\n"): + try: + pkg_version = "-".join(line.split("-")[-2:]) + pkg_name = "-".join(line.split("-")[:-2]) + if pkg == pkg_name: + if installed == pkg_version: + ret[pkg] = "" + else: + ret[pkg] = pkg_version + except ValueError: + pass + + # Return a string if only one package name passed + if len(names) == 1: + return ret[names[0]] + return ret + + +# TODO: Support specific version installation +def install(name=None, refresh=False, pkgs=None, sources=None, **kwargs): + """ + Install the passed package, add refresh=True to update the apk database. + + name + The name of the package to be installed. Note that this parameter is + ignored if either "pkgs" or "sources" is passed. Additionally, please + note that this option can only be used to install packages from a + software repository. To install a package file manually, use the + "sources" option. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.install + + refresh + Whether or not to refresh the package database before installing. + + + Multiple Package Installation Options: + + pkgs + A list of packages to install from a software repository. Must be + passed as a python list. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.install pkgs='["foo", "bar"]' + + sources + A list of IPK packages to install. Must be passed as a list of dicts, + with the keys being package names, and the values being the source URI + or local path to the package. Dependencies are automatically resolved + and marked as auto-installed. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.install sources='[{"foo": "salt://foo.deb"},{"bar": "salt://bar.deb"}]' + + install_recommends + Whether to install the packages marked as recommended. Default is True. + + Returns a dict containing the new package names and versions:: + + {'': {'old': '', + 'new': ''}} + """ + refreshdb = salt.utils.data.is_true(refresh) + pkg_to_install = [] + + old = list_pkgs() + + if name and not (pkgs or sources): + if "," in name: + pkg_to_install = name.split(",") + else: + pkg_to_install = [name] + + if pkgs: + # We don't support installing specific version for now + # so transform the dict in list ignoring version provided + pkgs = [next(iter(p)) for p in pkgs if isinstance(p, dict)] + pkg_to_install.extend(pkgs) + + if not pkg_to_install: + return {} + + if refreshdb: + refresh_db() + + cmd = ["apk", "add"] + + # Switch in update mode if a package is already installed + for _pkg in pkg_to_install: + if old.get(_pkg): + cmd.append("-u") + break + + cmd.extend(pkg_to_install) + + out = __salt__["cmd.run_all"](cmd, output_loglevel="trace", python_shell=False) + + if out["retcode"] != 0 and out["stderr"]: + errors = [out["stderr"]] + else: + errors = [] + + __context__.pop("pkg.list_pkgs", None) + new = list_pkgs() + ret = salt.utils.data.compare_dicts(old, new) + + if errors: + raise CommandExecutionError( + "Problem encountered installing package(s)", + info={"errors": errors, "changes": ret}, + ) + + return ret + + +def purge(name=None, pkgs=None, **kwargs): + """ + Alias to remove + """ + return remove(name=name, pkgs=pkgs, purge=True) + + +def remove( + name=None, pkgs=None, purge=False, **kwargs +): # pylint: disable=unused-argument + """ + Remove packages using ``apk del``. + + name + The name of the package to be deleted. + + + Multiple Package Options: + + pkgs + A list of packages to delete. Must be passed as a python list. The + ``name`` parameter will be ignored if this option is passed. + + Returns a dict containing the changes. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.remove + salt '*' pkg.remove ,, + salt '*' pkg.remove pkgs='["foo", "bar"]' + """ + old = list_pkgs() + pkg_to_remove = [] + + if name: + if "," in name: + pkg_to_remove = name.split(",") + else: + pkg_to_remove = [name] + + if pkgs: + pkg_to_remove.extend(pkgs) + + if not pkg_to_remove: + return {} + + if purge: + cmd = ["apk", "del", "--purge"] + else: + cmd = ["apk", "del"] + + cmd.extend(pkg_to_remove) + + out = __salt__["cmd.run_all"](cmd, output_loglevel="trace", python_shell=False) + if out["retcode"] != 0 and out["stderr"]: + errors = [out["stderr"]] + else: + errors = [] + + __context__.pop("pkg.list_pkgs", None) + new = list_pkgs() + ret = salt.utils.data.compare_dicts(old, new) + + if errors: + raise CommandExecutionError( + "Problem encountered removing package(s)", + info={"errors": errors, "changes": ret}, + ) + + return ret + + +def upgrade(name=None, pkgs=None, refresh=True, **kwargs): + """ + Upgrades all packages via ``apk upgrade`` or a specific package if name or + pkgs is specified. Name is ignored if pkgs is specified + + Returns a dict containing the changes. + + {'': {'old': '', + 'new': ''}} + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.upgrade + """ + ret = { + "changes": {}, + "result": True, + "comment": "", + } + + if salt.utils.data.is_true(refresh): + refresh_db() + + old = list_pkgs() + + pkg_to_upgrade = [] + + if name and not pkgs: + if "," in name: + pkg_to_upgrade = name.split(",") + else: + pkg_to_upgrade = [name] + + if pkgs: + pkg_to_upgrade.extend(pkgs) + + if pkg_to_upgrade: + cmd = ["apk", "add", "-u"] + cmd.extend(pkg_to_upgrade) + else: + cmd = ["apk", "upgrade"] + + call = __salt__["cmd.run_all"]( + cmd, output_loglevel="trace", python_shell=False, redirect_stderr=True + ) + + if call["retcode"] != 0: + ret["result"] = False + if call["stdout"]: + ret["comment"] = call["stdout"] + + __context__.pop("pkg.list_pkgs", None) + new = list_pkgs() + ret["changes"] = salt.utils.data.compare_dicts(old, new) + + return ret + + +def list_upgrades(refresh=True, **kwargs): + """ + List all available package upgrades. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.list_upgrades + """ + ret = {} + if salt.utils.data.is_true(refresh): + refresh_db() + + cmd = ["apk", "upgrade", "-s"] + call = __salt__["cmd.run_all"](cmd, output_loglevel="trace", python_shell=False) + + if call["retcode"] != 0: + comment = "" + if "stderr" in call: + comment += call["stderr"] + if "stdout" in call: + comment += call["stdout"] + raise CommandExecutionError(comment) + else: + out = call["stdout"] + + for line in out.splitlines(): + if "Upgrading" in line: + name = line.split(" ")[2] + _oldversion = line.split(" ")[3].strip("(") + newversion = line.split(" ")[5].strip(")") + ret[name] = newversion + + return ret + + +def file_list(*packages, **kwargs): + """ + List the files that belong to a package. Not specifying any packages will + return a list of _every_ file on the system's package database (not + generally recommended). + + CLI Examples: + + .. code-block:: bash + + salt '*' pkg.file_list httpd + salt '*' pkg.file_list httpd postfix + salt '*' pkg.file_list + """ + return file_dict(*packages) + + +def file_dict(*packages, **kwargs): + """ + List the files that belong to a package, grouped by package. Not + specifying any packages will return a list of _every_ file on the system's + package database (not generally recommended). + + CLI Examples: + + .. code-block:: bash + + salt '*' pkg.file_list httpd + salt '*' pkg.file_list httpd postfix + salt '*' pkg.file_list + """ + errors = [] + ret = {} + cmd_files = ["apk", "info", "-L"] + + if not packages: + return "Package name should be provided" + + for package in packages: + files = [] + cmd = cmd_files[:] + cmd.append(package) + out = __salt__["cmd.run_all"](cmd, output_loglevel="trace", python_shell=False) + for line in out["stdout"].splitlines(): + if line.endswith("contains:"): + continue + else: + files.append(line) + if files: + ret[package] = files + + return {"errors": errors, "packages": ret} + + +def owner(*paths, **kwargs): + """ + Return the name of the package that owns the file. Multiple file paths can + be passed. Like :mod:`pkg.version (file|key)s)[\w\s]+:$" + list_pattern = r"^\s+-\s+(?P.*)$" + current_block = None + + for line in cmd_ret.splitlines(): + if current_block: + match = re.search(list_pattern, line) + if match: + package_type = f"deleted_{current_block}" + ret[package_type].append(match.group("package")) + else: + current_block = None + # Intentionally not using an else here, in case of a situation where + # the next list header might be bordered by the previous list. + if not current_block: + match = re.search(type_pattern, line) + if match: + current_block = match.group("package_type") + + log.debug("Package keys identified for deletion: %s", len(ret["deleted_keys"])) + log.debug("Package files identified for deletion: %s", len(ret["deleted_files"])) + return ret diff --git a/salt/modules/aptpkg.py b/salt/modules/aptpkg.py index e67efb55b056..11a407d0d0cc 100644 --- a/salt/modules/aptpkg.py +++ b/salt/modules/aptpkg.py @@ -6,20 +6,27 @@ minion, and it is using a different module (or gives an error similar to *'pkg.install' is not available*), see :ref:`here `. + + For repository management, the ``python-apt`` package must be installed. """ import copy +import datetime import fnmatch import logging import os import pathlib import re import shutil +import tempfile import time +from collections import OrderedDict from urllib.error import HTTPError from urllib.request import Request as _Request from urllib.request import urlopen as _urlopen +import salt.config +import salt.syspaths import salt.utils.args import salt.utils.data import salt.utils.environment @@ -32,7 +39,6 @@ import salt.utils.pkg.deb import salt.utils.stringutils import salt.utils.systemd -import salt.utils.timeutil import salt.utils.versions import salt.utils.yaml from salt.exceptions import ( @@ -42,17 +48,32 @@ SaltInvocationError, ) from salt.modules.cmdmod import _parse_env -from salt.utils.pkg.deb import ( - Deb822Section, - Deb822SourceEntry, - SourceEntry, - SourcesList, - _invalid, - string_to_bool, -) log = logging.getLogger(__name__) +# pylint: disable=import-error +try: + from aptsources.sourceslist import SourceEntry, SourcesList + + HAS_APT = True +except ImportError: + HAS_APT = False + +try: + import apt_pkg + + HAS_APTPKG = True +except ImportError: + HAS_APTPKG = False + +try: + import softwareproperties.ppa + + HAS_SOFTWAREPROPERTIES = True +except ImportError: + HAS_SOFTWAREPROPERTIES = False +# pylint: enable=import-error + APT_LISTS_PATH = "/var/lib/apt/lists" PKG_ARCH_SEPARATOR = ":" @@ -61,22 +82,7 @@ LP_PVT_SRC_FORMAT = "deb https://{0}private-ppa.launchpad.net/{1}/{2}/ubuntu {3} main" _MODIFY_OK = frozenset( - [ - "uri", - "uris", - "comps", - "architectures", - "disabled", - "file", - "dist", - "suites", - "signedby", - "trusted", - "types", - ] -) -_MODIFY_OK_LEGACY = frozenset( - ["uri", "comps", "architectures", "disabled", "file", "dist", "signedby", "trusted"] + ["uri", "comps", "architectures", "disabled", "file", "dist", "signedby"] ) DPKG_ENV_VARS = { "APT_LISTBUGS_FRONTEND": "none", @@ -115,6 +121,215 @@ def __init__(opts): os.environ.update(DPKG_ENV_VARS) +def _invalid(line): + """ + This is a workaround since python3-apt does not support + the signed-by argument. This function was removed from + the class to ensure users using the python3-apt module or + not can use the signed-by option. + """ + disabled = False + invalid = False + comment = "" + line = line.strip() + if not line: + invalid = True + return disabled, invalid, comment, "" + + if line.startswith("#"): + disabled = True + line = line[1:] + + idx = line.find("#") + if idx > 0: + comment = line[idx + 1 :] + line = line[:idx] + + cdrom_match = re.match(r"(.*)(cdrom:.*/)(.*)", line.strip()) + if cdrom_match: + repo_line = ( + [p.strip() for p in cdrom_match.group(1).split()] + + [cdrom_match.group(2).strip()] + + [p.strip() for p in cdrom_match.group(3).split()] + ) + else: + repo_line = line.strip().split() + if ( + not repo_line + or repo_line[0] not in ["deb", "deb-src", "rpm", "rpm-src"] + or len(repo_line) < 3 + ): + invalid = True + return disabled, invalid, comment, repo_line + + if repo_line[1].startswith("["): + if not any(x.endswith("]") for x in repo_line[1:]): + invalid = True + return disabled, invalid, comment, repo_line + + return disabled, invalid, comment, repo_line + + +if not HAS_APT: + + class SourceEntry: # pylint: disable=function-redefined + def __init__(self, line, file=None): + self.invalid = False + self.comps = [] + self.disabled = False + self.comment = "" + self.dist = "" + self.type = "" + self.uri = "" + self.line = line + self.architectures = [] + self.signedby = "" + self.file = file + if not self.file: + self.file = str(pathlib.Path(os.sep, "etc", "apt", "sources.list")) + self._parse_sources(line) + + def str(self): + return self.repo_line() + + def repo_line(self): + """ + Return the repo line for the sources file + """ + repo_line = [] + if self.invalid: + return self.line + if self.disabled: + repo_line.append("#") + + repo_line.append(self.type) + opts = _get_opts(self.line) + if self.architectures: + if "arch" not in opts: + opts["arch"] = {} + opts["arch"]["full"] = f"arch={','.join(self.architectures)}" + opts["arch"]["value"] = self.architectures + if self.signedby: + if "signedby" not in opts: + opts["signedby"] = {} + opts["signedby"]["full"] = f"signed-by={self.signedby}" + opts["signedby"]["value"] = self.signedby + + ordered_opts = [] + + for opt in opts.values(): + if opt["full"] != "": + ordered_opts.append(opt["full"]) + + if ordered_opts: + repo_line.append(f"[{' '.join(ordered_opts)}]") + + repo_line += [self.uri, self.dist, " ".join(self.comps)] + if self.comment: + repo_line.append(f"#{self.comment}") + return " ".join(repo_line) + "\n" + + def _parse_sources(self, line): + """ + Parse lines from sources files + """ + self.disabled, self.invalid, self.comment, repo_line = _invalid(line) + if self.invalid: + return False + if repo_line[1].startswith("["): + repo_line = [x for x in (line.strip("[]") for line in repo_line) if x] + opts = _get_opts(self.line) + if "arch" in opts: + self.architectures.extend(opts["arch"]["value"]) + if "signedby" in opts: + self.signedby = opts["signedby"]["value"] + for opt in opts.values(): + opt = opt["full"] + if opt: + try: + repo_line.pop(repo_line.index(opt)) + except ValueError: + repo_line.pop(repo_line.index("[" + opt + "]")) + self.type = repo_line[0] + self.uri = repo_line[1] + self.dist = repo_line[2] + self.comps = repo_line[3:] + return True + + class SourcesList: # pylint: disable=function-redefined + def __init__(self): + self.list = [] + self.files = [ + pathlib.Path(os.sep, "etc", "apt", "sources.list"), + pathlib.Path(os.sep, "etc", "apt", "sources.list.d"), + ] + for file in self.files: + if file.is_dir(): + for fp in file.glob("*.list"): + self.add_file(file=fp) + else: + self.add_file(file) + + def __iter__(self): + yield from self.list + + def add_file(self, file): + """ + Add the lines of a file to self.list + """ + if file.is_file(): + with salt.utils.files.fopen(str(file)) as source: + for line in source: + self.list.append(SourceEntry(line, file=str(file))) + else: + log.debug("The apt sources file %s does not exist", file) + + def add(self, type, uri, dist, orig_comps, architectures, signedby): + opts_count = [] + opts_line = "" + if architectures: + architectures = "arch={}".format(",".join(architectures)) + opts_count.append(architectures) + if signedby: + signedby = f"signed-by={signedby}" + opts_count.append(signedby) + if len(opts_count) > 1: + opts_line = "[" + " ".join(opts_count) + "]" + elif len(opts_count) == 1: + opts_line = "[" + "".join(opts_count) + "]" + repo_line = [ + type, + opts_line, + uri, + dist, + " ".join(orig_comps), + ] + return SourceEntry(" ".join([line for line in repo_line if line.strip()])) + + def remove(self, source): + """ + remove a source from the list of sources + """ + self.list.remove(source) + + def save(self): + """ + write all of the sources from the list of sources + to the file. + """ + filemap = {} + with tempfile.TemporaryDirectory() as tmpdir: + for source in self.list: + fname = pathlib.Path(tmpdir, pathlib.Path(source.file).name) + with salt.utils.files.fopen(str(fname), "a") as fp: + fp.write(source.repo_line()) + if source.file not in filemap: + filemap[source.file] = {"tmp": fname} + + for fp in filemap: + shutil.move(str(filemap[fp]["tmp"]), fp) + + def _get_ppa_info_from_launchpad(owner_name, ppa_name): """ Idea from softwareproperties.ppa. @@ -127,12 +342,21 @@ def _get_ppa_info_from_launchpad(owner_name, ppa_name): :return: """ - lp_url = f"https://launchpad.net/api/1.0/~{owner_name}/+archive/{ppa_name}" + lp_url = "https://launchpad.net/api/1.0/~{}/+archive/{}".format( + owner_name, ppa_name + ) request = _Request(lp_url, headers={"Accept": "application/json"}) lp_page = _urlopen(request) return salt.utils.json.load(lp_page) +def _reconstruct_ppa_name(owner_name, ppa_name): + """ + Stringify PPA name from args. + """ + return f"ppa:{owner_name}/{ppa_name}" + + def _call_apt(args, scope=True, **kwargs): """ Call apt* utilities. @@ -163,6 +387,18 @@ def _call_apt(args, scope=True, **kwargs): return cmd_ret +def _warn_software_properties(repo): + """ + Warn of missing python-software-properties package. + """ + log.warning( + "The 'python-software-properties' package is not installed. " + "For more accurate support of PPA repositories, you should " + "install this package." + ) + log.warning("Best guess at ppa format: %s", repo) + + def normalize_name(name): """ Strips the architecture from the specified package name, if necessary. @@ -403,18 +639,7 @@ def refresh_db(cache_valid_time=0, failhard=False, **kwargs): except OSError as exp: log.warning("could not stat cache directory due to: %s", exp) - call = _call_apt( - ["apt-get", "-q", "update"], - scope=False, - timeout=kwargs.get("timeout", __opts__.get("aptpkg_refresh_db_timeout", 30)), - ) - if "Timed out" in call["stdout"]: - # In some cases with inconsistent configuration of sources apt-get could - # got stuck on calling apt-get update for a long time. - # In most cases cleaning up the cache could help, - # but update should be triggered again. - _call_apt(["apt-get", "-q", "clean"], scope=False) - call = _call_apt(["apt-get", "-q", "update"], scope=False) + call = _call_apt(["apt-get", "-q", "update"], scope=False) if call["retcode"] != 0: comment = "" if "stderr" in call: @@ -442,7 +667,9 @@ def refresh_db(cache_valid_time=0, failhard=False, **kwargs): error_repos.append(ident) if failhard and error_repos: - raise CommandExecutionError(f"Error getting repos: {', '.join(error_repos)}") + raise CommandExecutionError( + "Error getting repos: {}".format(", ".join(error_repos)) + ) return ret @@ -688,23 +915,22 @@ def install( ) else: pkg_params_items = [] - # we don't need to do the test below for every package in the list. - # it either exists or doesn't. test once then loop. - if "lowpkg.bin_pkg_info" in __salt__: - for pkg_source in pkg_params: + for pkg_source in pkg_params: + if "lowpkg.bin_pkg_info" in __salt__: deb_info = __salt__["lowpkg.bin_pkg_info"](pkg_source) - pkg_params_items.append( - [deb_info["name"], pkg_source, deb_info["version"]] - ) - else: - for pkg_source in pkg_params: + else: + deb_info = None + if deb_info is None: log.error( "pkg.install: Unable to get deb information for %s. " "Version comparisons will be unavailable.", pkg_source, ) pkg_params_items.append([pkg_source]) - + else: + pkg_params_items.append( + [deb_info["name"], pkg_source, deb_info["version"]] + ) # Build command prefix cmd_prefix.extend(["apt-get", "-q", "-y"]) if kwargs.get("force_yes", False): @@ -712,14 +938,8 @@ def install( if "force_conf_new" in kwargs and kwargs["force_conf_new"]: cmd_prefix.extend(["-o", "DPkg::Options::=--force-confnew"]) else: - cmd_prefix.extend( - [ - "-o", - "DPkg::Options::=--force-confold", - "-o", - "DPkg::Options::=--force-confdef", - ] - ) + cmd_prefix.extend(["-o", "DPkg::Options::=--force-confold"]) + cmd_prefix += ["-o", "DPkg::Options::=--force-confdef"] if "install_recommends" in kwargs: if not kwargs["install_recommends"]: cmd_prefix.append("--no-install-recommends") @@ -774,8 +994,12 @@ def install( ) if target is None: errors.append( - f"No version matching '{pkgname}{version_num}' could be found " - f"(available: {', '.join(candidates) if candidates else None})" + "No version matching '{}{}' could be found " + "(available: {})".format( + pkgname, + version_num, + ", ".join(candidates) if candidates else None, + ) ) continue else: @@ -1256,7 +1480,9 @@ def hold(name=None, pkgs=None, sources=None, **kwargs): # pylint: disable=W0613 ret[target]["comment"] = f"Package {target} is now being held." else: ret[target].update(result=True) - ret[target]["comment"] = f"Package {target} is already set to be held." + ret[target]["comment"] = "Package {} is already set to be held.".format( + target + ) return ret @@ -1320,14 +1546,20 @@ def unhold(name=None, pkgs=None, sources=None, **kwargs): # pylint: disable=W06 elif salt.utils.data.is_true(state.get("hold", False)): if "test" in __opts__ and __opts__["test"]: ret[target].update(result=None) - ret[target]["comment"] = f"Package {target} is set not to be held." + ret[target]["comment"] = "Package {} is set not to be held.".format( + target + ) else: result = set_selections(selection={"install": [target]}) ret[target].update(changes=result[target], result=True) - ret[target]["comment"] = f"Package {target} is no longer being held." + ret[target]["comment"] = "Package {} is no longer being held.".format( + target + ) else: ret[target].update(result=True) - ret[target]["comment"] = f"Package {target} is already set not to be held." + ret[target]["comment"] = "Package {} is already set not to be held.".format( + target + ) return ret @@ -1447,7 +1679,7 @@ def _get_upgradable(dist_upgrade=True, **kwargs): else: cmd.append("upgrade") try: - cmd.extend(["-o", f"APT::Default-Release={kwargs['fromrepo']}"]) + cmd.extend(["-o", "APT::Default-Release={}".format(kwargs["fromrepo"])]) except KeyError: pass @@ -1552,6 +1784,23 @@ def normalize(x): # if we have apt_pkg, this will be quickier this way # and also do not rely on shell. + if HAS_APTPKG: + try: + # the apt_pkg module needs to be manually initialized + apt_pkg.init_system() + + # if there is a difference in versions, apt_pkg.version_compare will + # return an int representing the difference in minor versions, or + # 1/-1 if the difference is smaller than minor versions. normalize + # to -1, 0 or 1. + try: + ret = apt_pkg.version_compare(pkg1, pkg2) + except TypeError: + ret = apt_pkg.version_compare(str(pkg1), str(pkg2)) + return 1 if ret > 0 else -1 if ret < 0 else 0 + except Exception: # pylint: disable=broad-except + # Try to use shell version in case of errors w/python bindings + pass try: for oper, ret in (("lt", -1), ("eq", 0), ("gt", 1)): cmd = ["dpkg", "--compare-versions", pkg1, oper, pkg2] @@ -1565,13 +1814,54 @@ def normalize(x): return None +def _get_opts(line): + """ + Return all opts in [] for a repo line + """ + get_opts = re.search(r"\[(.*=.*?)\]", line) + + ret = OrderedDict() + if not get_opts: + return ret + opts = get_opts.group(0).strip("[]") + architectures = [] + for opt in opts.split(): + if opt.startswith("arch"): + architectures.extend(opt.split("=", 1)[1].split(",")) + ret["arch"] = {} + ret["arch"]["full"] = opt + ret["arch"]["value"] = architectures + elif opt.startswith("signed-by"): + ret["signedby"] = {} + ret["signedby"]["full"] = opt + ret["signedby"]["value"] = opt.split("=", 1)[1] + else: + other_opt = opt.split("=", 1)[0] + ret[other_opt] = {} + ret[other_opt]["full"] = opt + ret[other_opt]["value"] = opt.split("=", 1)[1] + return ret + + def _split_repo_str(repo): """ Return APT source entry as a dictionary """ entry = SourceEntry(repo) invalid = entry.invalid - signedby = entry.signedby + if not HAS_APT: + signedby = entry.signedby + else: + opts = _get_opts(line=repo) + if "signedby" in opts: + signedby = opts["signedby"].get("value", "") + else: + signedby = "" + if signedby: + # python3-apt does not support signedby. So if signedby + # is in the repo we have to check our code to see if the + # repo is invalid ourselves. + _, invalid, _, _ = _invalid(repo) return { "invalid": invalid, @@ -1581,7 +1871,6 @@ def _split_repo_str(repo): "dist": entry.dist, "comps": entry.comps, "signedby": signedby, - "trusted": entry.trusted, } @@ -1729,45 +2018,29 @@ def list_repos(**kwargs): """ repos = {} sources = SourcesList() - for source in sources: + for source in sources.list: if _skip_source(source): continue - # deb822 could contain multiple URIs, types and suites - # for backward compatibility we need to expand it - # to get separate entries for each URI, type and suite - for uri in source.uris: - for suite in source.suites: - for source_type in source.types: - if isinstance(source, Deb822SourceEntry): - compat_source = SourceEntry( - f"{source_type} {uri} {suite} {' '.join(getattr(source, 'comps', []))}" - ) - for attr in ( - "disabled", - "architectures", - "signedby", - "trusted", - ): - setattr(compat_source, attr, getattr(source, attr)) - compat_source_line = str(compat_source) - else: - compat_source_line = source.line.strip() - repo = { - "file": source.file, - "comps": getattr(source, "comps", []), - "disabled": source.disabled, - "enabled": not source.disabled, # This is for compatibility with the other modules - "dist": suite, - "suites": source.suites, - "type": source_type, - "types": source.types, - "uri": uri, - "uris": source.uris, - "line": compat_source_line, - "architectures": getattr(source, "architectures", []), - "signedby": source.signedby, - } - repos.setdefault(uri, []).append(repo) + if not HAS_APT: + signedby = source.signedby + else: + opts = _get_opts(line=source.line) + if "signedby" in opts: + signedby = opts["signedby"].get("value", "") + else: + signedby = "" + + repo = {} + repo["file"] = source.file + repo["comps"] = getattr(source, "comps", []) + repo["disabled"] = source.disabled + repo["dist"] = source.dist + repo["type"] = source.type + repo["uri"] = source.uri + repo["line"] = source.line.strip() + repo["architectures"] = getattr(source, "architectures", []) + repo["signedby"] = signedby + repos.setdefault(source.uri, []).append(repo) return repos @@ -1776,17 +2049,12 @@ def get_repo(repo, **kwargs): Display a repo from the sources.list / sources.list.d The repo passed in needs to be a complete repo entry. - When system uses repository in the deb822 format, - get_repo uses a partial match of distributions. - - In that case, include any distribution of the deb822 - repository in the repo name to match that repo. CLI Examples: .. code-block:: bash - salt '*' pkg.get_repo "deb URL noble main" + salt '*' pkg.get_repo "myrepo definition" """ ppa_auth = kwargs.get("ppa_auth", None) # we have to be clever about this since the repo definition formats @@ -1800,7 +2068,20 @@ def get_repo(repo, **kwargs): auth_info = f"{ppa_auth}@" repo = LP_PVT_SRC_FORMAT.format(auth_info, owner_name, ppa_name, dist) else: - repo = LP_SRC_FORMAT.format(owner_name, ppa_name, dist) + if HAS_SOFTWAREPROPERTIES: + try: + if hasattr(softwareproperties.ppa, "PPAShortcutHandler"): + repo = softwareproperties.ppa.PPAShortcutHandler(repo).expand( + dist + )[0] + else: + repo = softwareproperties.ppa.expand_ppa_line(repo, dist)[0] + except NameError as name_error: + raise CommandExecutionError( + f"Could not find ppa {repo}: {name_error}" + ) + else: + repo = LP_SRC_FORMAT.format(owner_name, ppa_name, dist) repos = list_repos() @@ -1811,8 +2092,8 @@ def get_repo(repo, **kwargs): uri_match = re.search("(http[s]?://)(.+)", repo_entry["uri"]) if uri_match: if not uri_match.group(2).startswith(ppa_auth): - repo_entry["uri"] = ( - f"{uri_match.group(1)}{ppa_auth}@{uri_match.group(2)}" + repo_entry["uri"] = "{}{}@{}".format( + uri_match.group(1), ppa_auth, uri_match.group(2) ) except SyntaxError: raise CommandExecutionError( @@ -1845,17 +2126,11 @@ def del_repo(repo, **kwargs): The repo passed in must be a fully formed repository definition string. - When system uses repository in the deb822 format, - del_repo uses a partial match of distributions. - - In that case, include any distribution of the deb822 - repository in the repo name to match that repo. - CLI Examples: .. code-block:: bash - salt '*' pkg.del_repo "deb URL noble main" + salt '*' pkg.del_repo "myrepo definition" """ is_ppa = False if repo.startswith("ppa:") and __grains__["os"] in ("Ubuntu", "Mint", "neon"): @@ -1863,12 +2138,19 @@ def del_repo(repo, **kwargs): # to derive the name. is_ppa = True dist = __grains__["oscodename"] - owner_name, ppa_name = repo[4:].split("/") - if "ppa_auth" in kwargs: - auth_info = f"{kwargs['ppa_auth']}@" - repo = LP_PVT_SRC_FORMAT.format(auth_info, dist, owner_name, ppa_name) + if not HAS_SOFTWAREPROPERTIES: + _warn_software_properties(repo) + owner_name, ppa_name = repo[4:].split("/") + if "ppa_auth" in kwargs: + auth_info = "{}@".format(kwargs["ppa_auth"]) + repo = LP_PVT_SRC_FORMAT.format(auth_info, dist, owner_name, ppa_name) + else: + repo = LP_SRC_FORMAT.format(owner_name, ppa_name, dist) else: - repo = LP_SRC_FORMAT.format(owner_name, ppa_name, dist) + if hasattr(softwareproperties.ppa, "PPAShortcutHandler"): + repo = softwareproperties.ppa.PPAShortcutHandler(repo).expand(dist)[0] + else: + repo = softwareproperties.ppa.expand_ppa_line(repo, dist)[0] sources = SourcesList() repos = [s for s in sources.list if not s.invalid] @@ -1883,26 +2165,14 @@ def del_repo(repo, **kwargs): for source in repos: if ( - repo_entry["type"] in source.type.split() + source.type == repo_entry["type"] and source.architectures == repo_entry["architectures"] - and repo_entry["uri"].rstrip("/") - in [uri.rstrip("/") for uri in source.uris] - and repo_entry["dist"] in source.suites + and source.uri.rstrip("/") == repo_entry["uri"].rstrip("/") + and source.dist == repo_entry["dist"] ): + s_comps = set(source.comps) r_comps = set(repo_entry["comps"]) - if s_comps == r_comps: - r_suites = list(source.suites) - r_suites.remove(repo_entry["dist"]) - source.suites = r_suites - deleted_from[source.file] = 0 - if not source.suites: - try: - sources.remove(source) - except ValueError: - pass - sources.save() - continue if s_comps.intersection(r_comps) or (not s_comps and not r_comps): deleted_from[source.file] = 0 source.comps = list(s_comps.difference(r_comps)) @@ -1919,23 +2189,11 @@ def del_repo(repo, **kwargs): and repo_entry["type"] == "deb" and source.type == "deb-src" and source.uri == repo_entry["uri"] - and repo_entry["dist"] in source.suites + and source.dist == repo_entry["dist"] ): s_comps = set(source.comps) r_comps = set(repo_entry["comps"]) - if s_comps == r_comps: - r_suites = list(source.suites) - r_suites.remove(repo_entry["dist"]) - source.suites = r_suites - deleted_from[source.file] = 0 - if not source.suites: - try: - sources.remove(source) - except ValueError: - pass - sources.save() - continue if s_comps.intersection(r_comps) or (not s_comps and not r_comps): deleted_from[source.file] = 0 source.comps = list(s_comps.difference(r_comps)) @@ -1948,8 +2206,6 @@ def del_repo(repo, **kwargs): if deleted_from: ret = "" for source in sources: - if source.invalid: - continue if source.file in deleted_from: deleted_from[source.file] += 1 for repo_file, count in deleted_from.items(): @@ -1995,8 +2251,7 @@ def _parse_repo_keys_output(cmd_ret): lines = [line for line in cmd_ret.splitlines() if line.strip()] # Reference for the meaning of each item in the colon-separated - # record can be found here: - # https://github.com/CSNW/gnupg/blob/master/doc/DETAILS + # record can be found here: https://goo.gl/KIZbvp for line in lines: items = [ _convert_if_int(item.strip()) if item.strip() else None @@ -2267,7 +2522,9 @@ def add_repo_key( kwargs.update({"stdin": text}) elif keyserver: if not keyid: - error_msg = f"No keyid or keyid too short for keyserver: {keyserver}" + error_msg = "No keyid or keyid too short for keyserver: {}".format( + keyserver + ) raise SaltInvocationError(error_msg) if not aptkey: @@ -2428,12 +2685,6 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): ``ppa:/repo`` format is acceptable. ``ppa:`` format can only be used to create a new repository. - When system uses repository in the deb822 format, mod_repo uses a partial - match of distributions. - - In that case, include any distribution of the deb822 repository in the - repo definition to match that repo. - The following options are available to modify a repo definition: architectures @@ -2488,8 +2739,8 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): .. code-block:: bash - salt '*' pkg.mod_repo 'deb URL noble main' uri=http://new/uri - salt '*' pkg.mod_repo 'deb URL noble main' comps=main,universe + salt '*' pkg.mod_repo 'myrepo definition' uri=http://new/uri + salt '*' pkg.mod_repo 'myrepo definition' comps=main,universe """ if "refresh_db" in kwargs: refresh = kwargs["refresh_db"] @@ -2525,20 +2776,30 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): out = _call_apt(cmd, env=env, scope=False, **kwargs) if out["retcode"]: raise CommandExecutionError( - f"Unable to add PPA '{repo[4:]}'. '{cmd}' exited with status {out['retcode']!s}: '{out['stderr']}'" + "Unable to add PPA '{}'. '{}' exited with " + "status {!s}: '{}' ".format( + repo[4:], cmd, out["retcode"], out["stderr"] + ) ) # explicit refresh when a repo is modified. if refresh: refresh_db() return {repo: out} else: + if not HAS_SOFTWAREPROPERTIES: + _warn_software_properties(repo) + else: + log.info("Falling back to urllib method for private PPA") # fall back to urllib style try: owner_name, ppa_name = repo[4:].split("/", 1) except ValueError: raise CommandExecutionError( - f"Unable to get PPA info from argument. Expected format \"/\" (e.g. saltstack/salt) not found. Received '{repo[4:]}' instead." + "Unable to get PPA info from argument. " + 'Expected format "/" ' + "(e.g. saltstack/salt) not found. Received " + "'{}' instead.".format(repo[4:]) ) dist = __grains__["oscodename"] # ppa has a lot of implicit arguments. Make them explicit. @@ -2546,10 +2807,8 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): kwargs["dist"] = dist ppa_auth = "" if "file" not in kwargs: - filename = ( - f"/etc/apt/sources.list.d/{owner_name}-{ppa_name}-{dist}.list" - ) - kwargs["file"] = filename + filename = "/etc/apt/sources.list.d/{0}-{1}-{2}.list" + kwargs["file"] = filename.format(owner_name, ppa_name, dist) try: launchpad_ppa_info = _get_ppa_info_from_launchpad( owner_name, ppa_name @@ -2558,16 +2817,23 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): kwargs["keyid"] = launchpad_ppa_info["signing_key_fingerprint"] else: if "keyid" not in kwargs: + error_str = ( + "Private PPAs require a keyid to be specified: {0}/{1}" + ) raise CommandExecutionError( - f"Private PPAs require a keyid to be specified: {owner_name}/{ppa_name}" + error_str.format(owner_name, ppa_name) ) except HTTPError as exc: raise CommandExecutionError( - f"Launchpad does not know about {owner_name}/{ppa_name}: {exc}" + "Launchpad does not know about {}/{}: {}".format( + owner_name, ppa_name, exc + ) ) except IndexError as exc: raise CommandExecutionError( - f"Launchpad knows about {owner_name}/{ppa_name} but did not return a fingerprint. Please set keyid manually: {exc}" + "Launchpad knows about {}/{} but did not " + "return a fingerprint. Please set keyid " + "manually: {}".format(owner_name, ppa_name, exc) ) if "keyserver" not in kwargs: @@ -2575,13 +2841,15 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): if "ppa_auth" in kwargs: if not launchpad_ppa_info["private"]: raise CommandExecutionError( - f"PPA is not private but auth credentials passed: {repo}" + "PPA is not private but auth credentials passed: {}".format( + repo + ) ) # assign the new repo format to the "repo" variable # so we can fall through to the "normal" mechanism # here. if "ppa_auth" in kwargs: - ppa_auth = f"{kwargs['ppa_auth']}@" + ppa_auth = "{}@".format(kwargs["ppa_auth"]) repo = LP_PVT_SRC_FORMAT.format( ppa_auth, owner_name, ppa_name, dist ) @@ -2609,14 +2877,12 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): repos = [] for source in sources: - if isinstance(source, Deb822SourceEntry): - if source.types == [""] or not bool(source.types) or not source.type: - continue - else: + if HAS_APT: _, invalid, _, _ = _invalid(source.line) - if invalid: - continue - repos.append(source) + if not invalid: + repos.append(source) + else: + repos.append(source) mod_source = None try: @@ -2628,13 +2894,16 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): except SyntaxError: raise SyntaxError(f"Error: repo '{repo}' not a well formatted definition") - full_comp_list = [comp.strip() for comp in repo_entry["comps"]] + full_comp_list = {comp.strip() for comp in repo_entry["comps"]} no_proxy = __salt__["config.option"]("no_proxy") kwargs["signedby"] = ( pathlib.Path(repo_entry["signedby"]) if repo_entry["signedby"] else "" ) + if not aptkey and not kwargs["signedby"]: + raise SaltInvocationError("missing 'signedby' option when apt-key is missing") + if "keyid" in kwargs: keyid = kwargs.pop("keyid", None) keyserver = kwargs.pop("keyserver", None) @@ -2704,7 +2973,9 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): ret = _call_apt(cmd, scope=False, **kwargs) if ret["retcode"] != 0: raise CommandExecutionError( - f"Error: key retrieval failed: {ret['stdout']}" + "Error: key retrieval failed: {}".format( + ret["stdout"] + ) ) elif "key_url" in kwargs: @@ -2744,9 +3015,7 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): if "comps" in kwargs: kwargs["comps"] = [comp.strip() for comp in kwargs["comps"].split(",")] - for comp in kwargs["comps"]: - if comp not in full_comp_list: - full_comp_list.append(comp) + full_comp_list |= set(kwargs["comps"]) else: kwargs["comps"] = list(full_comp_list) @@ -2769,12 +3038,11 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): # we are not returning bogus data because the source line # has already been modified on a previous run. repo_matches = ( - repo_entry["type"] in apt_source.type.split() - and repo_entry["uri"].rstrip("/") - in [uri.rstrip("/") for uri in apt_source.uris] - and repo_entry["dist"] in apt_source.suites + apt_source.type == repo_entry["type"] + and apt_source.uri.rstrip("/") == repo_entry["uri"].rstrip("/") + and apt_source.dist == repo_entry["dist"] ) - kw_matches = kw_dist in apt_source.suites and kw_type in apt_source.type.split() + kw_matches = apt_source.dist == kw_dist and apt_source.type == kw_type if repo_matches or kw_matches: for comp in full_comp_list: @@ -2792,65 +3060,51 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs): repo_source_entry = SourceEntry(repo) if not mod_source: - if not aptkey and not ( - kwargs["signedby"] or string_to_bool(kwargs.get("trusted", "no")) - ): - raise SaltInvocationError( - "missing 'signedby' or 'trusted' option when apt-key is missing" - ) - - apt_source_file = kwargs.get("file") - - if apt_source_file and apt_source_file.endswith(".sources"): - section = Deb822Section("") - section["Types"] = repo_entry["type"] - section["URIs"] = repo_entry["uri"] - section["Suites"] = repo_entry["dist"] - section["Components"] = " ".join(repo_entry["comps"]) - trusted_kwargs = ( - kwargs.get("trusted") is True or kwargs.get("Trusted") is True - ) - if trusted_kwargs or ( - "trusted" not in kwargs and repo_entry["trusted"] is True - ): - section["Trusted"] = "yes" - mod_source = Deb822SourceEntry(section, apt_source_file) - else: - mod_source = SourceEntry(repo, apt_source_file) + mod_source = SourceEntry(repo) if "comments" in kwargs: mod_source.comment = kwargs["comments"] sources.list.append(mod_source) elif "comments" in kwargs: mod_source.comment = kwargs["comments"] - if not isinstance(mod_source, Deb822SourceEntry): - mod_source.line = repo_source_entry.line - if not mod_source.line.endswith("\n"): - mod_source.line = mod_source.line + "\n" - - if not kwargs["architectures"] and not mod_source.architectures: - kwargs.pop("architectures") + mod_source.line = repo_source_entry.line + if not mod_source.line.endswith("\n"): + mod_source.line = mod_source.line + "\n" for key in kwargs: - if ( - (isinstance(mod_source, Deb822SourceEntry) and key in _MODIFY_OK) - or key in _MODIFY_OK_LEGACY - ) and hasattr(mod_source, key): + if key in _MODIFY_OK and hasattr(mod_source, key): setattr(mod_source, key, kwargs[key]) - if ( - not isinstance(mod_source, Deb822SourceEntry) - and mod_source.uri != repo_entry["uri"] - ): + if mod_source.uri != repo_entry["uri"]: mod_source.uri = repo_entry["uri"] - mod_source.line = str(mod_source) + mod_source.line = mod_source.str() sources.save() # on changes, explicitly refresh if refresh: refresh_db() - return {repo: get_repo(repo)} + if not HAS_APT: + signedby = mod_source.signedby + else: + opts = _get_opts(repo) + if "signedby" in opts: + signedby = opts["signedby"].get("value", "") + else: + signedby = "" + + return { + repo: { + "architectures": getattr(mod_source, "architectures", []), + "comps": mod_source.comps, + "disabled": mod_source.disabled, + "file": mod_source.file, + "type": mod_source.type, + "uri": mod_source.uri, + "line": mod_source.line, + "signedby": signedby, + } + } def file_list(*packages, **kwargs): @@ -2908,12 +3162,19 @@ def _expand_repo_def(os_name, os_codename=None, **kwargs): auth_info = "{}@".format(kwargs["ppa_auth"]) repo = LP_PVT_SRC_FORMAT.format(auth_info, owner_name, ppa_name, dist) else: - repo = LP_SRC_FORMAT.format(owner_name, ppa_name, dist) + if HAS_SOFTWAREPROPERTIES: + if hasattr(softwareproperties.ppa, "PPAShortcutHandler"): + repo = softwareproperties.ppa.PPAShortcutHandler(repo).expand(dist)[ + 0 + ] + else: + repo = softwareproperties.ppa.expand_ppa_line(repo, dist)[0] + else: + repo = LP_SRC_FORMAT.format(owner_name, ppa_name, dist) if "file" not in kwargs: - kwargs["file"] = ( - f"/etc/apt/sources.list.d/{owner_name}-{ppa_name}-{dist}.list" - ) + filename = "/etc/apt/sources.list.d/{0}-{1}-{2}.list" + kwargs["file"] = filename.format(owner_name, ppa_name, dist) source_entry = SourceEntry(repo) for list_args in ("architectures", "comps"): @@ -2921,44 +3182,67 @@ def _expand_repo_def(os_name, os_codename=None, **kwargs): kwargs[list_args] = [ kwarg.strip() for kwarg in kwargs[list_args].split(",") ] - for kwarg in _MODIFY_OK_LEGACY: + for kwarg in _MODIFY_OK: if kwarg in kwargs: setattr(source_entry, kwarg, kwargs[kwarg]) source_list = SourcesList() - - new_kwargs = {} - for arg in ("file", "suites", "trusted", "types", "uris"): - if arg in kwargs: - new_kwargs[arg] = kwargs[arg] - - signedby = source_entry.signedby - new_kwargs["signedby"] = signedby + kwargs = {} + if not HAS_APT: + signedby = source_entry.signedby + kwargs["signedby"] = signedby + else: + opts = _get_opts(repo) + if "signedby" in opts: + signedby = opts["signedby"].get("value", "") + else: + signedby = "" _source_entry = source_list.add( type=source_entry.type, uri=source_entry.uri, dist=source_entry.dist, - orig_comps=source_entry.comps, - architectures=source_entry.architectures, - **new_kwargs, + orig_comps=getattr(source_entry, "comps", []), + architectures=getattr(source_entry, "architectures", []), + **kwargs, ) - _source_entry.disabled = source_entry.disabled - if not isinstance(_source_entry, Deb822SourceEntry): + if hasattr(_source_entry, "set_enabled"): + _source_entry.set_enabled(not source_entry.disabled) + else: + _source_entry.disabled = source_entry.disabled _source_entry.line = _source_entry.repo_line() sanitized["file"] = _source_entry.file - sanitized["comps"] = _source_entry.comps + sanitized["comps"] = getattr(_source_entry, "comps", []) sanitized["disabled"] = _source_entry.disabled sanitized["dist"] = _source_entry.dist - sanitized["suites"] = _source_entry.suites sanitized["type"] = _source_entry.type - sanitized["types"] = _source_entry.types sanitized["uri"] = _source_entry.uri - sanitized["uris"] = _source_entry.uris - sanitized["line"] = str(_source_entry) - sanitized["architectures"] = _source_entry.architectures + sanitized["line"] = _source_entry.line.strip() + sanitized["architectures"] = getattr(_source_entry, "architectures", []) sanitized["signedby"] = signedby + if HAS_APT and signedby: + # python3-apt does not supported the signed-by opt currently. + # creating the line with all opts including signed-by + if signedby not in sanitized["line"]: + line = sanitized["line"].split() + repo_opts = _get_opts(repo) + opts_order = [ + opt_type + for opt_type, opt_def in repo_opts.items() + if opt_def["full"] != "" + ] + for opt in repo_opts: + if "index" in repo_opts[opt]: + idx = repo_opts[opt]["index"] + opts_order[idx] = repo_opts[opt]["full"] + + opts = "[" + " ".join(opts_order) + "]" + if line[1].startswith("["): + line[1] = opts + else: + line.insert(1, opts) + sanitized["line"] = " ".join(line) return sanitized @@ -3094,7 +3378,9 @@ def set_selections(path=None, selection=None, clear=False, saltenv="base"): valid_states = ("install", "hold", "deinstall", "purge") bad_states = [x for x in selection if x not in valid_states] if bad_states: - raise SaltInvocationError(f"Invalid state(s): {', '.join(bad_states)}") + raise SaltInvocationError( + "Invalid state(s): {}".format(", ".join(bad_states)) + ) if clear: cmd = ["dpkg", "--clear-selections"] @@ -3348,7 +3634,7 @@ def list_downloaded(root=None, **kwargs): "path": package_path, "size": os.path.getsize(package_path), "creation_date_time_t": pkg_timestamp, - "creation_date_time": salt.utils.timeutil.utcfromtimestamp( + "creation_date_time": datetime.datetime.utcfromtimestamp( pkg_timestamp ).isoformat(), } @@ -3388,31 +3674,3 @@ def services_need_restart(**kwargs): services.add(service) return list(services) - - -def which(path): - """ - Displays which package installed a specific file - - CLI Examples: - - .. code-block:: bash - - salt * pkg.which - """ - filepath = pathlib.Path(path) - cmd = ["dpkg"] - if filepath.is_absolute(): - if filepath.exists(): - cmd.extend(["-S", str(filepath)]) - else: - log.debug("%s does not exist", filepath) - return False - else: - log.debug("%s is not absolute path", filepath) - return False - cmd_ret = _call_apt(cmd) - if "no path found matching pattern" in cmd_ret["stdout"]: - return None - pkg = cmd_ret["stdout"].split(":")[0] - return pkg diff --git a/salt/modules/asymmetric.py b/salt/modules/asymmetric.py deleted file mode 100644 index a0e5b6140024..000000000000 --- a/salt/modules/asymmetric.py +++ /dev/null @@ -1,330 +0,0 @@ -""" -.. versionadded:: 3008.0 - -Low-level asymmetric cryptographic operations. - -:depends: cryptography - -.. note:: - - All parameters that take a public key or private key can be specified either - as a PEM/hex/base64 string or a path to a local file encoded in all supported - formats for the type. - - A signature can be specified as a base64 string or a path to a file with the - raw signature or its base64 encoding. - - Public keys and signatures can additionally be specified as a URL that can be - retrieved using :py:func:`cp.cache_file `. -""" - -import base64 -import logging -from pathlib import Path -from urllib.parse import urlparse - -import salt.utils.files -from salt.exceptions import CommandExecutionError, SaltInvocationError - -try: - from salt.utils import asymmetric as asym - from salt.utils import x509 - - HAS_CRYPTOGRAPHY = True -except ImportError: - HAS_CRYPTOGRAPHY = False - -log = logging.getLogger(__name__) - -__virtualname__ = "asymmetric" - - -def __virtual__(): - if HAS_CRYPTOGRAPHY: - return __virtualname__ - return False, "Missing `cryptography` library" - - -def sign( - privkey, passphrase=None, text=None, filename=None, digest=None, raw=None, path=None -): - """ - Sign a file or text using an (RSA|ECDSA|Ed25519|Ed448) private key. - You can employ :py:func:`x509.create_private_key ` - to generate one. Returns the signature encoded in base64 by default. - - CLI Example: - - .. code-block:: bash - - salt '*' asymmetric.sign /root/my_privkey.pem text='I like you' - salt '*' asymmetric.sign /root/my_privkey.pem filename=/data/to/be/signed - - privkey - The private key to sign with. - - passphrase - If the private key is encrypted, the passphrase to decrypt it. Optional. - - text - Pass the text to sign. Either this or ``filename`` is required. - - filename - Pass the path of a file to sign. Either this or ``text`` is required. - - digest - The name of the hashing algorithm to use when creating signatures. - Defaults to ``sha256``. Only relevant for ECDSA or RSA. - - raw - Return the raw bytes instead of encoding them to base64. Defaults to false. - - path - Instead of returning the data, write it to a path on the local filesystem. - Optional. - """ - if text is not None: - try: - data = text.encode() - except AttributeError: - data = text - elif filename: - data = Path(filename) - else: - raise SaltInvocationError("Either `text` or `filename` is required") - raw = raw if raw is not None else bool(path) - sig = asym.sign(privkey, data, digest, passphrase=passphrase) - mode = "wb" - if not raw: - sig = base64.b64encode(sig).decode() - mode = "w" - if path: - with salt.utils.files.fopen(path, mode) as out: - out.write(sig) - return f"Signature written to '{path}'" - return sig - - -def verify( - text=None, - filename=None, - pubkey=None, - signature=None, - digest=None, - signed_by_any=None, - signed_by_all=None, - **kwargs, # pylint: disable=unused-argument -): - """ - Verify signatures on a specific input against (RSA|ECDSA|Ed25519|Ed448) public keys. - - .. note:: - - This function is supposed to be compatible with the same interface - as :py:func:`gpg.verify `` regarding keyword - arguments and return value format. - - CLI Example: - - .. code-block:: bash - - salt '*' asymmetric.verify pubkey=/root/my_pubkey.pem text='I like you' signature=/root/ilikeyou.sig - salt '*' asymmetric.verify pubkey=/root/my_pubkey.pem path=/root/confidential signature=/root/confidential.sig - - text - The text to verify. Either this or ``filename`` is required. - - filename - The path of a file to verify. Either this or ``text`` is required. - - pubkey - The single public key to verify ``signature`` against. Specify either - this or make use of ``signed_by_any``/``signed_by_all`` for compound checks. - - signature - If ``pubkey`` is specified, the single signature to verify. - If ``signed_by_any`` and/or ``signed_by_all`` is specified, this can be - a list of multiple signatures to check against the provided keys. - Required. - - digest - The name of the hashing algorithm to use when verifying signatures. - Defaults to ``sha256``. Only relevant for ECDSA or RSA. - - signed_by_any - A list of pubkeys from which any valid signature will mark verification - as passed. If none of the listed pubkeys provided a signature, - verification fails. Works with ``signed_by_all``, but mutually - exclusive with ``pubkey``. - - signed_by_all - A list of pubkeys, all of which must provide a signature for verification - to pass. If a single one of the listed pubkeys did not provide a signature, - verification fails. Works with ``signed_by_any``, but mutually - exclusive with ``pubkey``. - """ - # Basic compatibility with gpg.verify - ret = {"res": False, "message": "internal error"} - - signed_by_any = signed_by_any or [] - signed_by_all = signed_by_all or [] - if text and filename: - raise SaltInvocationError( - "`text` and `filename` arguments are mutually exclusive" - ) - if not signature: - raise SaltInvocationError("Missing `signature` parameter") - # We're constrained by compatibility with gpg.verify, so ensure the parameters - # are as expected. - multi_check = bool(signed_by_any or signed_by_all) - if multi_check: - if pubkey: - raise SaltInvocationError( - "Either specify pubkey + signature or signed_by_(any|all)" - ) - if isinstance(signature, (str, bytes)): - signature = [signature] - if not isinstance(signed_by_any, list): - signed_by_any = [signed_by_any] - if not isinstance(signed_by_all, list): - signed_by_all = [signed_by_all] - elif not pubkey: - raise SaltInvocationError("Missing pubkey(s) to check against") - elif not isinstance(signature, (str, bytes)): - raise SaltInvocationError( - "`signature` must be a string or bytes when verifying a single signing `pubkey`" - ) - else: - signed_by_all = [pubkey] - if not isinstance(signature, list): - signature = [signature] - - file_digest = None - if text: - try: - data = text.encode() - except AttributeError: - data = text - elif filename: - data = Path(filename) - if not data.exists(): - raise CommandExecutionError(f"Path '{filename}' does not exist") - else: - raise SaltInvocationError( - "Missing data to verify. Either specify `text` or `filename`" - ) - any_check = all_check = False - sigs = [] - for sig in signature: - try: - sigs.append(_fetch(sig)) - except CommandExecutionError as err: - if pubkey: - return {"res": False, "message": str(err)} - log.error(str(err), exc_info_on_loglevel=logging.DEBUG) - if not sigs: - raise CommandExecutionError("Unable to locate any of the provided signatures") - if signed_by_any: - for signer in signed_by_any: - try: - # Since we don't know if the signature algorithm supports - # `prehashed` (only rsa/ec), don't calculate it early, but - # cache it once it has been calculated. If a verification fails, - # it throws an exception. - _, data, file_digest = _verify_pubkey_against_list( - signer, sigs, data, digest, file_digest=file_digest - ) - any_check = True - break - except asym.InvalidSignature as err: - log.info(str(err), exc_info_on_loglevel=logging.DEBUG) - if err.file_digest is not None: - file_digest = err.file_digest - if err.data is not None: - data = err.data - except Exception as err: # pylint: disable=broad-except - log.error(str(err), exc_info_on_loglevel=logging.DEBUG) - else: - ret["res"] = False - ret["message"] = ( - "None of the public keys listed in signed_by_any provided a valid signature" - ) - return ret - - if signed_by_all: - all_sigs = sigs.copy() - for signer in signed_by_all: - try: - match, data, file_digest = _verify_pubkey_against_list( - signer, all_sigs, data, digest, file_digest=file_digest - ) - # Remove already associated signatures from list of possible ones - # Since pubkeys can be specified in different ways, this fails if - # the user passes in the same pubkey twice - all_sigs = list(set(all_sigs).difference(match)) - continue - except Exception as err: # pylint: disable=broad-except - log.error(str(err), exc_info_on_loglevel=logging.DEBUG) - ret["res"] = False - if pubkey: - ret["message"] = f"Failed checking signature: {err}" - else: - ret["message"] = f"Failed while checking `signed_by_all`: {err}" - return ret - all_check = True - - if bool(signed_by_any) is any_check and bool(signed_by_all) is all_check: - ret["res"] = True - if pubkey: - ret["message"] = "The signature is valid" - else: - ret["message"] = "All required keys have provided a signature" - return ret - # This should never be reached - ret["res"] = False - return ret - - -def _verify_pubkey_against_list(pub, sigs, data, digest, file_digest=None): - pubkey = _fetch(pub) - pubkey = x509.load_pubkey(pubkey) - match = [] - for sig in sigs: - try: - data, file_digest = asym.verify( - pubkey, sig, data, digest, file_digest=file_digest - ) - match.append(sig) - except asym.InvalidSignature as err: - if err.file_digest is not None: - file_digest = err.file_digest - if err.data is not None: - data = err.data - if not match: - raise asym.InvalidSignature( - f"Invalid signature for key {asym.fingerprint(pubkey)}", - file_digest=file_digest, - data=data, - pubkey=pubkey, - ) - return match, data, file_digest - - -def _fetch(url): - try: - parsed = urlparse(url) - except (TypeError, ValueError): - return url - sfn = None - if parsed.scheme == "": - sfn = url - elif parsed.scheme == "file": - sfn = parsed.path - else: - sfn = __salt__["cp.cache_file"](url) - if not sfn: - raise CommandExecutionError(f"Failed fetching '{url}'") - if parsed.scheme != "": - if not Path(sfn).exists(): - raise CommandExecutionError(f"Failed fetching '{url}'") - return sfn diff --git a/salt/modules/augeas_cfg.py b/salt/modules/augeas_cfg.py new file mode 100644 index 000000000000..adc4fa22b21f --- /dev/null +++ b/salt/modules/augeas_cfg.py @@ -0,0 +1,544 @@ +""" +Manages configuration files via augeas + +This module requires the ``augeas`` Python module. + +.. _Augeas: http://augeas.net/ + +.. warning:: + + Minimal installations of Debian and Ubuntu have been seen to have packaging + bugs with python-augeas, causing the augeas module to fail to import. If + the minion has the augeas module installed, but the functions in this + execution module fail to run due to being unavailable, first restart the + salt-minion service. If the problem persists past that, the following + command can be run from the master to determine what is causing the import + to fail: + + .. code-block:: bash + + salt minion-id cmd.run 'python -c "from augeas import Augeas"' + + For affected Debian/Ubuntu hosts, installing ``libpython2.7`` has been + known to resolve the issue. +""" + +import logging +import os +import re + +import salt.utils.args +import salt.utils.data +import salt.utils.stringutils +from salt.exceptions import SaltInvocationError + +# Make sure augeas python interface is installed +HAS_AUGEAS = False +try: + from augeas import Augeas as _Augeas # pylint: disable=no-name-in-module + + HAS_AUGEAS = True +except ImportError: + pass + + +log = logging.getLogger(__name__) + +# Define the module's virtual name +__virtualname__ = "augeas" + +METHOD_MAP = { + "set": "set", + "setm": "setm", + "mv": "move", + "move": "move", + "ins": "insert", + "insert": "insert", + "rm": "remove", + "remove": "remove", +} + + +def __virtual__(): + """ + Only run this module if the augeas python module is installed + """ + if HAS_AUGEAS: + return __virtualname__ + return (False, "Cannot load augeas_cfg module: augeas python module not installed") + + +def _recurmatch(path, aug): + """ + Recursive generator providing the infrastructure for + augtools print behavior. + + This function is based on test_augeas.py from + Harald Hoyer in the python-augeas + repository + """ + if path: + clean_path = path.rstrip("/*") + yield (clean_path, aug.get(path)) + + for i in aug.match(clean_path + "/*"): + i = i.replace("!", "\\!") # escape some dirs + yield from _recurmatch(i, aug) + + +def _lstrip_word(word, prefix): + """ + Return a copy of the string after the specified prefix was removed + from the beginning of the string + """ + + if str(word).startswith(prefix): + return str(word)[len(prefix) :] + return word + + +def _check_load_paths(load_path): + """ + Checks the validity of the load_path, returns a sanitized version + with invalid paths removed. + """ + if load_path is None or not isinstance(load_path, str): + return None + + _paths = [] + + for _path in load_path.split(":"): + if os.path.isabs(_path) and os.path.isdir(_path): + _paths.append(_path) + else: + log.info("Invalid augeas_cfg load_path entry: %s removed", _path) + + if not _paths: + return None + + return ":".join(_paths) + + +def execute(context=None, lens=None, commands=(), load_path=None): + """ + Execute Augeas commands + + .. versionadded:: 2014.7.0 + + CLI Example: + + .. code-block:: bash + + salt '*' augeas.execute /files/etc/redis/redis.conf \\ + commands='["set bind 0.0.0.0", "set maxmemory 1G"]' + + context + The Augeas context + + lens + The Augeas lens to use + + commands + The Augeas commands to execute + + .. versionadded:: 2016.3.0 + + load_path + A colon-spearated list of directories that modules should be searched + in. This is in addition to the standard load path and the directories + in AUGEAS_LENS_LIB. + """ + ret = {"retval": False} + + arg_map = { + "set": (1, 2), + "setm": (2, 3), + "move": (2,), + "insert": (3,), + "remove": (1,), + } + + def make_path(path): + """ + Return correct path + """ + if not context: + return path + + if path.lstrip("/"): + if path.startswith(context): + return path + + path = path.lstrip("/") + return os.path.join(context, path) + else: + return context + + load_path = _check_load_paths(load_path) + + flags = _Augeas.NO_MODL_AUTOLOAD if lens and context else _Augeas.NONE + aug = _Augeas(flags=flags, loadpath=load_path) + + if lens and context: + aug.add_transform(lens, re.sub("^/files", "", context)) + aug.load() + + for command in commands: + try: + # first part up to space is always the + # command name (i.e.: set, move) + cmd, arg = command.split(" ", 1) + + if cmd not in METHOD_MAP: + ret["error"] = f"Command {cmd} is not supported (yet)" + return ret + + method = METHOD_MAP[cmd] + nargs = arg_map[method] + + parts = salt.utils.args.shlex_split(arg) + + if len(parts) not in nargs: + err = f"{method} takes {nargs} args: {parts}" + raise ValueError(err) + if method == "set": + path = make_path(parts[0]) + value = parts[1] if len(parts) == 2 else None + args = {"path": path, "value": value} + elif method == "setm": + base = make_path(parts[0]) + sub = parts[1] + value = parts[2] if len(parts) == 3 else None + args = {"base": base, "sub": sub, "value": value} + elif method == "move": + path = make_path(parts[0]) + dst = parts[1] + args = {"src": path, "dst": dst} + elif method == "insert": + label, where, path = parts + if where not in ("before", "after"): + raise ValueError(f'Expected "before" or "after", not {where}') + path = make_path(path) + args = {"path": path, "label": label, "before": where == "before"} + elif method == "remove": + path = make_path(parts[0]) + args = {"path": path} + except ValueError as err: + log.error(err) + # if command.split fails arg will not be set + if "arg" not in locals(): + arg = command + ret["error"] = ( + f"Invalid formatted command, see debug log for details: {arg}" + ) + return ret + + args = salt.utils.data.decode(args, to_str=True) + log.debug("%s: %s", method, args) + + func = getattr(aug, method) + func(**args) + + try: + aug.save() + ret["retval"] = True + except OSError as err: + ret["error"] = str(err) + + if lens and not lens.endswith(".lns"): + ret["error"] += ( + '\nLenses are normally configured as "name.lns". ' + 'Did you mean "{}.lns"?'.format(lens) + ) + + aug.close() + return ret + + +def get(path, value="", load_path=None): + """ + Get a value for a specific augeas path + + CLI Example: + + .. code-block:: bash + + salt '*' augeas.get /files/etc/hosts/1/ ipaddr + + path + The path to get the value of + + value + The optional value to get + + .. versionadded:: 2016.3.0 + + load_path + A colon-spearated list of directories that modules should be searched + in. This is in addition to the standard load path and the directories + in AUGEAS_LENS_LIB. + """ + load_path = _check_load_paths(load_path) + + aug = _Augeas(loadpath=load_path) + ret = {} + + path = path.rstrip("/") + if value: + path += "/{}".format(value.strip("/")) + + try: + _match = aug.match(path) + except RuntimeError as err: + return {"error": str(err)} + + if _match: + ret[path] = aug.get(path) + else: + ret[path] = "" # node does not exist + + return ret + + +def setvalue(*args): + """ + Set a value for a specific augeas path + + CLI Example: + + .. code-block:: bash + + salt '*' augeas.setvalue /files/etc/hosts/1/canonical localhost + + This will set the first entry in /etc/hosts to localhost + + CLI Example: + + .. code-block:: bash + + salt '*' augeas.setvalue /files/etc/hosts/01/ipaddr 192.168.1.1 \\ + /files/etc/hosts/01/canonical test + + Adds a new host to /etc/hosts the ip address 192.168.1.1 and hostname test + + CLI Example: + + .. code-block:: bash + + salt '*' augeas.setvalue prefix=/files/etc/sudoers/ \\ + "spec[user = '%wheel']/user" "%wheel" \\ + "spec[user = '%wheel']/host_group/host" 'ALL' \\ + "spec[user = '%wheel']/host_group/command[1]" 'ALL' \\ + "spec[user = '%wheel']/host_group/command[1]/tag" 'PASSWD' \\ + "spec[user = '%wheel']/host_group/command[2]" '/usr/bin/apt-get' \\ + "spec[user = '%wheel']/host_group/command[2]/tag" NOPASSWD + + Ensures that the following line is present in /etc/sudoers:: + + %wheel ALL = PASSWD : ALL , NOPASSWD : /usr/bin/apt-get , /usr/bin/aptitude + """ + load_path = None + load_paths = [x for x in args if str(x).startswith("load_path=")] + if load_paths: + if len(load_paths) > 1: + raise SaltInvocationError("Only one 'load_path=' value is permitted") + else: + load_path = load_paths[0].split("=", 1)[1] + load_path = _check_load_paths(load_path) + + aug = _Augeas(loadpath=load_path) + ret = {"retval": False} + + tuples = [ + x + for x in args + if not str(x).startswith("prefix=") and not str(x).startswith("load_path=") + ] + prefix = [x for x in args if str(x).startswith("prefix=")] + if prefix: + if len(prefix) > 1: + raise SaltInvocationError("Only one 'prefix=' value is permitted") + else: + prefix = prefix[0].split("=", 1)[1] + + if len(tuples) % 2 != 0: + raise SaltInvocationError("Uneven number of path/value arguments") + + tuple_iter = iter(tuples) + for path, value in zip(tuple_iter, tuple_iter): + target_path = path + if prefix: + target_path = os.path.join(prefix.rstrip("/"), path.lstrip("/")) + try: + aug.set(target_path, str(value)) + except ValueError as err: + ret["error"] = f"Multiple values: {err}" + + try: + aug.save() + ret["retval"] = True + except OSError as err: + ret["error"] = str(err) + return ret + + +def match(path, value="", load_path=None): + """ + Get matches for path expression + + CLI Example: + + .. code-block:: bash + + salt '*' augeas.match /files/etc/services/service-name ssh + + path + The path to match + + value + The value to match on + + .. versionadded:: 2016.3.0 + + load_path + A colon-spearated list of directories that modules should be searched + in. This is in addition to the standard load path and the directories + in AUGEAS_LENS_LIB. + """ + load_path = _check_load_paths(load_path) + + aug = _Augeas(loadpath=load_path) + ret = {} + + try: + matches = aug.match(path) + except RuntimeError: + return ret + + for _match in matches: + if value and aug.get(_match) == value: + ret[_match] = value + elif not value: + ret[_match] = aug.get(_match) + return ret + + +def remove(path, load_path=None): + """ + Get matches for path expression + + CLI Example: + + .. code-block:: bash + + salt '*' augeas.remove \\ + /files/etc/sysctl.conf/net.ipv4.conf.all.log_martians + + path + The path to remove + + .. versionadded:: 2016.3.0 + + load_path + A colon-spearated list of directories that modules should be searched + in. This is in addition to the standard load path and the directories + in AUGEAS_LENS_LIB. + """ + load_path = _check_load_paths(load_path) + + aug = _Augeas(loadpath=load_path) + ret = {"retval": False} + try: + count = aug.remove(path) + aug.save() + if count == -1: + ret["error"] = "Invalid node" + else: + ret["retval"] = True + except (RuntimeError, OSError) as err: + ret["error"] = str(err) + + ret["count"] = count + + return ret + + +def ls(path, load_path=None): # pylint: disable=C0103 + """ + List the direct children of a node + + CLI Example: + + .. code-block:: bash + + salt '*' augeas.ls /files/etc/passwd + + path + The path to list + + .. versionadded:: 2016.3.0 + + load_path + A colon-spearated list of directories that modules should be searched + in. This is in addition to the standard load path and the directories + in AUGEAS_LENS_LIB. + """ + + def _match(path): + """Internal match function""" + try: + matches = aug.match(salt.utils.stringutils.to_str(path)) + except RuntimeError: + return {} + + ret = {} + for _ma in matches: + ret[_ma] = aug.get(_ma) + return ret + + load_path = _check_load_paths(load_path) + + aug = _Augeas(loadpath=load_path) + + path = path.rstrip("/") + "/" + match_path = path + "*" + + matches = _match(match_path) + ret = {} + + for key, value in matches.items(): + name = _lstrip_word(key, path) + if _match(key + "/*"): + ret[name + "/"] = value # has sub nodes, e.g. directory + else: + ret[name] = value + return ret + + +def tree(path, load_path=None): + """ + Returns recursively the complete tree of a node + + CLI Example: + + .. code-block:: bash + + salt '*' augeas.tree /files/etc/ + + path + The base of the recursive listing + + .. versionadded:: 2016.3.0 + + load_path + A colon-spearated list of directories that modules should be searched + in. This is in addition to the standard load path and the directories + in AUGEAS_LENS_LIB. + """ + load_path = _check_load_paths(load_path) + + aug = _Augeas(loadpath=load_path) + + path = path.rstrip("/") + "/" + match_path = path + return dict([i for i in _recurmatch(match_path, aug)]) diff --git a/salt/modules/aws_sqs.py b/salt/modules/aws_sqs.py new file mode 100644 index 000000000000..428f4062dadf --- /dev/null +++ b/salt/modules/aws_sqs.py @@ -0,0 +1,296 @@ +""" +Support for the Amazon Simple Queue Service. +""" + +import logging + +import salt.utils.json +import salt.utils.path + +log = logging.getLogger(__name__) + +_OUTPUT = "--output json" + + +def __virtual__(): + if salt.utils.path.which("aws"): + # awscli is installed, load the module + return True + return (False, "The module aws_sqs could not be loaded: aws command not found") + + +def _region(region): + """ + Return the region argument. + """ + return f" --region {region}" + + +def _run_aws(cmd, region, opts, user, **kwargs): + """ + Runs the given command against AWS. + cmd + Command to run + region + Region to execute cmd in + opts + Pass in from salt + user + Pass in from salt + kwargs + Key-value arguments to pass to the command + """ + # These args need a specific key value that aren't + # valid python parameter keys + receipthandle = kwargs.pop("receipthandle", None) + if receipthandle: + kwargs["receipt-handle"] = receipthandle + num = kwargs.pop("num", None) + if num: + kwargs["max-number-of-messages"] = num + + _formatted_args = [f'--{k} "{v}"' for k, v in kwargs.items()] + + cmd = "aws sqs {cmd} {args} {region} {out}".format( + cmd=cmd, args=" ".join(_formatted_args), region=_region(region), out=_OUTPUT + ) + + rtn = __salt__["cmd.run"](cmd, runas=user, python_shell=False) + + return salt.utils.json.loads(rtn) if rtn else "" + + +def receive_message(queue, region, num=1, opts=None, user=None): + """ + Receive one or more messages from a queue in a region + + queue + The name of the queue to receive messages from + + region + Region where SQS queues exists + + num : 1 + The max number of messages to receive + + opts : None + Any additional options to add to the command line + + user : None + Run as a user other than what the minion runs as + + CLI Example: + + .. code-block:: bash + + salt '*' aws_sqs.receive_message + salt '*' aws_sqs.receive_message num=10 + + .. versionadded:: 2014.7.0 + + """ + ret = { + "Messages": None, + } + queues = list_queues(region, opts, user) + url_map = _parse_queue_list(queues) + if queue not in url_map: + log.info('"%s" queue does not exist.', queue) + return ret + + out = _run_aws("receive-message", region, opts, user, queue=url_map[queue], num=num) + ret["Messages"] = out["Messages"] + return ret + + +def delete_message(queue, region, receipthandle, opts=None, user=None): + """ + Delete one or more messages from a queue in a region + + queue + The name of the queue to delete messages from + + region + Region where SQS queues exists + + receipthandle + The ReceiptHandle of the message to delete. The ReceiptHandle + is obtained in the return from receive_message + + opts : None + Any additional options to add to the command line + + user : None + Run as a user other than what the minion runs as + + CLI Example: + + .. code-block:: bash + + salt '*' aws_sqs.delete_message receipthandle='' + + .. versionadded:: 2014.7.0 + + """ + queues = list_queues(region, opts, user) + url_map = _parse_queue_list(queues) + if queue not in url_map: + log.info('"%s" queue does not exist.', queue) + return False + + out = _run_aws( + "delete-message", + region, + opts, + user, + receipthandle=receipthandle, + queue=url_map[queue], + ) + return True + + +def list_queues(region, opts=None, user=None): + """ + List the queues in the selected region. + + region + Region to list SQS queues for + + opts : None + Any additional options to add to the command line + + user : None + Run hg as a user other than what the minion runs as + + CLI Example: + + .. code-block:: bash + + salt '*' aws_sqs.list_queues + + """ + out = _run_aws("list-queues", region, opts, user) + + ret = { + "retcode": 0, + "stdout": out["QueueUrls"], + } + return ret + + +def create_queue(name, region, opts=None, user=None): + """ + Creates a queue with the correct name. + + name + Name of the SQS queue to create + + region + Region to create the SQS queue in + + opts : None + Any additional options to add to the command line + + user : None + Run hg as a user other than what the minion runs as + + CLI Example: + + .. code-block:: bash + + salt '*' aws_sqs.create_queue + + """ + + create = {"queue-name": name} + out = _run_aws("create-queue", region=region, opts=opts, user=user, **create) + + ret = { + "retcode": 0, + "stdout": out["QueueUrl"], + "stderr": "", + } + return ret + + +def delete_queue(name, region, opts=None, user=None): + """ + Deletes a queue in the region. + + name + Name of the SQS queue to deletes + region + Name of the region to delete the queue from + + opts : None + Any additional options to add to the command line + + user : None + Run hg as a user other than what the minion runs as + + CLI Example: + + .. code-block:: bash + + salt '*' aws_sqs.delete_queue + + """ + queues = list_queues(region, opts, user) + url_map = _parse_queue_list(queues) + + log.debug("map %s", url_map) + if name in url_map: + delete = {"queue-url": url_map[name]} + + rtn = _run_aws("delete-queue", region=region, opts=opts, user=user, **delete) + success = True + err = "" + out = f"{name} deleted" + + else: + out = "" + err = "Delete failed" + success = False + + ret = { + "retcode": 0 if success else 1, + "stdout": out, + "stderr": err, + } + return ret + + +def queue_exists(name, region, opts=None, user=None): + """ + Returns True or False on whether the queue exists in the region + + name + Name of the SQS queue to search for + + region + Name of the region to search for the queue in + + opts : None + Any additional options to add to the command line + + user : None + Run hg as a user other than what the minion runs as + + CLI Example: + + .. code-block:: bash + + salt '*' aws_sqs.queue_exists + + """ + output = list_queues(region, opts, user) + + return name in _parse_queue_list(output) + + +def _parse_queue_list(list_output): + """ + Parse the queue to get a dict of name -> URL + """ + queues = {q.split("/")[-1]: q for q in list_output["stdout"]} + return queues diff --git a/salt/modules/bamboohr.py b/salt/modules/bamboohr.py new file mode 100644 index 000000000000..e1582ff9a3fb --- /dev/null +++ b/salt/modules/bamboohr.py @@ -0,0 +1,290 @@ +""" +Support for BambooHR + +.. versionadded:: 2015.8.0 + +Requires a ``subdomain`` and an ``apikey`` in ``/etc/salt/minion``: + +.. code-block:: yaml + + bamboohr: + apikey: 012345678901234567890 + subdomain: mycompany +""" + +import logging +import xml.etree.ElementTree as ET + +import salt.utils.http +import salt.utils.yaml + +log = logging.getLogger(__name__) + + +def __virtual__(): + """ + Only load the module if apache is installed + """ + if _apikey(): + return True + return ( + False, + 'The API key was not specified. Please specify it using the "apikey" config.', + ) + + +def _apikey(): + """ + Get the API key + """ + return __opts__.get("bamboohr", {}).get("apikey", None) + + +def list_employees(order_by="id"): + """ + Show all employees for this company. + + CLI Example: + + .. code-block:: bash + + salt myminion bamboohr.list_employees + + By default, the return data will be keyed by ID. However, it can be ordered + by any other field. Keep in mind that if the field that is chosen contains + duplicate values (i.e., location is used, for a company which only has one + location), then each duplicate value will be overwritten by the previous. + Therefore, it is advisable to only sort by fields that are guaranteed to be + unique. + + CLI Examples: + + .. code-block:: bash + + salt myminion bamboohr.list_employees order_by=id + salt myminion bamboohr.list_employees order_by=displayName + salt myminion bamboohr.list_employees order_by=workEmail + """ + ret = {} + status, result = _query(action="employees", command="directory") + root = ET.fromstring(result) + for cat in root: + if cat.tag != "employees": + continue + for item in cat: + emp_id = next(iter(item.values())) + emp_ret = {"id": emp_id} + for details in item: + emp_ret[next(iter(details.values()))] = details.text + ret[emp_ret[order_by]] = emp_ret + return ret + + +def show_employee(emp_id, fields=None): + """ + Show all employees for this company. + + CLI Example: + + .. code-block:: bash + + salt myminion bamboohr.show_employee 1138 + + By default, the fields normally returned from bamboohr.list_employees are + returned. These fields are: + + - canUploadPhoto + - department + - displayName + - firstName + - id + - jobTitle + - lastName + - location + - mobilePhone + - nickname + - photoUploaded + - photoUrl + - workEmail + - workPhone + - workPhoneExtension + + If needed, a different set of fields may be specified, separated by commas: + + CLI Example: + + .. code-block:: bash + + salt myminion bamboohr.show_employee 1138 displayName,dateOfBirth + + A list of available fields can be found at + http://www.bamboohr.com/api/documentation/employees.php + """ + ret = {} + if fields is None: + fields = ",".join( + ( + "canUploadPhoto", + "department", + "displayName", + "firstName", + "id", + "jobTitle", + "lastName", + "location", + "mobilePhone", + "nickname", + "photoUploaded", + "photoUrl", + "workEmail", + "workPhone", + "workPhoneExtension", + ) + ) + + status, result = _query(action="employees", command=emp_id, args={"fields": fields}) + + root = ET.fromstring(result) + + ret = {"id": emp_id} + for item in root: + ret[next(iter(item.values()))] = item.text + return ret + + +def update_employee(emp_id, key=None, value=None, items=None): + """ + Update one or more items for this employee. Specifying an empty value will + clear it for that employee. + + CLI Examples: + + .. code-block:: bash + + salt myminion bamboohr.update_employee 1138 nickname Curly + salt myminion bamboohr.update_employee 1138 nickname '' + salt myminion bamboohr.update_employee 1138 items='{"nickname": "Curly"} + salt myminion bamboohr.update_employee 1138 items='{"nickname": ""} + """ + if items is None: + if key is None or value is None: + return {"Error": "At least one key/value pair is required"} + items = {key: value} + elif isinstance(items, str): + items = salt.utils.yaml.safe_load(items) + + xml_items = "" + for pair in items: + xml_items += f'{items[pair]}' + xml_items = f"{xml_items}" + + status, result = _query( + action="employees", + command=emp_id, + data=xml_items, + method="POST", + ) + + return show_employee(emp_id, ",".join(items.keys())) + + +def list_users(order_by="id"): + """ + Show all users for this company. + + CLI Example: + + .. code-block:: bash + + salt myminion bamboohr.list_users + + By default, the return data will be keyed by ID. However, it can be ordered + by any other field. Keep in mind that if the field that is chosen contains + duplicate values (i.e., location is used, for a company which only has one + location), then each duplicate value will be overwritten by the previous. + Therefore, it is advisable to only sort by fields that are guaranteed to be + unique. + + CLI Examples: + + .. code-block:: bash + + salt myminion bamboohr.list_users order_by=id + salt myminion bamboohr.list_users order_by=email + """ + ret = {} + status, result = _query(action="meta", command="users") + root = ET.fromstring(result) + for user in root: + user_id = None + user_ret = {} + for item in user.items(): + user_ret[item[0]] = item[1] + if item[0] == "id": + user_id = item[1] + for item in user: + user_ret[item.tag] = item.text + ret[user_ret[order_by]] = user_ret + return ret + + +def list_meta_fields(): + """ + Show all meta data fields for this company. + + CLI Example: + + .. code-block:: bash + + salt myminion bamboohr.list_meta_fields + """ + ret = {} + status, result = _query(action="meta", command="fields") + root = ET.fromstring(result) + for field in root: + field_id = None + field_ret = {"name": field.text} + for item in field.items(): + field_ret[item[0]] = item[1] + if item[0] == "id": + field_id = item[1] + ret[field_id] = field_ret + return ret + + +def _query(action=None, command=None, args=None, method="GET", data=None): + """ + Make a web call to BambooHR + + The password can be any random text, so we chose Salty text. + """ + subdomain = __opts__.get("bamboohr", {}).get("subdomain", None) + path = f"https://api.bamboohr.com/api/gateway.php/{subdomain}/v1/" + + if action: + path += action + + if command: + path += f"/{command}" + + log.debug("BambooHR URL: %s", path) + + if not isinstance(args, dict): + args = {} + + return_content = None + result = salt.utils.http.query( + path, + method, + username=_apikey(), + password="saltypork", + params=args, + data=data, + decode=False, + text=True, + status=True, + opts=__opts__, + ) + log.debug("BambooHR Response Status Code: %s", result["status"]) + + return [result["status"], result["text"]] diff --git a/salt/modules/baredoc.py b/salt/modules/baredoc.py index 4cdf4c5b24af..c3d048d21ce2 100644 --- a/salt/modules/baredoc.py +++ b/salt/modules/baredoc.py @@ -29,8 +29,7 @@ def _get_module_name(tree, filename: str) -> str: for assign in assignments: try: if assign.targets[0].id == "__virtualname__": - # ast.Constant.value replaces the removed ast.Str.s in Python 3.14. - module_name = assign.value.value + module_name = assign.value.s except AttributeError: pass return module_name @@ -71,10 +70,12 @@ def _get_args(function: str) -> dict: list_arg_defaults = function.args.defaults if list_arg_defaults: for arg_default in list_arg_defaults: - # ast.NameConstant / ast.Str / ast.Num were deprecated in 3.8 and - # removed in 3.14. ast.Constant covers all three. - if isinstance(arg_default, ast.Constant): + if isinstance(arg_default, ast.NameConstant): arg_default_strings.append(arg_default.value) + elif isinstance(arg_default, ast.Str): + arg_default_strings.append(arg_default.s) + elif isinstance(arg_default, ast.Num): + arg_default_strings.append(arg_default.n) # Since only some args may have default values, need to zip in reverse order backwards_args = OrderedDict( diff --git a/salt/modules/bigip.py b/salt/modules/bigip.py new file mode 100644 index 000000000000..6624b85c6f8b --- /dev/null +++ b/salt/modules/bigip.py @@ -0,0 +1,2430 @@ +""" +An execution module which can manipulate an f5 bigip via iControl REST + :maturity: develop + :platform: f5_bigip_11.6 +""" + +import salt.exceptions +import salt.utils.json + +try: + import requests + import requests.exceptions + + HAS_LIBS = True +except ImportError: + HAS_LIBS = False + + +# Define the module's virtual name +__virtualname__ = "bigip" + + +def __virtual__(): + """ + Only return if requests is installed + """ + if HAS_LIBS: + return __virtualname__ + return ( + False, + "The bigip execution module cannot be loaded: " + "python requests library not available.", + ) + + +BIG_IP_URL_BASE = "https://{host}/mgmt/tm" + + +def _build_session(username, password, trans_label=None): + """ + Create a session to be used when connecting to iControl REST. + """ + + bigip = requests.session() + bigip.auth = (username, password) + bigip.verify = True + bigip.headers.update({"Content-Type": "application/json"}) + + if trans_label: + # pull the trans id from the grain + trans_id = __salt__["grains.get"](f"bigip_f5_trans:{trans_label}") + + if trans_id: + bigip.headers.update({"X-F5-REST-Coordination-Id": trans_id}) + else: + bigip.headers.update({"X-F5-REST-Coordination-Id": None}) + + return bigip + + +def _load_response(response): + """ + Load the response from json data, return the dictionary or raw text + """ + + try: + data = salt.utils.json.loads(response.text) + except ValueError: + data = response.text + + ret = {"code": response.status_code, "content": data} + + return ret + + +def _load_connection_error(hostname, error): + """ + Format and Return a connection error + """ + + ret = { + "code": None, + "content": ( + "Error: Unable to connect to the bigip device: {host}\n{error}".format( + host=hostname, error=error + ) + ), + } + + return ret + + +def _loop_payload(params): + """ + Pass in a dictionary of parameters, loop through them and build a payload containing, + parameters who's values are not None. + """ + + # construct the payload + payload = {} + + # set the payload + for param, value in params.items(): + if value is not None: + payload[param] = value + + return payload + + +def _build_list(option_value, item_kind): + """ + pass in an option to check for a list of items, create a list of dictionary of items to set + for this option + """ + # specify profiles if provided + if option_value is not None: + + items = [] + + # if user specified none, return an empty list + if option_value == "none": + return items + + # was a list already passed in? + if not isinstance(option_value, list): + values = option_value.split(",") + else: + values = option_value + + for value in values: + # sometimes the bigip just likes a plain ol list of items + if item_kind is None: + items.append(value) + # other times it's picky and likes key value pairs... + else: + items.append({"kind": item_kind, "name": value}) + return items + return None + + +def _determine_toggles(payload, toggles): + """ + BigIP can't make up its mind if it likes yes / no or true or false. + Figure out what it likes to hear without confusing the user. + """ + + for toggle, definition in toggles.items(): + # did the user specify anything? + if definition["value"] is not None: + # test for yes_no toggle + if ( + definition["value"] is True or definition["value"] == "yes" + ) and definition["type"] == "yes_no": + payload[toggle] = "yes" + elif ( + definition["value"] is False or definition["value"] == "no" + ) and definition["type"] == "yes_no": + payload[toggle] = "no" + + # test for true_false toggle + if ( + definition["value"] is True or definition["value"] == "yes" + ) and definition["type"] == "true_false": + payload[toggle] = True + elif ( + definition["value"] is False or definition["value"] == "no" + ) and definition["type"] == "true_false": + payload[toggle] = False + + return payload + + +def _set_value(value): + """ + A function to detect if user is trying to pass a dictionary or list. parse it and return a + dictionary list or a string + """ + # don't continue if already an acceptable data-type + if isinstance(value, bool) or isinstance(value, dict) or isinstance(value, list): + return value + + # check if json + if value.startswith("j{") and value.endswith("}j"): + + value = value.replace("j{", "{") + value = value.replace("}j", "}") + + try: + return salt.utils.json.loads(value) + except Exception: # pylint: disable=broad-except + raise salt.exceptions.CommandExecutionError + + # detect list of dictionaries + if "|" in value and r"\|" not in value: + values = value.split("|") + items = [] + for value in values: + items.append(_set_value(value)) + return items + + # parse out dictionary if detected + if ":" in value and r"\:" not in value: + options = {} + # split out pairs + key_pairs = value.split(",") + for key_pair in key_pairs: + k = key_pair.split(":")[0] + v = key_pair.split(":")[1] + options[k] = v + return options + + # try making a list + elif "," in value and r"\," not in value: + value_items = value.split(",") + return value_items + + # just return a string + else: + + # remove escape chars if added + if r"\|" in value: + value = value.replace(r"\|", "|") + + if r"\:" in value: + value = value.replace(r"\:", ":") + + if r"\," in value: + value = value.replace(r"\,", ",") + + return value + + +def start_transaction(hostname, username, password, label): + """ + A function to connect to a bigip device and start a new transaction. + + hostname + The host/address of the bigip device + username + The iControl REST username + password + The iControl REST password + label + The name / alias for this transaction. The actual transaction + id will be stored within a grain called ``bigip_f5_trans: